# Collision fixes — session 2026-05-21 shipped handoff

**Status:** All 9 commits merged to **`main`** via fast-forward (HEAD `56d2b5e`).
Local main is ahead of `origin/main` (`7034be9`); not yet pushed.
The original session worktree (`claude/lucid-goldberg-1ba520`) is still
on disk but its branch is now identical to main and can be removed.
**Next session should branch from main into a fresh worktree.**

## TL;DR

User reported the world feeling buggy — collision in thin air inside
and outside buildings, walls walk-through-able in spots. A two-step
investigation surfaced a foundation-level math bug (`PolygonHitsSpherePrecise`
inverted vs retail) and four discrete registration / cell-tracking
bugs. **Four surgical fixes landed this session** (A1, A1.5, A1.6,
A1.7) plus a `[walk-miss]` / `[floor-polys]` diagnostic probe set that
quantified the bug rates. **What's left is one architectural change
(A4: multi-cell BSP iteration) and three smaller code-correctness
items.** Visual verification at the end of each phase confirmed
forward progress; remaining wall-walkthroughs in vestibule cells are
the A4 gap.

## What shipped this session

### Probe spike (3 commits)

| SHA | What | Why |
|---|---|---|
| `27c7284` | `ProbeWalkMissEnabled` flag + roundtrip test | Diagnostic gate for ISSUES #83 H-disambiguation |
| `31da57c` | `WalkMissDiagnostic` aggregator + 2 logic tests | Pure-function aggregator over `CellPhysics.Resolved` |
| `a2e7a87` | `[walk-miss]` + `[floor-polys]` emission sites | Wire flag + aggregator into `Transition.FindEnvCollisions` MISS branch + `PhysicsDataCache.CacheCellStruct` |
| `bb1e919` | Spec + plan + findings docs | The doc artifacts for the spike |

The walk-miss probe produced the **smoking-gun analysis** in
[`docs/research/2026-05-21-walk-miss-capture-findings.md`](2026-05-21-walk-miss-capture-findings.md):
0.38 % synthesis HIT rate, with a 2 cm boundary between HIT (`dz≈0.46 m`)
and MISS (`dz≈0.48 m`) at sphere radius 0.480 m. This proved
**`PolygonHitsSpherePrecise` is inverted vs retail's
`polygon_hits_sphere_slow_but_sure`** (BSPQuery.cs:117 vs
acclient_2013_pseudo_c.txt:322509-322517). That's Phase A2, still
pending.

### Collision fixes (4 commits)

| Phase | SHA | Fix |
|---|---|---|
| **A1** | `5f2b545` | **Skip mesh-AABB-fallback cylinder for landblock stabs.** Stabs (`entity.Id 0xC0XXYY00+n`) had their per-part BSP shadow correctly registered AND a redundant 1.5 m-clamped invisible cylinder at the mesh origin. The cylinder was the "thin air" collision inside cottages. Gate: `_isLandblockStab = (entity.Id & 0xFF000000u) == 0xC0000000u`. |
| **A1.5** | `4d3bf6f` | **Scope interior cell shadows to ParentCellId.** `ShadowObjectRegistry.Register` assigned every entity to outdoor landcells based on XY. Interior statics (fireplace, furniture in cell `0xA9B40121`) got stamped into the outdoor landcell whose XY they overlapped (e.g., `0xA9B40029`), firing collisions for players walking OUTSIDE the building. New optional `cellScope` parameter, passed `entity.ParentCellId ?? 0u` from all 5 entity-loop call sites. |
| **A1.6** | `700abad` | **Skip Setup CylSphere/Sphere shadows for landblock stabs.** A1 only gated the mesh-AABB-fallback path. Setup-derived registrations (lines 5910-6005 in GameWindow) still fired for stabs whose source is a Setup with CylSpheres. Same `_isLandblockStab` gate, extended to the outer `if (setup is not null)` block. |
| **A1.7** | `4679134` | **Fall through to outdoor cell when indoor BSP doesn't contain player.** `CellTransit.FindCellList` returns `currentCellId` when no candidate cell's `CellBSP` contains the sphere — but this also fired when the player walked OUTSIDE the entire portal-connected indoor graph. The player's CellId was stuck on an old indoor cell whose BSP was geometrically far away; every indoor-bsp query returned OK at the BSP root; no walls blocked. Fix: after `FindCellList`, verify with `PointInsideCellBsp`; if not inside, fall through to the existing outdoor resolution branch. |

### Visual verification at each phase

Each fix was visually verified by walking the same buildings before/after:
- **A1**: "thin air" inside cottage GONE.
- **A1.5**: "thin air" outside buildings → 71/97 interior-static-leak hits down to 0.
- **A1.6**: Setup-CylSphere bleed around buildings cleared.
- **A1.7**: cell-id correctly transitions between indoor doorway cell and adjacent outdoor cell on building exit.

## What's still broken

Per end-of-session user testing:

1. **Walls walk-through-able in "vestibule" cells.** Some interior cells (e.g., the Holtburg cell `0xA9B40164`) have very few physics polygons — only 4 polys, BSP bounding sphere of 2 m radius. When the player walks past the doorway, they're geometrically inside a *neighboring* cell's actual walls — but the collision check only queries the cell the player's center is "in." That cell (the vestibule) has no walls there. The neighboring cell's walls (e.g., `0xA9B40157` with 23 polys, 38 % hit rate when the player IS there) are never queried.
2. **Stairs walk-through.** Likely the same multi-cell iteration gap — stairs span cell boundaries.
3. **Lighting indoors broken.** Separate rendering concern; M7 polish.
4. **Items projecting spotlight on walls.** Per-entity light direction bug; M7 polish.
5. **PHSP inversion (A2).** Still pending. The `[walk-miss]` data proved this bug exists but fixing it alone doesn't fix walkable synthesis at the tangent boundary — needs to pair with synthesis removal (A3).
6. **Synthesis architecturally wrong (A3).** Retail's grounded path never re-synthesizes `ContactPlane`; it retains via Mechanisms A/B/C. Our `TryFindIndoorWalkablePlane` runs every frame and is the wrong shape. Removing it is Bug A from the 2026-05-20 session — was tried + reverted because retention had its own gaps. A1.7 closed one of those gaps; A2 + A4 close the others.

## The architectural picture (plain-English)

acdream's world is divided into invisible chunks called **cells**.
There are two flavors:

- **Outdoor cells**: the world is gridded into 24 m × 24 m squares. Each
  landblock (the 192 m × 192 m unit of streaming) has 64 such cells in
  an 8 × 8 grid. They get cell IDs like `0xA9B40029`.
- **Indoor cells**: each room (or section of room) inside a building
  gets its own cell. They're not grid-aligned — they follow the
  building's interior partitioning. Cell IDs have the high bit of the
  low-16 set, e.g. `0xA9B40157`.

Each cell carries:
- A **CellBSP** — defines the volume the cell occupies in space (used
  for "is this point inside this cell?" lookups during cell-id resolution).
- A **PhysicsBSP** — the collision geometry (walls, floors, stairs) the
  player can hit.
- **Portals** — connections to adjacent indoor cells (think doorways).
- **Static objects** — furniture, decoration meshes hydrated as entities.

The collision system asks two things per frame:
1. **What cell is the player in?** Driven by `PhysicsEngine.ResolveCellId`
   → `CellTransit.FindCellList`. Walks the portal graph from the
   current cell, picks the cell whose `CellBSP` contains the sphere
   center. With **A1.7**, when no indoor cell claims the player, falls
   through to outdoor landcell resolution.
2. **Does the player hit anything?** Drives `Transition.FindEnvCollisions`.
   Queries the **one cell** the player is "in" — its `PhysicsBSP` for
   walls/floor and its shadow-registered statics for furniture.

**The architectural gap** is step 2 only queries one cell. Retail
queries the **cell_array** — the sphere center's cell plus every
other cell the sphere geometrically overlaps. So if you're in a
vestibule cell with no real walls but your shoulder pokes into the
next room's wall, retail's collision sees the wall. acdream doesn't.

## Phase A4 — multi-cell iteration (the next big fix)

This is the gap. Implementation sketch:

### What to port from retail

`CTransition::check_other_cells` at `acclient_2013_pseudo_c.txt:272717-272798`.
After the primary cell's `find_collisions` runs, it iterates every
other cell in `this->cell_array` (built from `CObjCell::find_cell_list`
which fills via interior portals + `add_all_outside_cells` for outdoor
neighbors). For each cell:
- Calls the cell's vtable `find_collisions`.
- On Slid (4): clears `contact_plane_valid`, returns.
- On Collided (2) or Adjusted (3): returns immediately.
- On OK: continues to the next cell.

If the sphere is geometrically outside the original cell, the
fallback (line 272761-272797) sets `check_cell = var_4c` (the cell
containing the final position) and adjusts `check_pos.objcell_id`.

### What we already have

Phase 2 portal cell-tracking is shipped (commits `1969c55` → `eb0f772`,
2026-05-19). It gives us:
- `CellTransit.FindCellList` (sphere variant) — top-level driver.
- `CellTransit.FindTransitCellsSphere` — interior portal neighbour expansion.
- `CellTransit.AddAllOutsideCells` — outdoor landcell neighbour expansion.
- `CellPhysics.VisibleCellIds` — pre-computed visible-cell set per cell.

These currently feed **cell-id resolution** (step 1 above). They are
NOT yet used to drive **collision iteration** (step 2). A4's job is to
wire them into `Transition.FindEnvCollisions`.

### Implementation outline for A4

1. **In `Transition.FindEnvCollisions`** (`src/AcDream.Core/Physics/TransitionTypes.cs:1407-1559`):
   - Currently: queries one cell (`engine.DataCache.GetCellStruct(sp.CheckCellId)`)
     and runs `BSPQuery.FindCollisions` against its BSP.
   - Change to: build the cell_array from the current cell using
     `CellTransit.FindCellList` (or a new variant that returns the
     full set), then iterate each cell and run BSP collision against
     each. Combine results.
2. **Combine semantics** match retail's `check_other_cells`:
   - Any cell returning `Collided` (2) or `Adjusted` (3) → return that
     immediately (halt iteration).
   - Any cell returning `Slid` (4) → record but continue (in case
     another cell collides harder). After all cells: return Slid.
   - All cells OK → return OK.
3. **Outdoor case**: if the resolved cell is outdoor, iterate adjacent
   outdoor landcells via `AddAllOutsideCells` and any indoor cells
   accessible via building portals (`CheckBuildingTransit`). Both
   already exist as helpers.
4. **Shadow objects (the L.2d `[resolve-bldg]` path)** likely also need
   multi-cell awareness — `FindObjCollisions` only checks shadows
   keyed to the player's current cell. After A1.5, interior shadows
   are scoped to their `ParentCellId`, so multi-cell iteration
   automatically picks them up too.
5. **Testing strategy**:
   - Unit tests: synthetic two-cell fixture where wall lives in cell B
     and player is in cell A's vestibule. Assert collision fires.
   - Live capture: walk the Holtburg inn vestibule (`0xA9B40164`) and
     verify walls in `0xA9B40157` now block.
6. **Performance**: each cell query is ~50 µs. Multi-cell iteration
   visits ~3-7 cells in worst case. ~200-350 µs extra per resolve.
   At 30 Hz that's ~10 ms/sec. Acceptable.

### Risks

- **R1**: shadow objects in cells visible from multiple positions may
  get tested multiple times in one frame. Need dedup via the existing
  `_entityToCells` map.
- **R2**: cells in `cell_array` may have stale `CellPhysics` (loaded
  for rendering but not for physics). Guard with `cellPhysics?.BSP?.Root is not null`.
- **R3**: the existing `BSPQuery.FindCollisions` mutates `Transition`
  state (SpherePath.CheckPos, CollisionInfo). Running it multiple
  times per frame requires either save/restore between cells or
  letting the first-hit's mutations stand (matching retail).

## Other pending items

### Phase A2 — PHSP inversion fix

`BSPQuery.PolygonHitsSpherePrecise` at `BSPQuery.cs:117` has its
early-return condition inverted vs retail's `polygon_hits_sphere_slow_but_sure`
at `acclient_2013_pseudo_c.txt:322509-322517`. Ours bails when sphere
is FAR from plane; retail bails when sphere is OVERLAPPING plane.

The actual fix is one line, but it doesn't fix walkable synthesis on
its own (because `AdjustSphereToPlane` still rejects tangent). It DOES
affect wall-collision precision at the tangent boundary. Pair with A3
(synthesis removal) for the full benefit.

### Phase A3 — synthesis removal

Delete `TryFindIndoorWalkablePlane` (TransitionTypes.cs:1294) and rely
on the three retail CP retention mechanisms (Mechanisms A/B/C). The
previous session (2026-05-20) tried this and reverted because
multi-cell iteration was missing, so doorway transitions caused
free-fall. With A1.7 + A4 in place, A3 should work.

### Lighting bugs

- **Indoor lighting broken**: probably cell-light association or
  visibility culling for lights inside cells.
- **Spotlight projection**: per-entity light direction transform.

These are M7 polish, separate phase. Not blocking M2 ("kill a drudge").

## How to start a fresh session

Open a new Claude Code session **in the main acdream worktree**
(`C:\Users\erikn\source\repos\acdream`, branch `main` at SHA `56d2b5e`
or later). Then paste the block below:

---

```
Pick up the acdream collision-fix work from the 2026-05-21 session.

1. Read docs/research/2026-05-21-collision-fixes-shipped-handoff.md
   FIRST. It captures everything that shipped (4 fixes A1/A1.5/A1.6/A1.7
   + a probe spike) and what's left (Phase A4 multi-cell iteration is
   the next major user-visible win).

2. All 9 commits from the previous session are merged into main
   (HEAD 56d2b5e). Build green, no regressions in the 1129-test
   baseline, four user-visible visual improvements verified live.
   Local main is ahead of origin/main (origin at 7034be9, an older
   commit); push only if explicitly desired.

3. **Set up isolation FIRST.** Use the superpowers:using-git-worktrees
   skill to create a fresh worktree branched from main for the A4 work.
   Do NOT work directly on main in the parent worktree. The previous
   session's worktree (claude/lucid-goldberg-1ba520) can be removed —
   its branch is identical to main now.

4. The next phase to design + ship is **A4 (multi-cell BSP iteration)**.
   Sketch in §"Phase A4" of the handoff. Reads retail's
   CTransition::check_other_cells (acclient_2013_pseudo_c.txt:272717-272798).
   Wires the existing CellTransit helpers (FindCellList,
   FindTransitCellsSphere, AddAllOutsideCells) into
   Transition.FindEnvCollisions so collision is queried against ALL
   cells the sphere overlaps, not just the one cell the player's
   center is in.

5. CLAUDE.md rules apply:
   - No workarounds. Retail-faithful.
   - Probe-first, design-second. Already have [indoor-bsp] +
     [cell-transit] + [cell-cache] probes available.
   - Use the superpowers:brainstorming skill before writing code.
     A4 is a real architectural change deserving its own spec.
   - Visual verification at the Holtburg inn (cell 0xA9B40164
     vestibule) is the acceptance test — walls in cell 0xA9B40157
     should block when the player is "in" 0xA9B40164 but their sphere
     extends into 0xA9B40157.

6. M2 ("kill a drudge") is the active milestone. Indoor walking
   robustness is on the M2 critical path because dungeons have
   drudges. A4 is the last big collision fix needed for M2's
   "walkable indoor space" demo target.

7. Launch command (same as last session):
   $env:ACDREAM_DAT_DIR              = "$env:USERPROFILE\Documents\Asheron's Call"
   $env:ACDREAM_LIVE                 = "1"
   $env:ACDREAM_TEST_HOST            = "127.0.0.1"
   $env:ACDREAM_TEST_PORT            = "9000"
   $env:ACDREAM_TEST_USER            = "testaccount"
   $env:ACDREAM_TEST_PASS            = "testpassword"
   $env:ACDREAM_DEVTOOLS             = "1"
   $env:ACDREAM_PROBE_INDOOR_BSP     = "1"
   $env:ACDREAM_PROBE_CELL           = "1"
   $env:ACDREAM_PROBE_CELL_CACHE     = "1"
   dotnet build -c Debug
   dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
       Tee-Object -FilePath "launch-a4.log"

   DO NOT set ACDREAM_PROBE_RESOLVE — it lagged the client last
   session (400k+ log lines at 30 Hz).

State the milestone + chosen phase in your first action.
```

---

## Anti-patterns from this session

1. **Don't enable `ACDREAM_PROBE_RESOLVE` for live captures.** It
   emits one line per resolve call at 30 Hz, producing 400k+ lines
   per session and making the client laggy enough that the user
   couldn't move. Use the lighter `[indoor-bsp]` + `[cell-transit]`
   probes instead.

2. **Don't assume "walk through wall" means PHSP inversion.** This
   session walked through that misconception twice. The actual cause
   was different bugs each time (doubled cylinders, interior shadow
   bleed, cell-id stuck, missing physics polys in vestibule cells).
   Always capture probe data before designing fixes.

3. **Don't merge A1.5's pattern (`cellScope: entity.ParentCellId`)
   without understanding that interior shadows might need MULTI-cell
   scope, not just their parent cell.** A1.5 fixed the obvious leak
   but introduced "stairs span cells" gaps. The real fix needs A4.

4. **Don't skip visual verification between fixes.** Each of A1,
   A1.5, A1.6, A1.7 was visually confirmed before moving to the
   next. The user reported what was still broken at each step,
   which guided the next fix. Without that loop, we'd have shipped
   a "fix" that broke something else.

5. **Don't try to fix lighting bugs in the same session as
   collision bugs.** Different domain (rendering, not physics).
   Defer to its own session.

## Code anchors

### This session's fixes (in commit order)

- [`src/AcDream.Core/Physics/PhysicsDiagnostics.cs:246-277`](src/AcDream.Core/Physics/PhysicsDiagnostics.cs:246) — `ProbeWalkMissEnabled` flag.
- [`src/AcDream.Core/Physics/WalkMissDiagnostic.cs`](src/AcDream.Core/Physics/WalkMissDiagnostic.cs) — pure-function aggregator (full file).
- [`src/AcDream.Core/Physics/TransitionTypes.cs:1543-1586`](src/AcDream.Core/Physics/TransitionTypes.cs:1543) — `[walk-miss]` emission.
- [`src/AcDream.Core/Physics/PhysicsDataCache.cs:222-238`](src/AcDream.Core/Physics/PhysicsDataCache.cs:222) — `[floor-polys]` emission.
- [`src/AcDream.App/Rendering/GameWindow.cs:5830-5839`](src/AcDream.App/Rendering/GameWindow.cs:5830) — `_isLandblockStab` flag (A1).
- [`src/AcDream.App/Rendering/GameWindow.cs:6062-6064`](src/AcDream.App/Rendering/GameWindow.cs:6062) — mesh-AABB-fallback gate (A1).
- [`src/AcDream.Core/Physics/ShadowObjectRegistry.cs:34-92`](src/AcDream.Core/Physics/ShadowObjectRegistry.cs:34) — `cellScope` parameter (A1.5).
- [`src/AcDream.App/Rendering/GameWindow.cs`](src/AcDream.App/Rendering/GameWindow.cs) — 5 call sites pass `entity.ParentCellId ?? 0u` (A1.5).
- [`src/AcDream.App/Rendering/GameWindow.cs:5922-5933`](src/AcDream.App/Rendering/GameWindow.cs:5922) — `setup is not null && !_isLandblockStab` gate (A1.6).
- [`src/AcDream.Core/Physics/PhysicsEngine.cs:259-289`](src/AcDream.Core/Physics/PhysicsEngine.cs:259) — `PointInsideCellBsp` fall-through (A1.7).

### What A4 will touch

- [`src/AcDream.Core/Physics/TransitionTypes.cs:1407-1559`](src/AcDream.Core/Physics/TransitionTypes.cs:1407) — `FindEnvCollisions` (extend to iterate cell_array).
- [`src/AcDream.Core/Physics/CellTransit.cs`](src/AcDream.Core/Physics/CellTransit.cs) — already has the helpers; may need a new `EnumerateCells` variant that returns the set rather than picking one.
- [`src/AcDream.Core/Physics/PhysicsEngine.cs`](src/AcDream.Core/Physics/PhysicsEngine.cs) — `FindObjCollisions` may need similar treatment for shadow objects.

## Retail decomp anchors

- `acclient_2013_pseudo_c.txt:272717-272798` — `CTransition::check_other_cells` (A4 oracle).
- `:272565-272582` — `validate_transition` Mechanism B (LKCP proximity).
- `:273242-273340` — `transitional_insert` Mechanism C (step-down probe).
- `:322032-322077` — `CPolygon::adjust_sphere_to_plane`.
- `:322403-322500` — `CPolygon::polygon_hits_sphere`.
- `:322504-322593` — `CPolygon::polygon_hits_sphere_slow_but_sure` (A2 oracle — inversion).
- `:322974-322993` — `CPolygon::pos_hits_sphere` (front-face culling).
- `:323725-323939` — `BSPTREE::find_collisions` (full 6-path dispatcher).
- `:326211-326242` — `BSPNODE::find_walkable`.
- `:326706-326727` — `BSPLEAF::sphere_intersects_poly`.
- `:326793-326816` — `BSPLEAF::find_walkable`.

## Probe + diagnostic reference

| Env var | Volume | When to use |
|---|---|---|
| `ACDREAM_PROBE_INDOOR_BSP` | Low (indoor cells only) | Wall walk-through investigations. Logs `cell`, `wpos`, `lpos`, `result`, hit poly. |
| `ACDREAM_PROBE_CELL` | Very low (cell change events) | Cell-tracking issues. Logs old → new cell + position. |
| `ACDREAM_PROBE_CELL_CACHE` | One-shot per cell load | When you need cell BSP poly counts + bsphere. Identifies "vestibule" cells with sparse geometry. |
| `ACDREAM_PROBE_WALK_MISS` | High (per-frame MISS) | Walkable synthesis investigations (Phase A2/A3 work). |
| `ACDREAM_PROBE_BUILDING` | Medium | Building-shadow attribution. Multi-line `[resolve-bldg]` per hit. |
| `ACDREAM_PROBE_RESOLVE` | **VERY HIGH — DO NOT USE FOR LIVE PLAY** | Per-resolve attribution. 30 Hz × per-entity = 400k+ lines/session. Lagged the client this session. |
| `ACDREAM_PROBE_CONTACT_PLANE` | Medium | CP retention investigations. Bug B from 2026-05-20 era. |

### Log analysis recipe

```powershell
# 1. Convert UTF-16LE to UTF-8 for grep:
Get-Content launch.log -Encoding Unicode | Out-File launch.utf8.log -Encoding utf8

# 2. Quick counts:
grep -c '\[indoor-bsp\]' launch.utf8.log
grep -c '\[cell-transit\]' launch.utf8.log

# 3. Per-cell hit rate:
grep '\[indoor-bsp\] cell=0xA9B40164' launch.utf8.log | grep -oE 'result=[A-Za-z]+' | sort | uniq -c
```

## What this is NOT

This is **NOT** a complete fix for indoor walking. Walls walk-through-able
remain in cells where the PhysicsBSP has sparse coverage (vestibule
cells). A4 closes that gap by querying multiple cells per frame —
which is exactly what retail does.

This is **NOT** related to the PHSP inversion (A2). A2 fixes per-poly
overlap math precision at the tangent boundary. A4 fixes which cells
get queried. They're orthogonal.

This is **NOT** related to the lighting bugs the user reported. Those
are rendering-side; ignore in any collision work.

## References

- [`docs/research/2026-05-21-walk-miss-capture-findings.md`](2026-05-21-walk-miss-capture-findings.md) — probe spike findings.
- [`docs/superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md`](../superpowers/specs/2026-05-21-indoor-walk-miss-probe-design.md) — probe spec.
- [`docs/superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md`](../superpowers/specs/2026-05-21-cylinder-fallback-dedup-design.md) — A1 spec.
- [`docs/research/2026-05-20-indoor-walking-bug-a-handoff.md`](2026-05-20-indoor-walking-bug-a-handoff.md) — previous-session handoff (Bug B shipped, Bug A reverted).
- [`docs/research/2026-05-21-indoor-walking-doorway-investigation-prompt.md`](2026-05-21-indoor-walking-doorway-investigation-prompt.md) — the prompt that started this session.
