From a54cd7bef6e04cbb102143092aaf790cce08836d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 10:14:25 +0200 Subject: [PATCH 01/22] fix(lighting): match retail indoor ambient (0.20 neutral, not 0.10/0.09/0.08 warm) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Indoor cells rendered "almost black" because the hardcoded ambient at GameWindow.cs:8342-8345 was an early-2026 guess (0.10, 0.09, 0.08 — half retail brightness, warm-tinted) rather than the retail value. The named retail decomp (acclient.pdb, Sept 2013 EoR build) shows CellManager::ChangePosition @ 0x004559B0 calls SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF) whenever the player's CObjCell::seen_outside flag is 0 — a flat 0.20 white floor, not a dungeon-tone warm color. Investigation also confirmed: - EnvCell.dat does NOT carry inline lights — CEnvCell::UnPack reads numVisibleCells where Binary Ninja's heuristic decomp inferred "num_lights". Retail's CObjCell.light_list is populated at runtime via add_light() calls from neighbouring cell light registrations + per-cell static-object Setup.Lights, NOT from the dat byte stream. - Setup.Lights from indoor static objects (entity.SourceGfxObjOrSetupId prefix 0x02xxxxxx) DO flow through LightInfoLoader.Load (line 5765) and reach LightManager via LightingHookSink. The wire is intact; the per-frame Tick + UBO upload chain (line 6865-6867) is intact. - Retail's particle system does NOT emit lights from particles themselves. The light comes from the owning Setup's LightInfo records. Pre-existing failures in DispatcherToMovementIntegrationTests, BSPStepUpTests, and MotionInterpreterTests are on the branch already and unrelated to this change (verified by stashing + retesting). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/research/deepdives/r13-dynamic-lighting.md | 2 +- src/AcDream.App/Rendering/GameWindow.cs | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) 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/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index ff777f8..6f87a61 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -8319,7 +8319,13 @@ 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 + /// ). /// private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool cameraInsideCell) { @@ -8330,7 +8336,8 @@ public sealed class GameWindow : IDisposable if (cameraInsideCell) { - // 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 +8347,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); } From 1024ba34e0abb774bc8b0a566aae6d0c9713b7fe Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 10:38:48 +0200 Subject: [PATCH 02/22] fix(lighting): trigger indoor ambient on PLAYER cell, not camera cell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: third-person chase camera enters interiors before the player body does, so the camera-based cameraInsideCell flag was flipping the scene to indoor lighting prematurely (ambient drops to 0.2 white before the player has actually crossed the doorway). Retail keys lighting off the PLAYER's cell. CellManager::ChangePosition @ 0x004559B0 reads CObjCell::seen_outside on the player's current cell — never on the camera. Match that semantics. - CellVisibility.IsInsideAnyCell(Vector3): new non-caching brute-force scan that's safe to call alongside ComputeVisibility(cameraPos) without thrashing the camera cell cache. - GameWindow render loop: derive playerInsideCell from the player's Position when in player mode, otherwise fall back to cameraInsideCell (orbit/fly debug camera). - UpdateSunFromSky now takes playerInsideCell. The sky-render and depth-buffer-clear decisions still use cameraInsideCell — those are legitimately camera-POV concerns. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/CellVisibility.cs | 15 ++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 22 ++++++++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) 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 6f87a61..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); @@ -8326,15 +8339,18 @@ public sealed class GameWindow : IDisposable /// 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) { // Indoor default — retail's flat 0.2 neutral ambient, sun // zeroed. See xref to retail decomp in the doc comment above. From f6e9c58932a0eed5b0e4b26fb3a5db401c3efb14 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 11:02:05 +0200 Subject: [PATCH 03/22] =?UTF-8?q?docs(spec):=20indoor=20cell=20rendering?= =?UTF-8?q?=20fix=20=E2=80=94=20Phase=201=20diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Initial brainstorm assumed N.5 retirement broke EnvCell rendering by leaving _pendingCellMeshes unconsumed. Pivoted mid-brainstorm: - WB's PrepareMeshData routes EnvCell dat-record types to PrepareEnvCellMeshData (ObjectMeshManager.cs:557) which produces an IsSetup=true ObjectMeshData with the floor mesh as EnvCellGeometry. - WbDrawDispatcher correctly handles IsSetup=true (line 607-621) by iterating SetupParts and drawing each. - DefaultDatReaderWriter loads region cell dats; ResolveId resolves envCellId correctly. - LandblockSpawnAdapter calls IncrementRefCount on every entity's GfxObjId, including envCellId for cell entities. ServerGuid==0 passes the atlas-tier filter. Chain is structurally intact. The bug is somewhere subtler. Spec pivots to a diagnostics-first phase: ACDREAM_PROBE_INDOOR=1 captures per-frame cell-entity walk + render-data lookup + SetupParts traversal + composed-transform values. Six hypotheses (WB silently returns null, empty batches, cull bug, double-spawn, transform double-apply, dispatcher MeshRefs mismatch) match six concrete fix shapes. Phase 2 design follows the probe data. This is more honest than the original "build a new upload path" design, which would have hidden the actual bug. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-05-19-indoor-cell-rendering-fix-design.md | 185 ++++++++++++++++++ 1 file changed, 185 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md 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..3b1c018 --- /dev/null +++ b/docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md @@ -0,0 +1,185 @@ +# 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.** Add a runtime-toggleable `ACDREAM_PROBE_INDOOR=1` +env-var (mirrored as a DebugPanel checkbox) that prints one line per frame +with: + +- Number of cell entities walked by the dispatcher. +- Per-cell-entity: `TryGetRenderData(envCellId)` hit/miss. +- On hit: `renderData.IsSetup`, `renderData.SetupParts.Count`. +- For each SetupPart: `TryGetRenderData(partGfxObjId)` hit/miss. +- The composed world matrix for the cell-geometry part (so we can see + where the floor actually ends up in world space). +- Whether the entity was culled by `visibleCellIds` (and why). + +Run the client, walk into Holtburg Inn, capture probe output. The log +tells us exactly which step in the chain is breaking. + +**Phase 2: Fix the specific break.** 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_PROBE_INDOOR=1` env var + DebugPanel mirror. +- [ ] One log line per frame, per cell entity, showing render-data lookup + results, SetupParts traversal, and composed transforms. +- [ ] Probe captured at Holtburg Inn confirms which hypothesis matches. +- [ ] Phase 2 design (amended spec or new spec) documents the surgical fix. + +**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. +- [ ] Probe left in place for future regressions but defaulted off. From e798cb789817a6d9cf2b7b244e51004f4a17356b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 11:07:54 +0200 Subject: [PATCH 04/22] docs(spec): expand probe design with concrete line formats + code sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User feedback: "add the probes you need. Better info, better code." Original spec had a single ACDREAM_PROBE_INDOOR=1 with vague "log lookup results" guidance. Replaced with five individually-toggleable probes, each with: - Specific env var name + DebugPanel checkbox name. - Concrete log-line format. - Exact code site to instrument. - The hypothesis it disambiguates. Probe set: - ACDREAM_PROBE_INDOOR_WALK — dispatcher entity walk per cell - ACDREAM_PROBE_INDOOR_LOOKUP — render-data lookup hit/miss + SetupParts - ACDREAM_PROBE_INDOOR_UPLOAD — WB upload result (requested + completed) - ACDREAM_PROBE_INDOOR_XFORM — composed world transform for cell geom - ACDREAM_PROBE_INDOOR_CULL — visibility/frustum filter decisions Plus ACDREAM_PROBE_INDOOR_ALL master toggle. Implementation outline added: new RenderingDiagnostics static class (mirrors L.2a's PhysicsDiagnostics pattern), DebugPanel subsection, edits to WbDrawDispatcher + WbMeshAdapter. Acceptance criteria refreshed. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-05-19-indoor-cell-rendering-fix-design.md | 105 ++++++++++++++---- 1 file changed, 84 insertions(+), 21 deletions(-) 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 index 3b1c018..f433375 100644 --- 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 @@ -98,23 +98,74 @@ Six untested hypotheses, in rough order of probability: ## 3. Solution -**Phase 1: Diagnostics.** Add a runtime-toggleable `ACDREAM_PROBE_INDOOR=1` -env-var (mirrored as a DebugPanel checkbox) that prints one line per frame -with: +### Phase 1 — Diagnostics (this phase's work) -- Number of cell entities walked by the dispatcher. -- Per-cell-entity: `TryGetRenderData(envCellId)` hit/miss. -- On hit: `renderData.IsSetup`, `renderData.SetupParts.Count`. -- For each SetupPart: `TryGetRenderData(partGfxObjId)` hit/miss. -- The composed world matrix for the cell-geometry part (so we can see - where the floor actually ends up in world space). -- Whether the entity was culled by `visibleCellIds` (and why). +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. -Run the client, walk into Holtburg Inn, capture probe output. The log -tells us exactly which step in the chain is breaking. +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. -**Phase 2: Fix the specific break.** Once the probe identifies the -failure point, implement the surgical fix. Likely shapes per hypothesis: +| 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 | |---|---| @@ -170,16 +221,28 @@ bug), tests verify visibility BFS for indoor entities. **Phase 1 (this phase):** -- [ ] `ACDREAM_PROBE_INDOOR=1` env var + DebugPanel mirror. -- [ ] One log line per frame, per cell entity, showing render-data lookup - results, SetupParts traversal, and composed transforms. +- [ ] `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. -- [ ] Phase 2 design (amended spec or new spec) documents the surgical fix. + 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. +- [ ] Visual verification: walking into Holtburg Inn renders interior + floor + walls correctly. - [ ] Roadmap updated. -- [ ] Probe left in place for future regressions but defaulted off. +- [ ] Probes left in place for future regressions but defaulted off. From 1fc6c0fd6912a6c7d2f5ffbcaaaff2d16d36ea0d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 11:13:53 +0200 Subject: [PATCH 05/22] plan: Phase 1 indoor cell rendering diagnostics Eight bite-sized tasks: 1. RenderingDiagnostics static class (mirrors PhysicsDiagnostics pattern) 2. Unit tests (cascade + IsEnvCellId rows) 3. DebugVM mirror properties 4. DebugPanel "Indoor rendering" checkbox group 5. WbMeshAdapter [indoor-upload] probes (requested + completed via pending set) 6. WbDrawDispatcher [indoor-walk] + [indoor-cull] probes 7. WbDrawDispatcher [indoor-lookup] + [indoor-xform] probes 8. Build + visual capture + match captured data to hypothesis H1-H6 Plan ends with research note documenting captured data + hypothesis, which becomes the input to Phase 2's spec. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...ndoor-cell-rendering-phase1-diagnostics.md | 964 ++++++++++++++++++ 1 file changed, 964 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md 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. From 6b0230be436aa4df021bdbdbff57e994afaf6019 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 11:24:38 +0200 Subject: [PATCH 06/22] =?UTF-8?q?feat(rendering):=20Task=201=20=E2=80=94?= =?UTF-8?q?=20RenderingDiagnostics=20static=20class?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five indoor-cell probe flags (ProbeIndoorWalk/Lookup/Upload/Xform/Cull) + IndoorAll master cascade, seeded from ACDREAM_PROBE_INDOOR_* env vars. Mirrors L.2a PhysicsDiagnostics pattern exactly. IsEnvCellId helper for call-site filtering (low-16 ≥ 0x0100 = EnvCell). Zero warnings. Spec: docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md Plan task 1: docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/RenderingDiagnostics.cs | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/AcDream.Core/Rendering/RenderingDiagnostics.cs 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; +} From b57cb42fd708f6ace6f03aa35a045ab1aa4023a3 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 11:27:32 +0200 Subject: [PATCH 07/22] 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) --- .../Rendering/RenderingDiagnosticsTests.cs | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs new file mode 100644 index 0000000..8d020e5 --- /dev/null +++ b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs @@ -0,0 +1,62 @@ +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)); + } +} From fda8d651588f229297cbe11affe43ee2e2c77e48 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 11:30:25 +0200 Subject: [PATCH 08/22] 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) --- .../Panels/Debug/DebugVM.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) 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 . From 51a76192865b82ddbcf2f7e4e7c802f6e7f114ae Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 11:34:30 +0200 Subject: [PATCH 09/22] 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) --- .../Panels/Debug/DebugPanel.cs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) 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 From 1dd20ddd40e2527595f7d5f877ff142030819620 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 11:37:58 +0200 Subject: [PATCH 10/22] 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) --- src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index b57e043..23b2d68 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using AcDream.Core.Meshing; +using AcDream.Core.Rendering; using Chorizite.OpenGLSDLBackend; using Chorizite.OpenGLSDLBackend.Lib; using DatReaderWriter; @@ -34,6 +35,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. @@ -134,6 +144,13 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // 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}"); + } } } @@ -172,7 +189,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}"); + } } } From 36a29ceff542e3d74776a855f737a3f5a0732687 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 11:43:56 +0200 Subject: [PATCH 11/22] 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 IndoorProbeState, a nested class wrapping _lastIndoorProbeFrame dictionary and _indoorProbeFrameCounter (bumped at top of Draw()). WalkEntitiesInto accepts a new optional IndoorProbeState? parameter (null = probes off, default) so the test-friendly WalkEntities overload is unaffected. The ShouldEmitIndoorProbe instance helper is also retained for Task 7 use. Disambiguates hypothesis H3 (cull bug -- cell entity dropped before draw). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Wb/WbDrawDispatcher.cs | 150 +++++++++++++++++- 1 file changed, 144 insertions(+), 6 deletions(-) diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 36ebdc9..5af05ed 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 @@ -1289,6 +1392,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; From 9b948b6ad56d20dcd36816b2aae9853114b733e3 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 11:50:50 +0200 Subject: [PATCH 12/22] feat(dispatcher): [indoor-lookup] + [indoor-xform] probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 the ShouldEmitIndoorProbe instance helper added in Task 6 (now consumed — no longer dead code). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/Wb/WbDrawDispatcher.cs | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs index 5af05ed..6dbf0c8 100644 --- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs +++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs @@ -685,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 @@ -717,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; From 25f009140ac82796b5c79a8cc3eb8c269a8ebee0 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 12:03:25 +0200 Subject: [PATCH 13/22] =?UTF-8?q?docs(research):=20Phase=201=20indoor=20pr?= =?UTF-8?q?obe=20capture=20=E2=80=94=20H1=20confirmed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captured at Holtburg landblock 0xA9B4 with ACDREAM_PROBE_INDOOR_ALL=1. Result: 123 EnvCells in Holtburg get [indoor-upload] requested but ONLY 97 get a matching [indoor-upload] completed. 26 cells silently fail in WB's PrepareEnvCellMeshData / PrepareMeshData. The first interior cell 0xA9B40100 — likely the inn entry or another major building anchor — is among the failures, exactly matching the user's "floor missing" symptom. Other hypotheses ruled out: - H2 (empty batches): completed cells have cellGeomVerts=14-86. - H3 (cull bug): walk probe confirms cells pass all visibility filters. - H4 (double-spawn): partCount values match expected SetupParts. - H5 (transform double-apply): xform probe shows composedT==meshRefT; no double-apply. - H6 (MeshRefs structure): lookup probe shows isSetup=True and partsHit≈partCount for uploaded cells. Phase 2 plan: wrap PrepareMeshDataAsync with our own catch-and-log in WbMeshAdapter so the swallowed exception (most likely cause of the 26 silent failures, per WB ObjectMeshManager.cs:589) becomes visible. Once we know the actual failure reason, target the fix. Also flags IsEnvCellId false-positives on GfxObj IDs whose lower 24 bits ≥ 0x0100 — tightening recommended in Phase 2. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-19-indoor-cell-rendering-probe-capture.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md 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. From 9f152d9754b30dccaf12925eebeb53f4b82dceaa Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 12:08:51 +0200 Subject: [PATCH 14/22] test(diagnostics): restore RenderingDiagnostics state in try/finally Final code review of Phase 1 flagged that the three flag-mutating tests leaked static state across test boundaries. Wrap each in try/finally that snapshots IndoorAll on entry and restores it on exit, matching the PhysicsDiagnosticsTests pattern at line 30-49. Tests now safe under parallel test runs + future additions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Rendering/RenderingDiagnosticsTests.cs | 80 +++++++++++++------ 1 file changed, 55 insertions(+), 25 deletions(-) diff --git a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs index 8d020e5..f490b36 100644 --- a/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs +++ b/tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs @@ -5,47 +5,77 @@ 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() { - // 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; + 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; + 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); + 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() { - RenderingDiagnostics.IndoorAll = true; // start from all-on - RenderingDiagnostics.IndoorAll = false; + 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); + 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() { - RenderingDiagnostics.IndoorAll = true; - RenderingDiagnostics.ProbeIndoorCullEnabled = false; // flip one off - Assert.False(RenderingDiagnostics.IndoorAll); + bool initial = RenderingDiagnostics.IndoorAll; + try + { + RenderingDiagnostics.IndoorAll = true; + RenderingDiagnostics.ProbeIndoorCullEnabled = false; // flip one off + Assert.False(RenderingDiagnostics.IndoorAll); + } + finally + { + RenderingDiagnostics.IndoorAll = initial; + } } [Theory] From 251763b2c487128a7d205aaa1d9a57a673436622 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 12:18:04 +0200 Subject: [PATCH 15/22] docs(spec): Phase 2 indoor cell rendering fix design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three components: 1. WbMeshAdapter wraps the PrepareMeshDataAsync task with a continuation that surfaces faulted-task exceptions + null-result cases for EnvCell IDs only (gated by ProbeIndoorUploadEnabled). Two new log shapes: [indoor-upload] FAILED cellId=0x... exception=: stack=[] [indoor-upload] NULL_RESULT cellId=0x... 2. Capture procedure: re-launch at Holtburg with the probe on, grep for FAILED/NULL_RESULT lines, get definitive per-cell cause for the 26 missing-completion cells from Phase 1's capture. 3. Targeted fix: code change matching whichever exception type / null pattern dominates. Fix shape is data-driven — see the contingency table in the spec. WB's catch at ObjectMeshManager.cs:589 already calls _logger.LogError, but WbMeshAdapter constructs the manager with NullLogger.Instance, so the log is dropped. Our continuation surfaces the same data scoped to EnvCells only (avoids the thousands of GfxObj/Setup log lines a real logger would emit during landblock streaming). Co-Authored-By: Claude Opus 4.7 (1M context) --- ...phase2-indoor-cell-rendering-fix-design.md | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md 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. From e9cc9cb2285549bccb0ae3ab7674cae85651d717 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 12:20:57 +0200 Subject: [PATCH 16/22] plan: Phase 2 indoor cell rendering fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six tasks: 1. Add exception-surfacing ContinueWith in WbMeshAdapter.IncrementRefCount for EnvCell ids when ProbeIndoorUploadEnabled is on. Logs [indoor-upload] FAILED + [indoor-upload] NULL_RESULT. 2. Capture procedure: user walks Holtburg with the probe on; analyze log. 3. Write cause report documenting the captured exception type(s). 4. Apply targeted fix (4a/4b/4c/4d sub-shapes for the 4 most-likely causes — choice driven by Task 2's data). Or 4d: re-design if cause is none of the above. 5. Verification: re-capture confirms completed lines, user visually confirms floor in Holtburg Inn. 6. Roadmap update. Tasks 2 and 5 are user-driven (must walk the client). Tasks 1, 3, 4, 6 can be subagent-dispatched. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...-05-19-phase2-indoor-cell-rendering-fix.md | 550 ++++++++++++++++++ 1 file changed, 550 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md 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. From 011a5e43f4b3a4442491bb80a140b1d05296b0ba Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 12:25:31 +0200 Subject: [PATCH 17/22] feat(wb): surface WB-swallowed exceptions for EnvCell upload failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 30 ++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 23b2d68..0d8dee8 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -1,5 +1,7 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using AcDream.Core.Meshing; using AcDream.Core.Rendering; using Chorizite.OpenGLSDLBackend; @@ -143,13 +145,39 @@ 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) { _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 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) + { + Console.WriteLine($"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8}"); + } + }, TaskScheduler.Default); } } } From 914638819d419307167ab19683ce59bb6785eaa5 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 12:36:37 +0200 Subject: [PATCH 18/22] feat(wb): extend NULL_RESULT probe with reader-divergence diagnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 Task 1's continuation logged [indoor-upload] NULL_RESULT when WB's PrepareMeshDataAsync returned null. Extend the line to include two cross-checks: ourCellDb.TryGet= — acdream's DatCollection.Cell.TryGet wbResolveId.Count= — WB's DefaultDatReaderWriter.ResolveId().Count This narrows the cause among WB's null-return paths (ResolveId empty vs TryGet failure vs wrong type). Best-effort: both calls wrapped in try/catch so diagnostic failures don't propagate. Capture: 55 NULL_RESULTs across multiple landblocks ALL show ourCellDb.TryGet=True + wbResolveId.Count=1. Both readers find the cells in their indices, but WB's downstream PrepareMeshData logic still returns null. Divergence is downstream of ResolveId. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 0d8dee8..9893e31 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -175,7 +175,30 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter } else if (t.IsCompletedSuccessfully && t.Result is null) { - Console.WriteLine($"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8}"); + // 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; + try + { + wbResolveCount = _wbDats?.ResolveId((uint)cellId).Count() ?? -1; + } + catch { /* swallow — best-effort */ } + + Console.WriteLine( + $"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8} " + + $"ourCellDb.TryGet={ourCellFound} " + + $"wbResolveId.Count={wbResolveCount}"); } }, TaskScheduler.Default); } From b838eccb38e9a76a57b70173036887849ad5f30b Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 13:00:18 +0200 Subject: [PATCH 19/22] =?UTF-8?q?feat(wb):=20ConsoleErrorLogger=20+=20caus?= =?UTF-8?q?e=20report=20=E2=80=94=20H1=20swallowed-exception=20confirmed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 diagnostic chain identified the EXACT cause of 26/123 Holtburg cells silently failing in WB's PrepareEnvCellMeshData: ArgumentOutOfRangeException thrown from Setup.Unpack inside DatReaderWriter when WB calls TryGet(stab.Id, ...) on a stab id whose prefix is GfxObj (0x01xxxxxx), not Setup (0x02xxxxxx). DatReaderWriter finds the file in Portal's tree (GfxObjs and Setups share tree-lookups), attempts to parse GfxObj bytes as Setup format, throws OOR. Exception bubbles to PrepareMeshData's outer try/catch which silently swallows + returns null. Entire cell fails to upload. This commit lands the diagnostic infrastructure that surfaced the bug: - WbMeshAdapter: replaced NullLogger with a small Console-backed ConsoleErrorLogger private class. Filters to LogLevel.Error+. WB's existing _logger.LogError(ex, ...) at the swallow site now writes [wb-error] lines with type + message + top 5 stack frames. Bridges WB's intentional log point to acdream's console. - WbMeshAdapter: extended [indoor-upload] NULL_RESULT probe with reader-divergence diagnostic (ourCellDb.TryGet, wbResolveId.Count, wbSelectedType, wbDbIsPortal, wbDbTryGet, hadRenderData). Made it possible to rule out cache-hits and reader-divergence as causes before identifying the real one. - Cause report at docs/research/2026-05-19-indoor-cell-rendering-cause.md documents the full chain: 55 ArgumentOutOfRangeException stack traces captured in one launch, all from PrepareEnvCellMeshData line 1223. The fix itself (1-line guard at WB's TryGet call site) is applied to references/WorldBuilder/.../ObjectMeshManager.cs — which is a git submodule. Will be committed separately to the WB submodule after visual verification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-19-indoor-cell-rendering-cause.md | 94 +++++++++++++++++++ src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs | 67 ++++++++++++- 2 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 docs/research/2026-05-19-indoor-cell-rendering-cause.md 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/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs index 9893e31..2729747 100644 --- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs +++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs @@ -77,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() @@ -150,8 +192,9 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter // [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}"); + 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 @@ -189,16 +232,32 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter catch { /* swallow — this is best-effort diagnostic */ } int wbResolveCount = -1; + string wbSelectedType = "none"; + bool wbDbTryGetEnvCell = false; + bool wbDbIsPortal = false; try { - wbResolveCount = _wbDats?.ResolveId((uint)cellId).Count() ?? -1; + 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}"); + $"wbResolveId.Count={wbResolveCount} " + + $"wbSelectedType={wbSelectedType} " + + $"wbDbIsPortal={wbDbIsPortal} " + + $"wbDbTryGet={wbDbTryGetEnvCell}"); } }, TaskScheduler.Default); } From 73288657fd7c8bd6bda34fff04607d0ab12a4c7e Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 13:12:53 +0200 Subject: [PATCH 20/22] =?UTF-8?q?docs(research):=20Phase=202=20verificatio?= =?UTF-8?q?n=20=E2=80=94=20floor=20renders,=20fix=20landed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User visually confirmed floors render in Holtburg Inn after the WB TryGet guard. Probe re-capture: 0 [wb-error] lines (was 385), 0 NULL_RESULT (was 55), Holtburg 123/123 cells complete (was 97/123). Documents the nine pre-existing indoor bugs the user observed during verification (see-through floor, indoor collision, stairs, walls, clicking, indoor lighting artifacts, stabs-don't-react-to-atmospheric- lighting, slope terrain lighting). All pre-existing; filed for follow-up phases via docs/ISSUES.md. Phase 2 complete. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...5-19-indoor-cell-rendering-verification.md | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 docs/research/2026-05-19-indoor-cell-rendering-verification.md 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. From 98977b8f667f4aa6d40af7280a3d69da95321e53 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 13:17:08 +0200 Subject: [PATCH 21/22] =?UTF-8?q?docs:=20roadmap=20+=20ISSUES.md=20?= =?UTF-8?q?=E2=80=94=20Phase=202=20indoor=20cell=20rendering=20closure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Roadmap shipped-table: two new rows for Phase 1 (diagnostics) + Phase 2 (fix). Header status block updated to 2026-05-19 with the Phase 2 cause + fix one-liner and pointer to the 9 surfaced issues. - ISSUES.md: filed nine new issues (#78-#86) covering the indoor bugs the user observed once the floor rendered. Grouped under an "Indoor walking issue cluster" header. Cross-references the Phase 1 + Phase 2 work that surfaced them. Hypotheses + suspected root causes documented for each. The 9 issues split into two probable shared-cause groups: - Cell BSP / portal cull (#78, #84, #85, #86) — likely fixable in one phase. - Indoor lighting plumbing (#79, #80, #81, #82) — needs separate investigation per-symptom. Plus #83 (stairs) which probably needs its own physics phase work. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 276 ++++++++++++++++++++++++++++++- docs/plans/2026-04-11-roadmap.md | 4 +- 2 files changed, 278 insertions(+), 2 deletions(-) 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: From 2e422418ec66e3d59485d795f75e21ca0c322926 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 19 May 2026 13:23:19 +0200 Subject: [PATCH 22/22] chore(submodule): advance WB to acdream-fix-floor-rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps the references/WorldBuilder submodule pointer from 167788b to 34460c4 — picks up the Setup-prefix guard at PrepareEnvCellMeshData line 1223 that lands Phase 2 indoor cell rendering fix. WB branch acdream-fix-floor-rendering pushed to git@github.com:eriknihlen/WorldBuilder.git earlier in this session. Co-Authored-By: Claude Opus 4.7 (1M context) --- references/WorldBuilder | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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