Merge branch 'claude/competent-robinson-dec1f4' — Indoor walking Phase 1 + Phase 2

Cluster A (Indoor walking Phase 1 — BSP cluster):
- WorldPicker cell-BSP occlusion → #86 closed
- CellId promotion via AABB containment (partial Phase D fix)
- Diagnostic infrastructure: [indoor-bsp], [cell-cache] probes

Indoor walking Phase 2 (Portal-based cell tracking):
- CellBSP + Portals wired into CellPhysics
- CellTransit static class: FindTransitCellsSphere + AddAllOutsideCells + FindCellList
- ResolveCellId rename + sphereRadius plumbing
- BuildingPhysics + CheckBuildingTransit (outdoor→indoor entry)
- Foot-sphere center fix (made portal tracking actually work in production)
- Indoor walkable-plane synthesis (closes the falling-stuck bug)

Closes ISSUES.md #84, #85, #86, #87.
Files new issues #88 (indoor object vibration) + #89 (port SphereIntersectsCellBsp).

Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
Handoff: docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md
This commit is contained in:
Erik 2026-05-19 19:34:13 +02:00
commit 1af49b710e
34 changed files with 6618 additions and 79 deletions

View file

@ -308,9 +308,10 @@ to the second floor without getting stuck.
---
## #84 — Blocked by air indoors
## #84 [DONE 2026-05-19] Blocked by air indoors
**Status:** OPEN
**Status:** DONE
**Closed:** 2026-05-19
**Severity:** HIGH (blocks indoor navigation)
**Filed:** 2026-05-19
**Component:** physics, collision
@ -337,57 +338,126 @@ visible cell mesh. Possibilities:
**Acceptance:** Walking through interior cell space hits collisions
only where visible walls/furniture exist.
**Resolution (2026-05-19 partial · `c19d6fb`):** Phase D of Cluster A
extended `ResolveOutdoorCellId` in `PhysicsEngine.cs` with an indoor
cell-containment scan: when the player's world position falls inside any
cached EnvCell's AABB, `CellId` is promoted to that indoor cell, which
enables the `FindEnvCollisions` indoor-BSP branch. This resolved the
"spawn in building and be stuck above the floor" variant of #84
player's CellId now promotes to the interior cell on spawn-in, the floor
is walkable, and the player can move freely. The "invisible air obstacle"
symptom for rooms the player walks INTO from outside was tracked under #87
and required portal-based cell tracking.
**Resolution (2026-05-19 full · `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772`):**
Indoor walking Phase 2 replaced AABB containment with portal-graph cell traversal
(`CellTransit.FindCellList` + `CheckBuildingTransit`). CellId now promotes to indoor
cells via portals and remains promoted during normal walking through doorways. Indoor
cell-BSP collision fires consistently. Indoor walkable plane synthesized from floor
poly (`TryFindIndoorWalkablePlane`) so the resolver tracks walkability correctly when
the player is standing on an indoor floor. User visually verified at Holtburg cottage:
walls block from inside, multi-room navigation works, walking outdoors through a door
works. Issue fully closed.
---
## #85 — Pass through walls from outside→in
## #85 [DONE 2026-05-19 · 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772] Pass through walls from outside→in
**Status:** OPEN
**Severity:** HIGH (gameplay-breaking)
**Status:** DONE
**Closed:** 2026-05-19
**Commits:** `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772`
**Filed:** 2026-05-19
**Component:** physics, collision
**Description:** Approaching a building from the outside, the player
**Resolution (2026-05-19 · Indoor walking Phase 2):** The root cause (CellId never promoted
to the indoor cell during outdoor→indoor walking) was resolved by portal-graph cell
traversal in `CellTransit.CheckBuildingTransit`. Once `CellId` promotes to the indoor
cell, the indoor-BSP collision branch in `FindEnvCollisions` fires for approaches from
both inside and outside. User visually verified walls block from outside (player must
use the door portal to enter). See #87 and handoff:
[`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](2026-05-19-indoor-walking-phase2-shipped-handoff.md).
**Original 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.
The root cause was pinned (Cluster A 2026-05-19) as the same failure as
#84's remaining symptom — `CellId` wasn't promoted to the indoor cell
during normal outdoor→indoor walking because AABB containment was too
tight for threshold/doorway cells. Without CellId in the indoor cell,
the indoor-BSP collision branch in `FindEnvCollisions` never fired
regardless of approach direction.
---
## #86 — Click selection penetrates walls
## #87 — [DONE 2026-05-19 · 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772] Indoor cell tracking uses AABB containment instead of portal traversal
**Status:** DONE
**Closed:** 2026-05-19
**Commits:** `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772`
**Filed:** 2026-05-19
**Component:** physics
**Resolution (2026-05-19 · Indoor walking Phase 2):** Portal-graph cell traversal
(`CellTransit.FindCellList` + `CheckBuildingTransit`) replaced the AABB containment
shortcut. Player CellId now correctly promotes to indoor cells via portals;
indoor cell-BSP collision branch fires consistently; walls block from inside.
Outdoor→indoor entry via `BuildingPhysics` + `BldPortalInfo` (`CheckBuildingTransit`)
wires the building-shell portal graph. Indoor walkable plane synthesized from the
cell's floor poly so the resolver tracks walkability during indoor movement (`TryFindIndoorWalkablePlane`).
See handoff: [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](2026-05-19-indoor-walking-phase2-shipped-handoff.md).
**Original description:** `PhysicsDataCache.TryFindContainingCell` promotes the
player's `CellId` to an indoor EnvCell when their world position falls
inside any cached cell's local AABB. This is too tight to keep `CellId`
promoted to an indoor cell during normal walking. Threshold/doorway cells
(the polys that sit at a room boundary) have AABB Z ranges of only ~0.2 m;
a standing player at local Z=0.46 m is OUTSIDE the AABB and containment
fails. Because `CellId` drifts back to the outdoor cell, the indoor-BSP
collision branch in `TransitionTypes.FindEnvCollisions` is gated out for
most movement, so walls don't block from inside the house and the floor
physics is unreliable. The retail fix is portal-based cell traversal —
when the player crosses a cell portal boundary, the cell ownership
propagates through portal connectivity data in `CEnvCell`.
---
## #88 — Indoor static objects vibrate (bookshelves, open furnaces)
**Status:** OPEN
**Severity:** MEDIUM
**Severity:** MEDIUM (visual jitter; doesn't block gameplay)
**Filed:** 2026-05-19
**Component:** input, interaction
**Component:** rendering, animation
**Description:** Clicking through a wall from the outside selects NPCs
and objects inside the building. The `WorldPicker` raycast doesn't
intersect cell BSP geometry.
**Description:** Static objects inside cells (bookshelves, open furnaces, possibly other interior props) show per-frame transform jitter / vibration. Pre-existing (user noticed before Phase 2 shipped). Likely candidates:
**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.
1. `EntityScriptActivator.OnCreate/OnRemove` firing repeatedly as the player's CellId promotes/demotes near cell boundaries (less likely after Phase 2's portal-based tracking — but worth investigating).
2. Per-part transforms for cell-static `WorldEntity` instances getting recomputed each frame with floating-point drift.
3. Particle-emitter offsets accumulating instead of resetting.
**Files to investigate:**
- `src/AcDream.App/Rendering/Vfx/EntityScriptActivator.cs` — OnCreate/OnRemove call patterns
- `src/AcDream.App/Rendering/GpuWorldState.cs` — entity transform updates per frame
- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` — per-batch transform composition
**Acceptance:** Indoor static objects render stable (no per-frame jitter).
---
## #89 — Port BSPQuery.SphereIntersectsCellBsp for retail-faithful CheckBuildingTransit
**Status:** OPEN
**Severity:** LOW (Phase 2 ships with a documented approximation)
**Filed:** 2026-05-19
**Component:** physics
**Description:** Retail's `CEnvCell::check_building_transit` uses `CCellStruct::sphere_intersects_cell` — a radius-aware sphere-vs-BSP test that returns Inside/Crossing/Outside. Phase 2's `CellTransit.CheckBuildingTransit` uses `BSPQuery.PointInsideCellBsp` (radius-less, tests only the sphere CENTER). Practical effect: outdoor→indoor entry fires ~sphereRadius (~0.48m) deeper into the doorway than retail. The sphereRadius parameter is plumbed through but currently unused.
**Files:**
- `src/AcDream.App/Rendering/WorldPicker.cs` (or equivalent — check
Phase B.4b reference).
- `src/AcDream.Core/Physics/CellTransit.cs::CheckBuildingTransit` (line ~162)
- `src/AcDream.Core/Physics/BSPQuery.cs::PointInsideCellBsp` (line ~940) — existing point test to model the new sphere variant after
**Acceptance:** Clicking on a wall doesn't select NPCs behind it.
**Acceptance:** `CellTransit.CheckBuildingTransit` calls a new `BSPQuery.SphereIntersectsCellBsp(node, sphereCenter, sphereRadius)` that returns `Inside`/`Crossing`/`Outside`. Entry timing matches retail visually at the Holtburg cottage door.
---
@ -2918,6 +2988,23 @@ Unverified. The likely culprits, ranked by suspected probability:
# Recently closed
## #86 — [DONE 2026-05-19 · 3764867 + 4e308d5] Click selection penetrates walls
**Closed:** 2026-05-19
**Commits:** `3764867` — fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker; `4e308d5` — test(picker): Cluster A #86 — screen-rect cell-occlusion tests
**Component:** input, interaction
**Resolution:** `WorldPicker.Pick` now accepts a `cellOccluder` callback
(`CellBspRayOccluder`). Before returning a hit, both `Pick` overloads
consult the occluder's `NearestWallT` value; any candidate entity whose
ray parameter exceeds the nearest-wall intersection is filtered out.
The occluder is wired from `GameWindow` using the loaded `PhysicsDataCache`
cell structs. Entities behind walls from the camera's perspective are no
longer selectable. Screen-rect occlusion tests verify the filter across
several hit/miss scenarios.
---
## #77 — [DONE 2026-05-18 · 3be7000] Auto-walk doesn't engage at walking range; pickup at walking range overshoots and snaps back
**Closed:** 2026-05-18

View file

@ -71,6 +71,8 @@
| Indoor lighting + rendering — Phase 1 (diagnostics) | Five `[indoor-*]` probes wired through new `AcDream.Core.Rendering.RenderingDiagnostics` static class + DebugVM mirrors + DebugPanel checkboxes. `WbMeshAdapter` emits `[indoor-upload] requested/completed`; `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` per cell entity. All rate-limited via per-cellId frame counter; lookup probe uses high-bit-tagged key namespace to avoid cross-probe suppression. Holtburg `ACDREAM_PROBE_INDOOR_ALL=1` capture identified 26/123 cells silently failing — confirmed H1 (WB swallowed exception). Spec: [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md`](../superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md). Capture: [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../research/2026-05-19-indoor-cell-rendering-probe-capture.md). | Tests ✓ |
| Indoor lighting + rendering — Phase 2 (fix) | Three-component diagnostic-driven fix for missing-floor bug. Component 1: `WbMeshAdapter` captures the `Task<ObjectMeshData?>` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids — surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger<ObjectMeshManager>` with a Console-backed `ConsoleErrorLogger<T>` so WB's intentional `_logger.LogError(ex, ...)` at the swallow site at `ObjectMeshManager.cs:589` writes `[wb-error]` lines. **Root cause definitively identified in one capture: `ArgumentOutOfRangeException` from `DatReaderWriter.Setup.Unpack` at WB's `PrepareEnvCellMeshData` line 1223 — `TryGet<Setup>(stab.Id, ...)` was called blindly on every `envCell.StaticObjects` id without checking the Setup-prefix bit. GfxObj-typed stabs (0x01xxxxxx) caused mid-deserialization throws, bubbling up to PrepareMeshData's outer catch which silently returned null. Entire cell upload failed, room mesh never reached `_renderData`.** Component 3 fix: one-line type-check guard `(stab.Id & 0xFF000000u) == 0x02000000u && _dats.Portal.TryGet<Setup>(stab.Id, out var stabSetup)`. Committed to WB submodule on branch `acdream-fix-floor-rendering` at SHA `34460c4` — needs submodule pointer advance at merge time. **Verification: 0 [wb-error] (was 385), 0 NULL_RESULT (was 55), Holtburg 123/123 cells complete (was 97/123). User visually confirmed floors render in Holtburg Inn.** Surfaced 9 pre-existing indoor bugs (see-through floor, indoor collision, stairs, walls, click-thru, indoor lighting artifacts, atmospheric-lighting-on-stabs, slope terrain lighting) — all filed in `docs/ISSUES.md` for follow-up phases. Cause report: [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](../research/2026-05-19-indoor-cell-rendering-cause.md). Verification: [`docs/research/2026-05-19-indoor-cell-rendering-verification.md`](../research/2026-05-19-indoor-cell-rendering-verification.md). Plan: [`docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md`](../superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md). | Live ✓ |
| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]``[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ |
| Indoor walking Phase 1 — BSP cluster (partial) | 2026-05-19. Probe + WorldPicker cell-BSP occlusion (#86 closed) + CellId promotion via AABB containment (partial #84 fix). Seven commits across 5 phases: `18a2e28` plan, `27d7de1` Phase A `[indoor-bsp]` probe + toggle, `3764867` Phase B CellBspRayOccluder in WorldPicker, `4e308d5` Phase B screen-rect tests, `c19d6fb` Phase D AABB containment + L.2e bare-low-byte fix, `fda6af7` Phase E `[cell-cache]` diagnostic, `1f11ba9` Phase E extended AABB/bsphere/poly-count fields. **#86 closed** (picker occlusion). **#84 partially closed** (spawn-in-building stuck-above-floor resolved; threshold/doorway walls remain open under #87). **#85 open** (wall pass-through root cause confirmed as same as #84 remaining symptom — CellId doesn't stay promoted during outdoor→indoor walking). **#87 filed** (portal-based indoor cell tracking — retail-faithful follow-up). `[indoor-bsp]` + `[cell-cache]` probes stay in place as scaffolding for the follow-up phase. Handoff: [`docs/research/2026-05-19-cluster-a-shipped-handoff.md`](../research/2026-05-19-cluster-a-shipped-handoff.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md`](../superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md). | Tests ✓ |
| Indoor walking Phase 2 — Portal-based cell tracking | 2026-05-19. Portal-graph traversal replaces Phase D's AABB containment. Six commits: `1969c55` CellBSP+Portals wired into CellPhysics; `aad6976` CellTransit.FindCellList + FindTransitCellsSphere + AddAllOutsideCells + ResolveCellId rename; `069534a` BuildingPhysics + CheckBuildingTransit for outdoor→indoor entry; `702b30a` code-review polish; `3ffe1e4` pass foot-sphere center to ResolveCellId (critical fix — was passing CheckPos instead of GlobalSphere[0].Origin, causing PointInsideCellBsp to return false at floor level); `eb0f772` TryFindIndoorWalkablePlane synthesizes walkable plane from cell floor poly so the resolver doesn't fall through to outdoor SampleTerrainWalkable. **Closes #87, #85, and the wall-pass-through portion of #84 (fully closes #84).** Files #88 (indoor static object vibration — pre-existing) and #89 (BSPQuery.SphereIntersectsCellBsp — approximation in CheckBuildingTransit). `[cell-transit]`, `[indoor-bsp]`, `[check-bldg]`, `[cell-cache]` probes stay in place. Handoff: [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](../research/2026-05-19-indoor-walking-phase2-shipped-handoff.md). | Live ✓ |
Plus polish that doesn't get its own phase number:
- FlyCamera default speed lowered + Shift-to-boost
@ -224,7 +226,8 @@ Research: R9 + R12 + R13.
- **✓ SHIPPED — G.1 — Sky + weather + day-night.** Deterministic client-side from Portal Year time. Sky dome geometry + keyframe gradients + rain/snow particles. See `r12-weather-daynight.md`. Full data + visual stack shipped: Region dat loader, keyframe interp, WeatherSystem with 5-kind PDF + transitions + storm flashes, WorldSession→WorldTimeService sync via ConnectRequest+TimeSync, SkyRenderer with sky-object arcs + UV scroll, rain/snow billboard renderer, F7/F10 debug cycle keys.
- **✓ SHIPPED — G.2 — Dynamic lighting.** 8-light D3D-style fixed pipeline. Hard-cutoff at Range, no attenuation inside. Cell ambient. Shader UBO per frame. See `r13-dynamic-lighting.md`. SceneLightingUbo std140 at binding=1 feeds terrain + mesh + mesh_instanced + sky shaders. LightingHookSink auto-registers Setup.Lights at entity stream-in, flips IsLit on SetLightHook, unregisters on landblock unload.
- **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. **Blocked on L.2e** for trustworthy `cell_bsp`, indoor/outdoor portal transit, adjacent-cell ownership, and building entry/exit collision boundaries. See `r09-dungeon-portal-space.md`.
- **Indoor portal-based cell tracking (follow-up to Indoor walking Phase 1 / issue #87).** Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's `CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses a cell portal boundary, `CellId` propagates through the `CEnvCell` portal connectivity graph. Prerequisite for wall collision from outside (#85) and the remaining #84 threshold symptom. PDB symbols and `acclient.h` `CCellStructure` refs are in place (see #87). **Unblocks G.3.**
- **G.3 — Dungeon streaming + portal space.** `EnvCellStreamer`, portal-visibility BFS, `PlayerTeleport (0xF751)` handling with `LoginComplete` re-send, "pink bubble" loading state. **Blocked on indoor portal-based cell tracking above** (and previously on L.2e) for trustworthy indoor/outdoor portal transit, adjacent-cell ownership, and building entry/exit collision boundaries. See `r09-dungeon-portal-space.md`.
**Acceptance:** walk outside at dusk, see the sky gradient + sun moving; enter a torch-lit dungeon via portal; leave back to daylight.

View file

@ -0,0 +1,256 @@
# Indoor walking Phase 1 — BSP cluster (Cluster A) — handoff (2026-05-19)
**Date:** 2026-05-19.
**Branch:** `claude/competent-robinson-dec1f4` (commits land here; merge to main handled by controller).
**Predecessor:** Indoor lighting + rendering Phase 2 (fix) — floors now render in Holtburg Inn. Nine pre-existing indoor bugs surfaced the moment floors were visible; this cluster addresses the collision/interaction subset (#84, #85, #86) and adds diagnostic infrastructure for the follow-up portal-traversal phase.
**Plan:** [`docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md`](../superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md).
---
## TL;DR
Cluster A shipped **partially**. Three of the five planned phases (A, B, D)
produced real behavior changes; two (C — obstacle audit — and E — cell-cache
diagnostics) are diagnostic/research phases. The cluster's investigation
confirmed that the wall-collision failures (#84, #85) all root in one cause:
the player's `CellId` is never promoted to an indoor cell during normal
walking, so the indoor-BSP collision branch in `TransitionTypes.FindEnvCollisions`
never fires. Phase D implemented an AABB-containment shortcut that resolves
the specific "spawn inside a building and be stuck above the floor" case but
proved too tight to keep `CellId` promoted through threshold/doorway cells
during normal outdoor→indoor entry.
**#86** (click selection penetrates walls) is **fully closed** — a clean,
self-contained fix in `WorldPicker`.
**#84** is **partially closed** — the spawn-in-building symptom is gone; the
remaining wall-collision symptom during normal walking is tracked under the
new **#87**.
**#85** remains **open**; its root cause is confirmed identical to #84's
remaining symptom and is also tracked under #87.
**#87** (indoor portal-based cell tracking) is **filed** and ready for the
follow-up phase.
---
## Commits
| # | SHA | Subject | Phase |
|---|---|---|---|
| 1 | `18a2e28` | `docs(plan): implementation plan written` | Plan doc |
| 2 | `27d7de1` | `feat(physics): Cluster A — indoor BSP collision probe` | Phase A |
| 3 | `3764867` | `fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker` | Phase B |
| 4 | `4e308d5` | `test(picker): Cluster A #86 — screen-rect cell-occlusion tests` | Phase B follow-up |
| 5 | `c19d6fb` | `fix(physics): Cluster A #84 + #85 — indoor cell tracking` | Phase D |
| 6 | `fda6af7` | `feat(physics): Cluster A — cell-cache diagnostic` | Phase E (1st) |
| 7 | `1f11ba9` | `feat(diag): Cluster A — extend [cell-cache] with AABB + bsphere + recursive poly count` | Phase E (2nd) |
**Build:** clean on all commits.
**Tests:** `dotnet test` shows the same 8 pre-existing failures in
`AcDream.Core.Tests` (MotionInterpreter / BSPStepUp / etc., unchanged across
the entire cluster). All targeted test projects green. Phase B follow-up
adds screen-rect occlusion tests; Phase D adds `RegisterCellStructForTest`
helper used by caller-side tests.
---
## What shipped
### Phase A — `[indoor-bsp]` probe
New `PhysicsDiagnostics.ProbeIndoorBspEnabled` toggle (env var
`ACDREAM_PROBE_INDOOR_BSP` + DebugPanel checkbox under
`ACDREAM_DEVTOOLS=1`). When enabled, logs one `[indoor-bsp]` line each time
`TransitionTypes.FindEnvCollisions` takes the indoor-cell branch —
i.e., when `CellId` is an EnvCell id and the BSP contains physics polys. The
probe serves as a presence detector: if `[indoor-bsp]` never fires during
indoor walking, the BSP is not being consulted at all.
### Phase B — WorldPicker cell-BSP ray occlusion (closes #86)
New `CellBspRayOccluder` class (in `src/AcDream.App/Rendering/`) computes
`NearestWallT`: the smallest ray parameter at which the pick ray intersects
any cached EnvCell BSP polygon. Both `WorldPicker.Pick` overloads now accept
an optional `cellOccluder` callback and filter out any hit candidate whose
ray T exceeds `NearestWallT`. The occluder is wired from `GameWindow` using
the `PhysicsDataCache` cell structs that Phase D also extends.
Before Phase B: clicking through a wall from the outside selected NPCs/items
inside the building — `WorldPicker.BuildRay + Pick` (Phase B.4b) tested only
entity AABBs and scenery BSPs, not EnvCell BSP geometry.
After Phase B: entities behind the nearest wall from the camera's perspective
are filtered out of the candidate set. Screen-rect unit tests verify the
filter across hit/miss/occlusion scenarios.
### Phase D — AABB containment for indoor CellId (partial #84 fix)
`PhysicsEngine.ResolveOutdoorCellId` is extended with an indoor
cell-containment scan. After resolving the outdoor cell, the method checks
whether the player's world position falls inside any cached `CellPhysics`
AABB; if so, `CellId` is promoted to that EnvCell. This enables the
`FindEnvCollisions` indoor-BSP branch.
New `PhysicsDataCache.TryFindContainingCell(worldPos)` does the AABB scan.
New `CellPhysics.WorldAabb` caches the cell-local AABB in world space on
first call (transforms the BSP bounding sphere's local AABB by the cell
origin). New `RegisterCellStructForTest` helper allows unit test callers to
populate the cache directly.
Also fixes the L.2e bare-low-byte preservation bug: `ResolveOutdoorCellId`
was silently truncating the player CellId to the low 16 bits; the fix
preserves the full 32-bit value.
**What this solved:** player spawning inside a building (e.g., logging in
from a position inside Holtburg cottage) no longer sees `walkable=False` for
hundreds of resolves with world Z=94.000. Phase D promotes CellId to the
indoor cell, the floor's BSP polys are found, the player can move.
**What this did NOT solve:** the `[indoor-bsp]` probe fires only 6 times
during an entire indoor walking session (all mid-jump, when the body happens
to be at a height that falls inside a room AABB). During normal walking on
the floor, the player's world Z is at the AABB floor level or lower —
outside the AABB for threshold/doorway cells that have only a 0.2 m Z range.
See Phase E evidence below.
### Phase E — Cell-cache diagnostic infrastructure
Two commits add `[cell-cache]` log output (env var
`ACDREAM_PROBE_CELL_CACHE`, also DebugPanel). For each EnvCell in the
physics cache, the probe logs:
```
[cell-cache] id=0xA9B40143 physicsPolyCount=14 bspTotalLeafPolys=14
bspUnmatchedIds=0 aabbMin=(-11.60,-1.60,0.00) aabbMax=(-6.20,7.60,2.80)
bspOrigin=(0.00,0.00,0.00) bspRadius=9.97
```
The extended second commit adds `bspTotalLeafPolys`, `bspUnmatchedIds`,
`bspOrigin`, and `bspRadius` fields to give a complete picture of cell
geometry from the physics cache perspective. This infrastructure stays in
place as scaffolding for the portal-traversal phase.
---
## Issue status after Cluster A
| Issue | Status | Notes |
|---|---|---|
| #84 Blocked by air indoors | OPEN (partial) | Spawn-in-building variant resolved by Phase D. Threshold/doorway wall-blocking remains open under #87. |
| #85 Pass through walls outside→in | OPEN | Root cause confirmed as same as #84 remaining symptom. See #87. |
| #86 Click selection penetrates walls | **CLOSED** | Phase B. `WorldPicker.Pick` + `CellBspRayOccluder`. |
| #87 Indoor portal-based cell tracking | OPEN (new) | Filed 2026-05-19. Retail-faithful fix via `CObjMaint::HandleObjectEnterCell`. |
---
## Probe evidence — log file findings
### `launch-cluster-a-capture.log`
Initial probe run with `ACDREAM_PROBE_INDOOR_BSP=1`. Result: **zero
`[indoor-bsp]` lines** during outdoor walking and during approach to the
Holtburg cottage doorway. This was the first confirmation that the indoor-BSP
branch was entirely gated out. The player's CellId remained an outdoor cell
for all movement.
### `launch-cluster-a-verify.log`
Post-Phase-D run. Observed `[indoor-bsp]` lines **only during jump frames**
(6 total). When the player jumped inside the cottage, the body briefly rose
to a height inside the room AABB, CellId promoted to `0xA9B40143`, and the
indoor-BSP branch fired. On landing, the body returned to floor level, fell
outside the AABB, and CellId reverted to the outdoor cell. Confirmed that
AABB containment works for the room cell when the player is mid-air, but
fails at floor level.
### `launch-cluster-a-cache-diag2.log`
First `[cell-cache]` probe run (Phase E first commit). Showed all cached
cells with their physics poly counts and local AABBs. Confirmed 14 physics
polys in cell `0xA9B40143` (the room), indicating BSP geometry is present
and complete. Identified cell `0xA9B40146` as a 4-poly threshold cell.
### `launch-cluster-a-cache-diag3.log`
Extended `[cell-cache]` probe run (Phase E second commit). Full data:
```
[cell-cache] id=0xA9B40143 physicsPolyCount=14 bspTotalLeafPolys=14
bspUnmatchedIds=0 aabbMin=(-11.60,-1.60,0.00) aabbMax=(-6.20,7.60,2.80)
bspOrigin=(0.00,0.00,0.00) bspRadius=9.97
```
Room cell: 2.80 m AABB height — works for mid-air player.
```
[cell-cache] id=0xA9B40146 physicsPolyCount=4
aabbMin=(-11.60,2.80,-0.20) aabbMax=(-10.00,7.60,0.00)
bspRadius=2.3
```
Threshold/doorway cell: 0.20 m AABB Z range (from -0.20 to 0.00). A standing
player at local Z=0.46 m is outside this AABB. **This is why AABB containment
fails for normal walking through doorways.**
Key conclusion: the geometry is correct and complete (14/14 polys match between
physics cache and BSP leaf count). The problem is purely in the cell-ownership
tracking mechanism, not the collision data itself.
---
## Diagnostic infrastructure remaining in place
Both probes stay committed and wired. They serve as scaffolding for the
portal-traversal follow-up phase:
- **`ACDREAM_PROBE_INDOOR_BSP=1`** / DebugPanel "Indoor BSP probe": logs one
`[indoor-bsp]` line each time `FindEnvCollisions` takes the indoor-cell
branch. After portal traversal is implemented, this probe should fire
consistently whenever the player is indoors.
- **`ACDREAM_PROBE_CELL_CACHE=1`** / DebugPanel "Cell cache probe": dumps all
cached EnvCell physics data (poly counts, BSP bounding sphere, AABB,
unmatched ID count). Useful for verifying that cell structs load correctly
and that portal connectivity data is present.
Both are gated behind `PhysicsDiagnostics` static class (existing pattern
from L.2a).
---
## Follow-up items for the portal-traversal phase
**1. Implement portal-based indoor cell tracking (issue #87).**
Replace `PhysicsDataCache.TryFindContainingCell` AABB containment with retail's
`CObjMaint::HandleObjectEnterCell` portal traversal. When the player crosses
a cell portal boundary, `CellId` propagates through `CEnvCell` portal
connectivity data. PDB symbols in `docs/research/named-retail/acclient_2013_pseudo_c.txt`
and struct definitions in `docs/research/named-retail/acclient.h` lines
31715-31726 (`CCellStructure` shape). The retail reference implementation
is the right oracle — do not guess at the traversal algorithm.
**2. Audit-trail note: add retail PDB symbol citations to `TryFindContainingCell`.**
The current implementation in `src/AcDream.Core/Physics/PhysicsDataCache.cs`
~line 261 is documented as a shortcut. The follow-up phase should add
the PDB symbol citation (e.g., `// retail: CObjMaint::HandleObjectEnterCell
// docs/research/named-retail/acclient_2013_pseudo_c.txt:XXXXX`)
per the Phase D code-review I1 note, so future readers know this is intentionally
replacing an interim implementation.
**3. Consider renaming `ResolveOutdoorCellId``ResolveCellId`.**
The method now handles both outdoor and indoor cell resolution. The rename
is low-risk (one call site in `PhysicsEngine.cs`) and would reduce the
cognitive overhead for the next phase's author. Noted as a Phase D code-review
M2 suggestion — do it in the same commit as the portal-traversal implementation
to keep the rename and the semantic change together.
---
## State at handoff
- **Branch:** `claude/competent-robinson-dec1f4`, 7 commits of implementation/test/diagnostic work.
- **Build state:** `dotnet build -c Debug` clean.
- **Tests:** 8 pre-existing failures unchanged (MotionInterpreter / BSPStepUp baseline). All new tests green.
- **Issues:** #86 CLOSED; #84 PARTIAL; #85 OPEN; #87 OPEN (new).
- **Diagnostic probes:** `[indoor-bsp]` + `[cell-cache]` active and wired.
- **Next:** portal-based indoor cell tracking (#87) or M2 critical path — Claude's choice per work-order autonomy.

View file

@ -0,0 +1,284 @@
# Indoor walking Phase 2 — Portal-based cell tracking — handoff (2026-05-19)
**Date:** 2026-05-19.
**Branch:** `claude/competent-robinson-dec1f4` (commits land here; merge to main handled by controller).
**Predecessor:** Indoor walking Phase 1 — BSP cluster (Cluster A). Partially shipped 2026-05-19; closed #86 cleanly, filed #87 for the portal-traversal root cause. Diagnostic infrastructure (`[indoor-bsp]` + `[cell-cache]` probes) remained as scaffolding. Handoff: [`docs/research/2026-05-19-cluster-a-shipped-handoff.md`](2026-05-19-cluster-a-shipped-handoff.md).
---
## TL;DR
Phase 2 fully closes the indoor-walking story. Six commits replace Phase D's
AABB-containment shortcut with retail-faithful portal-graph cell traversal.
`CellId` now promotes to indoor cells via portals and remains promoted through
doorways, thresholds, and multi-room navigation. Indoor cell-BSP collision fires
consistently. A critical fix in commit 5 passes the foot-sphere center (not the
entity reference point) to `ResolveCellId`, which was the production failure that
made PointInsideCellBsp return false at floor level. Commit 6 adds
`TryFindIndoorWalkablePlane` so the walkability resolver doesn't fall through to
outdoor terrain when the player is inside.
**Visual verification at Holtburg cottage (2026-05-19, user testing live ACE):**
- Walls block from inside — player cannot walk through cottage walls.
- Multi-room navigation via doorways works — `[cell-transit]` log shows `0xA9B40145 → 0x143 → 0x144 → 0x13F` chains.
- Walking back outdoors through a door works (post-walkable fix in commit 6).
- Cell tracking is robust through multiple indoor sessions.
---
## Commits
| # | SHA | Subject |
|---|---|---|
| 1 | `1969c55` | `feat(physics): Phase 2 — wire CellBSP + Portals into CellPhysics` |
| 2 | `aad6976` | `feat(physics): Phase 2 — port CellTransit + wire into ResolveCellId` |
| 3 | `069534a` | `feat(physics): Phase 2 — BuildingPhysics + CheckBuildingTransit` |
| 4 | `702b30a` | `refactor(physics): Phase 2 — code-review polish on BuildingPhysics commit` |
| 5 | `3ffe1e4` | `fix(physics): Phase 2 — pass foot-sphere center to ResolveCellId` |
| 6 | `eb0f772` | `fix(physics): Phase 2 — synthesize indoor walkable plane from cell floor` |
**Build:** clean on all commits.
**Tests:** `dotnet test` shows the same 8 pre-existing failures in
`AcDream.Core.Tests` (MotionInterpreter / BSPStepUp / etc., unchanged). All
new Phase 2 tests and the walkable-plane tests green.
---
## What shipped
### Commit 1 — CellBSP + Portals wired into CellPhysics
New `PortalInfo` struct holds `PortalId`, `PortalPolygonIndex`, `PortalFlags`,
and `OtherCellId`. `CellPhysics` extended with:
- `CellBSP` — a third BSP tree (alongside `PhysicsBSP` and the render BSP) used
for point-in-cell tests. Retail: `CCellStructure::cell_bsp`.
- `Portals``IReadOnlyList<PortalInfo>` built from `envCell.CellPortals`.
- `PortalPolygons` — the visible polygons that portals reference (`cellStruct.Polygons`,
not `PhysicsPolygons`; portals reference the visible-geometry polygon list).
- `VisibleCellIds` — cells visible from this cell (used by `AddAllOutsideCells`).
Phase D's `LocalAabbMin/Max` + `TryFindContainingCell` are deleted — they are now
superseded by the portal traversal in `CellTransit`.
### Commit 2 — CellTransit + ResolveCellId
New `CellTransit` static class implements the retail portal-neighbour walk.
Three public entry points:
- **`FindTransitCellsSphere(sphereCenter, sphereRadius, startCell, cache)`** —
walks portal connectivity from `startCell` outward. For each portal, tests
whether the sphere overlaps the portal polygon (using `PointInsideCellBsp` on
the sphere center as an approximation — see issue #89 for the retail-faithful
sphere variant). Recurses into neighbour cells up to a depth limit.
- **`AddAllOutsideCells(sphereCenter, blockId, cache, results)`** — for the
outdoor path: populates a 24m grid of outdoor cell ids around the sphere center
using `TerrainSurface.ComputeOutdoorCellId`. Mirrors retail's `add_all_outside_cells`.
- **`FindCellList(sp, startCell, cache)`** — top-level driver. Determines whether
`startCell` is an indoor (EnvCell) or outdoor cell and dispatches accordingly.
Returns a list of candidate cell ids.
`PhysicsEngine.ResolveOutdoorCellId` renamed to `ResolveCellId` (accepts
`sphereRadius` parameter). Body splits on indoor vs outdoor:
- **Indoor:** delegates to `FindCellList` and picks the candidate cell where
`PointInsideCellBsp` returns true for the sphere center.
- **Outdoor:** existing terrain-grid loop (`AddAllOutsideCells`).
`BSPQuery.PointInsideCellBsp` retyped from `PhysicsBSPNode?` to `CellBSPNode?`
(dead code retype — no behavior change). Phase D's test file deleted.
### Commit 3 — BuildingPhysics + CheckBuildingTransit
Outdoor→indoor entry path via building-shell portal graph. New `BuildingPhysics`
class caches per-building portal data (`BldPortalInfo` structs with `PortalId`,
`OtherCellId`, `CellBSP`). `PhysicsDataCache` gains `_buildings` cache keyed by
building entity id. `GameWindow` iterates `lbInfo.Buildings` at landblock load and
populates the cache.
`CellTransit.CheckBuildingTransit(sphereCenter, sphereRadius, blockId, physicsCache)`
ports retail's outdoor→indoor portal-graph entry:
1. For each building in the landblock's physics cache, test whether the sphere
center is inside the building's shell cell BSP (`PointInsideCellBsp`).
2. If inside, walk the building's portal graph to find the indoor EnvCell that
contains the sphere center.
3. Returns the EnvCell id (or 0 if no match).
`PhysicsEngine.ResolveCellId`'s outdoor branch hooks `CheckBuildingTransit` after
the terrain-grid loop, so outdoor→indoor transition is detected during normal walking.
### Commit 4 — Code-review polish
Five items addressed from reviewer:
1. DRY cell-id derivation via existing `TerrainSurface.ComputeOutdoorCellId`
(removed inline duplicate in `CheckBuildingTransit`).
2. Named `PortalFlags.ExactMatch` enum instead of raw `0x01` literal.
3. Comment clarity on `ExactMatch` reserved field.
4. Doc comment on `CheckBuildingTransit` calling out the sphere-vs-point
divergence from retail's `sphere_intersects_cell` (see issue #89).
5. Rename misleading test method name.
### Commit 5 — Critical fix: foot-sphere center to ResolveCellId
**This was the production bug that prevented Phase 2 from working until the last run.**
`ResolveCellId` was being called with `sp.CheckPos` (the entity's reference point
at feet level, world Z = terrain Z after the +0.02f bump) instead of
`sp.GlobalSphere[0].Origin` (the foot sphere CENTER, approximately +0.48m above terrain).
Combined with the +0.02f Z-bump applied to cell origins in `PhysicsDataCache`, the
test point landed at cell-local Z = -0.02 m — just below the cell's floor — and
`PointInsideCellBsp` returned false for every cell. CellId never promoted to indoor
cells during normal walking despite `FindCellList` correctly finding the right
candidate cells.
Passing the foot-sphere center (which sits 0.48m above the floor, well inside any
room cell) made portal-based cell tracking actually work in production.
Also adds the `[check-bldg]` diagnostic line (logged when `CheckBuildingTransit`
returns a non-zero indoor cell id).
### Commit 6 — TryFindIndoorWalkablePlane
**Root cause of the post-Phase-2 falling-stuck bug.**
When indoor cell-BSP returned OK (no wall collision), the code fell through to
outdoor `SampleTerrainWalkable` + `ValidateWalkable`. Outdoor terrain Z is below
the indoor floor (due to the +0.02f Z-bump), so `ValidateWalkable` computed the
player as floating well above terrain → not walkable → player stuck in the falling
animation when blocked by an indoor wall.
New `TryFindIndoorWalkablePlane(worldPos, cellPhysics)`: finds the floor polygon
directly under the player's world position by testing `worldPos` against each
physics polygon's plane normal (upward-facing = floor) and building a `ContactPlane`
from it. Called from the indoor branch of `ResolveWithTransition` before the outdoor
terrain fallback. Returns true when a floor poly is found; the resolver uses the
synthesized plane for walkability.
---
## Issue status after Phase 2
| Issue | Status | Notes |
|---|---|---|
| #84 Blocked by air indoors | **FULLY CLOSED** | Spawn-in-building variant: Phase D (Cluster A). Wall-block-from-inside + falling-stuck variants: Phase 2 commits 2, 5, 6. |
| #85 Pass through walls outside→in | **CLOSED** | `CheckBuildingTransit` + portal traversal. CellId promotes to indoor on outdoor→indoor entry. |
| #86 Click selection penetrates walls | CLOSED (Phase 1) | `WorldPicker.Pick` + `CellBspRayOccluder`. |
| #87 Indoor portal-based cell tracking | **CLOSED** | `CellTransit.FindCellList` + `FindTransitCellsSphere` + `AddAllOutsideCells`. Portal-graph traversal replaces AABB containment. |
| #88 Indoor static objects vibrate | OPEN (new) | Pre-existing visual jitter on bookshelves/furnaces. Filed 2026-05-19. Medium severity. |
| #89 Port BSPQuery.SphereIntersectsCellBsp | OPEN (new) | `CheckBuildingTransit` uses `PointInsideCellBsp` (radius-less approximation) instead of retail's `sphere_intersects_cell`. Filed 2026-05-19. Low severity. |
---
## Probe evidence — log file findings
### `launch-phase2-verify3.log`
First run that showed indoor cell-transits firing. `[cell-transit]` output
confirmed the portal traversal was finding indoor cells. `[indoor-bsp]` probe
fired consistently during indoor walking (not just during mid-jump frames as in
Cluster A). This log is the first evidence that `CellTransit.FindCellList` was
working correctly for room interiors, though outdoor→indoor entry was not yet
exercised.
### `launch-phase2-verify4.log`
Multi-room navigation run. `[cell-transit]` log shows
`0xA9B40145 → 0x143 → 0x144 → 0x13F` chains as the player walked between
rooms in the Holtburg cottage via doorways. Confirmed the `FindTransitCellsSphere`
recursive portal walk was promoting CellId correctly through threshold cells.
Walls blocked from inside in all rooms tested.
### `launch-phase2-verify5.log`
Walkable bug evidence run. After the outdoor→indoor transition was wired
(`CheckBuildingTransit`), the player could walk into the cottage from outside,
but colliding with an indoor wall produced a falling-stuck state (the `[indoor-bsp]`
probe fired for the wall collision, but `ValidateWalkable` returned false because
it was sampling outdoor terrain Z). This log captured the falling-stuck symptom
and the `SampleTerrainWalkable` fallthrough trace, motivating commit 6.
### `launch-phase2-verify6.log`
Post-walkable-fix verification run. After `TryFindIndoorWalkablePlane` was added:
- Outdoor→indoor entry works (player walks through doorway, CellId promotes).
- Indoor wall collision works (walls block, player doesn't pass through).
- Walking back outdoors through the door works (CellId demotes to outdoor cell).
- No falling-stuck state observed. User confirmed all three behaviors.
---
## Diagnostic infrastructure remaining in place
All four probes stay committed and wired. They serve as production diagnostics
and as debugging aids for follow-up issues:
- **`ACDREAM_PROBE_INDOOR_BSP=1`** / DebugPanel "Indoor BSP probe": logs one
`[indoor-bsp]` line each time `FindEnvCollisions` takes the indoor-cell branch.
After Phase 2, this fires consistently whenever the player is indoors. Useful
for confirming the indoor-BSP path is active.
- **`ACDREAM_PROBE_CELL_CACHE=1`** / DebugPanel "Cell cache probe": dumps all
cached EnvCell physics data (poly counts, BSP bounding sphere, AABB, unmatched
ID count, portal count). Useful for verifying cell struct loads and portal
connectivity.
- **`ACDREAM_PROBE_CELL=1`** (existing L.2a slice 1): one `[cell-transit]` line
per `PlayerMovementController.CellId` change (old → new cell, world position,
reason tag). Essential for tracing indoor promotion/demotion sequences.
- **`[check-bldg]`** (commit 5): logged by `ResolveCellId` when
`CheckBuildingTransit` returns a non-zero indoor cell id. Fires once per
outdoor→indoor transition detection.
All gated behind `PhysicsDiagnostics` static class (existing pattern from L.2a).
---
## Visual verification outcomes
**2026-05-19, user testing live against local ACE at Holtburg.**
| Scenario | Result |
|---|---|
| Walk into cottage wall from inside | Blocked ✓ |
| Walk between rooms via doorway | CellId transitions logged, multi-room navigation works ✓ |
| Walk from outside into cottage through door | Outdoor→indoor entry promoted CellId; indoor BSP collision active ✓ |
| Walk back outside through door | CellId demoted to outdoor cell; outdoor physics resumed ✓ |
| No falling-stuck after post-walkable fix | Confirmed ✓ |
| Robust across multiple indoor sessions | Confirmed ✓ |
---
## Known follow-ups
**#88 — Indoor static objects vibrate (bookshelves, open furnaces).** Pre-existing
visual jitter spotted before Phase 2 shipped. Medium severity. Candidates: repeated
`EntityScriptActivator.OnCreate/OnRemove` near cell boundaries, per-part transform
drift, or particle-emitter offset accumulation. Investigate in a follow-up session.
**#89 — Port `BSPQuery.SphereIntersectsCellBsp`.** `CellTransit.CheckBuildingTransit`
currently uses `PointInsideCellBsp` (tests sphere CENTER only). Retail's
`CEnvCell::check_building_transit` uses `CCellStruct::sphere_intersects_cell`
(radius-aware, returns Inside/Crossing/Outside). Practical effect: entry fires
~0.48m deeper into the doorway than retail. Low severity — visually acceptable.
The `sphereRadius` parameter is already plumbed through for when this is ported.
**#80 — Indoor darkness (camera on 2nd floor goes very dark).** Still open.
Not in Phase 2's scope. Lighting / ambient-occlusion issue that predates indoor
rendering Phase 2.
---
## State at handoff
- **Branch:** `claude/competent-robinson-dec1f4`, 6 commits of Phase 2 work
(plus 7 from Phase 1 / Cluster A on the same branch).
- **Build state:** `dotnet build -c Debug` clean.
- **Tests:** 8 pre-existing failures unchanged (MotionInterpreter / BSPStepUp
baseline). All targeted test projects green.
- **Issues:** #84, #85, #87 CLOSED. #86 CLOSED (Phase 1). #88, #89 OPEN (new).
- **Diagnostic probes:** `[indoor-bsp]`, `[cell-cache]`, `[cell-transit]`,
`[check-bldg]` all active and wired.
- **Next:** M2 critical path (F.2 / F.3 / F.5a / L.1c / L.1b — kill-a-drudge
demo) or other candidates per work-order autonomy in CLAUDE.md.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,427 @@
# Indoor Portal-Based Cell Tracking — Design
**Status:** Brainstormed 2026-05-19. Awaiting user spec review before plan.
**Scope:** Port retail's portal-graph cell traversal to replace Phase D's AABB containment shortcut. Closes ISSUES.md #87 and the remaining wall-collision parts of #84 and #85 (indoor walking — walls don't block, walking through doors doesn't update CellId).
**Predecessor:** Cluster A (`docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md`) shipped 2026-05-19. Phase D's AABB containment was a deliberate shortcut that the capture log proved insufficient for normal indoor walking.
**Retail pseudocode reference:** `docs/research/acclient_indoor_transitions_pseudocode.md` (2026-04-13) — the entire algorithm is already documented from ACE source cross-referenced against the retail header. This spec is the porting plan, not a re-derivation.
---
## 1. What we know
The 2026-04-13 research doc enumerates:
- **`CObjCell::find_cell_list`** — the top-level driver, called every movement tick. Builds the list of cells a sphere overlaps + identifies the new "current cell" via point-in-cell.
- **`CEnvCell::find_transit_cells` (sphere variant)** — walks portal neighbors of an indoor cell. Adds neighbor cells whose `sphere_intersects_cell` returns `Inside` or `Crossing`.
- **`CEnvCell::check_building_transit`** — the outdoor→indoor entry path, invoked from `BuildingObj::find_building_transit_cells`.
- **`CLandCell::add_all_outside_cells`** — outdoor neighbor expansion on the 24m landcell grid.
- **`CCellStruct::point_in_cell`** → tail-calls `BSPTREE::point_inside_cell_bsp(cell_bsp, localPoint)`. The `cell_bsp` is a third BSP per cell, separate from `physics_bsp` and `drawing_bsp`.
acdream already has:
- **`BSPQuery.PointInsideCellBsp(node, point)`** at [src/AcDream.Core/Physics/BSPQuery.cs:940](src/AcDream.Core/Physics/BSPQuery.cs:940) — the canonical retail port of `point_inside_cell_bsp`. Currently wired but unused.
- **`LoadedCell.Portals`** (in `AcDream.App.Rendering`) — populated from `envCell.CellPortals` for the visibility renderer. Used for portal-BFS visibility, not collision.
- **`PhysicsDataCache.CacheCellStruct`** caches `CellStruct.PhysicsBSP` (collision BSP) + `PhysicsPolygons` + `VertexArray`. Does NOT currently cache `CellStruct.CellBSP` or portal data.
Capture evidence (`launch-cluster-a-cache-diag3.log`, `launch-cluster-a-verify.log`):
- Holtburg interior cells DO have full physics geometry (e.g. `0xA9B40143` has 14 polys all resolved, AABB `(-11.60, -1.60, 0.00) → (-6.20, 7.60, 2.80)`).
- Phase D's AABB containment fires for ~6 frames per session (mid-jump apex). The threshold/doorway cells with thin Z AABB (e.g. `0xA9B40146` with AABB Z `[-0.20, 0.00]`) never capture a standing player.
- Result: indoor cell-BSP collision branch fires intermittently; walls don't consistently block.
---
## 2. Goal
Port retail's portal-graph cell traversal so:
1. The player's CellId tracks indoor cells correctly when walking inside a building.
2. Walking through a doorway (portal) promotes/demotes CellId correctly.
3. Walking into a building from outside (through a `BuildingObj` portal) promotes CellId to the right interior cell.
4. The indoor cell-BSP collision branch fires every frame the player is in an indoor cell, so walls block consistently.
Out of scope:
- Visibility-side portal traversal (`CellVisibility` / `LoadedCell.Portals`) — kept as-is. This phase is collision-side only.
- Two-sphere parts/AABB variant of `find_transit_cells` (used for creatures and large objects) — port only the player's single-sphere case for now.
- `VisibleCells` cleanup filter — the optional last step of `find_cell_list` that strips invisible cells from the candidate set. Skip; the BSP-based point-in-cell already picks one winner.
- Multi-step sub-tick portal crossings within a single movement step — retail handles fast movement that crosses multiple portals; we'll port the basic single-crossing case and revisit if regressions surface.
---
## 3. Architecture
```
Movement tick (per substep)
PhysicsEngine.ResolveCellId(worldPos, currentCellId)
╔═══════════════════════════════════════════════╗
║ CellTransit.FindCellList ║
║ ║
║ current is indoor (low >= 0x0100)? ║
║ yes ─► seed cellArray with current EnvCell ║
║ no ─► add_all_outside_cells (LandCell) ║
║ + check_building_transit hits ║
║ ║
║ for each cell in cellArray (BFS-like): ║
║ cell.find_transit_cells(sphere) ──► add ║
║ neighbours via portal-graph walk ║
║ ║
║ for each cell in cellArray: ║
║ if PointInsideCellBsp(cell.CellBSP, lpos): ║
║ ─► newCurrentCell = cell, break ║
╚═══════════════════════════════════════════════╝
sp.CheckCellId = newCurrentCell.Id (full prefix)
[indoor-bsp] probe fires correctly for indoor cells
Cell-BSP collision branch in FindEnvCollisions runs
```
The hot path runs once per `FindEnvCollisions` call. Portal-graph traversal walks the local neighborhood (current cell + 1-2 hops). Typical work per tick: ~5-10 BSP point tests, each O(BSP depth) ≈ O(log N). Cheaper than the current AABB scan over all loaded cells.
---
## 4. Components
### 4.1 Data types (extend / add)
**`CellPhysics`** (extended — same record/class as today):
| Field | Status | Source |
|---|---|---|
| `BSP` | existing | `cellStruct.PhysicsBSP` (collision) |
| `PhysicsPolygons` | existing | `cellStruct.PhysicsPolygons` |
| `Vertices` | existing | `cellStruct.VertexArray` |
| `WorldTransform` | existing | passed in from `GameWindow` |
| `InverseWorldTransform` | existing | computed |
| `Resolved` | existing | from `ResolvePolygons` |
| `LocalAabbMin` / `LocalAabbMax` | **delete** | Phase D AABB shortcut |
| **`CellBSP`** | **add** | `cellStruct.CellBSP` (third BSP for point-in-cell) |
| **`Portals`** | **add** | `IReadOnlyList<PortalInfo>` from `envCell.CellPortals` |
| **`VisibleCellIds`** | **add (optional, deferred)** | `envCell.VisibleCells` keys — for future cleanup filter; populated but unused in this phase |
| **`PortalPolygons`** | **add** | `cellStruct.Polygons` resolved by id (separate from `PhysicsPolygons`; portals reference visible polys) |
**`PortalInfo`** (new readonly struct in `AcDream.Core.Physics`):
```csharp
public readonly struct PortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags)
{
/// <summary>Bit 2 of Flags. See research doc §"PortalSide flag semantics".</summary>
public bool PortalSide => (Flags & 2) == 0;
}
```
**`BuildingPhysics`** (new sealed class in `AcDream.Core.Physics`):
```csharp
public sealed class BuildingPhysics
{
public required Matrix4x4 WorldTransform;
public required Matrix4x4 InverseWorldTransform;
public required IReadOnlyList<BldPortalInfo> Portals;
}
public readonly struct BldPortalInfo(uint OtherCellId, ushort OtherPortalId, ushort Flags, bool ExactMatch);
```
One `BuildingPhysics` per outdoor landcell that contains a building stab. Used for outdoor→indoor entry.
### 4.2 Caching (extend `PhysicsDataCache`)
**`CacheCellStruct(envCellId, cellStruct, worldTransform)` — extended:**
After the existing `Resolved = ResolvePolygons(...)` step, also populate the new fields:
- `CellBSP = cellStruct.CellBSP` (verify field name during plan-writing; the DAT type may use `CellBSP`, `CellBsp`, or similar)
- `Portals = envCell.CellPortals.Select(cp => new PortalInfo(cp.OtherCellId, cp.PolygonId, cp.Flags)).ToList()`. **Decision:** change `CacheCellStruct`'s signature to `CacheCellStruct(uint envCellId, EnvCell envCell, CellStruct cellStruct, Matrix4x4 worldTransform)` so portal data and other `EnvCell`-side fields are available in a single atomic call. One call site (`GameWindow.cs:5384`); change is mechanical.
- `VisibleCellIds = new HashSet<uint>(envCell.VisibleCells.Keys)` — populated but unused in this phase.
- `PortalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray)` — same shape as `Resolved` but built from the visible polygon table (since portal `PolygonId` indexes `Polygons`, not `PhysicsPolygons` — confirmed in `GameWindow.cs:5685`).
**`CacheBuilding(landcellId, portals, buildingWorldTransform)` — new:**
Invoked from `GameWindow.BuildInteriorEntitiesForStreaming` for each landcell that contains a building stab. The DAT data shape (BldPortals from `LandBlockInfo.Buildings`) needs verification during plan-writing.
**Deleted methods:**
- `PhysicsDataCache.TryFindContainingCell` — Phase D's AABB containment scan.
- The AABB-compute block inside `CacheCellStruct`.
### 4.3 `CellTransit` (new static class)
New file: `src/AcDream.Core/Physics/CellTransit.cs`. Pure-static, owns three public functions:
```csharp
public static class CellTransit
{
/// <summary>
/// Top-level driver. Ported from retail CObjCell::find_cell_list (sphere variant).
/// Returns the cell id whose CellBSP contains the sphere center, or the original
/// fallback cell id if no cell matches.
/// </summary>
public static uint FindCellList(
PhysicsDataCache cache,
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
out CellSet candidateSet);
/// <summary>
/// Indoor portal-neighbour expansion. Ported from CEnvCell::find_transit_cells
/// (sphere variant). For each portal of `currentCell`, tests whether the sphere
/// could overlap the neighbour cell and adds it to `candidateSet`.
/// </summary>
public static void FindTransitCellsSphere(
PhysicsDataCache cache,
CellPhysics currentCell,
uint currentCellId,
Vector3 worldSphereCenter,
float sphereRadius,
ref CellSet candidateSet);
/// <summary>
/// Outdoor→indoor entry. Ported from BuildingObj::find_building_transit_cells +
/// CEnvCell::check_building_transit. For each BldPortal of `buildingPhysics`,
/// resolves the destination EnvCell and tests whether the sphere is inside it
/// via PointInsideCellBsp.
/// </summary>
public static void CheckBuildingTransit(
PhysicsDataCache cache,
BuildingPhysics buildingPhysics,
Vector3 worldSphereCenter,
float sphereRadius,
ref CellSet candidateSet);
/// <summary>
/// Outdoor neighbour expansion. Ported from CLandCell::add_all_outside_cells.
/// Computes the player's 2D position within the 24×24m landcell and adds
/// neighbour landcells whose boundary the sphere crosses.
/// </summary>
public static void AddAllOutsideCells(
PhysicsDataCache cache,
Vector3 worldSphereCenter,
float sphereRadius,
uint currentCellId,
ref CellSet candidateSet);
}
```
`CellSet` is a small helper — either `HashSet<uint>` or a thin wrapper allocating a stackalloc-backed list. Pick during plan-writing based on allocation profile.
### 4.4 `PhysicsEngine.ResolveCellId` (rename + rewrite)
Replaces `PhysicsEngine.ResolveOutdoorCellId`. New name + signature extended with a `sphereRadius` argument (needed by `FindTransitCellsSphere` for the sphere-vs-portal-plane test). Body becomes:
```csharp
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
{
if (fallbackCellId == 0) return 0;
if (DataCache is null) return fallbackCellId;
uint newCellId = CellTransit.FindCellList(
DataCache,
worldPos,
sphereRadius,
currentCellId: fallbackCellId,
out _);
return newCellId != 0 ? newCellId : fallbackCellId;
}
```
The caller (`Transition.FindEnvCollisions` at TransitionTypes.cs:1181) has `sp.GlobalSphere[0].Radius` available and passes it through. The other two `PhysicsEngine` call sites (`Resolve`, `ResolveWithTransition`) need to plumb the sphere radius from their respective callers; the existing physics types carry it.
Three existing call sites of `ResolveOutdoorCellId` get renamed AND updated to pass the sphere radius:
- `PhysicsEngine.ResolveWithTransition` (line ~729)
- `PhysicsEngine.Resolve` (line ~287)
- `Transition.FindEnvCollisions` (TransitionTypes.cs:1181)
### 4.5 Bootstrap on teleport
When the player teleports to a new cell (server-provided cell id from the network), the existing teleport path stores the cell id and triggers `ResolveCellId` on the next physics tick. Two cases:
- **Server-provided cell id is loaded** in our cache → `FindCellList` starts from that cell, walks the portal graph, point-in-cell determines the actual current cell. Works correctly.
- **Server-provided cell id is NOT yet loaded**`FindCellList` falls through to `AddAllOutsideCells` (treats as outdoor). The next tick after streaming loads the cell, the portal-graph walk picks it up.
Acceptance for teleport: player teleporting to an indoor cell (e.g. Holtburg cottage interior) gets the correct CellId on the first or second tick after spawn. Documented as a known edge case if the streaming takes more than one tick.
---
## 5. Data flow
### Landblock load (one-time per landblock)
```
GameWindow.BuildInteriorEntitiesForStreaming(landblockId, lbInfo)
For each EnvCell:
envCell = _dats.Get<EnvCell>(envCellId)
cellStruct = environment.Cells[envCell.CellStructure]
cellTransform = R(envCell.Position.Orientation) * T(envCell.Position.Origin + lbOffset + Z-bump)
_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform)
│ populates: BSP, CellBSP, PhysicsPolygons, Vertices, WorldTransform,
│ InverseWorldTransform, Resolved, Portals, PortalPolygons,
│ VisibleCellIds
For each landcell containing a building (LandBlockInfo.Buildings):
_physicsDataCache.CacheBuilding(landcellId, building.Portals, buildingTransform)
│ populates: BldPortals list + buildingWorldTransform
```
### Movement tick (per substep)
```
PhysicsEngine.ResolveWithTransition starts
Transition.FindEnvCollisions:
sp.CheckCellId = ... (current cell estimate)
sphereRadius = sp.GlobalSphere[0].Radius
newCellId = engine.ResolveCellId(sp.CheckPos, sphereRadius, sp.CheckCellId)
if newCellId != sp.CheckCellId:
sp.SetCheckPos(sp.CheckPos, newCellId)
Cell-BSP branch fires if sp.CheckCellId & 0xFFFF >= 0x0100
├── BSPQuery.FindCollisions(cellPhysics.BSP, ...) ← walls collide here
└── [indoor-bsp] probe emits a log line
Outdoor terrain collision (unchanged)
```
---
## 6. Commit shape (preview)
1. **`feat(physics): wire CellBSP + Portals + PortalPolygons into CellPhysics`** — extend `CellPhysics` shape; update `CacheCellStruct` signature to accept `envCell` (for portal data); deletes `LocalAabbMin/Max` fields and the AABB compute. Tests verify a synthetic `EnvCell` with portals + CellBSP populates the new fields correctly.
2. **`feat(physics): port find_transit_cells sphere variant for indoor portals`** — new `CellTransit.FindTransitCellsSphere`. Tests use a synthetic two-cell portal pair to verify a sphere crossing the portal poly adds the neighbour cell.
3. **`feat(physics): port BuildingPhysics + check_building_transit for outdoor→indoor`** — `CacheBuilding` + `CellTransit.CheckBuildingTransit`. GameWindow wiring at landblock load. Tests verify a sphere overlapping a building portal triggers indoor-cell add.
4. **`feat(physics): port add_all_outside_cells for landcell neighbours`** — `CellTransit.AddAllOutsideCells`. Tests cover the 24×24m grid boundary cases.
5. **`feat(physics): port find_cell_list driver, wire into ResolveCellId, delete AABB containment`** — top-level driver; rename `ResolveOutdoorCellId``ResolveCellId` and update 3 call sites; delete `PhysicsDataCache.TryFindContainingCell`. Rewrites the 4 Phase D tests (`ResolveOutdoorCellIdIndoorContainmentTests`) to use the portal traversal mechanism.
6. **Capture session (user-driven)** — walk the Holtburg cottage with `ACDREAM_PROBE_INDOOR_BSP=1` + `ACDREAM_PROBE_CELL=1` + `ACDREAM_PROBE_CELL_CACHE=1`. Verify all four acceptance criteria below.
7. **`docs(phase): Indoor portal cell tracking shipped`** — closes #87 and the remaining wall-collision parts of #84 + #85; updates ISSUES.md, roadmap, CLAUDE.md; writes shipped-handoff doc.
---
## 7. Files touched
**Modified:**
- `src/AcDream.Core/Physics/PhysicsDataCache.cs``CellPhysics` shape extended; `CacheCellStruct` signature change; new `CacheBuilding`; deleted `TryFindContainingCell` + AABB compute.
- `src/AcDream.Core/Physics/PhysicsEngine.cs` — rename `ResolveOutdoorCellId``ResolveCellId`; body rewritten to call `CellTransit.FindCellList`; 3 call sites in this file updated.
- `src/AcDream.Core/Physics/TransitionTypes.cs` — call site update at line 1181.
- `src/AcDream.App/Rendering/GameWindow.cs` — pass `envCell` into the extended `CacheCellStruct`; wire `CacheBuilding` at landblock load.
**New:**
- `src/AcDream.Core/Physics/CellTransit.cs` — the new static class with `FindCellList`, `FindTransitCellsSphere`, `CheckBuildingTransit`, `AddAllOutsideCells`.
- `tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs` — indoor portal traversal.
- `tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs` — outdoor→indoor entry.
- `tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs` — outdoor neighbours.
- `tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs` — integration tests.
**Rewritten:**
- `tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs` — renamed and ported to test the portal-based replacement.
**Closed in ISSUES.md:**
- #87 (indoor cell tracking via AABB containment) — fully closed by this phase.
- #85 (pass through walls outside→in) — closed; the outdoor→indoor entry path through `BuildingObj` handles this.
- #84 (blocked by air indoors) — the wall-pass-through portion that remained after Phase D is closed here.
---
## 8. Error handling
- **Cell loaded without `CellBSP`**`PointInsideCellBsp(null, pt)` per its current contract returns `true`, which over-matches. Add an explicit `cellPhysics.CellBSP?.Root == null` skip in `FindTransitCellsSphere` and in `FindCellList`'s containment loop. The cell is treated as "not findable" until its BSP loads.
- **Portal references an unloaded `OtherCellId`** — retail handles this with a "load hint" path that adds a null-cell entry for the streamer. We skip the add and continue; the next physics tick after streaming loads the cell picks it up. Document the one-tick latency as a known edge case.
- **Player teleports to a cell ID with no cached `CellPhysics`** — fall back to `AddAllOutsideCells` (treat as outdoor) for that tick; the next tick after streaming loads the cell, portal traversal takes over.
- **No try/catch swallows.** If the BSP traversal hits a malformed tree, the underlying `BSPQuery` asserts (Debug) or returns `false` (Release).
---
## 9. Testing
### Unit tests (per commit)
- **`CellPhysicsCellBspWiringTests`** — `CacheCellStruct` populates `CellBSP`, `Portals`, `PortalPolygons`, `VisibleCellIds`.
- **`CellTransitFindTransitCellsSphereTests`** — synthetic two-cell portal pair:
- Sphere overlapping portal poly → adds neighbour.
- Sphere far from portal → doesn't add neighbour.
- Sphere on wrong side of portal (per `PortalSide`) → doesn't add neighbour.
- Sphere crossing exit portal (`OtherCellId == 0xFFFF`) → sets `checkOutside = true`.
- **`CellTransitCheckBuildingTransitTests`** — outdoor sphere overlapping building portal plane + inside destination cell's CellBSP → adds the indoor cell.
- **`CellTransitAddAllOutsideCellsTests`** — sphere at boundary X+Y, +XY, X+Y, XY of a 24m cell → 1, 2, or 4 cells in the result set.
- **`CellTransitFindCellListTests`** — integration:
- Indoor seed → returns matching indoor cell after portal walk.
- Outdoor seed → returns matching landcell.
- Outdoor seed near building portal → returns indoor cell via `check_building_transit`.
- Indoor seed crossing exit portal → returns outdoor landcell.
### Rewritten tests
- The four `ResolveOutdoorCellIdIndoorContainmentTests` (Phase D) — same scenarios, but using the portal-traversal mechanism rather than synthetic AABB-only cells. Some may merge with `CellTransitFindCellListTests`.
### Live test (user-driven)
Same launch incantation as Phase E:
```powershell
$env:ACDREAM_PROBE_INDOOR_BSP = "1"
$env:ACDREAM_PROBE_RESOLVE = "1"
$env:ACDREAM_PROBE_CELL = "1"
$env:ACDREAM_PROBE_CELL_CACHE = "1"
$env:ACDREAM_DEVTOOLS = "1"
```
Walk the Holtburg cottage end-to-end. Verify all four acceptance criteria below.
---
## 10. Acceptance
1. **Indoor walking** — Player walks inside the Holtburg cottage freely; walls block from inside (current bug fixed); furniture still collides (no regression from per-object collision).
2. **Outdoor→indoor** — Player walks toward the cottage door from outside; CellId promotes to an indoor cell when crossing the doorway; walls beyond the door block.
3. **Indoor→outdoor** — Player walks back out through the door; CellId demotes to the outdoor landcell; outdoor terrain collision resumes; ACE doesn't report cell-state desync.
4. **Indoor→indoor** — Player walks from one room to another through an interior doorway; CellId transitions correctly between EnvCells; no momentary "stuck on portal plane" issues.
5. **`[indoor-bsp]` probe fires consistently** during indoor walking — not just during jumps (the Phase D failure mode).
6. **`dotnet build` + `dotnet test`** green with the new test suite. Pre-existing baseline of 8 failures unchanged.
---
## 11. Out of scope (deferred / explicit non-goals)
- **Parts/AABB variant of `find_transit_cells`** — used for creatures and large objects with multi-part bounding boxes. Only the player's single-sphere case is in scope here; the AABB variant ports as a follow-up if remote-entity cell tracking proves broken.
- **`VisibleCells` cleanup filter** — the optional last step of `find_cell_list` that strips invisible cells from the candidate set. Skipped; the BSP point-in-cell already picks one winner. Data is populated for future use.
- **Multi-portal crossings within a single movement step** — retail's resolver handles fast movement crossing multiple portals via the per-substep loop. We rely on the per-substep loop being fine-grained enough; if a regression surfaces, address as a follow-up.
- **Unification with `LoadedCell.Portals` in `AcDream.App.Rendering`** — two parallel portal stores remain (Core for collision, App for visibility). Future cleanup could unify them, but not in this phase.
- **`CellTransit` for moving entities other than the player** — the function works for any sphere, but only the player's resolve path is wired this phase. Remote-entity cell tracking remains as-is.
---
## 12. Risks
1. **DAT field name mismatch.** The pseudocode doc references `CellStruct.CellBSP` but DatReaderWriter may name it differently (e.g. `cell_bsp`, `CellBsp`, `CellTree`). Verify at plan-writing time by reading DatReaderWriter's `CellStruct.cs` (NuGet source). If the field is missing entirely, file a sub-phase to extend DatReaderWriter — but this is unlikely given the dat format includes the BSP.
2. **`BuildingObj.Portals` structure differs from indoor portals.** Retail's `BldPortal` has more fields (`OtherPortalId`, `ExactMatch`). The DAT representation lives under `LandBlockInfo.Buildings[...]`; verify the field shape at plan-writing time.
3. **Sphere radius plumbing.** `FindTransitCellsSphere` needs the player's sphere radius to test against the portal plane. The caller (`Transition.FindEnvCollisions`) has access via `sp.GlobalSphere[0].Radius`; plumb it through `ResolveCellId`'s signature in the same commit that wires the call.
4. **Rename cost.** Renaming `ResolveOutdoorCellId``ResolveCellId` cascades through 4 call sites + test names + commit messages. Bundling the rename with the wiring commit keeps the change atomic; spreading it across commits creates a transient state where the function name doesn't match its behavior.
5. **Phase D test rewrites.** The 4 Phase D tests assert AABB-containment behavior that no longer exists. Rewriting them to use the portal-traversal mechanism requires synthetic test fixtures with portals + CellBSP — more setup boilerplate. Acceptable cost; integration coverage improves.
---
## 13. Phase name + roadmap placement
**Proposed name:** "Indoor portal-based cell tracking" (sometimes abbreviated "Indoor walking Phase 2" since it follows Cluster A / Indoor walking Phase 1).
**Roadmap placement:** add to `docs/plans/2026-04-11-roadmap.md` ahead-table as the next item in the indoor track. Sits in front of any remaining indoor-rendering polish (issues #78, #79-#82) since indoor walking is the gating issue.
**Milestone:** still parallel to M2 (Kill a drudge). Completing indoor walking unblocks demos that involve buildings (e.g. talking to interior NPCs, picking up items from inside shops).

View file

@ -0,0 +1,313 @@
# Indoor Walking Phase 1 — BSP cluster (#84 / #85 / #86)
**Status:** Brainstormed 2026-05-19. Awaiting user spec review before plan.
**Scope:** Diagnostic-first investigation pass across the three "indoor walking is broken" bugs that share a cell-BSP / picker root-cause cluster. Surface evidence with a single probe + one capture session, then ship surgical fixes (one commit per issue).
**Predecessors:**
- Indoor cell rendering Phase 1 (`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`) — the five `[indoor-*]` render-side probes.
- Indoor cell rendering Phase 2 (`docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md`) — silent-failure surfacing + WB Setup-prefix guard. Made floors render.
- Handoff: `docs/research/2026-05-19-indoor-followup-handoff.md`.
The indoor cell rendering Phase 1+2 pair made floors render. The moment floors rendered, nine pre-existing indoor bugs (`docs/ISSUES.md` #78-#86) became user-observable. This phase tackles the **BSP cluster** subset: #84, #85, #86.
`#78` (outdoor stabs visible through floor) is in the same handoff cluster but a fundamentally different code path (render-side visibility / stencil), so it's deferred to a separate phase. `#79-#83` (lighting / terrain / stairs) are in different clusters.
---
## 1. What we know from the code
Pre-investigation reads (2026-05-19) of the three issue surfaces:
### `#84` (blocked by air indoors) — cell BSP IS consulted
The handoff hypothesized "cell BSP isn't being used". Code reading says otherwise:
- **Cell BSP IS cached.** `PhysicsDataCache.CacheCellStruct` ([src/AcDream.Core/Physics/PhysicsDataCache.cs:131](src/AcDream.Core/Physics/PhysicsDataCache.cs:131)) stores `BSP`, `PhysicsPolygons`, `Vertices`, `WorldTransform`, `InverseWorldTransform`, and pre-resolved polygons (planes computed at cache time).
- **Cell BSP IS consulted in collision.** `Transition.FindEnvCollisions` ([src/AcDream.Core/Physics/TransitionTypes.cs:1188-1241](src/AcDream.Core/Physics/TransitionTypes.cs:1188)) has an explicit indoor branch gated on `cellLow >= 0x0100` that:
1. Looks up `cellPhysics` via `engine.DataCache.GetCellStruct(sp.CheckCellId)`,
2. Transforms the player's sphere to cell-local space via `InverseWorldTransform`,
3. Calls `BSPQuery.FindCollisions` with the cell's pre-resolved polys,
4. Returns `cellState` if `!= OK`.
So #84's root cause is not "wiring missing". It's one of: (a) extra physics-only polys with no visible counterpart, (b) `+0.02f` Z-bump misalignment between cellTransform (applied to physics) and player Z (computed from terrain), (c) `BSPQuery` returning false positives at certain poly side-types, (d) `cellTransform` quaternion error on rotated cells. Capture data will pin which.
### `#85` (pass through walls outside→in) — likely asymmetric path
Walking outside-in keeps `CheckCellId` as the outdoor land cell (low byte `0x00xx-0x00FF`), so the indoor cell-BSP branch at TransitionTypes.cs:1192 is **gated out by design** (`cellLow >= 0x0100` is false). The only collision tested on the outside-in approach is:
- **Terrain** (always tested),
- **Outdoor stab BSPs** ([`PhysicsDataCache.GetGfxObj`](src/AcDream.Core/Physics/PhysicsDataCache.cs) for `LandBlockInfo.Objects`) — building stab is hit via `FindObjCollisions`.
L.2d slice 1+1.5 ported `CBuildingObj` collision (per CLAUDE.md), so the outer building shell SHOULD be hit. If #85 reproduces, hypotheses:
1. The outdoor stab BSP for the Inn covers floor+roof but is missing wall polys (authoring shape — retail's interior cells own the walls, outdoor shell is a partial envelope).
2. The outdoor stab BSP has wall polys but with one-sided normals; outside approach hits the back face which BSP treats as "behind plane" → no collision (`feedback_no_patching_collision` memory's faithful-port rule means we'd need to follow retail's handling).
3. The L.2g dynamic-physics-state flag work doesn't include outdoor building shells in the collision sweep for the player's CheckCellId.
4. **Retail's actual behavior** may be that outside-in BSP probing queries the EnvCell's BSP across the cell boundary — retail's `CCellStructure::find_env_collisions` may walk neighbor-cell BSPs.
### `#86` (click selection penetrates walls) — root cause definitively pinned by code reading
`WorldPicker.Pick` ([src/AcDream.Core/Selection/WorldPicker.cs:88-160](src/AcDream.Core/Selection/WorldPicker.cs:88), and the screen-rect overload at line 202) is **pure ray-sphere against entity AABBs**. There is no cell BSP test, no scenery BSP test, no terrain test. Any entity along the ray within `maxDistance` is a candidate; nothing occludes.
No probe needed for #86. Fix is structural: add a cell-BSP ray-poly occlusion test that runs once per `Pick` call and culls entities whose ray-distance exceeds the nearest wall hit.
---
## 2. The three issues
| # | Title | Code path | Fix shape |
|---|---|---|---|
| #84 | Blocked by air indoors | `Transition.FindEnvCollisions` cell branch | TBD — pinned by probe capture |
| #85 | Pass through walls outside→in | `FindObjCollisions` outdoor-stab path or cross-cell BSP probing | TBD — pinned by probe capture |
| #86 | Click selection penetrates walls | `WorldPicker.Pick` (both overloads) | Add cell-BSP ray-poly occlusion test |
---
## 3. Architecture
```
[indoor-bsp] probe
┌───────────────────┴────────────────────┐
▼ ▼
Movement path Picker path
(FindEnvCollisions cell branch) (WorldPicker.Pick)
│ │
├─→ #84: blocked by air └─→ #86: click through walls
└─→ #85: pass through walls (cause already pinned by code reading)
(cause TBD — needs capture)
```
The probe spans only the movement path. #86's diagnosis is already known; its fix is independent of the capture and can land in parallel.
---
## 4. Components
### Component 1 — `PhysicsDiagnostics.IndoorBspEnabled`
New static toggle on `AcDream.Core.Physics.PhysicsDiagnostics`. Mirrors the existing `ResolveProbeEnabled` / `CellProbeEnabled` pattern:
- Backed by `ACDREAM_PROBE_INDOOR_BSP` env var read once at startup.
- Mutable at runtime via the DebugPanel checkbox.
- Zero-cost when off — checked before any string formatting.
Also extends `PhysicsDiagnostics.IndoorAllEnabled` cascading the way Phase 1 cascaded the render-side `ACDREAM_PROBE_INDOOR_ALL`.
### Component 2 — `[indoor-bsp]` log site
One `Console.WriteLine` block in `Transition.FindEnvCollisions` ([TransitionTypes.cs:1222](src/AcDream.Core/Physics/TransitionTypes.cs:1222)), wrapping the existing `BSPQuery.FindCollisions` call. Captured fields per call:
| Field | Source | Why |
|---|---|---|
| `cellId` | `sp.CheckCellId` | Which cell's BSP was queried (hex, full 32-bit) |
| `localPos` | `localCenter` | Sphere foot center in cell-local space (3 floats) |
| `localPrevPos` | `localCurrCenter` | Sphere previous-frame foot center in cell-local space |
| `worldPos` | `footCenter` | Sphere foot center in world space (for cross-ref with user-reported spot) |
| `result` | `cellState` | `TransitionState` enum (`OK` / `Collided` / etc.) |
| `polyId` | `ci.LastHitCellPolyId` (NEW field if needed) | Which cell poly was hit, if any |
| `polyNormal` | `cellPhysics.Resolved[polyId].Plane.Normal` | Local-space normal (3 floats) — diagnoses one-sided / orientation bugs |
| `sidesType` | `cellPhysics.Resolved[polyId].SidesType` | `Front` / `Back` / `Both` — diagnoses #85 candidate |
| `walkable` | `ci.LastKnownContactPlaneValid` | Walkable surface tracking state |
Log line format (one line, pipe-separated, machine-greppable):
```
[indoor-bsp] cell=0xA9B40100 wpos=(82.45,71.23,1.04) lpos=(0.45,2.10,1.02) result=Collided poly=0x0042 n=(0.00,1.00,0.00) sides=Front walkable=true
```
If `BSPQuery.FindCollisions` doesn't already expose the hit poly id, the log fields shrink to what's available without expanding the BSPQuery API. A separate small change to surface `lastHitPolyId` from `BSPQuery` would be in-scope for this phase if needed.
### Component 3 — DebugPanel checkbox
Adds a checkbox row in the DebugPanel's Diagnostics section (already hosts the L.2a `Resolve` and `Cell-transit` toggles, plus the Phase 1 `Indoor walk/cull/upload/lookup/xform` toggles). Surface area: ~3 lines. No new file.
### Component 4 — `WorldPicker` cell-BSP occluder
Two implementation options:
**Option C1 — Inline in `WorldPicker.Pick`.** Add a `cellOccluder` callback parameter `Func<Vector3, Vector3, float>?` that returns the nearest wall-hit `t` along the ray (or `float.PositiveInfinity` if no hit). Inside `Pick`, after computing the entity hit `t`, gate by `entityHit < cellOccluder(origin, direction)`.
**Option C2 — Separate `CellBspRayOccluder` static class.** New file `src/AcDream.Core/Selection/CellBspRayOccluder.cs`. Function `NearestWallT(Vector3 origin, Vector3 direction, IEnumerable<CellPhysics> loadedCells)` — Möller-Trumbore ray-triangle against each cell's resolved polys, returns nearest `t`. WorldPicker calls it once per `Pick` invocation.
**Recommend C2.** Reasons: testable in isolation (synthetic cell + ray); two `WorldPicker.Pick` overloads share one implementation; future picker improvements (entity body refine, scenery BSP refine) get a parallel structure to copy.
The caller (`GameWindow` Use/Select handlers) must supply the loaded `CellPhysics` set. `PhysicsDataCache` already has `GetCellStruct(id)` so the caller iterates currently-loaded `LoadedCell` ids from `CellVisibility._cellLookup` (Holtburg radius 4 keeps maybe 80 cells loaded — fast Möller-Trumbore).
### Component 5 — Fix patches (TBD)
Concrete commits drafted only after capture data lands. Candidates by issue:
**#84**:
- Remove `+0.02f` Z bump from the physics-side `cellTransform` while keeping it for render's `cellMeshRef` (separate transforms). Or apply the bump symmetrically (also bump player Z by `+0.02f` when entering an indoor cell).
- Filter out physics-only polys with no visible counterpart, IF capture data shows phantom polys are the issue.
- Patch `BSPQuery.FindCollisions` side-type handling, IF capture data shows specific side-types misbehaving.
**#85**:
- Port retail's outside-in BSP cross-cell probing — query an EnvCell's BSP from an outdoor cell when the sphere overlaps the EnvCell's world AABB. Reference: PDB-named `CCellStructure::find_env_collisions` and neighbors.
- OR ensure outdoor building-shell stab BSPs include wall polys with two-sided handling.
- Path picked from capture evidence + decomp grep.
---
## 5. Data flow
### Capture session
User runs the canonical Holtburg launch (`ACDREAM_LIVE=1`, `+Acdream` char) with `ACDREAM_PROBE_INDOOR_BSP=1` + `ACDREAM_PROBE_RESOLVE=1` (latter already shipped from L.2a). Three scripted scenarios:
1. **Inside Inn walkaround (~30 s)** — walk slowly around the common room, attempt to reproduce #84. Note world-position when an invisible block happens.
2. **Outside-in approach (~30 s)** — stand 5+ m west of the Inn, sprint at the west wall. Reproduce #85.
3. **Inside-out sanity (~30 s)** — stand inside, walk into east wall from interior. This SHOULD block (per issue text); confirms inside-out path works.
Total launch: one. Captures all three.
### Offline analysis
```
grep "\[indoor-bsp\]" launch.log | head -200 # see what fired during scenario 1
grep "\[resolve\]" launch.log | grep "obj=0x" # see which objects were hit during scenario 2
grep "\[cell-transit\]" launch.log # confirm cell ids during transitions
```
Diagnosis per issue:
- **#84**: in scenario-1 lines, find `result=Collided` events where world-pos is in open space (no visible wall). Cross-ref `polyId` with the cell's `cellStruct.PhysicsPolygons` to identify what the offending poly is. Compare its local-Z with player's local-Z to test the Z-bump hypothesis.
- **#85**: in scenario-2 lines, expect zero `[indoor-bsp]` events (gated out). Check `[resolve]` lines for the moment the player crosses the wall plane — did `FindObjCollisions` fire for any building stab? If yes, what poly? If no, the outdoor stab path is missing wall geometry → fix shape is the cross-cell BSP probing.
- **#86**: no capture needed. Code reading already pinned the cause; fix is structural.
### Fix application
Per CLAUDE.md "no workarounds" rule:
- The probe data must point at one specific code site before any fix lands.
- Each fix commit cites the evidence in its message ("`[indoor-bsp] cell=0x... wpos=... poly=... n=...` — the poly at local-Z=0.0 is the floor poly; player local-Z=-0.02 from the +0.02f bump puts foot below floor → spurious floor-up push at cell boundary").
- No try/catch swallow, no early-return guard at the symptom site.
---
## 6. Commit shape
```
1. feat(physics): Cluster A — indoor BSP collision probe
- PhysicsDiagnostics.IndoorBspEnabled toggle + env var + DebugPanel checkbox
- [indoor-bsp] log site in TransitionTypes.FindEnvCollisions cell branch
- (if needed) BSPQuery.LastHitPolyId surfacing
[CAPTURE SESSION — user-driven, no commit]
2. fix(physics): Cluster A #84<root cause from probe>
- One surgical change to TransitionTypes / GameWindow / BSPQuery
- Commit message cites probe evidence line
- Closes ISSUES.md #84
3. fix(physics): Cluster A #85<root cause from probe + decomp>
- One surgical change to TransitionTypes or PhysicsDataCache
- Commit message cites probe evidence + retail decomp anchor
- Closes ISSUES.md #85
4. fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker
- New CellBspRayOccluder static class (Option C2)
- WorldPicker.Pick (both overloads) consults occluder before returning hit
- Unit test covering synthetic wall-between-camera-and-entity case
- Closes ISSUES.md #86
5. docs(roadmap+issues): Cluster A shipped — close #84/#85/#86, update roadmap
- ISSUES.md moves three issues to Recently closed
- docs/plans/2026-04-11-roadmap.md shipped table updated
- CLAUDE.md "Currently in Phase L.2..." line advanced if appropriate
```
Visual verification gate sits between commits 4 and 5. User confirms each acceptance criterion in the live client before closing.
---
## 7. Files touched
**Definite:**
- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` — new `IndoorBspEnabled` toggle.
- `src/AcDream.Core/Physics/TransitionTypes.cs``[indoor-bsp]` log site at the cell branch.
- `src/AcDream.App/UI/Panels/DebugPanel.cs` (or wherever the diagnostics checkboxes live) — UI toggle.
- `src/AcDream.Core/Selection/WorldPicker.cs` — call the new occluder.
- `src/AcDream.Core/Selection/CellBspRayOccluder.cs` — new file.
- `src/AcDream.App/Rendering/GameWindow.cs` — wire `LoadedCell` set / `CellPhysics` enumeration into the Use/Select handlers' picker calls.
- `tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs` — new unit test for #86 fix.
- `docs/ISSUES.md` — close #84/#85/#86.
- `docs/plans/2026-04-11-roadmap.md` — shipped table entry.
**TBD (depends on capture):**
- `src/AcDream.App/Rendering/GameWindow.cs:5362` (+0.02f Z bump site).
- `src/AcDream.Core/Physics/BSPQuery.cs`.
- `src/AcDream.Core/Physics/PhysicsDataCache.cs`.
---
## 8. Error handling
- Probe always behind `PhysicsDiagnostics.IndoorBspEnabled`. Zero-cost when off.
- Probe writes to `Console.WriteLine`, captured by the launch.log `Tee-Object` pipe (matches existing probe convention).
- `CellBspRayOccluder` returns `float.PositiveInfinity` when no cells are loaded (outdoor camera). Picker behaves exactly as today in that case.
- No try/catch around fix sites. If a fix doesn't behave, the user reports the residual symptom and the probe re-fires to identify the new cause.
---
## 9. Testing
### Unit tests
- **`WorldPickerCellOcclusionTests`** (new): synthetic `CellPhysics` with one wall poly between origin and an entity at 5 m. `Pick` returns null. Remove the wall — `Pick` returns the entity. Verifies the occluder is wired and triangulates correctly.
- **`CellBspRayOccluderTests`** (new): direct unit tests for the Möller-Trumbore intersection — ray hits poly front, back, edge, miss, parallel-to-poly. Standard ray-triangle coverage.
- **Existing tests**: `dotnet test` green. `WorldPickerTests` + `WorldPickerRectOverloadTests` + all `BSPQuery` tests must remain green.
### Visual verification (user-driven)
Three checks, one per issue:
1. **#84 acceptance** — User walks the common-room loop in Holtburg Inn. No invisible blocks. Probe shows no `TransitionState != OK` events at positions away from visible walls/furniture.
2. **#85 acceptance** — User stands 5+ m west of the Inn, runs at the west wall. Player blocks at the wall plane (within ~0.05 m of the visible wall surface). User cannot enter the building except via a door portal.
3. **#86 acceptance** — Mouse over a wall pixel from outside the Inn → cursor shows no selection. Mouse over an NPC through an open door portal → cursor shows the NPC selection ring (selection still works through real apertures).
---
## 10. Acceptance criteria
- All three issues meet their respective acceptance gates above (visual confirmation by user).
- `dotnet build` green.
- `dotnet test` green (new tests + all existing).
- Roadmap "shipped" table updated.
- `docs/ISSUES.md` #84/#85/#86 moved to "Recently closed" with commit SHAs.
- A short post-phase handoff doc (`docs/research/<ship-date>-indoor-walking-phase1-shipped-handoff.md`) records the probe evidence + the three root causes, parallel to the existing Phase 1+2 docs.
---
## 11. Phase name + roadmap placement
**Proposed name:** "Indoor walking Phase 1 — BSP cluster (#84/#85/#86)".
Reasons:
- Continues the "Indoor X Phase N" naming established by Phase 1 (probes) + Phase 2 (rendering fix).
- Distinguishes from indoor RENDERING work (which is done) — the focus has shifted to indoor WALKING.
- "Phase 1" implies more phases follow (Phase 2 likely = #78 outdoor-stab visibility cluster).
**Roadmap placement:** Add to `docs/plans/2026-04-11-roadmap.md` ahead-table as the next item in the indoor track. Insert after the Indoor cell rendering Phase 2 entry. Cross-link to ISSUES.md #84/#85/#86.
**Milestone:** This is parallel to the M2 critical path (which is F.2 / F.3 / F.5a / L.1c / L.1b). M1 already landed and is frozen. Indoor walking work is a quality-of-life parallel track — the user's recent commits put it ahead of M2 work because the rendering Phase 2 ship made it actionable.
---
## 12. Out of scope
- **#78** — outdoor stabs/buildings visible through rendered floor. Different code path (visibility / stencil). Filed for Indoor walking Phase 2.
- **#79-#82** — lighting / terrain shading. Cluster B in the handoff. Separate phase.
- **#83** — walking up stairs broken. Standalone issue. May share code with this phase if the cell BSP fix touches step-up; address opportunistically only if so.
- **Refactoring `WorldPicker`** beyond adding the occluder. The existing two-overload structure stays.
- **Stage B picker refine** (Möller-Trumbore against entity body polygons) — Issue #71, deferred per existing roadmap.
---
## 13. Risks
1. **Capture is inconclusive.** If the probe fires zero unexpected events during scenario 1 (i.e., #84 cannot be reproduced live during the capture), we extend the probe to also log `BSPQuery` internals or capture a longer session. Probably one more launch.
2. **#85 fix requires significant retail-decomp port.** Cross-cell BSP probing (querying an EnvCell's BSP from an outdoor cell) is not in the current code. The retail decomp at `named-retail/acclient_2013_pseudo_c.txt` has `CCellStructure::find_env_collisions` and neighbors that handle this. If the port is non-trivial (more than ~100 lines), promote #85 to its own dedicated phase rather than including it here. Decision point: after the capture, before commit 3.
3. **`CellBspRayOccluder` performance.** Möller-Trumbore against ~80 cells × ~50 polys each = ~4K triangle tests per `Pick` call. Picker fires once per click — acceptable. If we ever move to hover-pick (every frame), this needs an acceleration structure; not in scope here.
4. **Probe gets noisy.** If `FindEnvCollisions` fires at 30 Hz × N cells, the log can grow fast. Add a per-call rate limit only if the capture log is unreadable; default to unlimited (Phase 1+2 didn't need limiting).