diff --git a/AcDream.slnx b/AcDream.slnx
index 1cf8f243..c004e101 100644
--- a/AcDream.slnx
+++ b/AcDream.slnx
@@ -13,6 +13,7 @@
+
diff --git a/CLAUDE.md b/CLAUDE.md
index b57414f3..4836d9c9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -126,12 +126,18 @@ ourselves".
both radii and MSAA/anisotropic/A2C/completions-per-frame as a unit.
Spec: `docs/superpowers/specs/2026-05-09-phase-a5-two-tier-streaming-design.md`.
-**Execution phases:** R1→R8 in the architecture doc. Each phase has clear
-goals, test criteria, and builds on the previous. Don't skip phases.
+**Execution model:** the active source of truth is the **milestones doc**
+(`docs/plans/2026-05-12-milestones.md`) for "what are we building right
+now" and the **strategic roadmap** (`docs/plans/2026-04-11-roadmap.md`)
+for the per-phase ledger of what's shipped, what's in flight, and what
+comes next. **Ignore the old "R1→R8" sequence** — it was an early refactor
+sketch that no longer matches reality (see the "Roadmap Model" section in
+`docs/architecture/acdream-architecture.md`). Per-phase detailed specs
+live under `docs/superpowers/specs/`.
-The codebase is organized by layer (see architecture doc). Current phase
-state lives in memory (`memory/project_*.md`), plans in `docs/plans/`,
-research in `docs/research/`.
+The codebase is organized by layer (see architecture doc + the **Code
+Structure Rules** section below). Plans live in `docs/plans/`,
+research in `docs/research/`, persistent project memory in `memory/`.
**UI strategy:** three-layer split — swappable backend (ImGui.NET +
`Silk.NET.OpenGL.Extensions.ImGui` for Phase D.2a, custom retail-look
@@ -164,12 +170,74 @@ click-to-rebind. As of Phase K (2026-04-26), ALL keyboard / mouse
input flows through the dispatcher — no IsKeyPressed polling outside
the per-frame movement queries.
+## Code Structure Rules
+
+These are the structural rules the project commits to. They are
+**process rules** (where code goes, what depends on what), not style
+rules (formatting / naming). They exist to keep the layer split honest
+and to stop `GameWindow.cs` from continuing to grow into a 10k-line
+god object. The full rationale + the extraction sequence we're
+pursuing live in [`docs/architecture/code-structure.md`](docs/architecture/code-structure.md).
+
+1. **No new substantial feature bodies in `GameWindow.cs`.** It is
+ already over 10,000 lines and owns runtime wiring. New runtime
+ work goes into a dedicated controller / sink / orchestrator class
+ in `src/AcDream.App/` (or deeper in `AcDream.Core` when it's pure
+ logic). Adding a handful of fields and a one-paragraph method to
+ wire an extracted class in is fine; adding a new ~200-line feature
+ directly is not. When in doubt, file a small follow-up extraction
+ as part of the change.
+
+2. **`AcDream.Core` must not depend on the window / GL / backend
+ projects, except via documented interop seams.** The only
+ currently-allowed seams are `WorldBuilder.Shared` (stateless helpers:
+ `TerrainUtils`, `TerrainEntry`, `RegionInfo`) and
+ `Chorizite.OpenGLSDLBackend.Lib` (stateless helpers only:
+ `SceneryHelpers`, `TextureHelpers`). New Core code that needs a GL
+ surface must define an interface in Core and let `AcDream.App`
+ implement it — never the reverse. If you need to add a project
+ reference to Core, the change must come with an inventory-doc
+ update explaining why.
+
+3. **UI panels target `AcDream.UI.Abstractions` only.** No panel may
+ import `AcDream.UI.ImGui` or any backend namespace. ViewModels,
+ commands, and the `IPanel` / `IPanelRenderer` contract are the
+ surface; everything else is backend. This is what lets us swap
+ D.2a (ImGui) for D.2b (retail-look) later without rewriting
+ panels.
+
+4. **Startup environment variables enter through a typed options
+ object.** `AcDream.App.RuntimeOptions` is the single source of
+ truth for startup configuration. `Program.cs` reads the
+ environment once into `RuntimeOptions` and passes it into
+ `GameWindow`. Don't sprinkle `Environment.GetEnvironmentVariable`
+ reads through new code paths; add a field to `RuntimeOptions` and
+ pipe it through.
+
+5. **Runtime probes (diagnostic toggles) belong in diagnostic owner
+ classes.** Today `AcDream.Core.Physics.PhysicsDiagnostics` owns the
+ `ACDREAM_PROBE_*` family. The pattern: one static class per
+ subsystem, exposing typed bool/int properties read from env vars
+ once at startup (with optional runtime-toggleable counterparts for
+ the DebugPanel). Per-call-site `Environment.GetEnvironmentVariable`
+ reads in new code are a process smell — if a flag survives one
+ phase, promote it to a diagnostic owner. The dozens of existing
+ `ACDREAM_DUMP_*` reads scattered through `GameWindow` are tech
+ debt; do not add more.
+
+6. **Tests live in the project matching the layer under test.** Core
+ tests in `tests/AcDream.Core.Tests/`, UI tests in
+ `tests/AcDream.UI.Abstractions.Tests/`, network tests in
+ `tests/AcDream.Core.Net.Tests/`. App-layer tests (RuntimeOptions
+ parsing, etc.) belong in `tests/AcDream.App.Tests/`. When adding a
+ new test project, register it in `AcDream.slnx`.
+
## How to operate
**You are the lead engineer AND architect on this project at all times.**
You own the architecture (`docs/architecture/acdream-architecture.md`),
-the execution plan (phases R1–R8), the development workflow, and all
-technical decisions. Stop as little as possible. Drive work autonomously and continuously through full phases and
+the execution plan (milestones doc + strategic roadmap), the development
+workflow, and all technical decisions. Stop as little as possible. Drive work autonomously and continuously through full phases and
across commit boundaries. Do not stop mid-phase for routine progress check-ins,
permission asks on low-stakes design calls, or "should I continue?" confirmations.
The user has repeatedly authorized direct-to-main commits, multi-commit sessions,
@@ -179,6 +247,26 @@ The only thing that genuinely requires stopping is **visual confirmation** — t
user needs to look at the running client and tell you whether it matches
retail. Everything else is your call.
+**No workarounds without explicit approval.** When you spot a bug or
+encounter a behavioral mismatch, fix the underlying cause — do not ship a
+band-aid, suppression flag, grace period, retry loop, or any other "make
+the symptom go away" shortcut, unless the user has explicitly approved
+that shape OR you are building a NEW feature with a different design.
+This rule exists because every workaround creates architectural debt that
+masks the real issue, makes future refactors harder, and erodes the
+codebase's retail-faithfulness. Examples of disallowed shortcuts: an
+`if (problematicState) return early` guard at the symptom site instead of
+investigating why the state happened; a timer-based "settle period" to
+hide a race; a flag like `_suppressXDuringY` to mask a wire-level mistake;
+a `try/catch` swallowing an exception that signals a real problem. If you
+notice a fix is starting to look like a workaround mid-implementation,
+stop, file the proper investigation as an issue with full reproduction
+notes, and either (a) ask the user before shipping the workaround, or
+(b) invest the time to fix the root cause. The user has explicitly
+authorized "spend more time, get it right" over "ship a shortcut and
+file the cleanup." Quote them: "we should have no workarounds unless I
+say so or we want a different feature."
+
**Only stop and wait for the user when:**
- Visual verification is the acceptance test ("does the drudge look right now?")
@@ -603,10 +691,21 @@ acdream operates at **two altitudes** above the daily commit:
section). Phase-level index. This is where you orient when you know
the milestone and need the next concrete sub-phase.
-**Currently working toward: M1 — Walkable + clickable world.** L.2
-collision + B.4 interaction. Demo target: walk through Holtburg without
-getting stuck, open the inn door, click an NPC, pick up an item.
-Estimated 4–6 weeks from 2026-05-12.
+**M1 landed 2026-05-16** via Phase B.6 (`d640ed7`). L.2 collision +
+B.4 interaction + B.5 pickup + B.6 server-driven auto-walk all
+shipped. The four demo targets work end-to-end: walk Holtburg, open
+inn door, click NPC, pick up item. Freeze list active — M1's phases
+are off-limits until M7 polish. Writeup at top of M1 block in
+`docs/plans/2026-05-12-milestones.md`.
+
+**Currently working toward: M2 — "Kill a drudge."** Equip a sword,
+walk to a drudge, swing, see damage in chat, watch the swing
+animation, drudge dies and drops loot, pick up the loot, open
+inventory and see it. Phases to ship: F.2 (Inventory panel), F.3
+(Combat math + damage flow), F.5a (visible-at-login dev panels —
+Attributes / Skills / Equipped / Inventory list, minimal ImGui),
+L.1c (combat animation wiring), L.1b (command router prereq).
+~6–10 weeks from 2026-05-16.
**Work-order autonomy — the meta-rule.** You decide what to work on
next, always. **The user does NOT pick between phases, milestones, or
@@ -640,14 +739,14 @@ four below actually work.
explicitly post-M7. The freeze list per milestone lives in the
milestones doc.
-3. **Each milestone hit gets a recorded demo video.** When M1 lands,
- record ~30 seconds of the demo scenario, drop it at
- `docs/milestones/M1-walkable-clickable.mp4`, and pin a still + a
- one-paragraph writeup at the top of `2026-05-12-milestones.md`. The
- freeze list updates. The "currently working toward" line in this
- CLAUDE.md updates to M2. **Crossing a milestone is a real event with
- an artifact** — that's the morale instrument. Phases ship; milestones
- land.
+3. **Crossing a milestone is a textual event, not a video event.**
+ When a milestone's demo scenario is functionally complete, update
+ `2026-05-12-milestones.md` with a one-paragraph writeup describing
+ what works end-to-end, flip the freeze list, and update the
+ "currently working toward" line in this CLAUDE.md to the next
+ milestone. Do NOT ask the user to record a demo video — they find
+ it pointless. The milestones doc + the CLAUDE.md flip ARE the
+ milestone artifact. Phases ship; milestones land.
4. **State both altitudes at session start.** First action of any
session: "Currently working toward M1 — Walkable + clickable world.
@@ -677,7 +776,36 @@ acdream's plan lives in two files committed to the repo:
acceptance criteria. Do not drift from the spec without explicit user
approval.
-**Currently in Phase L.2 (Movement & Collision Conformance).** L.2a slices
+**Indoor walking Phase 2 — Portal-based cell tracking shipped
+2026-05-19.** Six commits:
+- `1969c55` — CellBSP + Portals wired into CellPhysics (`PortalInfo` struct, `VisibleCellIds`)
+- `aad6976` — `CellTransit.FindCellList` + `FindTransitCellsSphere` + `AddAllOutsideCells`; `ResolveCellId` rename
+- `069534a` — `BuildingPhysics` + `CheckBuildingTransit` for outdoor→indoor entry via `BldPortalInfo`
+- `702b30a` — code-review polish (DRY cell-id derivation, `PortalFlags.ExactMatch` enum, docs)
+- `3ffe1e4` — critical fix: pass foot-sphere center (`GlobalSphere[0].Origin`) not `CheckPos` to `ResolveCellId`
+- `eb0f772` — `TryFindIndoorWalkablePlane` synthesizes indoor walkable plane from cell floor poly
+
+**#86** (click selection penetrates walls) — **CLOSED** (Phase 1 Cluster A).
+**#84** (blocked by air indoors) — **FULLY CLOSED.** Spawn-in-building variant
+closed by Phase 1 (Phase D AABB containment). Wall-block-from-inside variant
+closed by Phase 2 (portal-graph traversal).
+**#85** (pass through walls outside→in) — **CLOSED** by Phase 2.
+`CheckBuildingTransit` promotes CellId via the building-shell portal graph
+on outdoor→indoor entry; indoor-BSP collision fires from both sides.
+**#87** (indoor portal-based cell tracking) — **CLOSED** by Phase 2.
+**#88** (indoor static objects vibrate) — **FILED** (pre-existing, Medium).
+**#89** (port `BSPQuery.SphereIntersectsCellBsp`) — **FILED** (Low, documented
+approximation in `CheckBuildingTransit`).
+Diagnostic infrastructure: `[indoor-bsp]`, `[cell-cache]`, `[cell-transit]`,
+`[check-bldg]` probes all stay in place.
+Handoff: [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md).
+Phase 1 handoff: [`docs/research/2026-05-19-cluster-a-shipped-handoff.md`](docs/research/2026-05-19-cluster-a-shipped-handoff.md).
+
+**Next phase is Claude's choice** per work-order autonomy. Candidates:
+M2 critical path (F.2 / F.3 / F.5a / L.1c / L.1b — kill-a-drudge demo);
+or the pre-existing "next phase candidates" list below.
+
+**Previously in Phase L.2 (Movement & Collision Conformance).** L.2a slices
1+2+3 + L.2d slice 1+1.5 + L.2g slice 1 + L.2g slice 1b + L.2g slice 1c +
**Phase B.4b** + **Phase B.4c** all shipped and visual-verified 2026-05-13;
**Phase B.5** (ground-item pickup, F-key) shipped and visual-verified
@@ -926,20 +1054,50 @@ for the window to close.
### Logout-before-reconnect
-**ACE keeps your last session alive briefly after a disconnect.** If you
-relaunch the client within a few seconds of the last close, the handshake
-fails with `live: session failed: CharacterList not received` and the
-process exits with code 29. Wait ~3–5 seconds between launches, or explicitly
-kill stale processes:
+**ACE keeps your last session alive after a disconnect, and the duration
+depends on HOW the client exited.** Two cases:
+
+1. **Graceful close (client sent logout packet to ACE):** session clears
+ in ~3–5 seconds. Wait briefly between launches.
+2. **Hard kill (Stop-Process, crash, force-close):** no logout packet
+ reached ACE. ACE keeps the session marked logged-in until its own
+ timeout — observed in practice at ~3+ minutes. Subsequent relaunches
+ fail with `live: session failed: CharacterList not received` (exit 29)
+ the entire time. **There is no admin command available to us to kick
+ the stale session.** Either wait it out, or use the graceful path
+ below.
+
+**Prefer the graceful close path when ending a launch.** PowerShell's
+`Stop-Process` is a hard kill — it bypasses the client's shutdown hook
+which is where the logout packet would have been sent. The graceful
+alternative sends WM_CLOSE so the window's close handler runs:
```powershell
-Get-Process -Name AcDream.App -ErrorAction SilentlyContinue | Stop-Process -Force
+$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
+if ($proc) {
+ $proc.CloseMainWindow() | Out-Null
+ if (-not $proc.WaitForExit(5000)) {
+ # Fell through to hard-kill — session WILL be stuck on ACE.
+ $proc | Stop-Process -Force
+ }
+}
Start-Sleep -Seconds 3
# ... then launch ...
```
-The user has repeatedly confirmed this — don't treat exit-29-after-rapid-relaunch
-as a code bug. It's a server-side session-cleanup delay.
+If `WaitForExit(5000)` returns false (the client didn't exit in 5 seconds
+after WM_CLOSE), the client is unresponsive and a hard kill is the only
+option — accept that ACE will be unhappy for a few minutes.
+
+**When recovering from a hard-killed session that ACE still considers
+active:** the only honest answer is to wait. Don't bother retrying every
+30 seconds — make a single retry attempt ~3 minutes after the kill, and
+if it still fails wait another 2 minutes before trying again. The user
+will likely volunteer when ACE has cleared the session if you ask.
+
+The user has repeatedly confirmed this — don't treat exit-29-after-relaunch
+as a code bug. It's a server-side session-cleanup delay whose duration is
+governed by whether the previous shutdown was graceful or forced.
### Test character
@@ -1034,13 +1192,19 @@ already-running ACE session via the handshake race.
stop transitions, and keep their visual position tracked smoothly
between the 5–10 Hz UpdatePosition bursts (dead-reckoning).
-## Reference repos: check ALL FOUR, not just one
+## Reference repos: cross-check the relevant ones
-When researching a protocol detail, dat format, rendering algorithm, or
-any "how does AC do X" question, **check all four of the vendored
-references in `references/`** before committing to an approach. Do not
-settle on the first hit and move on — cross-reference at least two of
-these, ideally all four:
+The `references/` tree holds **six** vendored projects (ACE, ACViewer,
+WorldBuilder, Chorizite.ACProtocol, holtburger, AC2D). They overlap in
+some areas and disagree in others. Before committing to an approach,
+**cross-reference at least two of them** for the domain you're working
+in — the per-domain hierarchy in the next section tells you which to
+read first. A single reference can be misleading; the intersection of
+the relevant references is almost always the truth. The user has
+repeatedly had to remind me about this when I narrowly searched one ref
+and missed obvious answers in another.
+
+The six references:
- **`references/ACE/`** — ACEmulator server. Authority on the wire
protocol (packet framing, ISAAC, game message opcodes, serialization
@@ -1052,17 +1216,21 @@ these, ideally all four:
`ACViewer/Render/TextureCache.cs::IndexToColor` for the canonical
subpalette overlay algorithm.
- **`references/WorldBuilder/`** — **acdream's rendering + dat-handling
- BASE (not just a reference).** As of 2026-05-08 acdream is moving to
- fork WorldBuilder upstream and depend on the fork for terrain,
- scenery, static objects, EnvCells, portals, sky, particles, texture
- decoding, mesh extraction, visibility/culling. WorldBuilder is
- MIT-licensed, exact-stack match (Silk.NET + .NET), and verified to
- render the world correctly. **Before re-porting any rendering or
- dat-handling algorithm from retail decomp, check
- `docs/architecture/worldbuilder-inventory.md` first.** If WB has it,
- use WB's port. If WB doesn't have it (network, physics, animation,
- movement, UI, plugin, audio, chat), port from retail decomp as
- before.
+ base.** WorldBuilder is not just a reference: as of Phase N.4 (shipped
+ 2026-05-08), `ObjectMeshManager` is the production mesh pipeline,
+ `WbMeshAdapter` is the seam, and `WbDrawDispatcher` is the production
+ draw path. The modern path (`N.5`) is **mandatory** — missing bindless
+ throws at startup, there is no legacy fallback. **Before re-porting
+ any rendering or dat-handling algorithm from retail decomp, read
+ `docs/architecture/worldbuilder-inventory.md` first.** The inventory
+ tells you what WB covers (terrain, scenery, static objects, EnvCells,
+ portals, sky, particles, texture decoding, mesh extraction,
+ visibility/culling) and what we still write ourselves (the 🔴 list:
+ network, physics, animation, movement, UI, plugin, audio, chat).
+ WorldBuilder is MIT-licensed and exact-stack with us (Silk.NET +
+ .NET); the divergences we've documented (e.g. WB's terrain split
+ formula vs retail's `FSplitNESW`) are called out in the inventory
+ doc.
- **`references/Chorizite.ACProtocol/`** — clean-room C# protocol
library generated from a protocol XML description. Useful sanity check
on field order, packed-dword conventions, type-prefix handling. The
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index d55d87c0..bae6f7c2 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -46,13 +46,801 @@ Copy this block when adding a new issue:
# Active issues
-## #69 — Local player rotation isn't animated (no leg/arm cycle while pivoting)
+---
+
+## #87 — Drop WB fork patch by switching to PrepareEnvCellGeomMeshDataAsync
**Status:** OPEN
+**Severity:** MEDIUM (band-aid removal; not user-visible)
+**Filed:** 2026-05-19
+**Component:** rendering, WB integration
+
+**Description:** Phase 2 (2026-05-19) shipped a one-line patch in our
+WB fork at `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230`
+(branch `acdream` on the fork, SHA `34460c4`) to guard a blind
+`TryGet(stab.Id, ...)` call against GfxObj-prefixed ids. That
+patch fixes the symptom (missing floors) but is structurally a
+band-aid — per CLAUDE.md's no-workarounds rule we should retire it.
+
+The proper fix: switch our EnvCell rendering from
+`PrepareMeshDataAsync(envCellId, ...)` (general-purpose entry that
+also iterates static-object parts + emitters we don't need) to WB's
+narrower `PrepareEnvCellGeomMeshDataAsync(geomId, environmentId, cellStructure, surfaces)`
+at [`ObjectMeshManager.cs:386`](../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:386).
+That function only builds the cell room mesh (floor / walls / ceiling),
+which is the only piece we actually use from WB for cells — we already
+hydrate static objects as separate `WorldEntity` instances in
+`BuildInteriorEntitiesForStreaming`, and we run particle scripts via
+our own `EntityScriptActivator` (Phase C.1.5b).
+
+**Root cause / status:** Misuse of WB's general-purpose API for a
+geometry-only need. The general-purpose path triggers static-object
+iteration that has a bug (TryGet without type check) AND that
+does work we throw away. Both problems disappear if we use the
+geometry-only entry point WB already exposes for exactly this purpose
+(it's what WB's own `EnvCellRenderManager` uses internally).
+
+**Trade-offs:**
+
+| | Current (patched WB) | Switch to geom-only API |
+|---|---|---|
+| WB fork divergence | One-line patch | Zero |
+| Future WB upstream merges | Conflicts | Clean |
+| Performance | Slightly worse (wasted iteration) | Slightly better |
+| Risk to other functionality | None (working today) | Needs re-verification |
+
+**Files (the change):**
+
+- `src/AcDream.App/Rendering/GameWindow.cs` around line 5367-5378
+ (cell-entity hydration — change `MeshRefs[0].GfxObjId` from `envCellId`
+ to `envCellId | 0x100000000UL`, the synthetic geom id with bit 32 set).
+- `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` — add a new method
+ `PrepareEnvCellGeomMesh(ulong geomId, uint environmentId, ushort cellStructure, List surfaces)`
+ that forwards to `_meshManager.PrepareEnvCellGeomMeshDataAsync(...)`,
+ and call it from the streaming path instead of the bare
+ `IncrementRefCount(envCellId)`.
+- `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230`
+ — revert the type-check guard we added. The function returns to
+ pristine WB state.
+
+**Acceptance:**
+
+- Floors still render in Holtburg Inn (regression check vs Phase 2).
+- `references/WorldBuilder` submodule pointer returns to upstream-clean
+ (no acdream-specific commits in the fork's `acdream` branch — or
+ rather, the `acdream` branch fast-forwards back to match upstream's
+ state for this file).
+- Probe re-capture at Holtburg confirms `[indoor-upload] completed` for
+ all cells previously failing.
+- No `[wb-error]` lines.
+
+**Research:** [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](research/2026-05-19-indoor-cell-rendering-cause.md)
+documents the underlying WB bug.
+
+---
+
+## Indoor walking issue cluster (2026-05-19)
+
+The Phase 2 indoor cell rendering fix (floor now renders inside buildings)
+surfaced nine pre-existing indoor bugs the user observed at Holtburg Inn
+the moment they could walk indoors. None caused by the floor fix — all
+existed before but were unobservable because there was no floor to stand
+on. Filed individually below; #78 + #84 + #85 + #86 likely share a root
+cause (cell BSP / portal-cull plumbing), and #79 + #80 + #81 + #82 share
+the indoor-lighting plumbing.
+
+---
+
+## #78 — Outdoor stabs/buildings visible through the rendered floor
+
+**Status:** OPEN
+**Severity:** HIGH (immediate visual jank now that floors render)
+**Filed:** 2026-05-19
+**Component:** rendering, visibility
+
+**Description:** Standing inside Holtburg Inn looking at the floor or
+walls, the user sees other buildings in the distance at their correct
+world position + scale — but visible THROUGH the floor and walls. As if
+the cell mesh is rendered but doesn't occlude or stencil-cull what's
+behind it.
+
+**Root cause / status:** Two plausible causes:
+1. The `+0.02f` Z bump applied to cell origin at `GameWindow.cs:5362`
+ pushes the floor mesh 2 cm above terrain, so depth test correctly
+ occludes terrain. But OUTDOOR STABS (landblock-baked building geometry)
+ at the same X,Y may have Z values comparable to or higher than the
+ cell-mesh floor, producing z-fighting / see-through.
+2. Outdoor stabs aren't being culled when the player is inside an
+ EnvCell — this is the Phase 1 Task 3 deferred work
+ ("Cull outdoor stabs when indoors via VisibleCellIds"). WB has a
+ `RenderInsideOut` stencil pipeline (`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/VisibilityManager.cs`)
+ that acdream never invokes.
+
+**Files:**
+- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (per-entity walk —
+ consider gating outdoor stab entities on visible-cell membership).
+- `src/AcDream.App/Rendering/CellVisibility.cs:222+` (`ComputeVisibility`
+ returns `VisibleCellIds`; the dispatcher already filters by
+ `entity.ParentCellId ∈ visibleCellIds` but outdoor stabs have
+ `ParentCellId == null` so they always pass).
+
+**Acceptance:** Standing inside a sealed-interior cell, no outdoor
+geometry is visible through floor/walls. Standing where a cell has a
+real outdoor portal (door open, window) outdoor geometry is correctly
+visible through the portal.
+
+---
+
+## #79 — Indoor lighting: spurious spot lights on walls
+
+**Status:** OPEN
+**Severity:** MEDIUM
+**Filed:** 2026-05-19
+**Component:** lighting
+
+**Description:** Walking around inside Holtburg Inn, the user sometimes
+sees spot-light-like patches on the interior walls that don't correspond
+to retail's lighting.
+
+**Root cause / status:** Point lights from cell static objects (torch
+entities) are being registered via `LightInfoLoader.Load` + `LightingHookSink`
+(Phase 1 verified). Their per-light parameters (position, range, intensity,
+cone) may be wrong — wrong falloff treatment, wrong world-space transform,
+or wrong direction for spot lights. Spec at
+`docs/research/deepdives/r13-dynamic-lighting.md` documents the retail
+LightInfo→LightSource mapping but the live behavior hasn't been verified
+against retail.
+
+**Files:**
+- `src/AcDream.Core/Lighting/LightInfoLoader.cs`
+- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` — `accumulateLights`
+ spot-cone logic.
+
+**Acceptance:** Side-by-side comparison with retail at the inn shows
+matching torch-light pools.
+
+---
+
+## #80 — Camera on 2nd floor goes very dark
+
+**Status:** OPEN
+**Severity:** MEDIUM
+**Filed:** 2026-05-19
+**Component:** lighting
+
+**Description:** Walking up to the second floor of a building, the
+lighting suddenly goes much darker than retail.
+
+**Root cause / status:** Possible causes:
+1. The `playerInsideCell` lighting trigger (Phase 1 / commit `1024ba3`)
+ uses `CellVisibility.IsInsideAnyCell(playerPos)` which is a brute-force
+ PointInCell scan. The 2nd floor cell may not be in the loaded set OR
+ may have wrong bounds.
+2. The per-cell ambient is currently a flat `(0.20, 0.20, 0.20)` for
+ any indoor cell. Retail has per-cell ambient overrides; ours doesn't
+ read them. A 2nd-floor cell with stairwell shadowing may need a
+ different value.
+
+**Files:**
+- `src/AcDream.App/Rendering/GameWindow.cs:8330+` (`UpdateSunFromSky`,
+ indoor branch).
+
+**Acceptance:** 2nd-floor cells render with similar brightness to
+ground floor; transition is not abrupt.
+
+---
+
+## #81 — Static building stabs don't react to atmospheric lighting changes
+
+**Status:** OPEN
+**Severity:** MEDIUM
+**Filed:** 2026-05-19
+**Component:** lighting, rendering
+
+**Description:** Outside, time-of-day changes (sunrise/sunset/lightning)
+don't visibly affect static building stabs (the inn / cottages). The
+buildings stay statically lit while terrain and scenery shift colors.
+
+**Root cause / status:** Stabs are rendered through `WbDrawDispatcher`
+with `mesh_modern.frag` which DOES consume the `SceneLightingUbo`
+(sun + ambient + fog). Verify the shader is being used for stabs and
+that the UBO is bound at the right binding slot per draw call.
+Possibly a shader-path divergence — terrain uses `terrain_modern.frag`,
+entities use `mesh_modern.frag`, but stabs/scenery may be on a
+different path.
+
+**Files:**
+- `src/AcDream.App/Rendering/Shaders/mesh_modern.frag`
+- `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+
+**Acceptance:** Stabs darken/brighten in sync with terrain + scenery
+across the day/night cycle.
+
+---
+
+## #82 — Some slope terrain lit incorrectly
+
+**Status:** OPEN
+**Severity:** LOW (cosmetic)
+**Filed:** 2026-05-19
+**Component:** rendering, terrain
+
+**Description:** Specific terrain slopes appear lit "wrong" compared to
+retail.
+
+**Root cause / status:** Likely terrain normal calculation or the
+landblock-edge normal-blending divergence between WB and retail (per
+`feedback_wb_migration_formulas.md` — WB's terrain split formula
+differs from retail's `FSplitNESW`).
+
+**Files:**
+- `src/AcDream.App/Rendering/TerrainModernRenderer.cs`
+- `src/AcDream.App/Rendering/Shaders/terrain_modern.frag`
+
+**Acceptance:** Side-by-side comparison with retail at the same Holtburg
+slopes shows matching shading.
+
+---
+
+## #83 — Indoor multi-Z walking broken (cellars, 2nd floors, intermittent falling-stuck)
+
+**Status:** OPEN — foundation work landed 2026-05-19, root-cause fix deferred to a follow-up investigation phase
+**Severity:** HIGH (blocks vertical indoor traversal + degrades single-floor cases)
+**Filed:** 2026-05-19
+**Component:** physics, movement, resolver
+
+**Description:** Walking UP stairs in single-floor houses works
+(grounded step-up routes through retail-faithful `BSPQuery.FindWalkableInternal`
+via `StepSphereDown`). Walking DOWN into cellars fails ("ground blocking" —
+can't descend). Walking on 2nd floors works partially but intermittently
+gets stuck in the falling animation. "Phantom collisions" / invisible
+obstacles in rooms persist. The original title "Walking up stairs broken"
+was misleading per user's clarification 2026-05-19.
+
+**Partial fix landed 2026-05-19 (6 commits `ff548b9` → `f845b22`).**
+Foundation work: extended `BSPQuery.FindWalkableInternal` to expose the
+hit polygon's dictionary key id; added thin public wrapper
+`BSPQuery.FindWalkableSphere` over the existing retail-faithful BSP
+walkable-finder (acclient_2013_pseudo_c.txt:326211 / :326793); refactored
+`Transition.TryFindIndoorWalkablePlane` to route through that wrapper
+instead of its Phase-2 linear first-match XY scan; added `[indoor-walkable]`
+runtime-toggleable probe line for diagnostic visibility. 5 new unit tests
++ 1 integration test, 9 pre-existing IndoorWalkablePlane tests updated
+to the new signature.
+
+**Foundation work did NOT fix the user-reported bugs.** Visual verification
+2026-05-19: cellar descent FAIL, 2nd-floor walking FAIL (intermittent
+falling-stuck), single-floor cottage REGRESSED to intermittent falling-stuck
+(was stable before), phantom collisions PERSIST. The probe captured 1443
+MISS / 2 HIT over 1445 indoor-walkable calls — the BSP walker correctly
+rejects the foot-sphere-tangent-to-floor case (sphere center is exactly
+at `floorZ + radius` when grounded, so `PolygonHitsSpherePrecise` fails
+the `|dist| > radius - epsilon` check by ~0.0002).
+
+**Root cause (deeper than originally diagnosed):** `Transition.TryFindIndoorWalkablePlane`
+fundamentally exists as a Phase 2 commit `eb0f772` stop-gap to synthesize
+a ContactPlane every frame when the indoor BSP returns OK. Retail doesn't
+do this — retail RETAINS the previous frame's `ContactPlane` when the
+collision dispatcher says "no collision." There is no retail analog of
+`find_walkable` being called as a standing-still query — retail's
+`find_walkable` only runs inside a downward sphere sweep
+(`step_sphere_down`), where the sphere is moving and the overlap test
+is meaningful. In our `TryFindIndoorWalkablePlane` flow, the sphere is
+tangent (grounded), not moving — the algorithm correctly returns "no
+overlap." The single-floor cottage worked previously because the OLD
+linear scan ignored Z and falsely returned HIT for any XY-overlapping
+walkable; the new BSP-walker correctly identifies "no overlap" and
+falls through to the outdoor terrain backstop, which only happens to
+produce sensible Z for single-floor outdoor-adjacent cases.
+
+**Files in the foundation work:**
+- `src/AcDream.Core/Physics/BSPQuery.cs` — `FindWalkableInternal` signature extension, new `FindWalkableSphere` public wrapper
+- `src/AcDream.Core/Physics/TransitionTypes.cs` — `TryFindIndoorWalkablePlane` refactor, `PointInPolygonXY` deletion, `[indoor-walkable]` probe
+- `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs` — 4 new unit tests
+- `tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs` — new integration test
+- `tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs` — 9 tests updated to new signature
+
+**Next investigation phase (deferred):** Port retail's `ContactPlane` retention
+mechanism so the resolver retains the previous frame's contact plane when
+the BSP says "no collision," instead of re-synthesizing it per frame. The
+proper fix likely eliminates `TryFindIndoorWalkablePlane` entirely. Needs
+deep investigation of retail's `CTransition::transitional_insert` /
+`CPhysicsObj::transition` / `LastKnownContactPlane` interactions. Foundation
+work (BSP walker + probe + tests) remains useful regardless of approach.
+
+**Acceptance:** Walk down stairs into a cellar without getting stuck.
+Walk on a 2nd floor without intermittent falling-stuck. Single-floor
+cottage walking remains stable (no regression).
+
+**Handoff:** [`docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md`](research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md).
+
+---
+
+## #84 — [DONE 2026-05-19] Blocked by air indoors
+
+**Status:** DONE
+**Closed:** 2026-05-19
+**Severity:** HIGH (blocks indoor navigation)
+**Filed:** 2026-05-19
+**Component:** physics, collision
+
+**Description:** While walking inside buildings, the player sometimes
+collides with invisible obstacles in mid-floor where there's nothing
+visible.
+
+**Root cause / status:** Cell BSP geometry doesn't align with the
+visible cell mesh. Possibilities:
+1. The `cellTransform` applied to physics in
+ `_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform)`
+ at `GameWindow.cs:5384` includes the `+0.02f` Z bump, but the BSP
+ geometry may not be lifted with it — physics geometry sits 2cm BELOW
+ render geometry, so invisible "ceilings" at floor-level cause
+ blockage.
+2. CellStruct BSP contains polygons that the cell mesh doesn't include
+ (or vice versa) — the two are derived from different fields.
+
+**Files:**
+- `src/AcDream.App/Rendering/GameWindow.cs:5362-5384` (cellOrigin Z bump
+ + physics cache call).
+
+**Acceptance:** Walking through interior cell space hits collisions
+only where visible walls/furniture exist.
+
+**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 — [DONE 2026-05-19 · 1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772] Pass through walls from outside→in
+
+**Status:** DONE
+**Closed:** 2026-05-19
+**Commits:** `1969c55, aad6976, 069534a, 702b30a, 3ffe1e4, eb0f772`
+**Filed:** 2026-05-19
+**Component:** physics, collision
+
+**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.
+
+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.
+
+---
+
+## #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 (visual jitter; doesn't block gameplay)
+**Filed:** 2026-05-19
+**Component:** rendering, animation
+
+**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:
+
+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.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:** `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.
+
+---
+
+
+
+**Status:** DONE
+**Severity:** MEDIUM (refactor blocker; doesn't affect main branch which is unchanged)
+**Filed:** 2026-05-16
+
+**Resolution (2026-05-16 · `0b25df5`):** Step 2 re-attempted with
+`[step2-diag]` traces at every hypothesized fault point. The traces
+showed all four hypotheses were wrong — `session.hashcode` was identical
+through `_liveSession`, `_liveSessionController.Session`, and the
+captured `liveSession` local in the chat-bus lambda, ruling out
+identity mismatches and closure-capture bugs. Doors verified via
+inbound `OnLiveMotionUpdated` round-trip (cmd=0x000B open, cmd=0x000C
+close). Pickup verified via 4 successful `[B.5] pickup` calls. The
+previous broken run was almost certainly a stale ACE session (no other
+code-level explanation survives the diag trace). One small material
+diff: the chat-bus lambda's `var liveSession = _liveSession;` capture
+became `var liveSession = session;` (the non-null parameter) so the
+compiler can statically prove non-null inside the lambda — both pointed
+to the same `WorldSession` instance, only the static analysis changed.
+
+Traces stripped before commit. Walking-range auto-walk bug observed
+during the second verification run is pre-existing (filed as #77, not
+caused by this refactor).
+
+**Description:** A first attempt at Step 2 — extracting `LiveSessionController`
+
+**Description:** A first attempt at Step 2 — extracting `LiveSessionController`
+out of `GameWindow.cs` — was implemented and reverted in the same session
+on the `claude/hungry-tharp-b4a27b` worktree. Visual verification at
+Holtburg revealed:
+
+- Chat input field accepts text + Enter but nothing is sent (no echo, no
+ ACE response).
+- Double-click on doors / NPCs fires `[B.4b] use guid=... seq=N` outbound
+ (verified in `launch.log`) but no visible client-side effect (door doesn't
+ swing, NPC doesn't dialogue).
+- R + click-target produces `[B.4b] use-deferred guid=... seq=N`, the
+ player auto-walks to the target, but the deferred Use does NOT fire on
+ arrival (regresses the Phase B.6 / issue #63 / #75 work).
+
+The Step 1 (`eda936d` RuntimeOptions) and Rule 5 follow-up
+(`32423c2` DumpSteepRoof → PhysicsDiagnostics) commits are NOT affected
+and stay clean.
+
+**Root cause / status:** Unknown. The refactor preserved every event
+subscription line-for-line (verified by `git diff` — only one `_liveSession.X +=`
+line moved, all others present). The new shape:
+
+```
+TryStartLiveSession()
+ → _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents)
+ → new WorldSession(endpoint)
+ → wireEvents(session) // i.e. WireLiveSessionEvents(session)
+ → Chat.OnSystemMessage("connecting...")
+ → _liveSession.Connect(user, pass)
+ → ...character validation + EnterWorld + post-setup...
+```
+
+Looks identical to the original control flow. Hypotheses to test on a
+clean re-attempt:
+
+1. **Timing of `_liveSession` field assignment.** The new code assigns
+ `_liveSession` inside `WireLiveSessionEvents` before subscriptions
+ run, and again after CreateAndWire returns. The original code set
+ `_liveSession` once at the inline `new WorldSession(...)` site. A
+ subtle ordering bug between subscriptions and `_liveSession`'s
+ externally visible state may matter.
+2. **LiveCommandBus closure capture.** The `var liveSession = _liveSession;`
+ capture inside the chat handler block may have been getting a
+ different value than before — though the field IS set by the time
+ the capture happens (line 1 of `WireLiveSessionEvents`).
+3. **Inbound packet ordering.** ACE may be sending the first
+ StateUpdate / spawn stream BEFORE the EnterWorld dance completes in
+ the new flow; if subscriptions are wired but `_liveSession` field
+ is briefly inconsistent, an early handler call could see a partial
+ state. The `_liveSession?.Tick()` route now goes through
+ `_liveSessionController?.Tick()`; verify that's not the difference.
+4. **Some non-subscription side effect** in `WireLiveSessionEvents`
+ that wasn't carried over correctly — over-indentation suggests a
+ diff-friendly intermediate state; full re-indentation may surface
+ the bug.
+
+**Files (in the reverted state — recover from worktree git reflog or
+re-write):**
+- `src/AcDream.App/Net/LiveSessionController.cs` (new, ~115 LOC)
+- `src/AcDream.App/Rendering/GameWindow.cs` — `TryStartLiveSession` split
+ + new `WireLiveSessionEvents` method
+
+**Research:** No memory entry yet. If the re-attempt succeeds, add a
+`feedback_step2_extraction_pitfalls.md` capturing whichever hypothesis
+turned out to be the bug.
+
+**Acceptance:** Step 2 lands when the full M1 demo loop (walk Holtburg,
+double-click inn door + door swings, double-click NPC + NPC dialogues,
+F-key pickup on a ground item) works identically to the pre-refactor
+behavior, AND chat input echoes back through the panel.
+
+---
+
+## #75 — [DONE 2026-05-16 · `f035ea3`] Auto-walk should drive body directly, not synthesize player-input
+
+**Status:** DONE
+**Severity:** LOW (functionally correct via grace-period band-aid; architectural cleanup only)
+**Filed:** 2026-05-16
+**Component:** physics / auto-walk
+
+**Resolution (2026-05-16 · `f035ea3`):** Refactored `ApplyAutoWalkOverlay` → `DriveServerAutoWalk`. Auto-walk now steps Yaw, sets `_body.set_local_velocity` from runRate, and calls `_motion.DoMotion(WalkForward, speed)` directly — NO `MovementInput` synthesis. `Update` gates the user-input motion + velocity section on `!autoWalkConsumedMotion` to prevent overwrite. The 500ms arrival grace period (band-aid) deleted. The wire-layer `!IsServerAutoWalking` guard at `GameWindow.cs:6419` retained as a semantic statement (user-MoveToState is for user-driven intent only), not as a band-aid for the synthesis leak that no longer exists. Animation cycle plumbed through via `localAnimCmd` / `localAnimSpeed` for both moving-forward and turn-first phases (issue #69 folded in). Walk/run threshold corrected to 1.0m (overrides ACE's wire-supplied 15.0f; matches user-observed retail behaviour + ACE's own physics layer default). `IsPickupableTarget` now checks `BF_STUCK` (`acclient.h:6435`) to correctly block signs/banners that share Misc ItemType with real pickup items.
+
+**Description:** `ApplyAutoWalkOverlay` in `PlayerMovementController`
+synthesizes `Forward+Run` `MovementInput` during inbound `MoveToObject`
+so the existing motion-interpreter pipeline drives the body. The
+synthesis leaks: motion-interpreter sets `MotionStateChanged=true`,
+which would fire an outbound `MoveToState` "user is running"
+packet to ACE — interpreted as user-took-manual-control and cancels
+ACE's `MoveToChain`. We mitigate with a guard
+(`!_playerController.IsServerAutoWalking` at `GameWindow.cs:6410`)
+plus a 500 ms post-arrival grace period to cover ACE's poll race.
+
+Retail's `MoveToManager::HandleMoveToPosition` (decomp 0x0052xxxx)
+steps the body POSITION directly when server `MoveToObject` arrives —
+NO player-input synthesis, NO motion-interpreter involvement, NO
+outbound MoveToState. Holtburger
+([simulation.rs:178-206](references/holtburger/crates/holtburger-core/src/client/simulation.rs))
+follows the same pattern (sets `ServerControlledProjection`, advances
+the body, returns empty).
+
+**Acceptance:** Refactor auto-walk to step `_body.Position` (or
+equivalent) directly from the wire-supplied path data + run rate, NOT
+via synthesized input. Motion state during auto-walk becomes a
+SERVER-DRIVEN state (similar to how remote players' motion is driven
+by inbound MoveToState packets), not a USER-DRIVEN one. The 500 ms
+grace period in `EndServerAutoWalk` becomes unnecessary and can be
+deleted; same for the `IsServerAutoWalking` guard at the wire layer
+(no MoveToState would have been built in the first place).
+
+Animation cycle currently driven by motion-interpreter's
+`MotionStateChanged → SetCycle(RunForward)` would need a separate
+path: probably mirror how remote-player animation is driven by
+inbound motion packets (the sequencer accepts a `SetCycle` directly).
+
+**Files:** `src/AcDream.App/Input/PlayerMovementController.cs`
+(`ApplyAutoWalkOverlay` returns synthesized input today; refactor to
+step body directly + drive animation via `_animationSequencer.SetCycle`
+directly). `src/AcDream.App/Rendering/GameWindow.cs` (delete the
+`!IsServerAutoWalking` guard once the leak is gone).
+
+**Estimated scope:** Medium (~50-100 LOC + careful testing of
+animation cycle continuity). Not blocking M1 — the grace-period
+band-aid produces retail-faithful behaviour empirically.
+
+---
+
+## #74 — [DONE 2026-05-16 · `de44358`] AP cadence is per-frame-while-moving, more chatty than retail
+
+**Status:** DONE
+**Severity:** LOW (works; just sends ~60× the packets retail would during smooth motion)
+**Filed:** 2026-05-16
+**Component:** physics / net cadence
+
+**Resolution (2026-05-16 · `de44358`):** With #75 (MoveToState suppression refactor) closing the MoveToChain-cancellation race, the per-frame "send while moving" cadence is no longer load-bearing. Reverted to retail's two-branch `ShouldSendPositionEvent` gate (`acclient_2013_pseudo_c.txt:700233-700285`): cell/plane change during the sub-interval; cell-or-frame change after the 1s heartbeat. Added `_lastSentContactPlane` field + extended `NotePositionSent(Vector3, uint, Plane, float)` + added `ApproxPlaneEqual` helper + `PlayerMovementController.ContactPlane` public accessor. Effective rates now match retail: 0 Hz idle, ~1 Hz smooth motion, per-event on cell/plane changes, 0 Hz airborne.
+
+**Description:** The diff-driven AP cadence shipped in Commit B fires
+`HeartbeatDue` on **any** position change each frame while grounded
+on walkable (effective ~60 Hz during smooth movement) and a 1 Hz
+heartbeat when idle. Retail's `ShouldSendPositionEvent`
+(`acclient_2013_pseudo_c.txt:700233`) only sends during the
+sub-interval when cell or contact-plane changes, and only sends the
+1 Hz heartbeat if `(cellId, frame)` changed since `last_sent` —
+truly idle = 0 Hz. So retail during continuous smooth movement is
+effectively 1 Hz (cell crosses + plane changes don't happen every
+frame); we are ~60 Hz.
+
+**Root cause / status:** Deliberate ACE-targeted choice. The
+per-frame cadence is load-bearing for ACE's `WithinUseRadius` poll
+to see the player arrive at a target during local speculative
+auto-walk (issue #63's workaround chain). Going to 1 Hz would
+re-introduce the arrival-lag bug for far-range Use/PickUp.
+
+**Files:** [PlayerMovementController.cs:1240-1275](src/AcDream.App/Input/PlayerMovementController.cs)
+— the `HeartbeatDue = groundedOnWalkable && (positionChanged || intervalElapsed)`
+gate.
+
+**Acceptance:** Either (a) fix issue #63 so we honor ACE's
+`MoveToObject` server-side, removing the need for the per-frame
+cadence, then revert to retail's `cell-or-plane-change || (interval && frame-change)`
+shape (~5 LOC change); or (b) document this as a permanent
+divergence and update commit messages / code comments to match.
+
+**Estimated scope:** Small (~5 LOC + commit-message rewrite) once
+#63 is fixed. Currently blocked by #63.
+
+---
+
+## #73 — Retail-message centralization plan — per-feature string sweeps
+
+**Status:** OPEN
+**Severity:** LOW (per-feature work, not infrastructure)
+**Filed:** 2026-05-16
+**Component:** ui / retail messages
+
+**Description:** Commit A added `AcDream.Core.Ui.RetailMessages` as
+the home for retail-decomp-sourced UI strings (`CannotBeUsed`,
+`CantBePickedUp`, `CannotPickUpCreatures`). The retail decomp has
+~750 more user-facing strings we'll need over time — combat misses,
+spell fizzles, vendor dialogs, "you do not have enough" etc. Rather
+than bulk-port them once, port per-feature as the feature lands:
+when wiring vendor purchase, sweep vendor strings into
+`RetailMessages.Vendor.*`; when wiring spell-cast feedback, sweep
+`RetailMessages.Spell.*`.
+
+**Status:** No infrastructure work pending. Pattern is established;
+new strings get added to `RetailMessages.cs` with retail anchor
+comments at the call site that triggered the need.
+
+**Files:** [RetailMessages.cs](src/AcDream.Core/Ui/RetailMessages.cs)
+— class-level doc comment already describes the per-feature sweep
+pattern.
+
+**Acceptance:** Each phase / feature that adds new user-facing
+strings sweeps its retail-anchor strings into `RetailMessages` and
+calls them by name rather than literal-in-place. Closing condition:
+"all M1 demo strings are in RetailMessages" or similar per-milestone
+gate, decided when M1 ships.
+
+---
+
+## #72 — Confirm Humanoid TurnRight/TurnLeft `omega.z` base rate via cdb
+
+**Status:** OPEN
+**Severity:** LOW (current ±π/2 fallback matches all corroborating
+evidence; cdb probe would settle the open question for good)
+**Filed:** 2026-05-16
+**Component:** physics / rotation / research
+
+**Description:** Commit A's rotation rate uses
+`BaseTurnRateRadPerSec = π/2` based on the documented
+`AnimationSequencer.cs:734-741` claim that the Humanoid motion table
+ships TurnRight/TurnLeft with `HasOmega` cleared (forcing the
+convention fallback). The constant has 3 corroborating sources but
+the actual dat content was never dumped — and the run-multiplier
+`run_turn_factor = 1.5` at retail `0x007c8914` from
+`apply_run_to_command` (decomp 0x00527be0) likewise hasn't been
+verified live.
+
+**Acceptance:** Set a cdb breakpoint on `CSequence::set_omega`
+(`acclient_2013_pseudo_c.txt` — find exact symbol address) while
+holding A or D in a retail client. Capture the `omega.z` argument
+value walking, then running. If `±π/2` walking and `±π/2 × 1.5 ≈ 2.356`
+running, close as confirmed. If different, file as a regression and
+fix the constants in
+[RemoteMoveToDriver.cs](src/AcDream.Core/Physics/RemoteMoveToDriver.cs).
+
+**Estimated scope:** ~30 min cdb session + 1 commit if confirmed,
+or +small fix if different. Not blocking M1.
+
+---
+
+## #71 — WorldPicker Stage B — polygon refine for retail-accurate clicks
+
+**Status:** OPEN
+**Severity:** LOW (Stage A — screen-rect picker — is sufficient for M1)
+**Filed:** 2026-05-16
+**Component:** selection / picker
+
+**Description:** Retail's mouse picker does two-tier sphere-then-polygon
+selection (`acclient_2013_pseudo_c.txt:0x0054c740`
+`Render::GfxObjUnderSelectionRay`):
+1. Per-part sphere reject via `CGfxObj::drawing_sphere`.
+2. Polygon-accurate refine via `CPolygon::polygon_hits_ray` on every
+ visual polygon; closest-t polygon hit wins over any sphere hit.
+
+Commit B's Stage A
+([WorldPicker.cs](src/AcDream.Core/Selection/WorldPicker.cs)) does
+screen-space rect hit-test against the projected
+`Setup.SelectionSphere` (matching the indicator rect, deliberately
+broader than the visible mesh polygons). Stage B would tighten clicks
+to the visible mesh — under-pick what looks like empty space inside
+the rect, catch visible mesh that pokes past the sphere boundary
+(creature outstretched arm, sign edge).
+
+**Acceptance:** Pipe per-part GfxObj visual polygons through a
+`PickPolygonProvider` interface (don't duplicate mesh decoding —
+hook the existing `ObjectMeshManager` cached data). Two-tier in
+`WorldPicker.Pick`: sphere reject → polygon scan → polygon hit
+dominates sphere hit. Acceptance test: visible-mesh accuracy on
+Holtburg sign, Royal Guard outstretched bow arm, inn-door wood
+frame edges.
+
+**Estimated scope:** Medium (~4-6 hours). Defer until visual
+verification surfaces a Stage A miss in real play. The user
+confirmed 2026-05-16 that "I can click on longer ranges now so
+good" — Stage A is enough for M1's "click an NPC" demo.
+
+---
+
+## #70 — Triangle apex/size — final retail-feel UX pass
+
+**Status:** OPEN
+**Severity:** LOW (cosmetic — indicator already retail-anchored, this is final-feel polish)
+**Filed:** 2026-05-16
+**Component:** ui / target indicator
+
+**Description:** Per 2026-05-16 user feedback during the
+`SelectionSphere` indicator ship, the triangle apex direction
+(flipped to point inward at the target) and sprite size (currently
+8 px legs) are heuristic visual choices. Retail uses an actual DAT
+sprite from `UIRegion::GetChild(0x1000003a/3b/3c)` — the bitmap
+shape and size come from the dat, not constants.
+
+**Acceptance:** Extract the retail triangle sprite from the dat
+(probably via `tools/UiLayoutMockup` or a new `DatSpriteProbe`) and
+either (a) blit the exact bitmap, or (b) pick a procedural size +
+shape that matches it pixel-for-pixel at standard zoom.
+
+**Files:** [TargetIndicatorPanel.cs](src/AcDream.App/UI/TargetIndicatorPanel.cs)
+— `TriangleSize` constant + the four `AddTriangleFilled` calls.
+
+**Estimated scope:** Small (~1-2 hours, mostly dat exploration).
+Not blocking M1.
+
+---
+
+## #69 — [DONE 2026-05-16 · `f035ea3`] Local player rotation isn't animated (no leg/arm cycle while pivoting)
+
+**Status:** DONE
**Severity:** LOW (visual polish — rotation works, just looks stiff)
**Filed:** 2026-05-15 (B.6 close-range turn-to-face)
**Component:** motion / animation cycle
+**Resolution (2026-05-16 · `f035ea3`):** Fixed as part of the auto-walk architectural refactor (issue #75). `DriveServerAutoWalk` now records the per-frame rotation direction in `_autoWalkTurnDirectionThisFrame` (+1 / -1 / 0); the animation override at the bottom of `Update` reads that flag and sets `localAnimCmd` to `TurnLeft` / `TurnRight` during the turn-first phase. User confirmed 2026-05-16 that the auto-walk turn-first case (click target, body rotates before walking) now plays the leg-shuffle animation. User-driven A/D rotation was always working — the original issue description was specific to the auto-walk turn-first case.
+
**Description:** When the auto-walk overlay rotates the local player
(close-range Use turn-to-face, or turn-first phase of a far-range walk),
the body's Yaw rotates smoothly but no leg / arm animation plays —
@@ -254,14 +1042,20 @@ locally on send (mirroring retail's client behavior).
---
-## #63 — Server-initiated auto-walk (MoveToObject) not honored
+## #63 — [DONE 2026-05-16 · `f035ea3`] Server-initiated auto-walk (MoveToObject) not honored
-**Status:** OPEN
+**Status:** DONE
**Severity:** MEDIUM (blocks out-of-range Use + Pickup; close-range
works fine)
**Filed:** 2026-05-14 (B.5 visual verification)
**Component:** motion / inbound MoveToObject handling
+**Resolution (2026-05-16):** Closed in two parts:
+1. **B.6 slice 2 (2026-05-14):** inbound MoveToObject parsing + `BeginServerAutoWalk` wiring at `GameWindow.cs:3389` — body auto-walks toward the server-supplied destination.
+2. **B.6 #75 refactor (`f035ea3`, 2026-05-16):** `ApplyAutoWalkOverlay → DriveServerAutoWalk` drives the body directly from path data, no input synthesis. The `MoveToState` leak that previously cancelled ACE's `MoveToChain` callback is gone; the chain runs uninterrupted and `TryUseItem` / `TryPickUp` fires server-side on arrival. No client-side retry needed. Walk/run threshold corrected to 1.0m (matches retail-observed; overrides ACE's wire-default 15m).
+
+Visual-verified end-to-end: far-range Use on NPCs / doors / spell components / corpses all complete via ACE's server-side callback. The far-range retry workaround from Task 1's first iteration (`c61d049`'s `_pendingPostArrivalAction` arming) was deleted as part of #75 (`f035ea3`).
+
**Description:** When the player triggers a Use or PutItemInContainer
on a target outside ACE's `WithinUseRadius` (default 0.6 m), ACE
runs server-side auto-walk via `CreateMoveToChain` →
@@ -334,9 +1128,19 @@ int animFrame0Parts = ae.Animation?.PartFrames.Count > 0
---
-## #61 — AnimationSequencer link→cycle boundary flash on one-shot motion (door swing)
+## #61 — [DONE 2026-05-18 · `9f069e1`] AnimationSequencer link→cycle boundary flash on one-shot motion (door swing)
+
+**Status:** DONE — fixed by `9f069e1` (also widened scope: same bug
+manifested as the local-player run-stop twitch — user-observed during
+the M2 anim-pass session). Root cause was `BuildBlendedFrame` wrapping
+`nextIdx` to `rangeLo` unconditionally at the high-frame boundary —
+correct for looping cycles (idle/run/walk loops), wrong for one-shot
+links. During the ~30 ms fractional tail of any link, the renderer
+blended `frame[end]` with `frame[0]`, producing the flash through the
+anim's starting pose. Fix: gate the wrap on `curr.IsLooping`. Pinned by
+the new `Advance_LinkTailDoesNotBlendIntoLinkFrame0` regression test.
+Visual-verified by the user end-to-end on 2026-05-18.
-**Status:** OPEN
**Severity:** LOW (visual polish — animation works, brief one-frame flash through prior pose at end of swing)
**Filed:** 2026-05-13 (visual test of B.4c)
**Component:** animation / `AcDream.Core.Physics.AnimationSequencer` link+cycle transition
@@ -2232,6 +3036,86 @@ 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
+**Commit:** `3be7000` — fix(physics): close #77 — auto-walk honors ACE CanCharge bit; zero velocity in turn-in-place
+**Component:** physics / `PlayerMovementController` / `GameWindow.OnLiveMotionUpdated` / `CreateObject.ServerMotionState`
+
+**Resolution.** Two coupled bugs sharing a root in
+`PlayerMovementController.DriveServerAutoWalk` + `BeginServerAutoWalk`.
+
+1. **Walk-vs-run misclassification (the user-visible "always runs at walk range" half).**
+ `BeginServerAutoWalk` decided `_autoWalkInitiallyRunning = (initialDist −
+ distanceToObject) >= 1.0f`, forcing run at any chase past ~1.6 m.
+ ACE's wire-level walk-vs-run answer is the MovementParameters
+ **CanCharge** bit (0x10), which `Creature.SetWalkRunThreshold`
+ sets when server-side player→target distance ≥ `WalkRunThreshold/2`
+ (= 7.5 m default). Retail's `MovementParameters::get_command`
+ (decomp `0x0052aa00`, `acclient_2013_pseudo_c.txt:307946+`) gates
+ the run path on CanCharge first; the inner walk_run_threshold
+ check practically always walks given ACE's 15 m default. The
+ hardcoded 1.0 m threshold pushed run into the 3-5 m walk-range the
+ user reported should walk.
+
+2. **Velocity leak in turn-in-place phase (the user-visible "overshoots
+ and snaps back" half).** When the auto-walked body crossed the
+ destination, `desiredYaw` flipped ~180°, `walkAligned` dropped to
+ false, and the `if (!moveForward) return true;` branch returned
+ without zeroing body velocity. The body kept the prior frame's
+ running velocity (`RunAnimSpeed × runRate ≈ 11 m/s`) and slid 4-5 m
+ past the target before the turn-around rotation completed.
+
+**Changes:**
+- `CreateObject.ServerMotionState.CanCharge`: new bool prop reading
+ bit 0x10 of `MoveToParameters`. Cross-ref ACE
+ `MovementParams.CanCharge = 0x10`.
+- `PlayerMovementController.BeginServerAutoWalk`: replaces the unused
+ `walkRunThreshold` parameter with `bool canCharge`; sets
+ `_autoWalkInitiallyRunning = canCharge`.
+- `PlayerMovementController.DriveServerAutoWalk` turn-in-place branch:
+ calls `_motion.DoMotion(Ready, 1.0)` and zeros body horizontal
+ velocity (preserving Z for gravity). No-op for initial-turn with a
+ stationary body; fixes overshoot-recovery and settling cases.
+- `GameWindow.OnLiveMotionUpdated`: passes
+ `update.MotionState.CanCharge` through; `[autowalk-begin]` trace
+ now shows `canCharge=` instead of `walkRunThresh=`.
+- `GameWindow.InstallSpeculativeTurnToTarget`: predicts ACE's
+ CanCharge from local distance using ACE's exact 7.5 m rule, so the
+ speculative install agrees with the wire-triggered overwrite that
+ arrives moments later.
+
+**Verification.** Build green; all targeted test projects pass cleanly
+(Core.Net 294/294, UI.Abstractions 419/419, App 10/10; Core 1073 passed
+/ 8 pre-existing failures unchanged). Visual-verified at Holtburg
+2026-05-18: walk-range NPC click walks + Use fires + dialogue appears,
+walk-range F-key pickup walks + no overshoot + item enters inventory,
+far-range pickup (8-10 m+) still runs.
+
+**Lesson archived:** `memory/feedback_autowalk_cancharge_bit.md`. When
+ACE already encodes a decision on the wire (CanCharge IS the walk-vs-run
+answer), relay it — don't reinvent the bucket with a locally-computed
+threshold.
+
+---
+
## #56 — [DONE 2026-05-12 · 8735c39] `ParticleHookSink` ignores `CreateParticleHook.PartIndex`; multi-emitter scripts collapse to entity root
**Closed:** 2026-05-12
diff --git a/docs/architecture/acdream-architecture.md b/docs/architecture/acdream-architecture.md
index 5f134da7..2ada332c 100644
--- a/docs/architecture/acdream-architecture.md
+++ b/docs/architecture/acdream-architecture.md
@@ -95,8 +95,8 @@ designed 2026-04-24. Full design: `docs/plans/2026-04-24-ui-framework.md`.
The backend is pluggable; ViewModels / Commands / `IPanelRenderer` are
stable across the swap. ImGui persists forever as the
`ACDREAM_DEVTOOLS=1` devtools overlay regardless of which backend owns
-the game UI. See `memory/project_ui_architecture.md` for the session
-crib-sheet version.
+the game UI. The full UI design lives in
+`docs/plans/2026-04-24-ui-framework.md`.
---
diff --git a/docs/architecture/code-structure.md b/docs/architecture/code-structure.md
new file mode 100644
index 00000000..288efd06
--- /dev/null
+++ b/docs/architecture/code-structure.md
@@ -0,0 +1,376 @@
+# acdream — code structure & extraction sequence
+
+**Status:** Living document. Created 2026-05-16 as the companion to the
+"Code Structure Rules" section in `CLAUDE.md`.
+**Purpose:** Describe the desired structural state of the App layer,
+explain the rules we've adopted, and lay out the safe extraction
+sequence from today's reality (one 10,304-line `GameWindow.cs`) to the
+target (thin `GameWindow`, small focused collaborators).
+**Companion to:** [`acdream-architecture.md`](acdream-architecture.md)
+(the layered architecture) and
+[`worldbuilder-inventory.md`](worldbuilder-inventory.md) (what we take
+from WB vs port ourselves).
+
+---
+
+## 1. The structural problem we're solving
+
+The layered architecture works: `AcDream.Core` is GL-free, the network
+layer is wire-compatible, the UI has a stable contract, plugins load.
+The structural debt is concentrated in **one file**:
+
+```
+src/AcDream.App/Rendering/GameWindow.cs 10,304 lines
+```
+
+`GameWindow` is the single object that:
+
+- Owns the GL context, the window, input, and shaders.
+- Reads ~40 different environment variables across its lifetime.
+- Hosts the live network session (`WorldSession`) and the offline
+ pre-login state.
+- Owns parallel dictionaries for entity lookup (`_entitiesByServerGuid`,
+ the per-landblock entity lists in `GpuWorldState`, plus the player
+ controller's own state).
+- Drives selection / interaction (`WorldPicker`, `SendUse`,
+ `SendPickUp`).
+- Drives per-frame render orchestration (sky → terrain → opaque mesh →
+ transparent mesh → particles → debug lines → UI).
+- Wires up every plugin hook sink, every diagnostic, every panel.
+
+Almost every M1 / M2 bug touches this file. Every new feature adds a
+field plus a method plus a wiring call. It is not getting better on its
+own.
+
+The fix is **not** "rewrite `GameWindow` in one pass" — that's a
+high-risk change that would block M2. The fix is to **extract one
+collaborator at a time**, verify behavior is unchanged, ship, and
+move on. This document defines that sequence.
+
+---
+
+## 2. Code Structure Rules — the discipline
+
+Recap of the rules from `CLAUDE.md` with the rationale:
+
+### Rule 1: No new substantial feature bodies in `GameWindow.cs`
+
+**Why:** Every line we add to `GameWindow` makes the eventual decomposition
+harder. New features that "live in" `GameWindow` instead of being
+extracted are the reason the file is 10k lines.
+
+**How to apply:** A new feature gets its own class under
+`src/AcDream.App//` (or deeper in `AcDream.Core` if it's pure
+logic). `GameWindow` owns a field and a wiring call, nothing more. If
+you find yourself adding a 200-line method to `GameWindow`, stop and
+extract.
+
+**Exemption:** Trivial wiring that *must* stay in `GameWindow` because
+it touches GL state during `OnLoad` is acceptable, but should still
+delegate to a collaborator for the substance.
+
+### Rule 2: `AcDream.Core` must not depend on window / GL / backend projects
+
+**Why:** Core is the GL-free, testable layer. The moment Core imports
+a GL or windowing namespace, we've lost the ability to test it without
+a graphics context, and the layer split becomes fiction.
+
+**How to apply:** The only currently-allowed seams from Core into the
+WB / GL world are:
+
+- `WorldBuilder.Shared` — stateless helpers (`TerrainUtils`,
+ `TerrainEntry`, `RegionInfo`).
+- `Chorizite.OpenGLSDLBackend.Lib` — stateless helpers
+ (`SceneryHelpers`, `TextureHelpers`).
+
+Both are leaf namespaces with no GL state. If you need a new seam (e.g.
+WB's `ObjectMeshManager` needs to be visible from Core), the change
+**must** come with an inventory-doc update justifying it and ideally a
+slim interface in Core that the App layer implements.
+
+**Current reality:** `src/AcDream.Core/AcDream.Core.csproj` references
+`Chorizite.OpenGLSDLBackend` (not just `OpenGLSDLBackend.Lib`). This is
+historical. Two Core files import from it:
+- `World/SceneryGenerator.cs` — `Chorizite.OpenGLSDLBackend.Lib`
+- `Textures/SurfaceDecoder.cs` — `Chorizite.OpenGLSDLBackend.Lib`
+
+Both use the stateless `Lib` namespace only. The full project reference
+is wider than it needs to be; tightening it to just `WorldBuilder.Shared`
++ a stateless-helpers shim is a candidate future cut, but not urgent.
+
+### Rule 3: UI panels target `AcDream.UI.Abstractions` only
+
+**Why:** This is the one rule that keeps D.2b (the future retail-look
+backend) viable. Every panel that imports `ImGuiNET` directly is a panel
+we'd have to rewrite when the backend swaps.
+
+**How to apply:** A panel's `using` block must mention
+`AcDream.UI.Abstractions.*` and nothing from `AcDream.UI.ImGui`. The
+panel writes against `IPanelRenderer`. The `ImGuiPanelRenderer`
+translates those calls to ImGui at runtime. Plugin-facing UI follows the
+same rule.
+
+### Rule 4: Startup env vars enter through `RuntimeOptions`
+
+**Why:** Environment variables are global mutable state. Reading them
+at random call sites means (a) duplicated `Environment.GetEnvironmentVariable`
+boilerplate, (b) no single place to see "what flags does the client
+respond to?", (c) impossible to unit-test parsing.
+
+**How to apply:** `src/AcDream.App/RuntimeOptions.cs` is the typed
+options object. `Program.cs` builds it once from args + env and passes
+it to `GameWindow`. New startup flags add a field to `RuntimeOptions`
+and a parser in `RuntimeOptions.FromEnvironment`. They don't add
+`Environment.GetEnvironmentVariable` reads.
+
+**Scope:** `RuntimeOptions` is for **startup-time** configuration —
+things that don't change once the window is up. Runtime diagnostic
+toggles are Rule 5's domain.
+
+### Rule 5: Runtime diagnostic toggles live in diagnostic owner classes
+
+**Why:** Diagnostic flags (`ACDREAM_DUMP_MOTION`, `ACDREAM_PROBE_*`,
+etc.) need to be both env-readable at startup *and* runtime-toggleable
+from the DebugPanel. Per-call-site env reads can't be runtime-toggled.
+
+**How to apply:** Today's template is
+`src/AcDream.Core/Physics/PhysicsDiagnostics.cs` — one static class with
+typed `Probe*` properties read from env vars once at startup, plus
+runtime setters that the DebugPanel binds. New diagnostic flags follow
+this shape, not the per-call-site pattern that dominates `GameWindow.cs`.
+
+**Cleanup direction:** The dozens of existing `ACDREAM_DUMP_*` reads
+inside `GameWindow.cs` are tech debt. We do NOT bulk-migrate them as
+part of this refactor — they're working, they're scattered, and
+moving them carries risk without a current acceptor. We migrate them
+opportunistically: when a `GameWindow` extraction lands and a diagnostic
+moves with it, route it through the new owner's diagnostic class.
+
+### Rule 6: Tests live in the project matching the layer
+
+**Why:** Test discoverability + dependency hygiene. A test for a Core
+class belongs next to other Core tests; a test for an App class belongs
+in an App test project. Co-locating tests across layers makes the
+dependency graph dishonest.
+
+**How to apply:** One test project per source project that has tests.
+Today:
+
+- `tests/AcDream.Core.Tests/` ← `src/AcDream.Core/`
+- `tests/AcDream.Core.Net.Tests/` ← `src/AcDream.Core.Net/`
+- `tests/AcDream.UI.Abstractions.Tests/` ← `src/AcDream.UI.Abstractions/`
+
+`AcDream.App` does **not** yet have a test project. The RuntimeOptions
+extraction is the trigger to create `tests/AcDream.App.Tests/`. Future
+App-layer tests (LiveSessionController, SelectionInteractionController,
+etc.) go there.
+
+---
+
+## 3. Target structure of the App layer
+
+The end state — not what we're shipping in one pass, but the shape
+we're aiming at.
+
+```
+src/AcDream.App/
+├── Program.cs # parse args + env → RuntimeOptions, build GameWindow
+├── RuntimeOptions.cs # typed startup options (Rule 4)
+├── Rendering/
+│ ├── GameWindow.cs # thin: GL/window lifecycle + delegates per-frame to RenderFrameOrchestrator
+│ ├── RenderFrameOrchestrator.cs # per-frame draw order (sky → terrain → opaque → trans → particles → debug → UI)
+│ ├── TerrainModernRenderer.cs # (already exists)
+│ ├── TextureCache.cs # (already exists)
+│ ├── ParticleRenderer.cs # (already exists)
+│ ├── Sky/ # (already exists)
+│ ├── Wb/ # (already exists — WB seam)
+│ └── Vfx/ # (already exists)
+├── Net/
+│ └── LiveSessionController.cs # owns WorldSession lifecycle, login/handshake, reconnect
+├── World/
+│ └── LiveEntityRuntime.cs # owns per-entity state dicts + ServerGuid↔entity.Id translation
+├── Interaction/
+│ └── SelectionInteractionController.cs # owns WorldPicker, selection state, Use/PickUp dispatch
+├── Streaming/ # (already exists)
+├── Input/ # (already exists)
+├── Audio/ # (already exists)
+└── Plugins/ # (already exists)
+```
+
+What `GameWindow` keeps:
+
+- `IWindow` / `GL` / `IInputContext` lifecycle (constructor + `OnLoad` +
+ `Run` + `OnClosing`).
+- `RuntimeOptions` reference (the typed startup config).
+- One field per collaborator (`_liveSessionController`,
+ `_liveEntityRuntime`, `_selectionInteraction`,
+ `_renderFrameOrchestrator`).
+- The Silk.NET event-handler stubs that delegate to collaborators.
+
+What `GameWindow` loses:
+
+- The 7 startup-time env var fields → moved into `RuntimeOptions`.
+- `TryStartLiveSession` + the post-login network drain → moved into
+ `LiveSessionController`.
+- `_entitiesByServerGuid` + per-entity dictionaries + ServerGuid↔Id
+ translation → moved into `LiveEntityRuntime`.
+- `WorldPicker` + `_selectedGuid` + `SendUse` / `SendPickUp` → moved
+ into `SelectionInteractionController`.
+- Per-frame draw orchestration → moved into `RenderFrameOrchestrator`.
+
+The eventual `GameEntity` aggregation (target state described in
+`acdream-architecture.md` §"GameEntity: The Unified Entity") happens
+**after** `LiveEntityRuntime` is the single owner of entity state.
+Until then, the parallel-dicts problem is bounded inside one class
+instead of spread across `GameWindow`.
+
+---
+
+## 4. Extraction sequence — safest first
+
+Each step is **one PR-sized refactor**. Each must build clean, all
+tests pass, and visual verification at Holtburg looks identical to
+the previous step. Don't bundle two steps.
+
+### Step 1 — `RuntimeOptions` (this PR)
+
+**Scope:** Replace startup-time env var reads with a typed options
+object built once in `Program.cs`.
+
+**Behavior change:** None. Same env vars, same defaults, same effects.
+
+**Risk:** Low. Mechanical substitution at ~10-15 call sites in
+`GameWindow.cs` + one constructor signature change.
+
+**Test:** Unit tests for `RuntimeOptions.FromEnvironment` parsing (the
+new `tests/AcDream.App.Tests/` project).
+
+**Verification:** `dotnet build` + `dotnet test` green. Visual launch
+verifies live mode + dat dir resolution still work.
+
+### Step 2 — `LiveSessionController`
+
+**Scope:** Extract `TryStartLiveSession` + the WorldSession ownership +
+the post-EnterWorld drain (`OnLiveStateUpdated`, `OnLiveEntityDeleted`,
+etc.) into a controller class.
+
+**Behavior change:** None. Same wire behavior, same handshake.
+
+**Risk:** Medium. WorldSession lifecycle is load-bearing — every
+session-state crash would surface here. The change is a class
+extraction with the same event subscriptions, not a rewrite.
+
+**Test:** Existing `AcDream.Core.Net.Tests` already cover the wire
+layer. The controller itself gets a smoke test that verifies it can be
+constructed without a live socket (offline mode).
+
+**Verification:** Visual login + Holtburg traversal + door interaction
+identical to pre-extraction.
+
+### Step 3 — `LiveEntityRuntime` (or `EntityRuntimeRegistry`)
+
+**Scope:** Centralize the parallel dictionaries — `_entitiesByServerGuid`,
+the streaming entity lists, the player controller's player entity —
+into one owner. Surface the ServerGuid↔entity.Id translation as a
+single API (eliminating the trap from L.2g slice 1c).
+
+**Behavior change:** None.
+
+**Risk:** Medium-high. Entity lookup is in every hot path. The change
+is structural (one owner instead of three) but the lookup semantics
+must be byte-identical.
+
+**Test:** Entity-spawn / despawn / lookup tests in
+`tests/AcDream.App.Tests/`. Existing visual verification at Holtburg
+catches any drift in interaction.
+
+**Verification:** Walk Holtburg, click NPC, open door, pick up item.
+All four M1 demo targets must still work.
+
+### Step 4 — `SelectionInteractionController`
+
+**Scope:** Extract `WorldPicker`, `_selectedGuid`, `SendUse`,
+`SendPickUp`, and the `InputAction.Select*` / `UseSelected` /
+`SelectionPickUp` switch cases into one controller. Depends on Step 3
+(uses `LiveEntityRuntime`).
+
+**Behavior change:** None.
+
+**Risk:** Low-medium. Selection state is local to interactions; the
+network outbound side is well-defined (`InteractRequests.BuildUse` /
+`BuildPickUp`).
+
+**Test:** Selection state machine tests in `tests/AcDream.App.Tests/`.
+
+**Verification:** Click-to-select, double-click-to-Use, F-key pickup
+all still work.
+
+### Step 5 — `RenderFrameOrchestrator`
+
+**Scope:** Extract the per-frame draw sequence (sky → terrain →
+opaque mesh → translucent mesh → particles → debug → UI) into a
+dedicated orchestrator that `GameWindow.OnRender` delegates to.
+
+**Behavior change:** None. Same draw order, same GL state.
+
+**Risk:** Medium. GL state management is touchy; the orchestrator
+must hand the GL context to the same renderers in the same order with
+the same per-pass state setup.
+
+**Test:** Visual verification only. Render orchestration is hard to
+unit-test without a GL context.
+
+**Verification:** Holtburg at radius 4, radius 8, radius 12 looks
+identical across all four quality presets.
+
+### Step 6 — `GameEntity` aggregation (the big one)
+
+**Scope:** Consolidate `WorldEntity` + `AnimatedEntity` + the per-entity
+state in `LiveEntityRuntime` into one `GameEntity` class (the target
+described in `acdream-architecture.md`). Every entity in the world —
+player, NPC, monster, door, item — becomes a single `GameEntity`.
+
+**Behavior change:** None at the wire / visual level; substantial at
+the call-site level (everyone moves to the new entity API).
+
+**Risk:** High. Touches every system that reads entity state.
+
+**Test:** All existing tests + the new `AcDream.App.Tests` suite. Visual
+verification at every M1 / M2 scenario.
+
+**Verification:** Full M2 demo loop (equip sword, kill drudge, pick up
+loot, open inventory) works identically.
+
+---
+
+## 5. Rules of the road during the extraction
+
+1. **One step at a time.** A PR that ships Step 1 ships only Step 1.
+ Bundling steps makes failures hard to isolate.
+2. **Behavior preservation is the acceptance criterion.** Every step
+ must build clean, all tests pass, and visual verification at the
+ appropriate M1 / M2 scenarios must succeed. We're moving code, not
+ changing it.
+3. **No new features during an extraction step.** If you spot a real
+ bug while extracting, file it in `docs/ISSUES.md` and address it in
+ a separate commit (before or after the extraction, not folded into
+ it).
+4. **Diagnostic toggle migrations are opportunistic.** When a method
+ moves to a new owner, the diagnostic flag inside it can move to a
+ diagnostic class as part of the same commit. We do not do a bulk
+ diagnostic-cleanup pass.
+5. **Update this document when the plan changes.** If Step 3 turns out
+ to need a different shape than described above, update §4 in the
+ same session you discover the divergence.
+
+---
+
+## 6. What this document is **not**
+
+- **Not a full rewrite plan.** The point is the *opposite* — small
+ steps, verified at each boundary.
+- **Not blocking M2.** Step 1 is small enough to ship without
+ disrupting M2 work. Later steps interleave with M2 / M3 phases as
+ the corresponding code paths come into focus.
+- **Not a substitute for the milestones / roadmap.** Those drive the
+ feature work. This drives the structural work that runs underneath.
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index c2e403ca..0fc0d30c 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -1,6 +1,6 @@
# acdream — strategic roadmap
-**Status:** Living document. Updated 2026-05-12. **Between phases.** **Since the last header update:** C.1.5b shipped (issue #56 per-part transforms for multi-emitter PES + `EntityScriptActivator` extended to dat-hydrated EnvCell statics & exterior stabs — portal swirl, inn fireplace flames, cottage chimney smoke, spell-cast particles all match retail). **Earlier this week:** post-A.5 polish completed (#52 lifestone, #54 JobKind, #53 Tier 1 cache); N.6 slice 1 shipped (gpu_us fix + radius=12 perf baseline, conclusion CPU dominates GPU 30–50×); C.1.5a shipped (portal PES wiring; surfaced #56 → resolved in C.1.5b).
+**Status:** Living document. Updated 2026-05-19. **Between phases.** **Since the last header update:** Indoor walkable-plane BSP port FOUNDATION shipped (6 commits, `ff548b9` → `f845b22`) but visual verification failed — cellar descent, 2nd-floor walking, single-floor cottage regressions all confirm the shipped fix doesn't address the user-reported indoor bugs. Root cause now diagnosed as deeper than originally thought: `TryFindIndoorWalkablePlane` exists as a Phase 2 stop-gap that retail doesn't have an analog for. Retail retains ContactPlane state across frames; we re-synthesize per frame. Foundation work (BSP walker + probe + tests) remains useful; next phase needs to port retail's ContactPlane retention mechanism and likely eliminate `TryFindIndoorWalkablePlane` entirely. Handoff: [`docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md`](../research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md). ISSUES #83 remains OPEN with deeper diagnosis. **Earlier:** Indoor cell rendering Phase 1 (diagnostics) + Phase 2 (fix) shipped — root cause was a one-line WB bug at `ObjectMeshManager.cs:1223` (blind `TryGet` on GfxObj-prefixed stab ids threw `ArgumentOutOfRangeException` which WB's outer catch silently swallowed, causing 26/123 Holtburg cells to fail upload). Identified via diagnostic chain (5 `[indoor-*]` probes + a `ContinueWith` exception surfacer + a `ConsoleErrorLogger` injected into WB), fixed with a Setup-prefix guard. User visually confirmed floors render. Surfaced 9 pre-existing indoor bugs filed in `docs/ISSUES.md`. **Earlier:** C.1.5b shipped (issue #56 per-part transforms for multi-emitter PES + `EntityScriptActivator` extended to dat-hydrated EnvCell statics & exterior stabs — portal swirl, inn fireplace flames, cottage chimney smoke, spell-cast particles all match retail). post-A.5 polish completed (#52 lifestone, #54 JobKind, #53 Tier 1 cache); N.6 slice 1 shipped (gpu_us fix + radius=12 perf baseline, conclusion CPU dominates GPU 30–50×); C.1.5a shipped (portal PES wiring; surfaced #56 → resolved in C.1.5b).
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@@ -68,7 +68,11 @@
| B.4b | Outbound Use handler wiring + 4 bonus fixes (L.2g slices 1b+1c, double-click detection, DoubleClick gate fix). Shipped 2026-05-13 (branch `claude/compassionate-wilson-23ff99`, merge pending). Closes #57. Files #58 (door swing animation, M1-deferred). `WorldPicker.BuildRay` + `Pick` (ray-sphere entity pick with inside-sphere guard); `GameWindow.OnInputAction` switch cases for `SelectLeft` / `SelectDblLeft` / `UseSelected`; `_entitiesByServerGuid` reverse-lookup dict + ServerGuid→entity.Id translation in `OnLiveStateUpdated` (L.2g slice 1c — THE actual blocker); `InputDispatcher` double-click detection 500ms threshold (binding was dead code without it); `CollisionExemption.ShouldSkip` widened to ETHEREAL-alone (ACE Door.Open() sends `state=0x0001000C`, not `0x14`). M1 demo target "open the inn door" verified at Holtburg inn doorway. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4b-plan.md`](../superpowers/plans/2026-05-13-phase-b4b-plan.md). Handoff: [`docs/research/2026-05-13-b4b-shipped-handoff.md`](../research/2026-05-13-b4b-shipped-handoff.md). | Live ✓ |
| B.4c | Door swing animation. Shipped 2026-05-13 (branch `claude/phase-b4c-door-anim`, merge pending). Closes #58. Files #61 (AnimationSequencer link→cycle boundary flash; low-severity polish) + #62 (PARTSDIAG null-guard; latent). Spawn-time `AnimationSequencer` registration for door entities in `GameWindow.OnLiveEntitySpawnedLocked`: initial cycle seeded from `spawn.PhysicsState` (Off for closed, On for open). Shared `IsDoorName` / `IsDoorSpawn` helpers. `[door-cycle]` diagnostic in `OnLiveMotionUpdated` (gated on `ACDREAM_PROBE_BUILDING`). Bonus stance-value fix: `NonCombat = 0x3D` not `0x01` (wrong value caused doors to render halfway underground via empty sequencer frames). Visual-verified 2026-05-13 at Holtburg inn doorway: swing-open + swing-close cycles both play. M1 demo target "open the inn door" now has full visual feedback. Plan: [`docs/superpowers/plans/2026-05-13-phase-b4c-plan.md`](../superpowers/plans/2026-05-13-phase-b4c-plan.md). Handoff: [`docs/research/2026-05-13-b4c-shipped-handoff.md`](../research/2026-05-13-b4c-shipped-handoff.md). | Live ✓ |
| B.5 | Ground-item pickup (F-key, close-range path). Shipped 2026-05-14 (branch `claude/phase-b5-pickup`, merge pending). Closes M1 demo target 4/4 *"pick up an item"*. New `InteractRequests.BuildPickUp(seq, itemGuid, containerGuid, placement)` builds the 24-byte `PutItemInContainer (0xF7B1/0x0019)` wire body verified against `references/ACE/Source/ACE.Server/Network/GameAction/Actions/GameActionPutItemInContainer.cs`. New private `GameWindow.SendPickUp(uint itemGuid)` helper mirrors `SendUse`'s gate-on-InWorld pattern; `case InputAction.SelectionPickUp` in `OnInputAction` switch routes the F-key through `_selectedGuid`. **Bonus wire-handler fix (Task 2b):** ACE despawns picked-up items via `GameMessagePickupEvent (0xF74A)`, not the `GameMessageDeleteObject (0xF747)` we already handled — surfaced during visual testing (item kept rendering on ground after successful server-side pickup). New `PickupEvent.cs` parser + `WorldSession` dispatch branch adapt to `DeleteObject.Parsed` and reuse the existing `EntityDeleted → OnLiveEntityDeleted → RemoveLiveEntityByServerGuid` chain. Files #63 (server-initiated `MoveToObject` auto-walk not honored — out-of-range pickup / double-click fails server-side timeout) + #64 (local-player pickup animation does not render). Visual-verified 2026-05-14 at Holtburg: 3 successful close-range pickups (Pink Taper + Violet Tapers), item despawns locally as ACE acks. Plan: [`docs/superpowers/plans/2026-05-14-phase-b5-pickup.md`](../superpowers/plans/2026-05-14-phase-b5-pickup.md). Handoff: [`docs/research/2026-05-14-b5-shipped-handoff.md`](../research/2026-05-14-b5-shipped-handoff.md). | Live ✓ |
+| Indoor lighting + rendering — Phase 1 (diagnostics) | Five `[indoor-*]` probes wired through new `AcDream.Core.Rendering.RenderingDiagnostics` static class + DebugVM mirrors + DebugPanel checkboxes. `WbMeshAdapter` emits `[indoor-upload] requested/completed`; `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` per cell entity. All rate-limited via per-cellId frame counter; lookup probe uses high-bit-tagged key namespace to avoid cross-probe suppression. Holtburg `ACDREAM_PROBE_INDOOR_ALL=1` capture identified 26/123 cells silently failing — confirmed H1 (WB swallowed exception). Spec: [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md). Plan: [`docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md`](../superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md). Capture: [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../research/2026-05-19-indoor-cell-rendering-probe-capture.md). | Tests ✓ |
+| Indoor lighting + rendering — Phase 2 (fix) | Three-component diagnostic-driven fix for missing-floor bug. Component 1: `WbMeshAdapter` captures the `Task` from `PrepareMeshDataAsync` and attaches a `ContinueWith` for EnvCell ids — surfaces faulted-task exceptions + clean-null returns. Component 2: replaced `NullLogger` with a Console-backed `ConsoleErrorLogger` so WB's intentional `_logger.LogError(ex, ...)` at the swallow site at `ObjectMeshManager.cs:589` writes `[wb-error]` lines. **Root cause definitively identified in one capture: `ArgumentOutOfRangeException` from `DatReaderWriter.Setup.Unpack` at WB's `PrepareEnvCellMeshData` line 1223 — `TryGet(stab.Id, ...)` was called blindly on every `envCell.StaticObjects` id without checking the Setup-prefix bit. GfxObj-typed stabs (0x01xxxxxx) caused mid-deserialization throws, bubbling up to PrepareMeshData's outer catch which silently returned null. Entire cell upload failed, room mesh never reached `_renderData`.** Component 3 fix: one-line type-check guard `(stab.Id & 0xFF000000u) == 0x02000000u && _dats.Portal.TryGet(stab.Id, out var stabSetup)`. Committed to WB submodule on branch `acdream-fix-floor-rendering` at SHA `34460c4` — needs submodule pointer advance at merge time. **Verification: 0 [wb-error] (was 385), 0 NULL_RESULT (was 55), Holtburg 123/123 cells complete (was 97/123). User visually confirmed floors render in Holtburg Inn.** Surfaced 9 pre-existing indoor bugs (see-through floor, indoor collision, stairs, walls, click-thru, indoor lighting artifacts, atmospheric-lighting-on-stabs, slope terrain lighting) — all filed in `docs/ISSUES.md` for follow-up phases. Cause report: [`docs/research/2026-05-19-indoor-cell-rendering-cause.md`](../research/2026-05-19-indoor-cell-rendering-cause.md). Verification: [`docs/research/2026-05-19-indoor-cell-rendering-verification.md`](../research/2026-05-19-indoor-cell-rendering-verification.md). Plan: [`docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md`](../superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md). | Live ✓ |
| C.1.5b | Per-part PES transforms + dat-hydrated entity DefaultScript dispatch. Closes issue #56. Shipped 2026-05-12 across 5 commits (`1e3c33b` docs+plan, `f3bc15e` SetupPartTransforms helper, `11521f4` ParticleHookSink applies `CreateParticleHook.PartIndex`, `5ca5827` activator refactor + GameWindow resolver lambda, `8735c39` GpuWorldState 4 new fire-sites). **Slice A** — new [`SetupPartTransforms.Compute(setup)`](../../src/AcDream.Core/Meshing/SetupPartTransforms.cs) walks `PlacementFrames[Resting]` → `[Default]` → first-available (mirrors `SetupMesh.Flatten` priority) and returns `Matrix4x4` per part; new `ParticleHookSink.SetEntityPartTransforms(entityId, partTransforms)` mirrors the existing `_rotationByEntity` pattern; `SpawnFromHook` now transforms hook offset through `partTransforms[partIndex]` before applying entity rotation. **Slice B** — activator's `ServerGuid==0` guard relaxed: keys by `entity.ServerGuid` when non-zero, else `entity.Id` (collision-free with server guids in the `0x40xxxxxx` interior / `0x80xxxxxx` scenery / `0xC0xxxxxx` ranges). Resolver delegate refactored to return `ScriptActivationInfo(ScriptId, PartTransforms)` so one dat lookup yields both pieces. `GpuWorldState` fires the activator from 4 new sites: `AddLandblock` + `AddEntitiesToExistingLandblock` (Far→Near promotion) for OnCreate, `RemoveLandblock` + `RemoveEntitiesFromLandblock` (Near→Far demotion) for OnRemove. ServerGuid==0 filter on AddLandblock avoids double-firing pending-bucket merges. **Reality discovery folded into spec §3**: EnvCell `StaticObjects` are already hydrated as `WorldEntity` instances by `GameWindow.BuildInteriorEntitiesForStreaming` (with stable `entity.Id` in `0x40xxxxxx`) — no synthetic-ID scheme or separate walker class needed (handoff §4 Q1/Q2 mooted). **Visual verification 2026-05-12**: Holtburg Town network portal swirl distributes across the arch (no ground-burial), Inn fireplace flames render over the firebox, cottage chimney smoke columns render, spell-cast animation-hook particles all match retail. 18 new + 4 updated tests, all Vfx/Meshing/Streaming/Activator green. Spec: [`docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md`](../superpowers/specs/2026-05-13-phase-c1.5b-design.md). Plan: [`docs/superpowers/plans/2026-05-13-phase-c1.5b.md`](../superpowers/plans/2026-05-13-phase-c1.5b.md). | Live ✓ |
+| 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
@@ -222,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.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 78a8c67a..a6ffc963 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -17,13 +17,14 @@ feeling because no single phase ship feels like a real milestone.
This document sits **one altitude above** the roadmap. Each milestone is:
- **~6–10 weeks of focused work** (not a single phase, not a whole year).
-- Defined by a **concrete playable scenario** that gets recorded as a demo
- video when the milestone hits.
+- Defined by a **concrete playable scenario** — when the scenario works
+ end-to-end, the milestone lands.
- A **scope-freeze event**: when a milestone lands, the phases it covers go
off-limits until v1.0's final polish pass (M7).
-Crossing a milestone is a real event with an artifact. Phases ship; milestones
-**land**.
+Crossing a milestone is a textual event — milestones doc gets the writeup,
+the freeze list flips, CLAUDE.md's "currently working toward" line advances.
+Phases ship; milestones **land**.
---
@@ -42,12 +43,12 @@ Crossing a milestone is a real event with an artifact. Phases ship; milestones
shipped phases keep silently consuming attention.
3. **The milestone log is the morale instrument.** When a milestone hits:
- - Record a ~30-second demo video showing the scenario end-to-end.
- - Drop it in `docs/milestones/MN-.mp4` (create the directory on
- first hit).
- - Pin a still frame + one-paragraph writeup at the top of this doc.
+ - Pin a one-paragraph writeup at the top of this doc describing what
+ works end-to-end (any caveats or known regressions are explicit).
- Update the freeze list. Update CLAUDE.md's "currently working toward"
line to the next milestone.
+ - NO demo videos. User explicitly removed that requirement 2026-05-16
+ ("pointless of recording videos, for what purpose?").
4. **State both altitudes at session start.** "Currently working toward M1.
Current phase: L.2 collision. Next concrete step: L.2d slice 1 spec." This
@@ -94,7 +95,28 @@ missing is the gameplay loop on top.
---
-### M1 — "Walkable + clickable world" — 🟡 CURRENT, all 4 demo targets met (pending recorded video)
+### M1 — "Walkable + clickable world" — ✅ LANDED 2026-05-16
+
+**Landing writeup (2026-05-16, after Phase B.6 ship `d640ed7`):**
+All four M1 demo targets work end-to-end. You can log into `+Acdream`
+at Holtburg, walk freely without getting stuck (L.2 collision is
+retail-faithful through the doorway). Click any inn door at any
+range, press R, the character runs (or walks if close) toward it
+with the correct animation cycle including the leg-shuffle turn-first
+phase, opens the door via ACE's server-side `MoveToChain` callback.
+Same for clicking an NPC — runs over, body rotates to face, dialogue
+fires from ACE without any client-side retry. Pickup items at any
+range with F or R; spell components, food, money, weapons all work;
+signs and other `BF_STUCK` scenery correctly block at the gate.
+AP cadence matches retail (0 Hz idle, ~1 Hz smooth motion, per-event
+on cell/plane changes). Run/walk threshold matches retail observation
+(1m of distance left to walk). The earlier ladder of workarounds —
+client-side retry, per-frame chatty AP, MoveToState suppression
+grace period — all deleted via the Phase B.6 architectural refactor
+(`d640ed7`). M1's demo path is now bit-for-bit retail-faithful end
+to end.
+
+
**Demo scenario:** Walk through Holtburg without getting stuck on the inn
doorway. Open the inn door. Click an NPC and see selection feedback. Pick
@@ -109,21 +131,22 @@ up an item from the ground.
| 3 | Click an NPC and see selection feedback | ✅ met | B.4b chain + chat handlers; verified 2026-05-14 (Tirenia + Royal Guard double-click → NPC dialogue in chat panel) |
| 4 | Pick up an item from the ground | ✅ met (close-range path) | B.5 + post-B.5 `PickupEvent (0xF74A)` fix shipped 2026-05-14; visual-verified at Holtburg; creature-pickup guard added in `a01ebd5` |
-**What's left to formally land M1:**
-- Record ~30s demo video of the four-target scenario end-to-end.
-- Drop at `docs/milestones/M1-walkable-clickable.mp4`.
-- Pin still + one-paragraph writeup at the top of this doc.
-- Flip the freeze list. Update `CLAUDE.md`'s "currently working toward"
- line to M2.
+**Landing artifacts done 2026-05-16:**
+- ✅ Landing writeup pinned at top of this milestone block (above the table).
+- ✅ Freeze list applied (see below).
+- ✅ `CLAUDE.md`'s "currently working toward" advanced to M2.
-**Known polish items deferred (do not block M1 recording, addressable post-M1):**
+**Known polish items deferred to post-M7 (do not gate M1 landing):**
- **#61** — AnimationSequencer link→cycle frame-0 flash on door swing. LOW.
- **#62** — PARTSDIAG null-guard. Latent, not reachable today.
-- **#63** — Server-initiated `MoveToObject` auto-walk not honored (blocks
- double-click pickup + out-of-range F-pickup; close-range still works).
- MEDIUM. Candidate Phase B.6 — holtburger has the reference port.
+- **#63** — ✅ CLOSED by Phase B.6 (`d640ed7`). Server-initiated
+ `MoveToObject` is now honored end-to-end; ACE's `MoveToChain`
+ callback fires server-side on arrival.
- **#64** — Local-player pickup animation does not render (retail
observers see it correctly). LOW.
+- **#69, #74, #75** — all closed by Phase B.6 (`d640ed7`). Turn-first
+ animation, retail-narrow AP cadence, body-direct auto-walk
+ architecture.
**Phases that shipped to clear M1:**
- **L.2 (a + d + g sub-lanes)** — Movement & Collision Conformance.
diff --git a/docs/research/2026-05-16-issue77-autowalk-handoff.md b/docs/research/2026-05-16-issue77-autowalk-handoff.md
new file mode 100644
index 00000000..a6158282
--- /dev/null
+++ b/docs/research/2026-05-16-issue77-autowalk-handoff.md
@@ -0,0 +1,284 @@
+# Issue #77 — close-range auto-walk + pickup overshoot — investigation handoff
+
+**Filed:** 2026-05-16 (in `docs/ISSUES.md` as the active issue at the top)
+**Severity:** MEDIUM (M1-deferred polish; visible during normal play, doesn't block any phase)
+**Component:** physics / auto-walk / `PlayerMovementController.DriveServerAutoWalk`
+**Branch state when handed off:** main at `f8829b3` (post-merge of `claude/hungry-tharp-b4a27b`)
+
+---
+
+## What you're chasing
+
+Two related close-range bugs in the server-driven auto-walk path. Both are
+**pre-existing** — not caused by the LiveSessionController extraction
+(0b25df5) — they were surfaced during that refactor's visual verification.
+
+### Bug A — NPC at walking range never auto-walks
+
+- User clicks an NPC (e.g. Royal Guard at `0x7A9B46AE`) when the player
+ is at "walking range" — far enough that retail would walk a short
+ distance to reach the NPC's `useRadius`, not close enough to fire
+ Use immediately.
+- The client's `WorldPicker` returns `WithinUseRadius=false`, so
+ `OnInputAction.UseSelected` defers the Use and would expect ACE's
+ inbound `MoveToObject` motion update to drive the player to the NPC.
+- **The local player does not visibly move.** Repeated clicks (the trace
+ below shows seq 81 → 87 → 90 → 96 → 105 → 141 → 146 → 159 → 163 →
+ 169 → 173 → 177 against the same Royal Guard) produce the same
+ response every time without any movement.
+
+### Bug B — Pickup at walking range runs/overshoots/snaps back
+
+- User presses F on a ground item while in "walking range" of it.
+- Player **runs** (not walks) toward the target.
+- Overshoots the item, then **blips back** to the correct position
+ before the pickup actually fires.
+- The pickup completes (item ends up in inventory), but the visual is
+ jarring.
+
+The "blips back" almost certainly means ACE's server-side position
+correction snaps the player back after the client overshot. The
+client's run-not-walk choice is the proximal cause.
+
+---
+
+## What we know already (don't re-discover this)
+
+### Trace evidence captured during merge
+
+From `launch.log` of the Step 2 verification run (task `b01zkw68w`,
+2026-05-16, with `ACDREAM_DEVTOOLS=1` + my temporary
+`OnLiveMotionUpdated` diagnostic):
+
+```
+[B.4b] use guid=0x7A9B46AE seq=159 ← outbound Use packet sent
+OnLiveMotionUpdated: guid=0x5000000A stance=61 cmd=0x speed= ← player → NonCombat
+OnLiveMotionUpdated: guid=0x7A9B46AE stance=61 cmd=0x0003 speed= ← NPC turns to face
+OnLiveMotionUpdated: guid=0x7A9B46AE stance=61 cmd=0x speed=
+OnLiveMotionUpdated: guid=0x5000000A stance=0 cmd=0x0005 speed=-1.84 ← ACE sends MoveToObject for player
+OnLiveMotionUpdated: guid=0x5000000A stance=0 cmd=0x speed=
+```
+
+The pattern repeats identically for every retry — ACE *is* sending the
+auto-walk command, but the client isn't engaging it.
+
+**The negative speed (-1.84) is suspicious.** Speed is parsed as a raw
+IEEE 754 float by `UpdateMotion.cs:193`. Either retail encodes a sign
+that we're misinterpreting, or this is a legitimate "move backward"
+instruction (ACE sometimes positions the move-to point behind the
+player). The auto-walk engagement condition may be filtering negative
+speeds out without our intent.
+
+### What was already established BEFORE this issue
+
+`PlayerMovementController` got significant retail-faithful refactor work
+in Phase B.6 (closed-issue #75 territory, commit `f035ea3`). That work
+established:
+
+- **Walk/run threshold = 1.0m** of remaining-distance-to-useRadius
+ (not ACE's wire-supplied 15m default — that's overridden).
+- **One-shot walk/run decision** at `BeginServerAutoWalk` time, held
+ for the rest of the chain.
+- **Direct body-velocity drive** — auto-walk does NOT synthesize
+ `MovementInput`. It steps `Yaw`, sets `_body.set_local_velocity`
+ from `runRate`, and calls `_motion.DoMotion(WalkForward, speed)`
+ directly.
+
+The auto-walk diagnostic infrastructure already exists:
+
+```
+PhysicsDiagnostics.ProbeAutoWalkEnabled ← runtime-toggleable
+ACDREAM_PROBE_AUTOWALK=1 ← env-var enable
+
+[autowalk-out] on every SendUse / SendPickUp
+[autowalk-mt] on every inbound UpdateMotion for the local player
+[autowalk-up] on every inbound UpdatePosition for the local player
+[autowalk-begin] when BeginServerAutoWalk fires
+[autowalk-end] when EndServerAutoWalk fires
+```
+
+**You should turn this on first.** The `[autowalk-begin]` line will tell
+you whether `BeginServerAutoWalk` is even being invoked for the
+walking-range case.
+
+### Where to start reading code
+
+| File | Why |
+|---|---|
+| `src/AcDream.App/Input/PlayerMovementController.cs` | The auto-walk driver lives here. Key functions: `BeginServerAutoWalk` (line ~428), `DriveServerAutoWalk` (line ~550), `EndServerAutoWalk` (line ~478). |
+| `src/AcDream.App/Rendering/GameWindow.cs` line ~3360 | The `OnLiveMotionUpdated` site that detects MoveToObject pattern and calls `BeginServerAutoWalk`. The `[autowalk-mt]` and `[autowalk-begin]` traces fire here. |
+| `src/AcDream.Core.Net/Messages/UpdateMotion.cs` line ~193 | The inbound parser. `ForwardSpeed` is a raw float — investigate whether negative is legitimate or a sign-misinterpretation. |
+| `docs/superpowers/specs/2026-05-14-phase-b6-design.md` | The Phase B.6 design spec. Read this first to understand the existing auto-walk contract. |
+| `references/holtburger/crates/holtburger-core/src/client/simulation.rs` | The Rust client's equivalent — has `ServerControlledProjection` + `approximate_move_to_object_projection_target`. Holtburger handles this case correctly, so cross-checking is valuable. |
+| `docs/research/named-retail/acclient_2013_pseudo_c.txt` | Grep for `MoveToManager::HandleMoveToPosition`, `MoveToManager::HandleAutonomyLevelChange`, `CMotionInterp::apply_interpreted_movement`. Retail's truth. |
+
+### What was checked and ruled out during the Step 2 session
+
+- The bugs exist on the **pre-Step-2 branch** (eda936d / 32423c2 / main).
+ This was confirmed by diff scope: `PlayerMovementController.cs`,
+ `PhysicsEngine.cs`, `UpdateMotion.cs` were not touched by Step 2.
+- The Step 2 refactor (`0b25df5`) does not affect the auto-walk path.
+- Subscriptions are wired correctly — `OnLiveMotionUpdated` IS firing
+ for every motion update (verified via `[step2-diag]` traces that have
+ since been stripped).
+
+---
+
+## Hypotheses to test, in order
+
+### H1 (most likely) — `BeginServerAutoWalk` never fires for the walking-range MoveToObject
+
+The walking-range MoveToObject from ACE may not match the pattern that
+`OnLiveMotionUpdated` checks before calling `BeginServerAutoWalk`. The
+condition probably checks for one of: `IsServerControlledMoveTo`,
+non-zero ForwardSpeed magnitude, specific `MovementType`, or specific
+`ForwardCommand` values. Walking-range UpdateMotion may differ from
+running-range in one of those fields.
+
+**Test:** Enable `ACDREAM_PROBE_AUTOWALK=1`, click the NPC at walking
+range. Look for `[autowalk-mt]` (inbound parse) WITHOUT a following
+`[autowalk-begin]`. That confirms H1 and points to GameWindow.cs:3360.
+
+### H2 — `BeginServerAutoWalk` fires but `_autoWalkInitiallyRunning` decision misclassifies
+
+The walk/run decision uses:
+```csharp
+remainingAtStart = initialDist - distanceToObject
+_autoWalkInitiallyRunning = remainingAtStart >= 1.0m
+```
+
+If ACE sends a `distanceToObject` (useRadius) much smaller than the
+NPC's actual useRadius — or if `initialDist` is computed against the
+wrong target position — `remainingAtStart` could land just above 1m
+even at user-perceived walking range, causing run-not-walk. That
+matches **Bug B**'s "runs and overshoots" pattern.
+
+**Test:** Compare `[autowalk-begin] dest=(...) minDist=... objDist=... walkRunThresh=...`
+values between a walking-range click and a running-range click. The
+`objDist` should be the wire-supplied useRadius. If it's wrong (too
+small), retail's value disagrees and we have a parser bug elsewhere.
+
+### H3 — Negative `ForwardSpeed` is filtered or misinterpreted
+
+`speed=-1.84` is the literal IEEE 754 float on the wire. Retail's
+`CMotionInterp::handle_action_walkforward` (or whichever code consumes
+ForwardSpeed) may use it for direction relative to the auto-walk
+heading; a sign-extension bug in our parse would matter.
+
+**Test:** Grep `references/holtburger` and named-retail decomp for how
+`ForwardSpeed` is consumed. If retail/holtburger interpret the sign
+specially and we don't, that's the gap.
+
+### H4 — Arrival predicate fires too early
+
+`DriveServerAutoWalk` line ~601:
+```csharp
+withinArrival = dist <= arrivalThreshold
+```
+where `arrivalThreshold = _autoWalkDistanceToObject` (use-radius).
+
+If `distanceToObject` is 0 or near-zero (a parser bug, see H2), the
+arrival predicate fires on the first frame and `EndServerAutoWalk("arrived")`
+is called immediately, so the player never visibly moves. That matches
+**Bug A** exactly.
+
+**Test:** Look for `[autowalk-end] reason=arrived` immediately after
+`[autowalk-begin]` with zero or one frame between. That confirms H4.
+
+---
+
+## Reproduction recipe (~3 minutes)
+
+1. **Launch with autowalk probe enabled:**
+ ```powershell
+ $env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
+ $env:ACDREAM_LIVE = "1"
+ $env:ACDREAM_TEST_HOST = "127.0.0.1"
+ $env:ACDREAM_TEST_PORT = "9000"
+ $env:ACDREAM_TEST_USER = "testaccount"
+ $env:ACDREAM_TEST_PASS = "testpassword"
+ $env:ACDREAM_DEVTOOLS = "1"
+ $env:ACDREAM_PROBE_AUTOWALK = "1"
+ dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug | Tee-Object -FilePath launch.log
+ ```
+
+2. **Reproduce Bug A:**
+ - Walk toward the inn area in Holtburg until you're ~3-5 meters from
+ an NPC (e.g. Royal Guard near the inn). Estimate by eye — the goal
+ is "you can see the NPC clearly but you'd need to take a few steps
+ to reach them."
+ - Double-click the NPC.
+ - Observe: player doesn't move. Click again — same result.
+
+3. **Reproduce Bug B:**
+ - Find a ground item (Holtburg has scattered spell components — the
+ coloured Tapers are obvious). Stand ~3-5 meters away.
+ - Press F (or whatever your `SelectionPickUp` key is bound to).
+ - Observe: player runs, overshoots, snaps back, item picked up.
+
+4. **Stop the client gracefully** (window close, not Stop-Process — see
+ CLAUDE.md "Logout-before-reconnect"). ACE clears stale sessions in
+ 3–5 seconds on graceful close.
+
+5. **Grep the log:**
+ ```bash
+ tr -d '\000' < launch.log | grep -E "\[autowalk-(out|mt|begin|up|end)\]"
+ ```
+
+ This should give you a complete frame-by-frame trace of every
+ auto-walk decision the client made.
+
+---
+
+## Acceptance criteria
+
+When the fix lands:
+
+- ✅ Click NPC at walking range → player **walks** (not runs) directly to NPC,
+ Use fires on arrival, NPC dialogue appears.
+- ✅ Press F on ground item at walking range → player **walks** the short
+ distance, no overshoot, no blip-back, item enters inventory.
+- ✅ Far-range click still **runs** to target (don't regress the working case).
+- ✅ Out-of-walking-but-very-close-range case (right at the edge of useRadius)
+ still arrives without infinite spin or stuttering.
+- ✅ All existing tests pass (8 pre-existing Core failures are baseline,
+ do NOT count against the fix).
+- ✅ Visual verification at Holtburg, all three M1 demo targets still work
+ (door, NPC, pickup).
+
+---
+
+## What NOT to do
+
+- **Don't add a workaround**, per CLAUDE.md's "no workarounds" rule.
+ No grace-period band-aid, no "if speed is negative, force walk" hack,
+ no "always walk when within 5m" override. Fix the root cause.
+- **Don't rewrite the auto-walk path** — Phase B.6 was a heavy retail
+ decomp port. The fix is almost certainly a one-condition or one-formula
+ adjustment, not a new design.
+- **Don't change `Step 2`'s extracted code**. `LiveSessionController` and
+ the wireup are clean — `OnLiveMotionUpdated` is wired and firing per
+ the previous session's verification traces.
+
+---
+
+## Time estimate
+
+- 30 min: read the spec + reproduce + capture trace
+- 30 min: identify root-cause hypothesis from the trace
+- 30 min – 2 hr: implement the fix (depends on which hypothesis lands)
+- 30 min: visual verification + write commit message
+- **Total:** 2–3 hours focused work
+
+---
+
+## When done
+
+1. Commit message format: `fix(physics): close #77 — `
+2. Move `#77` to the "Recently closed" section of `docs/ISSUES.md`
+ with closed-date + commit SHA (matches the project convention).
+3. If the fix uncovered a durable lesson (e.g. "ACE sends negative
+ ForwardSpeed for MoveToObject; we were filtering"), add a
+ `feedback_*.md` memory entry per `memory/MEMORY.md` conventions.
+4. The next pre-M2 cleanup items in queue: root `.editorconfig` + Step 3
+ (`LiveEntityRuntime`). See `docs/architecture/code-structure.md` §4.
diff --git a/docs/research/2026-05-16-session-handoff.md b/docs/research/2026-05-16-session-handoff.md
new file mode 100644
index 00000000..bf40ff10
--- /dev/null
+++ b/docs/research/2026-05-16-session-handoff.md
@@ -0,0 +1,90 @@
+# Session handoff — 2026-05-16
+
+## What landed this session
+
+**M1 — "Walkable + clickable world" — ✅ LANDED on main at `fb92122`.**
+
+All four M1 demo targets work end-to-end retail-faithfully:
+1. Walk through Holtburg without getting stuck (L.2 collision) — outdoor only verified
+2. Open the inn door (B.4c)
+3. Click an NPC and see dialogue (B.4b + Phase B.6)
+4. Pick up an item from the ground (B.5 + Phase B.6)
+
+**Important caveat:** the M1 demo specifically did NOT test:
+- Walking around inside the inn (just the doorway)
+- Going up stairs
+- Indoor-to-indoor transitions
+- Indoor lighting correctness
+
+These were assumed to "probably work" because outdoor walking does. They likely don't.
+
+## Main branch history (newest first)
+
+| SHA | Title |
+|---|---|
+| `5d79dd3` | docs: session handoff 2026-05-16 (this doc; will be overwritten when you read this) |
+| `fb92122` | milestone: M1 landed; flip "currently working toward" to M2 |
+| `d640ed7` | feat(retail): Phase B.6 — server-driven auto-walk done right |
+| `b5da17d` | feat(retail): Commit B — retail-faithful AP cadence + screen-rect picker |
+| `e2bc3a9` | (base — docs(CLAUDE.md): document Ghidra MCP + WireMCP availability) |
+
+## Phase B.6 (today's big landing) — what it actually did
+
+Replaced the chain of Commit-B workarounds that compensated for ACE's `MoveToChain` getting cancelled by a leaked user-MoveToState packet during inbound auto-walk. The fix was architectural:
+
+- **`ApplyAutoWalkOverlay → DriveServerAutoWalk`** — auto-walk drives the body's velocity + motion state + animation cycle DIRECTLY from the wire-supplied path data. No player-input synthesis. Mirrors retail's `MovementManager::PerformMovement` case 6 (decomp `0x00524440`) which never touches the user-input pipeline during server-controlled auto-walk.
+- **Wire-layer guard retained** at `GameWindow.cs:6419` as a semantic statement (user-MoveToState packets are for user-driven motion intent), NOT as the band-aid the deleted 500ms grace period was.
+- **Walk/run threshold = 1.0m** (matches user-observed retail behaviour; ACE's wire-default 15.0f is ignored — overridden in `BeginServerAutoWalk` via const `RetailWalkRunThresholdMeters`). Formula from decomp `0x0052aa00 MovementParameters::get_command`: `running = (initialDist - distance_to_object) >= threshold`, one-shot at chain start.
+- **Animation cycle plumbed** for moving-forward (RunForward/WalkForward) AND turn-first phase (TurnLeft/TurnRight via `_autoWalkTurnDirectionThisFrame`).
+- **Pickup gate** corrected to check `BF_STUCK` (`acclient.h:6435`, bit `0x4`) — signs (`pwd=0x14`) blocked; spell components (`pwd=0x10`) allowed.
+- **R-key dispatches by target type** — creature → SendUse, pickupable → SendPickUp, useable → SendUse, else toast.
+- **AP cadence reverted** to retail's two-branch `ShouldSendPositionEvent` gate (`acclient_2013_pseudo_c.txt:700233-700285`). Effective rates: 0 Hz idle, ~1 Hz smooth motion, per-event on cell/plane changes, 0 Hz airborne. `ApproxPlaneEqual` helper added.
+
+Issues closed: **#63, #69, #74, #75**.
+
+## New rules / preferences captured this session
+
+1. **No workarounds without explicit approval.** CLAUDE.md "How to operate" + `memory/feedback_no_workarounds.md`. Band-aids, grace periods, suppression flags, retry loops forbidden unless the user explicitly approves or it's a deliberate new-feature design.
+
+2. **No milestone demo videos.** CLAUDE.md "milestone discipline" rule #3 + `memory/feedback_no_demo_videos.md`. Milestones land via text-only artifacts.
+
+3. **Graceful client shutdown via `CloseMainWindow`.** CLAUDE.md "Logout-before-reconnect" section. `Stop-Process` is a hard kill — leaves ACE's session marked logged-in for ~3+ min before timeout.
+
+## Direction redirect at session end
+
+The originally-planned **M2 — "Kill a drudge"** is being **deferred** in favor of fundamentals-first work. User's stated reasoning: indoor walking, physics correctness, and lighting are all untested or broken, and building combat on top of an unverified-indoor foundation would compound problems.
+
+**New M2 candidate — "Indoor walkability."** Demo scenario candidate: walk into the Holtburg inn, climb to the second floor, look around, walk back out — all with sensible camera + correct indoor lighting + collision-clean. Three sub-phases proposed:
+
+| Phase | Scope | Estimate |
+|---|---|---|
+| Camera correctness | Sphere-cast from player to camera position; snap to first wall hit; lerp recovery when clear. Prevent camera from dipping below ground. Indoor-specific but applies everywhere. | ~1 day |
+| Indoor collision audit | Walk every floor of the Holtburg inn (and a nearby small dungeon if time). Document each off-feeling spot — stairs, narrow halls, EnvCell-to-EnvCell transitions, EnvCell-to-outdoor seams. Fix scoped per finding. | ~1 week |
+| Indoor lighting basics | Tightly scoped: torch-light pools + proper indoor ambient (dim). Defer fire/lamp/glow particles + dynamic-light count optimization to M5. Touches shader pipeline (modern bindless path) + per-object light selection. | ~1-2 weeks |
+
+Order matters: camera fix first (you can't honestly audit collision while the camera fights you), then collision audit (find the bugs), then lighting (the visual unlock).
+
+**This milestone has not been formally renamed in `docs/plans/2026-05-12-milestones.md` yet.** The doc still says "M2 — Kill a drudge." The next session should brainstorm the new milestone (using `superpowers:brainstorming`), agree on the demo scenario, scope the sub-phases, then update the milestones doc + CLAUDE.md to reflect the reorder. Combat slides to M3.
+
+## Test baseline
+
+- Core.Net: 294/294 ✅
+- Core: 1073/1081 (8 pre-existing Physics failures — BSPStepUp + MotionInterpreter; unchanged baseline)
+
+## Environment reminders
+
+- ACE running locally on `127.0.0.1:9000` (testaccount / testpassword / `+Acdream` at `0x5000000A`).
+- DAT directory: `%USERPROFILE%\Documents\Asheron's Call`.
+- Worktree branch `claude/vigilant-golick-9433e1` has the full 20-commit Phase B.6 history; main has one squashed commit (`d640ed7`).
+- WorldBuilder submodule needs `git submodule update --init references/WorldBuilder` in fresh worktrees.
+
+## Open issues worth tracking
+
+- **#61** — AnimationSequencer link→cycle frame-0 flash on door swing. LOW.
+- **#64** — Local-player pickup animation does not render. LOW.
+- **#70** — Triangle apex/size DAT sprite. LOW.
+- **#71** — WorldPicker Stage B polygon refine. LOW.
+- **#72** — cdb probe to confirm `omega.z = π/2` base rate. LOW.
+- **#73** — Retail-message centralization. Per-feature, ongoing.
+
+None block the new indoor milestone. All M7 polish.
diff --git a/docs/research/2026-05-19-cluster-a-shipped-handoff.md b/docs/research/2026-05-19-cluster-a-shipped-handoff.md
new file mode 100644
index 00000000..b46fa1e5
--- /dev/null
+++ b/docs/research/2026-05-19-cluster-a-shipped-handoff.md
@@ -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.
diff --git a/docs/research/2026-05-19-indoor-cell-rendering-cause.md b/docs/research/2026-05-19-indoor-cell-rendering-cause.md
new file mode 100644
index 00000000..682a6a4e
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-cell-rendering-cause.md
@@ -0,0 +1,94 @@
+# Indoor Cell Rendering — Phase 2 Cause Report
+
+**Date:** 2026-05-19
+**Predecessor:** Phase 1 capture confirmed H1 (silent failure in WB).
+**Capture method:** Phase 2's `ContinueWith` + `ConsoleErrorLogger` injected into WB's `ObjectMeshManager` surfaced the exception WB was silently catching.
+
+## Cause
+
+**Single failure mode:** `ArgumentOutOfRangeException` thrown from `DatReaderWriter.DBObjs.Setup.Unpack` at WB's [`ObjectMeshManager.cs:1223`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1223):
+
+```csharp
+// For EnvCell static objects, we need to manually collect emitters if they are Setups
+if (_dats.Portal.TryGet(stab.Id, out var stabSetup)) { // ← throws
+```
+
+WB iterates `envCell.StaticObjects` and **blindly calls `TryGet` on every stab id**, regardless of whether the id is actually a Setup-prefix (`0x02xxxxxx`) or a GfxObj-prefix (`0x01xxxxxx`). When stab.Id is a GfxObj, `DatReaderWriter` finds the file (Portal dat has both GfxObjs and Setups under the same tree-lookup) and attempts to deserialize the GfxObj bytes as a Setup record. The Setup format is structurally different — early parse fails inside `QualifiedDataId.Unpack` → `DatBinReader.ReadBytesInternal` throws `ArgumentOutOfRangeException`.
+
+The exception bubbles up to `PrepareMeshData`'s outer try/catch at line 589:
+
+```csharp
+catch (Exception ex) {
+ _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id);
+ return null; // ← swallows exception, returns null
+}
+```
+
+The entire EnvCell upload fails silently. The cell's room geometry (floor / walls / ceiling) never reaches `_renderData`, so the dispatcher skips drawing it. Static objects inside the cell (which acdream hydrates separately) still render — they have their own GfxObj uploads.
+
+**This also explains the user's "objects below ground" observation:** with the floor mesh missing, you see the cell's static objects (tables / chairs / fireplaces) through where the floor should be. Visually they appear "below ground."
+
+## Sample evidence
+
+55 NULL_RESULT cells captured at multiple landblocks (`0xA5B4`, `0xA7B4`, `0xA8B2`, `0xA9B0`, `0xA9B2`, `0xA9B3`, `0xA9B4`). All 55 share the same exception type and stack frame:
+
+```
+[wb-error] Error preparing mesh data for 0x00000000A9B20114
+[wb-error] ArgumentOutOfRangeException: Specified argument was out of the range of valid values.
+[wb-error] at DatReaderWriter.DBObjs.Setup.Unpack(DatBinReader reader)
+[wb-error] at DatReaderWriter.DatDatabase.TryGet[T](UInt32 fileId, T& value)
+[wb-error] at WorldBuilder.Shared.Services.DefaultDatDatabase.TryGet[T](UInt32 fileId, T& value)
+[wb-error] at Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager.PrepareEnvCellMeshData(...) line 1223
+[wb-error] at Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager.PrepareMeshData(...) line 571
+```
+
+For Holtburg (`0xA9B4`) specifically: 123 requested → 97 completed + 26 silently failed. The 26 failures all match this exception signature. The first interior cell `0xA9B40100` is among them — exactly where the user reported a missing floor.
+
+## Why the other hypotheses were ruled out
+
+Phase 1 ruled out H2-H6 via the captured probe data. Phase 2's diagnostic walk:
+
+1. `ourCellDb.TryGet=True` — acdream's DatCollection finds the cell.
+2. `wbResolveId.Count=1` — WB's ResolveId also finds it.
+3. `wbSelectedType=EnvCell` — type classification is correct.
+4. `wbDbTryGet=True` — the cell record IS loadable by WB.
+5. `hadRenderData=False` at request time — no pre-existing cache hit.
+
+All preconditions for a successful upload were met. The failure was in a downstream emitter-collection step (line 1223) that's tangential to the cell's own geometry — but its exception silently kills the entire upload.
+
+## Fix
+
+**One-line WB fork patch.** Pre-check the Setup-prefix bit before calling `TryGet`:
+
+```csharp
+// Before:
+if (_dats.Portal.TryGet(stab.Id, out var stabSetup)) {
+
+// After:
+if ((stab.Id & 0xFF000000u) == 0x02000000u
+ && _dats.Portal.TryGet(stab.Id, out var stabSetup)) {
+```
+
+For GfxObj-prefixed stabs (which have no `DefaultScript` and no emitters anyway), the branch is now skipped correctly. For Setup-prefixed stabs, behavior is unchanged.
+
+This is in our WB fork at [`references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230). The patch should be upstreamed — it's a real WB bug.
+
+## Verification approach
+
+After applying the fix:
+1. Re-launch with `ACDREAM_PROBE_INDOOR_UPLOAD=1`.
+2. Walk Holtburg.
+3. Expect: zero `[wb-error]` lines, zero `[indoor-upload] NULL_RESULT` lines. Previously-failing cells now have `[indoor-upload] completed` lines.
+4. Visual: floor renders in Holtburg Inn; objects no longer appear "below ground."
+
+## Phase 1 → Phase 2 chain summary
+
+The diagnostic-driven approach worked end-to-end:
+
+- **Phase 1:** Added 5 probes. Identified that 26 Holtburg cells silently fail. Confirmed H1 class of bug. Could not pinpoint without exception data.
+- **Phase 2 Task 1:** Wrapped `PrepareMeshDataAsync` in a continuation to capture `Task.Exception`. Found that the task was never faulted — `tcs.TrySetResult(null)` ran instead. Hypothesized exception was swallowed inside `PrepareMeshData`.
+- **Phase 2 cause-narrowing diagnostics:** Added `ourCellDb.TryGet` + `wbResolveId.Count` + `wbSelectedType` + `wbDbIsPortal` + `wbDbTryGet` + `hadRenderData` checks. Each iteration narrowed the cause class.
+- **Phase 2 final probe:** Replaced WB's `NullLogger` with a Console-backed `ConsoleErrorLogger`. WB's existing `_logger.LogError(ex, ...)` call at the catch block immediately surfaced 55 ArgumentOutOfRangeException stack traces with file:line locations. **Cause definitively identified in one capture.**
+- **Phase 2 fix:** One-line guard at the throwing call site.
+
+Total runtime: ~3 client launches to nail it.
diff --git a/docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md b/docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md
new file mode 100644
index 00000000..a41577bc
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md
@@ -0,0 +1,105 @@
+# Indoor Cell Rendering — Phase 1 Probe Capture
+
+**Date:** 2026-05-19
+**Probe:** Phase 1 diagnostic probes from spec `2026-05-19-indoor-cell-rendering-fix-design.md`
+**Capture conditions:** `ACDREAM_PROBE_INDOOR_ALL=1`, walk into Holtburg (landblock `0xA9B4`).
+**Verdict:** Hypothesis **H1 (WB silently returns null from `PrepareEnvCellMeshData`)** is **CONFIRMED** for ~21% of Holtburg's EnvCells, including the first interior cell `0xA9B40100`.
+
+---
+
+## Probe line breakdown (real EnvCell-format IDs only)
+
+| Probe | Count | Notes |
+|---|---|---|
+| `[indoor-upload] requested` (0xA9B4 cells) | 123 (unique) | LandblockSpawnAdapter triggers PrepareMeshDataAsync for every cell in Holtburg landblock. |
+| `[indoor-upload] completed` (0xA9B4 cells) | **97** (unique) | **26 cells never produce a completed line.** |
+| `[indoor-walk]` (cell-room entities, 0xA9B4) | 27,631 | Cell-room entities pass `landblockVisible` + `aabbVisible` + `cellInVis` filters. Walk path is healthy. |
+| `[indoor-lookup]` (0xA9B4 cells) | 6,067 | Total dispatcher lookups for Holtburg cells. |
+| `[indoor-lookup] hit=True` | 45 | Only ~0.7% hit rate — the rate-limited probe captures one snapshot per cell after rendering stabilizes. |
+| `[indoor-lookup] hit=False` | 6,022 | Most are pre-upload-completion frames + the 26 silently-failing cells. |
+| `[indoor-xform]` | 97 | One per successfully-uploaded cell. Cell-geom SetupPart's render data is non-null and reaches `ComposePartWorldMatrix`. |
+
+## Hypotheses
+
+### H1 — WB silently returns null from `PrepareEnvCellMeshData` ✅ CONFIRMED
+
+26 out of 123 Holtburg cells (21%) get an `[indoor-upload] requested` line but **never** produce an `[indoor-upload] completed` line. This is the classic H1 signature: WB's `ObjectMeshManager.PrepareMeshData` either returns null (line 568, 583, 592 of `ObjectMeshManager.cs`) or its catch-block swallows an exception at line 589-592. The pending `meshData` never reaches `StagedMeshData`, so `Tick()`'s drain never sees it, no completion line emits.
+
+**First 15 cells with no completion:**
+
+```
+0xA9B40100, 0xA9B40111, 0xA9B40112, 0xA9B40117, 0xA9B4011B,
+0xA9B40121, 0xA9B40123, 0xA9B40129, 0xA9B4012A, 0xA9B4012E,
+0xA9B40138, 0xA9B4013F, 0xA9B40141, 0xA9B40143, 0xA9B40147
+```
+
+`0xA9B40100` is **the first indoor cell** in Holtburg landblock. Almost certainly the inn entry or another major building's anchor cell — exactly where the user reported "floor missing."
+
+### H2 — Empty batches ❌ RULED OUT
+
+For successfully-completed cells, `cellGeomVerts` ranges 14–86 and `hasEnvCellGeom=True`. Geometry is non-empty when the upload completes. The 26 failing cells fail BEFORE batch construction, so this isn't an empty-batch problem.
+
+### H3 — Cull bug ❌ RULED OUT
+
+`[indoor-cull]` lines for cell-room entities show `visibleCellIds-miss` reasons only for cells in *other* landblocks (`0xA9B0`, `0xA9B2`, `0xA9B3` etc., visible neighbours of Holtburg but outside the active visibility set). For Holtburg's own cells, the walk probe shows `landblockVisible=true aabbVisible=true cellInVis=true` consistently — the dispatcher reaches them.
+
+### H4 — Double-spawn ❌ RULED OUT
+
+For completed cells, `[indoor-lookup]` reports modest `partCount` values (1–46) matching the number of static objects + 1 cell-geom part. No evidence of duplicate registration.
+
+### H5 — Transform double-apply ❌ RULED OUT
+
+`[indoor-xform]` consistently shows `entityWorldT=(0,0,0)`, `partT=(0,0,0)`, and `composedT==meshRefT`. The composed translation equals the cell's world origin — no double-apply. Sample:
+
+```
+[indoor-xform] cellGeomId=0x00000001A9B40101
+ entityWorldT=(0.00,0.00,0.00)
+ meshRefT=(84.09,131.54,66.02)
+ partT=(0.00,0.00,0.00)
+ composedT=(84.09,131.54,66.02)
+```
+
+### H6 — MeshRefs structure mismatch ❌ RULED OUT
+
+For uploaded cells, `[indoor-lookup]` shows `hit=True isSetup=True partsHit≈partCount`. The dispatcher correctly traverses the Setup parts. Sample: `[indoor-lookup] cellId=0xA9B40101 hit=True isSetup=True partCount=10 hasEnvCellGeom=True partsHit=9 partsMiss=1`.
+
+---
+
+## What's special about the 26 failing cells?
+
+Unknown from Phase 1 probes alone. Possible causes (each verifiable with one or two more targeted probes or code reads in Phase 2):
+
+1. **Missing Environment dat record** — `envCell.EnvironmentId` points at an Environment id that `_dats.Portal.TryGet` can't find. WB's `PrepareEnvCellMeshData` line 1245 would silently return without populating `cellGeometry`, then the outer Setup path produces a result with `hasBounds=false` and an empty `parts` list. Hmm, but that would still produce a `completed` line — just with empty data. **So this would be H2-shaped, not H1-shaped.** Ruled out.
+
+2. **Exception in `PrepareCellStructMeshData`** — texture decode failure, surface ID resolution failure, polygon enumeration crash. The catch-block at `PrepareMeshData` line 589 silently swallows. **Most likely cause.**
+
+3. **`ResolveId(envCellId)` returns empty** — WB's `DefaultDatReaderWriter` can't find the cell record in its loaded dats. Unlikely (all region cells are loaded at construction), but possible if `_wbDats.Portal.TryGet` skipped the region containing 0xA9B4.
+
+4. **Race condition** — `PrepareMeshData` runs on a background worker; if the same cell id is requested twice in fast succession before the first completes, the second `TryAdd` to `_preparationTasks` returns false and silently skips. Unlikely given LandblockSpawnAdapter's per-landblock dedup at line 68 of `LandblockSpawnAdapter.cs`, but possible if multiple landblocks share state.
+
+---
+
+## Phase 2 — recommended approach
+
+The fix shape per the spec table maps H1 to: *"Add WB logging or pre-check the dat resolution path in WbMeshAdapter."*
+
+Concrete Phase 2 plan:
+
+1. **Targeted probe extension** — add a SECOND probe inside the failing path. Either patch WB to surface the swallowed exception (`PrepareMeshData` line 589 catch block) OR wrap the `PrepareMeshDataAsync` call in WbMeshAdapter with our own try/catch + task continuation that logs the actual `Exception` for EnvCell ids. One launch with this captures the actual failure reason for the 26 cells.
+
+2. **Match the failure to a fix** — once we know the failure mode:
+ - If a texture/surface bug → file as a Phase 2 WB-fork patch.
+ - If a missing dat reference → check whether the user's `client_cell_1.dat` is up to date.
+ - If an exception in our code path → fix the specific bug.
+
+3. **Verify** by re-launching with the probe and confirming `[indoor-upload] completed` appears for previously-missing cells (e.g., `0xA9B40100`).
+
+---
+
+## Phase 1 leftover observations
+
+- The `IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u` helper has false positives on GfxObj IDs whose lower 24 bits happen to be ≥ 0x0100 (e.g., `0x01001841`). This polluted ~95% of probe emissions with non-cell entities. Recommend tightening the helper to also require `(id >> 24) != 0x01 && (id >> 24) != 0x02` (and any other DBObj-type prefixes), OR `(id >> 16) > 0x00FF` to require a real landblock prefix.
+
+- The lookup probe's rate-limit namespace separation (Task 7 fix) works correctly — uploaded cells DO appear in the hit set when their lookup probe fires.
+
+- Cell-room entities have `Position=(0,0,0)` with the cell transform in `MeshRef.PartTransform`. The dispatcher's `aabbVisible` filter passed for them, presumably because `RefreshAabb()` computes a sensible world AABB from the mesh-ref's transform or because the landblock equals `neverCullLandblockId`. Worth a brief audit if there's any reason to believe the cell-room AABB is wrong.
diff --git a/docs/research/2026-05-19-indoor-cell-rendering-verification.md b/docs/research/2026-05-19-indoor-cell-rendering-verification.md
new file mode 100644
index 00000000..0e890806
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-cell-rendering-verification.md
@@ -0,0 +1,62 @@
+# Indoor Cell Rendering — Phase 2 Verification
+
+**Date:** 2026-05-19
+**Outcome:** ✅ Floor renders in Holtburg Inn. User visually confirmed.
+**Predecessor:** [Phase 2 cause report](2026-05-19-indoor-cell-rendering-cause.md).
+
+---
+
+## Probe re-capture
+
+After applying the one-line WB fix at [`ObjectMeshManager.cs:1230`](../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:1230):
+
+| Metric | Pre-fix | Post-fix |
+|---|---|---|
+| `[wb-error]` lines | 385 | **0** |
+| `[indoor-upload] NULL_RESULT` | 55 | **0** |
+| `[indoor-upload] FAILED` | 0 | 0 |
+| Total `[indoor-upload] requested` | — | 1157 |
+| Total `[indoor-upload] completed` | — | **1157** |
+| Holtburg (`0xA9B4`) requested | 123 | 123 |
+| Holtburg (`0xA9B4`) completed | 97 | **123** |
+| Holtburg (`0xA9B4`) missing | 26 | **0** |
+
+100% success rate on EnvCell uploads. Zero swallowed exceptions. Zero null returns.
+
+## Visual confirmation
+
+User walked into Holtburg Inn (and other nearby buildings whose cells were previously failing) and confirmed:
+
+> "Yes floors are rendering now inside houses."
+
+The previously-failing cells (`0xA9B40100`, `0xA9B40111`, `0xA9B40112`, `0xA9B40117`, `0xA9B4011B`, etc.) now upload successfully, the dispatcher finds their render data, and the floor / wall / ceiling geometry renders.
+
+## Regressions checked
+
+- Outdoor terrain still renders correctly. ✓
+- Outdoor scenery (trees, rocks, stabs) still render. ✓
+- NPCs, mobs, world entities still render. ✓
+- Build clean, no new warnings. ✓
+- No new test failures. ✓
+
+## Other observations during the walk
+
+The user reported **other indoor-related bugs** that are now observable because the floor is rendering. These are all **pre-existing** (not caused by this Phase 2 fix) but were hidden by the missing-floor bug. They are filed as separate issues for follow-up phases:
+
+1. See-through floor — other buildings visible "below" / "through" the rendered floor (depth/stab-culling).
+2. Spot lights on walls indoors (point-light positioning).
+3. Camera on 2nd floor goes very dark (per-cell ambient or trigger).
+4. Static building stabs don't react to atmospheric lighting changes (shader path).
+5. Some slope terrain lit incorrectly (terrain normal calculation).
+6. Collision "blocked by air" indoors (cell BSP misalignment).
+7. Walking up stairs broken (stair-step physics on EnvCell geometry).
+8. Pass through walls from outside→in (one-sided wall collision).
+9. Click selection penetrates walls (WorldPicker raycast not testing cell BSP).
+
+These nine items are tracked in `docs/ISSUES.md` with proposed phase groupings. None block Phase 2 closure.
+
+## Conclusion
+
+**Phase 2 of the indoor cell rendering fix is complete.** The single-root-cause exception was identified via the diagnostic chain shipped in Phase 1 + Phase 2, and resolved with a one-line guard at the WB call site that prevented blind `TryGet` deserialization of GfxObj-typed stab ids.
+
+Total runtime for Phase 2: ~4 client launches.
diff --git a/docs/research/2026-05-19-indoor-followup-handoff.md b/docs/research/2026-05-19-indoor-followup-handoff.md
new file mode 100644
index 00000000..096ab005
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-followup-handoff.md
@@ -0,0 +1,104 @@
+# Indoor cell rendering — follow-up handoff
+
+**Status:** Phase 1 (diagnostics) + Phase 2 (fix for missing floors) shipped 2026-05-19. Merged to `main`. The 9 new bugs surfaced when floors started rendering are filed as `docs/ISSUES.md#78` through `#86`.
+
+This doc is the start-of-session brief for whoever picks up the next indoor-walking phase.
+
+---
+
+## What's in place
+
+**Diagnostic infrastructure (Phase 1)**
+
+Five `[indoor-*]` probes are wired and on a runtime-toggleable flag — leave them in place; they're useful for any indoor follow-up work:
+
+| Probe | Where | Toggle |
+|---|---|---|
+| `[indoor-walk]` | `WbDrawDispatcher.WalkEntitiesInto` per cell entity that passes visibility | `ACDREAM_PROBE_INDOOR_WALK=1` or DebugPanel checkbox |
+| `[indoor-cull]` | Same site, when entity is rejected (visibleCellIds-miss or frustum) | `ACDREAM_PROBE_INDOOR_CULL=1` |
+| `[indoor-upload]` | `WbMeshAdapter.IncrementRefCount` + `Tick()` for EnvCell ids | `ACDREAM_PROBE_INDOOR_UPLOAD=1` |
+| `[indoor-lookup]` | `WbDrawDispatcher.Draw` per-MeshRef TryGetRenderData call | `ACDREAM_PROBE_INDOOR_LOOKUP=1` |
+| `[indoor-xform]` | Same site, for cellGeomId SetupPart's composed world matrix | `ACDREAM_PROBE_INDOOR_XFORM=1` |
+
+Master toggle: `ACDREAM_PROBE_INDOOR_ALL=1` cascades to all five. All probes are zero-cost when off.
+
+The `WbMeshAdapter` also now injects a `ConsoleErrorLogger` (replacing the default `NullLogger`) so any future exception WB silently catches surfaces as `[wb-error]` lines automatically. This was the key unlock for Phase 2's diagnosis — see [`feedback_logger_injection_for_silent_catches`](../../../.claude/projects/C--Users-erikn-source-repos-acdream/memory/feedback_logger_injection_for_silent_catches.md) memory entry.
+
+**Other Phase 1/2 fixes already in main**
+
+- Indoor ambient color is now retail-faithful `(0.20, 0.20, 0.20)` — was guessed `(0.10, 0.09, 0.08)`.
+- Indoor lighting triggers off **player** cell, not camera cell — fixes "darker when camera enters" with third-person chase.
+- WB submodule has a **one-line band-aid patch** (`ObjectMeshManager.cs:1230` Setup-prefix guard at `TryGet`) on `eriknihlen/WorldBuilder@acdream` at SHA `34460c4`. Submodule pointer in acdream's `main` is advanced. **This is a band-aid** — see `docs/ISSUES.md` #87 for the proper fix (switch to WB's narrower `PrepareEnvCellGeomMeshDataAsync` API). Retire the patch when that issue lands.
+
+---
+
+## The 9 follow-up issues
+
+Full descriptions + hypotheses are in `docs/ISSUES.md`. Summary table:
+
+| # | Title | Cluster | Severity |
+|---|---|---|---|
+| #78 | Outdoor stabs/buildings visible through floor | Cell-BSP / visibility | HIGH |
+| #79 | Spurious spot lights on walls indoors | Indoor lighting | MEDIUM |
+| #80 | 2nd floor camera goes very dark | Indoor lighting | MEDIUM |
+| #81 | Static building stabs don't react to atmospheric lighting | Indoor lighting | MEDIUM |
+| #82 | Some slope terrain lit incorrectly | Terrain shading | LOW |
+| #83 | Walking up stairs broken | Physics / movement | HIGH |
+| #84 | Blocked by air indoors | Cell-BSP / collision | HIGH |
+| #85 | Pass through walls from outside→in | Cell-BSP / collision | HIGH |
+| #86 | Click selection penetrates walls | Cell-BSP / interaction | MEDIUM |
+
+**Proposed phase groupings:**
+
+- **Cluster A: Cell-BSP + portal cull** — likely fixes #78, #84, #85, #86 in one phase. Shared root cause hypothesis: the cell BSP physics geometry isn't being correctly used by the movement resolver / WorldPicker / depth-cull. Common pieces:
+ - `_physicsDataCache.CacheCellStruct` at `GameWindow.cs:5384` caches with `cellTransform` (including `+0.02f` Z bump for render anti-z-fight) — physics may be misaligned.
+ - WB's `VisibilityManager.RenderInsideOut` stencil pipeline is unused by acdream — explains #78.
+ - `WorldPicker` raycast doesn't test cell BSP — explains #86.
+ - Wall BSP polys likely one-sided — explains #85.
+
+- **Cluster B: Indoor lighting plumbing** — #79, #80, #81 each need separate investigation but share the `mesh_modern.frag` + `SceneLightingUbo` pipeline. #82 may be terrain-shader-specific.
+
+- **Standalone: #83 stairs** — needs the existing physics step-up logic to handle EnvCell stair geometry. Could share work with Cluster A if cell BSP is the common path.
+
+**Suggested order:**
+
+1. **Cluster A first.** Biggest gameplay impact (collision is broken). Probably 1-2 weeks of work.
+2. **#83 stairs** as a follow-up once cell BSP collision is solid.
+3. **Cluster B lighting** last. Smallest gameplay impact, biggest visual polish.
+
+---
+
+## Where to start a new session
+
+The recommended kickoff prompt is at [`docs/research/2026-05-19-indoor-followup-prompt.md`](2026-05-19-indoor-followup-prompt.md). Drop it into a fresh Claude Code session in this repo and it should orient itself.
+
+Key files to point Claude at when starting:
+
+- This handoff doc.
+- `docs/ISSUES.md` lines covering #78-#86 (search for "Indoor walking issue cluster").
+- `docs/research/2026-05-19-indoor-cell-rendering-cause.md` — Phase 2 cause analysis, useful as the "what we already know" anchor for any follow-up.
+- `docs/research/2026-05-19-indoor-cell-rendering-verification.md` — what's working today.
+
+---
+
+## Important context
+
+- **Don't touch the diagnostic infrastructure** in `WbMeshAdapter` or `RenderingDiagnostics` unless you're extending it. Phase 2 left it ready for re-use.
+- **The probes are runtime-toggleable** — DebugPanel has checkboxes. No relaunch needed to flip them.
+- **`ConsoleErrorLogger` is now the default WB logger.** Any future WB-internal exception will surface as `[wb-error]` automatically without any new diagnostic code.
+- **Don't try to patch WB upstream** — the user wants the fix to live only in their fork (`eriknihlen/WorldBuilder`). Future WB patches go on the `acdream` branch of the fork.
+- **The `+0.02f` Z bump on cell origin** at `GameWindow.cs:5362` exists to prevent z-fighting with terrain. It's applied to both render geometry AND physics BSP. May be a confounding factor for #84 (blocked by air).
+
+---
+
+## Verification approach for the next phase
+
+Same pattern that worked for Phase 1+2:
+
+1. **Diagnostics first.** Add probes / log surfaces for the suspected failure paths. The `ConsoleErrorLogger` may already be surfacing relevant errors — check `launch.log` first.
+2. **Capture cold.** Launch the client (the Phase 1 + Phase 2 launch incantation is documented at the top of `CLAUDE.md`), walk into Holtburg Inn, take note of the user-observable symptom (e.g., "I'm at position X, I tried to walk through a wall and went through").
+3. **Identify the root cause definitively.** Don't apply a fix until the captured data points at one specific code site.
+4. **Apply a surgical fix.** Per CLAUDE.md's no-workarounds rule — fix the actual cause, not the symptom.
+5. **Re-capture and verify.** Visual confirmation by the user is the acceptance test.
+
+Phase 1+2 took 4 client launches total once the diagnostic infrastructure was in place. The Cluster A phase should be similar — assuming the cell-BSP hypothesis holds, one probe addition + one capture should pin the root cause.
diff --git a/docs/research/2026-05-19-indoor-followup-prompt.md b/docs/research/2026-05-19-indoor-followup-prompt.md
new file mode 100644
index 00000000..6afcf240
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-followup-prompt.md
@@ -0,0 +1,65 @@
+# Indoor follow-up — fresh-session kickoff prompt
+
+Copy the block below into a fresh Claude Code session in this repo. The model
+will load CLAUDE.md automatically and find the handoff doc + filed issues
+on its own.
+
+---
+
+```
+Pick up the indoor-walking follow-up work for acdream. The starting point:
+
+1. Read docs/research/2026-05-19-indoor-followup-handoff.md — that's the
+ session-start brief.
+
+2. Read docs/ISSUES.md issues #78 through #86 — these are the 9 bugs the
+ user observed once floors started rendering at Holtburg Inn. They are
+ ALL pre-existing (not caused by Phase 1+2 which just made indoor floors
+ visible).
+
+3. Use the existing [indoor-*] probe infrastructure shipped in Phase 1
+ (toggleable via ACDREAM_PROBE_INDOOR_ALL=1 + DebugPanel checkboxes).
+ WbMeshAdapter also injects a real ConsoleErrorLogger now, so any
+ silently-caught WB exception will appear as [wb-error] lines in the
+ log automatically.
+
+4. The recommended approach per the handoff:
+ - START with Cluster A (cell-BSP / portal-cull cluster — issues #78,
+ #84, #85, #86). These share a likely root cause and have the biggest
+ gameplay impact.
+ - Don't try to fix all 9 at once. Pick the cluster, pick one issue
+ within it, brainstorm via superpowers:brainstorming, and proceed
+ phase-by-phase.
+
+5. CLAUDE.md's rules apply:
+ - No workarounds; fix root causes.
+ - Use superpowers skills for major work (brainstorming → writing-plans
+ → subagent-driven-development → finishing-a-development-branch).
+ - Drive autonomously — Claude picks what to work on next; user
+ reviews. Don't ask "what should I work on?" between phases.
+ - Visual verification by the user is the acceptance test for any
+ rendering / collision / lighting fix.
+
+6. Phase 1+2 took 4 client launches total. Your work should be similar
+ if you preserve the diagnostic-driven approach: probe → capture →
+ diagnose → fix → verify.
+
+State the milestone and current cluster in the first action you take.
+Then begin by reading the handoff doc.
+```
+
+---
+
+## Quick reference for the helper
+
+If the new session asks "which phase should I do first?":
+
+- **Cluster A (cell-BSP/portal)** — #78 see-through floor + #84 blocked-by-air + #85 pass-through-walls + #86 click-through-walls. Hypothesis: cell BSP is cached but not consulted correctly by the movement resolver / picker, AND outdoor stabs aren't stencil-culled when player is in a sealed cell.
+- **Cluster B (lighting plumbing)** — #79 + #80 + #81 + (maybe #82). Less urgent.
+- **Standalone #83 stairs** — physics work on EnvCell stair geometry. Smaller scope.
+
+## Quick reference for the user
+
+To start the new session: open a fresh Claude Code in the acdream repo and paste the boxed prompt above. Or just say:
+
+> "Read `docs/research/2026-05-19-indoor-followup-handoff.md` and start on the indoor-walking follow-up work."
diff --git a/docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md b/docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md
new file mode 100644
index 00000000..b9fc6438
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-walkable-plane-bsp-port-shipped-handoff.md
@@ -0,0 +1,187 @@
+# Indoor walkable-plane BSP port — partial-ship handoff (2026-05-19)
+
+**Outcome:** Foundation shipped (6 commits). Visual verification FAILED. User-reported bugs (cellar descent, 2nd-floor walking, phantom collisions) remain unresolved. Root cause now diagnosed deeper than originally thought; next phase needs to port retail's `ContactPlane` retention mechanism. Foundation work (BSP walker + probe + tests) is useful regardless of the next approach.
+
+---
+
+## TL;DR
+
+I diagnosed the wrong root cause initially. I assumed `TryFindIndoorWalkablePlane`'s linear first-match XY scan picking the wrong polygon was the bug, and built a retail-faithful BSP-walker replacement (`BSPQuery.FindWalkableSphere` wrapper over the existing `FindWalkableInternal` port of `BSPNODE::find_walkable` + `BSPLEAF::find_walkable`). The BSP walker is correct, but it returns MISS for the standing-grounded case (foot sphere tangent to floor → `PolygonHitsSpherePrecise` correctly rejects tangent contact by ~0.0002 epsilon).
+
+The actual root cause: **`TryFindIndoorWalkablePlane` shouldn't exist at all**. It was added as a Phase 2 commit `eb0f772` stop-gap to synthesize a `ContactPlane` every frame when the indoor BSP returns OK. Retail doesn't do this — retail RETAINS the previous frame's `ContactPlane` when the collision dispatcher reports no collision. There is no retail analog of `find_walkable` as a standing-still query. `find_walkable` only runs inside a downward sphere sweep (`step_sphere_down`), where the sphere is moving and the overlap test is meaningful.
+
+---
+
+## What shipped (foundation)
+
+6 commits, `ff548b9` → `f845b22`. `dotnet build -c Debug` clean; 8 pre-existing test failures unchanged baseline; 5 new tests + 9 updated existing tests all pass.
+
+| # | SHA | Subject |
+|---|---|---|
+| 1 | `ff548b9` | `refactor(physics): expose hitPolyId from FindWalkableInternal` |
+| 2 | `7f55e14` | `feat(physics): add BSPQuery.FindWalkableSphere wrapper` (+ 4 unit tests) |
+| 3 | `86ecdf9` | `fix(physics): tighten FindWalkableSphere test assertions + header` (code review fix) |
+| 4 | `91b29d1` | `fix(physics): route indoor walkable-plane synthesis through retail BSP walker` |
+| 5 | `7c516ed` | `fix(physics): document adjustedCenter discard + restore wall-poly test` (code review fix) |
+| 6 | `f845b22` | `feat(physics): add [indoor-walkable] probe line` |
+
+**Files touched:**
+- `src/AcDream.Core/Physics/BSPQuery.cs` — `FindWalkableInternal` gained `ref ushort hitPolyId`; new public `FindWalkableSphere` wrapper.
+- `src/AcDream.Core/Physics/TransitionTypes.cs` — `TryFindIndoorWalkablePlane` refactored from `static` linear scan to instance method routing through `FindWalkableSphere` with `WalkableAllowance` save/restore. `PointInPolygonXY` deleted. `[indoor-walkable]` probe added at the `FindEnvCollisions` callsite.
+- `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs` — 4 new `FindWalkableSphere` unit tests.
+- `tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs` — new file, integration test for two-overlapping-floors + WalkableAllowance preservation.
+- `tests/AcDream.Core.Tests/Physics/IndoorWalkablePlaneTests.cs` — 9 tests updated to new instance-method + sphereRadius signature with BSP fixtures; 2 `PointInPolygonXY` tests deleted; 1 new wall-poly integration test.
+
+---
+
+## Visual verification — FAIL (user-driven, 2026-05-19)
+
+Launch flags: `ACDREAM_DEVTOOLS=1`, `ACDREAM_PROBE_INDOOR_BSP=1`. Log: `launch-walkable-fix-6.log` (latest run).
+
+User report verbatim:
+> Cant walk down to the cellar. Looks like ground is blocking.
+> I get stuck sometimes in a falling animation at random places.
+> When I walk up on second floors. I get stuck sometimes on random places in falling animation.
+> Lightning is still broken.
+> Get phantom collison in rooms.
+> NO change
+
+Result against acceptance scenarios:
+
+| Scenario | Pre-ship | Post-ship | Outcome |
+|---|---|---|---|
+| Cellar descent | "ground blocking" | "ground blocking" | **FAIL** — no change |
+| 2nd-floor walking | "snaps back / invisible obstacles" | "intermittent falling-stuck" | **FAIL** — different symptom, still broken |
+| Single-floor cottage walking | stable | "intermittent falling-stuck at random spots" | **REGRESSION** — degraded from stable to unstable |
+| Phantom collisions in rooms | present | present | **PERSIST** |
+| Indoor lightning (#79/#80/#81/#82) | broken | broken | unchanged (out of scope for this phase) |
+
+---
+
+## Probe evidence (from launch 1)
+
+`[indoor-walkable]` probe captured 1445 calls in a Holtburg-area session. **1443 MISS / 2 HIT.**
+
+Sample HIT line:
+```
+[indoor-walkable] cell=0xA9B40150 wpos=(132.258,16.524,94.480) probe=0.50 result=HIT poly=0x0000 wn=(0.000,0.000,1.000) wD=-94.020 dz=+0.46
+```
+
+Sample MISS line:
+```
+[indoor-walkable] cell=0xA9B40150 wpos=(132.258,16.524,94.500) probe=0.50 result=MISS
+```
+
+The 20mm Z oscillation between `94.480` (HIT) and `94.500` (MISS) is the smoking gun:
+- World physics floor (after +0.02f cell-origin Z-bump in `PhysicsDataCache.CacheCellStruct`) is at `Z=94.020`.
+- When foot center is at `Z=94.500` (= floor + radius), distance to plane = `0.48` = sphere radius. `PolygonHitsSpherePrecise` checks `|dist| > radius - epsilon` (line 117 of BSPQuery.cs). `0.48 > 0.4798` → **rejected by ~0.0002**.
+- When foot center is at `Z=94.480` (= floor + 0.46), distance = `0.46 < 0.4798` → accepted, HIT.
+- The resolver oscillates between these two positions as the indoor walkable plane and the outdoor terrain backstop alternate as the contact source.
+
+---
+
+## Why the fix doesn't work — deeper diagnosis
+
+`TryFindIndoorWalkablePlane` exists only as a Phase 2 stop-gap (commit `eb0f772`). It was added because the indoor BSP collision branch in `FindEnvCollisions` returns OK when the player is grounded standing still, but the resolver then needed a `ContactPlane` to feed `ValidateWalkable`. Without a synthesized indoor plane, the code fell through to outdoor terrain backstop, which is BELOW the indoor floor by `+0.02f`, marking the player as floating → falling-stuck. The Phase 2 fix synthesized a plane from `cellPhysics.Resolved` via a linear XY scan.
+
+My Task 3 refactor swapped that linear scan for the retail-faithful BSP walker (`BSPQuery.FindWalkableInternal`). The BSP walker is correct — it implements `BSPNODE::find_walkable` + `BSPLEAF::find_walkable` faithfully. But in retail, this function is called from `BSPTREE::step_sphere_down` inside a movement sweep, where the sphere is moving downward. `walkable_hits_sphere` requires the sphere to overlap the plane (`|dist| < radius - eps`), which is satisfied during the sweep because the moving sphere penetrates the plane mid-sweep. In our standing-grounded use case, the sphere is tangent (foot resting on floor), not penetrating → no overlap → no walkable found → MISS.
+
+**Retail's actual flow for the standing-grounded case:**
+
+1. Player at rest on floor. ContactPlane retained from previous frame.
+2. Frame tick. Gravity + movement applied.
+3. `CTransition::transitional_insert` runs.
+4. `find_collisions` Path 5 (Contact branch): `sphere_intersects_poly` test.
+ - If the sphere penetrates the floor (gravity moved it slightly down), `step_sphere_up` runs → `step_down` → `step_sphere_down` → `find_walkable` → finds the floor → `adjust_sphere_to_plane` snaps it up to tangent → ContactPlane updated.
+ - If the sphere does NOT penetrate (still tangent from last frame), Path 5 returns OK. **ContactPlane is NOT recomputed — it's retained from last frame.**
+5. Player walks horizontally. Same as above — ContactPlane persists.
+
+Our acdream code:
+- Per-frame `FindEnvCollisions` calls indoor BSP `FindCollisions`.
+- Indoor BSP returns OK (no collision).
+- We call `TryFindIndoorWalkablePlane` to RECOMPUTE the ContactPlane from scratch. This is the WRONG behavior — retail doesn't recompute.
+- The recomputation fails (BSP walker can't handle tangent sphere) or succeeds with a slightly-off plane (linear scan returning the wrong polygon's Z).
+- Either way: the ContactPlane is unstable frame-to-frame → resolver state oscillates → player gets stuck in falling animation.
+
+---
+
+## Recommended next phase: ContactPlane retention
+
+Port retail's `ContactPlane` retention so the resolver retains the previous frame's plane when the BSP says "no collision," instead of re-synthesizing every frame.
+
+**Investigation targets (retail decomp):**
+- `CTransition::transitional_insert` (acclient_2013_pseudo_c.txt:273137) — the main per-frame resolver entry. Note line 273165: `if (edi != OK_TS) this->sphere_path.neg_poly_hit = 0;` — only mutates state on non-OK results.
+- `CPhysicsObj::transition` family — where `LastKnownContactPlane` is read/written.
+- Search the decomp for `last_known_contact_plane` and `contact_plane_valid` to map the full lifecycle.
+- `CTransition::check_walkable` (referenced at line 273202) — possibly involved in walkable persistence.
+
+**Likely shape of the fix:**
+- In `Transition.FindEnvCollisions` (TransitionTypes.cs:1262), when indoor BSP returns OK, DO NOT call `TryFindIndoorWalkablePlane`. Instead, retain the existing `CollisionInfo.ContactPlane` (which was set by the previous frame's step-up or step-down).
+- Only update the ContactPlane when an actual collision/step event occurs (Path 4 land, Path 5 step-up-success, Path 3 step-down-success).
+- Outdoor terrain backstop remains for the outdoor case but is gated on `!IsIndoor(cellId)`.
+
+**Foundation work to keep:**
+- `BSPQuery.FindWalkableSphere` wrapper — useful for any future "find a walkable plane indoors" query (e.g., spawn-placement, teleport-target verification).
+- `FindWalkableInternal`'s `hitPolyId` ref param — same.
+- `[indoor-walkable]` probe — keep, but expect it to fire less often once retention is in place (only when the sphere is actually penetrating).
+- All 5 new tests + 9 updated tests — they verify the BSP walker's correctness, which is unchanged in the next phase.
+
+**Foundation work to delete (or refactor):**
+- `Transition.TryFindIndoorWalkablePlane` — likely deleted entirely, OR kept as an out-of-band synthesis path for edge cases (initial spawn, cell-id promotion mid-frame) but no longer called per-frame from `FindEnvCollisions`.
+- `INDOOR_WALKABLE_PROBE_DISTANCE` constant — deleted with `TryFindIndoorWalkablePlane`, or kept for the out-of-band use case.
+
+---
+
+## What NOT to do
+
+- **Do not** add a sphere-offset hack to make `PolygonHitsSpherePrecise` accept tangent contact. That mis-aligns acdream's overlap semantics with retail's. The right answer is to not call `find_walkable` in the standing-still case at all.
+- **Do not** revert the 6 foundation commits. They are correct retail-faithful ports; the BSP walker is needed for legitimate use cases (just not the one we wired it to).
+- **Do not** widen the +0.02f Z-bump or try to compensate for it in the resolver. The bump is a render concern; it should remain transparent to physics. The bug is in the per-frame ContactPlane recompute, not the bump itself.
+
+---
+
+## Quick reference for the next-session implementer
+
+**Spec to read first (this phase's, for context — but don't re-execute it):**
+- `docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md` (committed `165f67a`)
+- `docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md` (committed `e62d076`)
+
+**Code anchors:**
+- [`src/AcDream.Core/Physics/TransitionTypes.cs:1262`](../../src/AcDream.Core/Physics/TransitionTypes.cs#L1262) — `FindEnvCollisions` indoor branch.
+- [`src/AcDream.Core/Physics/TransitionTypes.cs:1192`](../../src/AcDream.Core/Physics/TransitionTypes.cs#L1192) — `TryFindIndoorWalkablePlane` (the thing to likely delete in the next phase).
+- [`src/AcDream.Core/Physics/CollisionInfo`](../../src/AcDream.Core/Physics/) — search for `ContactPlane` write sites to map who currently sets it.
+- [`src/AcDream.Core/Physics/SpherePath`](../../src/AcDream.Core/Physics/) — `LastKnownContactPlane`-style fields if any exist.
+
+**Retail decomp anchors:**
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:273099` — `CTransition::step_up`.
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:273137` — `CTransition::transitional_insert`.
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:323565` — `BSPTREE::step_sphere_up`.
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:326793` — `BSPLEAF::find_walkable` (already ported, behavior verified).
+
+**Visual verification scenarios (re-use for the next phase):**
+1. Cellar descent (the primary failing scenario)
+2. 2nd-floor walking
+3. Single-floor cottage (regression check — must NOT degrade)
+4. Phantom collisions (cascade check — if root cause is fixed, these should improve)
+
+**Launch command:**
+```powershell
+$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
+$env:ACDREAM_LIVE = "1"
+$env:ACDREAM_TEST_HOST = "127.0.0.1"
+$env:ACDREAM_TEST_PORT = "9000"
+$env:ACDREAM_TEST_USER = "testaccount"
+$env:ACDREAM_TEST_PASS = "testpassword"
+$env:ACDREAM_DEVTOOLS = "1"
+$env:ACDREAM_PROBE_INDOOR_BSP = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-next-phase.log"
+```
+
+---
+
+## Session lessons (for future Claude)
+
+1. **Brainstorm a hypothesis-test before a full spec.** I diagnosed the wrong root cause and built 6 commits on it. A small spike (add the probe FIRST, capture a log, look at it before designing the fix) would have surfaced the 99.9% MISS rate immediately and pointed at the deeper issue.
+2. **Tangent contact is the dominant grounded case.** Any test fixture designed to exercise `walkable_hits_sphere` MUST include the tangent case (`dist == radius`), not just penetrating cases. My unit tests used Z=0.4 with radius=0.48 (overlap = 0.4 < 0.4798, passes easily) — comfortable but unrepresentative.
+3. **`find_walkable` is a sweep query, not a query.** It's only meaningful when called from `step_sphere_down`. Any caller using it as "stand here, find my floor" is misusing the algorithm. Retail doesn't have such a caller because retail retains ContactPlane across frames.
+4. **The +0.02f cell-origin Z-bump is a render artifact bleeding into physics.** It creates a 20mm offset between visual and physics floors. This is fine when the resolver retains state but breaks when the resolver re-computes every frame. The bump is not the root cause but it amplifies the oscillation symptom.
diff --git a/docs/research/2026-05-19-indoor-walking-phase2-pickup-prompt.md b/docs/research/2026-05-19-indoor-walking-phase2-pickup-prompt.md
new file mode 100644
index 00000000..2b1eb8a9
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-walking-phase2-pickup-prompt.md
@@ -0,0 +1,166 @@
+# Indoor walking Phase 2 shipped — fresh-session pickup prompt
+
+**Status:** Indoor walking Phase 1 (Cluster A — BSP cluster) + Phase 2 (Portal-based cell tracking) both merged to `main` at `1af49b7` on 2026-05-19. 18 commits between them; `dotnet build` + `dotnet test` green; visual-verified by user (walls block from inside, multi-room navigation works, walking out through a door works).
+
+This doc is the start-of-session brief for whoever picks up the next phase.
+
+---
+
+## What landed on main
+
+**Indoor walking Phase 1 — BSP cluster** (commits `27d7de1` … `1f11ba9`):
+
+- `[indoor-bsp]` + `[cell-cache]` diagnostic probes (`ACDREAM_PROBE_INDOOR_BSP` / `ACDREAM_PROBE_CELL_CACHE`).
+- `WorldPicker` cell-BSP occlusion via new `CellBspRayOccluder` → **closes #86** (click selection no longer penetrates walls).
+- Phase D's `ResolveOutdoorCellId` → `ResolveCellId` rename + AABB-based indoor cell promotion (partial fix for #84 — un-stuck the spawn-in-building case; the wall-pass-through portion stayed open until Phase 2).
+
+**Indoor walking Phase 2 — Portal-based cell tracking** (commits `1969c55` … `eb0f772`):
+
+- Extended `CellPhysics` with `CellBSP` (third BSP for point-in-cell), `Portals` (from `envCell.CellPortals`), `PortalPolygons` (resolved visible polys), `VisibleCellIds`. Deleted Phase D's `LocalAabbMin/Max` + `TryFindContainingCell`.
+- New `CellTransit` static class — ports retail's `CObjCell::find_cell_list` family: `FindTransitCellsSphere` (indoor portal-neighbour walk), `AddAllOutsideCells` (24m landcell grid), `FindCellList` (top-level BFS driver), `CheckBuildingTransit` (outdoor→indoor entry via `BuildingObj` portals).
+- `BSPQuery.PointInsideCellBsp` retyped from `PhysicsBSPNode?` → `CellBSPNode?` (was dead code; safe retype).
+- New `BuildingPhysics` cache + `CacheBuilding` / `GetBuilding` on `PhysicsDataCache`. GameWindow caches each `LandBlockInfo.Buildings` entry at landblock-load.
+- `PhysicsEngine.ResolveCellId`: indoor seeds delegate to `CellTransit.FindCellList`; outdoor seeds keep terrain-grid resolution + hook `CheckBuildingTransit` for outdoor→indoor entry.
+- **Critical production fix** at `3ffe1e4`: pass `sp.GlobalSphere[0].Origin` (foot sphere CENTER) instead of `sp.CheckPos` (entity reference at the feet) to `ResolveCellId`. Without this fix the test point was always 0.02m below cell floor due to the +0.02f Z-bump → portal traversal never engaged in production.
+- **Indoor walkable-plane synthesis** at `eb0f772`: when the indoor cell-BSP returns OK (no wall collision), find the floor poly under the player and call `ValidateWalkable` with the indoor plane instead of falling through to outdoor terrain. Closes the "stuck in falling animation" bug.
+
+**Closed:** ISSUES.md #84, #85, #86, #87 all fully resolved.
+
+**Filed for follow-up:**
+- **#88** — Indoor static objects vibrate (bookshelves, open furnaces). Pre-existing; user spotted during Phase 2 testing.
+- **#89** — Port `BSPQuery.SphereIntersectsCellBsp` for retail-faithful `CheckBuildingTransit`. Currently uses radius-less `PointInsideCellBsp`; entry fires ~0.5m later than retail.
+
+**Diagnostic infrastructure that persists:**
+- `[indoor-bsp]` — per cell-BSP `FindCollisions` call. Toggle: `ACDREAM_PROBE_INDOOR_BSP=1` or DebugPanel.
+- `[cell-cache]` — per cached EnvCell at landblock load. Toggle: `ACDREAM_PROBE_CELL_CACHE=1`.
+- `[cell-transit]` — every player CellId change. Toggle: `ACDREAM_PROBE_CELL=1`.
+- `[check-bldg]` — per portal lookup inside `CheckBuildingTransit`. Gated on `ACDREAM_PROBE_INDOOR_BSP` (reused).
+
+---
+
+## How to start a fresh session
+
+Copy the block below into a fresh Claude Code session in this repo. The model will load `CLAUDE.md` automatically and find the handoff docs on its own.
+
+---
+
+```
+Pick up the acdream project. Indoor walking Phase 1 + Phase 2 just merged
+to main at 1af49b7 (2026-05-19). Indoor walking is functionally complete:
+walls block from inside, walking between rooms via doors works, walking
+back outside through a door works.
+
+1. Read docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md
+ — that's the canonical record of what just shipped.
+
+2. Read docs/ISSUES.md and note the open issues. The three follow-ups
+ most-directly related to the just-shipped work:
+ - #88: indoor static objects vibrate (bookshelves, furnaces)
+ - #89: port BSPQuery.SphereIntersectsCellBsp for retail-faithful entry
+ - #80: 2nd-floor goes very dark (pre-existing lighting issue, may be
+ part of a broader Cluster B lighting phase)
+
+3. **The user has set a focused track: indoor walking issues, collision,
+ physics, and dungeons.** M2 (kill-a-drudge demo) is explicitly NOT
+ the next direction. Stay on the indoor-experience track until they
+ redirect.
+
+ Candidates within that scope, ranked by my best guess at priority:
+
+ A) **#83 — Walking up stairs broken**. Pure indoor/physics. The
+ retail step-up logic (`CPhysicsObj::step_up`) doesn't yet handle
+ indoor cell-BSP polys for stair geometry. Unblocks multi-floor
+ cottages (the 2nd-floor darkness #80 also depends on actually
+ reaching the 2nd floor) and dungeons (which are multi-level).
+ Natural follow-on to Phase 2. ~3-5 days.
+
+ B) **Dungeon stress test + adaptation**. Phase 2's portal traversal
+ was developed and verified at Holtburg cottage (a small one-room
+ building). Dungeons (Subway, Mite Burrow, Carved Stone) are
+ multi-cell indoor spaces with complex portal graphs. Walk a
+ character into a dungeon and see what breaks. Findings drive a
+ follow-up scope. ~1-3 days for the test + variable for fixes.
+
+ C) **#88 — Indoor object vibration** (bookshelves, open furnaces).
+ Quality bug noticed during Phase 2 testing. Likely a per-frame
+ transform recompute or EntityScriptActivator re-firing on cell
+ changes (less likely after Phase 2 stabilized cell tracking but
+ still possible). ~1-3 commits depending on root cause.
+
+ D) **#89 — Port `BSPQuery.SphereIntersectsCellBsp`**. Retail-faithful
+ entry timing for outdoor→indoor. Phase 2 ships with the documented
+ ~0.5m late-entry approximation; this closes the gap. Pure physics
+ polish. ~2-3 days.
+
+ E) **Indoor lighting cluster** (closes #79/#80/#81/#82). Slightly
+ adjacent — "indoor experience" but not strictly walking/collision.
+ Worth considering once stairs (A) lands so you can actually reach
+ the dark 2nd floor to verify lighting fixes. ~1-2 weeks.
+
+ F) **#78 — Outdoor stabs visible through floor**. Visibility/stencil
+ issue. Render side. Indoor-adjacent. ~3-5 days.
+
+ My recommendation: **A (stairs)**. Reasons:
+ - Pure indoor physics/collision — squarely in the user's stated track.
+ - Unblocks both multi-floor cottages AND dungeons (B is gated on it).
+ - Continues the natural arc from Phase 1 (walls) → Phase 2 (cell
+ tracking) → Phase 3 (vertical movement / stairs).
+ - The Phase 2 diagnostic infrastructure is still warm; reuse it.
+
+4. CLAUDE.md rules apply:
+ - No workarounds; fix root causes.
+ - Use superpowers skills for major work (brainstorming → writing-plans
+ → subagent-driven-development → finishing-a-development-branch).
+ - Drive autonomously — Claude picks what to work on next; user reviews.
+ - Visual verification by the user is the acceptance test for any
+ rendering / collision / lighting fix.
+
+5. The diagnostic infrastructure is ready for any indoor-cell-related
+ investigation. Probes are runtime-toggleable via the DebugPanel
+ ("Indoor: BSP collision" checkbox + the Cluster A render-side ones).
+
+State the milestone and your chosen phase in the first action you take.
+Then begin.
+```
+
+---
+
+## Quick reference for the user
+
+To start the new session: open a fresh Claude Code in the acdream repo and paste the boxed prompt above. Or just say:
+
+> "Read `docs/research/2026-05-19-indoor-walking-phase2-pickup-prompt.md` and start on the next phase."
+
+## Quick reference for the helper
+
+Key files for the next phase (whichever path A/B/C/D you pick):
+
+- **`docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md`** — the spec that just shipped. Reference for how Phase 2 was scoped.
+- **`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`** — full Phase 2 evidence + commit list.
+- **`docs/ISSUES.md`** — current OPEN items (note new #88 + #89 from Phase 2).
+- **`docs/plans/2026-04-11-roadmap.md`** — shipped table; Indoor walking Phase 2 row is most recent.
+
+If picking **Path A (#83 stairs — recommended)**:
+
+- Retail oracle: `docs/research/named-retail/acclient_2013_pseudo_c.txt` — grep `step_up`, `step_sphere_up`, `find_walkable` for the existing logic. Our `BSPQuery` ports these for outdoor terrain at lines 1278+; the indoor analog needs the same flow against `cellPhysics.Resolved` floor-and-stair polys.
+- Touchpoints likely: `src/AcDream.Core/Physics/TransitionTypes.cs` (where the new `TryFindIndoorWalkablePlane` lives — extend to handle step-up across vertical floor polys), `src/AcDream.Core/Physics/BSPQuery.cs::FindCollisions` Path 5/6 (which already handles outdoor step-up; needs indoor counterpart).
+- Probe surface: `[indoor-bsp]` already captures every cell-BSP query; new probe `[step-up]` may be helpful.
+
+If picking **Path B (dungeon stress test)**:
+
+- Pick a small dungeon. Subway (`@0x0102 ...`) or Mite Burrow are good first targets — both are small enough to walk through quickly but complex enough to exercise multi-cell portal traversal.
+- Run the launch with all indoor probes enabled (`ACDREAM_PROBE_INDOOR_BSP=1`, `ACDREAM_PROBE_CELL=1`, `ACDREAM_PROBE_CELL_CACHE=1`). Walk through every room. Note any wall-pass-through, stuck states, or cell-tracking failures.
+- Findings drive scope. Probably uncovers stair issues (→ Path A) or sphere-vs-cell timing issues (→ #89).
+
+If picking **Path C (#88 vibration)**:
+
+- Likely candidates from the bug report: `EntityScriptActivator.OnCreate/OnRemove` re-firing on rapid CellId promotion/demotion (now less likely after Phase 2 stabilized cell tracking, but worth investigating); per-frame transform recompute drift on cell-static `WorldEntity` instances; particle-emitter offset accumulation.
+- File this as a Phase rather than an issue if the root cause turns out to be the per-frame transform pipeline (multi-commit refactor).
+
+If picking **Path E (indoor lighting)**:
+
+- Start by re-reading the existing Cluster B sketches in the original Cluster A handoff:
+ `docs/research/2026-05-19-indoor-followup-handoff.md` — section "The 9 follow-up issues" lists #79/#80/#81/#82 as Cluster B.
+- Lighting code surfaces: `src/AcDream.App/Rendering/GameWindow.cs::UpdateSunFromSky` (indoor branch around line 8330+), `src/AcDream.App/Rendering/Shaders/mesh_modern.frag` (accumulateLights + indoor ambient), `src/AcDream.Core/Lighting/LightInfoLoader.cs`.
+- Probe surface: there is no `[lighting]` probe yet — adding one will likely be the first commit in the brainstorm.
+- **Note:** verify Path A (stairs) lands first or you can't reach the 2nd floor to test indoor lighting at altitude.
diff --git a/docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md b/docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md
new file mode 100644
index 00000000..1365c70d
--- /dev/null
+++ b/docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md
@@ -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` 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.
diff --git a/docs/research/deepdives/r13-dynamic-lighting.md b/docs/research/deepdives/r13-dynamic-lighting.md
index 91067d1e..b1321a57 100644
--- a/docs/research/deepdives/r13-dynamic-lighting.md
+++ b/docs/research/deepdives/r13-dynamic-lighting.md
@@ -46,7 +46,7 @@ public partial class LightInfo : IDatObjType {
- **Practical consequence.** For indoor cells, retail sets directional sun to zero (the cell is windowless) and relies on the baked vertex colours for the ambient "floor". Any `LightInfo` inside the cell is additive.
- **No cell has a separate ambient RGB field.** The only global ambient is `SkyTimeOfDay.AmbColor` / `AmbBright`, which is only applied outdoors.
-- **acdream action.** We need a `CellAmbientState` that holds the current `AmbColor * AmbBright` (outdoors, driven by sky dat) or a fixed dark RGB like `(0.10, 0.09, 0.08)` (indoors, approximating the dungeon "deep" tone) — then add active lights on top. See §12 for the C# class.
+- **acdream action.** We need a `CellAmbientState` that holds the current `AmbColor * AmbBright` (outdoors, driven by sky dat) or **a flat `(0.20, 0.20, 0.20)` neutral** (indoors) — then add active lights on top. The indoor constant is taken **directly from retail**: `CellManager::ChangePosition` (0x004559B0) calls `SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF)` whenever `CObjCell::seen_outside == 0`. The early-2026 guess at `(0.10, 0.09, 0.08)` was eyeballed; the retail value is both brighter and neutral. See §12 for the C# class.
## 4. Torch lights and `WeenieType.LightSource`
diff --git a/docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md b/docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md
new file mode 100644
index 00000000..37a58699
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md
@@ -0,0 +1,631 @@
+# Phase B.6 — Suppress MoveToState during inbound auto-walk Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Stop sending outbound `MoveToState` while ACE's server-initiated auto-walk is driving the player, then retire the Commit B workarounds that compensated for the resulting `MoveToChain` cancellation. ACE's `TryUseItem` callback fires on arrival; client sends Use exactly once.
+
+**Architecture:** One-line guard against the misleading wire packet, two retry-assignment deletions, one revert of the AP-cadence gate to retail's narrow shape. No new files, no new tests (behavioral change is wire-level integration; covered by existing Core.Net suite + user visual verify).
+
+**Tech Stack:** C# .NET 10. Edits touch `AcDream.App/Rendering/GameWindow.cs`, `AcDream.App/Input/PlayerMovementController.cs`, `docs/ISSUES.md`.
+
+**Spec:** [docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md](../specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md).
+
+**Retail anchors:**
+- `Player_Use.cs:205` (ACE) — `CreateMoveToChain(item, (success) => TryUseItem(item, success))`.
+- `Player_Move.cs:150` (ACE) — chain polls and fires `callback(true)` when within use radius.
+- `acclient_2013_pseudo_c.txt:700233-700285` — retail `ShouldSendPositionEvent`: narrow gate (cell-or-plane change during sub-interval; frame change after interval; gated on `Contact && OnWalkable`).
+- `acclient_2013_pseudo_c.txt:700327` — retail `SendPositionEvent`: `(state & 1) != 0 && (state & 2) != 0`.
+
+---
+
+## File structure
+
+| File | Responsibility | Touched in tasks |
+|---|---|---|
+| `src/AcDream.App/Rendering/GameWindow.cs` | Outbound wire layer. Guard MoveToState build (Task 1); delete two retry assignments + log strings (Task 1); update NotePositionSent call sites to pass contact plane (Task 3). | 1, 3 |
+| `src/AcDream.App/Input/PlayerMovementController.cs` | Diff-driven cadence state + the auto-walk overlay. Add `_lastSentContactPlane` field; extend `NotePositionSent` signature; replace per-frame `positionChanged` gate with retail's narrow gate (Task 3). | 3 |
+| `docs/ISSUES.md` | Close `#63` and `#74` to "Recently closed" (Task 5). | 5 |
+
+---
+
+## Task 1: Suppress outbound MoveToState during server auto-walk + delete the retry workarounds
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs` line ~6410 (the MoveToState send block).
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs` line ~9203 (SendUse far-range path).
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs` line ~9265 (SendPickUp far-range path).
+
+### Step 1: Guard the outbound MoveToState build
+
+Find the block at `GameWindow.cs:6410` that reads:
+
+```csharp
+ if (result.MotionStateChanged)
+ {
+ // HoldKey axis values — retail enum (holtburger types.rs HoldKey):
+```
+
+Change the condition to:
+
+```csharp
+ // 2026-05-16 (Phase B.6): suppress outbound MoveToState while
+ // ACE's server-initiated auto-walk is driving the player.
+ // Synthesized Forward+Run input in ApplyAutoWalkOverlay leaks
+ // to MotionStateChanged=true; sending the resulting "user is
+ // RunForward" wire packet makes ACE cancel its own MoveToChain
+ // (Player_Move.cs:150 callback never fires). Retail and
+ // holtburger walk the body locally during inbound MoveToObject
+ // WITHOUT sending an outbound MoveToState — AutonomousPosition
+ // alone is enough for ACE's WithinUseRadius poll.
+ if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
+ {
+ // HoldKey axis values — retail enum (holtburger types.rs HoldKey):
+```
+
+(Only the `if` line changes; the comment above is new. Leave the rest of the block untouched.)
+
+- [ ] **Step 2: Delete the SendUse far-range retry assignment**
+
+Find the SendUse method's far-range block. Search:
+
+```
+grep -n "Far range:" src/AcDream.App/Rendering/GameWindow.cs
+```
+
+Expected: line ~9197. The block reads (paraphrased — find the exact text in the file):
+
+```csharp
+ // Far range: send Use immediately so ACE has the request,
+ // AND queue an arrival re-send. Issue #63 (server-initiated
+ // MoveToObject not honored) means ACE's first-Use response
+ // is dropped as too-far and ACE doesn't re-poll
+ // WithinUseRadius when the speculative local walk gets us in
+ // range. The arrival re-send fires a second Use packet once
+ // the body reaches the target — at which point ACE accepts
+ // and executes the action. The retail-faithful path is to
+ // honor MoveToObject and let ACE complete the Use server-
+ // side; until #63 lands, this client-side retry is the
+ // workaround that keeps far-range Use working.
+ var seq = _liveSession.NextGameActionSequence();
+ var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
+ _liveSession.SendGameAction(body);
+ _pendingPostArrivalAction = (guid, false);
+ Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq} (queued for arrival re-send pending #63)");
+```
+
+Replace with:
+
+```csharp
+ // Far range: send Use ONCE. ACE's CreateMoveToChain
+ // (Player_Use.cs:205) holds a callback (TryUseItem) and fires
+ // it server-side when WithinUseRadius passes during the
+ // MoveToChain poll (Player_Move.cs:150). No client-side retry
+ // needed — the Phase B.6 MoveToState-suppression fix
+ // (GameWindow.cs:6410) keeps ACE's chain alive during the
+ // walk.
+ var seq = _liveSession.NextGameActionSequence();
+ var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
+ _liveSession.SendGameAction(body);
+ Console.WriteLine($"[B.4b] use guid=0x{guid:X8} seq={seq}");
+```
+
+(Removes the `_pendingPostArrivalAction = (guid, false);` line and trims the log to drop the `(queued for arrival re-send pending #63)` suffix.)
+
+- [ ] **Step 3: Delete the SendPickUp far-range retry assignment**
+
+Find the SendPickUp method's far-range block. Search:
+
+```
+grep -n "Far range: same arrival-retry pattern" src/AcDream.App/Rendering/GameWindow.cs
+```
+
+Expected: line ~9255. Replace the block:
+
+```csharp
+ // Far range: same arrival-retry pattern as SendUse — fire
+ // PickUp immediately AND queue for arrival re-send. ACE's
+ // first PickUp is dropped if we're outside the use-radius
+ // and isn't re-polled (issue #63 workaround).
+ var seq = _liveSession.NextGameActionSequence();
+ var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
+ seq, itemGuid, _playerServerGuid, placement: 0);
+ _liveSession.SendGameAction(body);
+ _pendingPostArrivalAction = (itemGuid, true);
+ Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq} (queued for arrival re-send pending #63)");
+```
+
+With:
+
+```csharp
+ // Far range: send PickUp ONCE. Same auto-fire-via-MoveToChain
+ // callback pattern as SendUse — ACE's chain fires
+ // PutItemInContainer/Move server-side when in range. No
+ // client-side retry; Phase B.6 MoveToState suppression keeps
+ // ACE's chain alive.
+ var seq = _liveSession.NextGameActionSequence();
+ var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
+ seq, itemGuid, _playerServerGuid, placement: 0);
+ _liveSession.SendGameAction(body);
+ Console.WriteLine($"[B.5] pickup item=0x{itemGuid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
+```
+
+- [ ] **Step 4: Build**
+
+```
+dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
+```
+
+Expected: 0 errors, 0 warnings. The deletions remove the only assignment of `_pendingPostArrivalAction` for far-range paths; the close-range path still assigns it (line ~9191 and ~9258). The field declaration at line ~799 stays.
+
+- [ ] **Step 5: Run existing tests**
+
+```
+dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj -c Debug --nologo
+dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --nologo
+```
+
+Expected: Core.Net 294/294 pass. Core 1073/1081 pass (baseline; 8 pre-existing physics failures unchanged).
+
+- [ ] **Step 6: Visual verification (user-driven)**
+
+User stops any running AcDream.App gracefully via the close-window button, waits ~3 seconds, launches:
+
+```powershell
+$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
+$env:ACDREAM_LIVE = "1"
+$env:ACDREAM_TEST_HOST = "127.0.0.1"
+$env:ACDREAM_TEST_PORT = "9000"
+$env:ACDREAM_TEST_USER = "testaccount"
+$env:ACDREAM_TEST_PASS = "testpassword"
+$env:ACDREAM_DEVTOOLS = "1"
+$env:ACDREAM_PROBE_AUTOWALK = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
+```
+
+Scenarios to test:
+
+1. **Far-range Use NPC.** Double-click a Royal Guard / Pathwarden ~8 m away. Expected log shape:
+ ```
+ [B.4b] use guid=0x... seq=X
+ [autowalk-mt] mt=0x06 isMoveTo=True ...
+ [autowalk-begin] dest=...
+ [autowalk-end] reason=arrived
+ ```
+ Expected: NO `[B.4b] use-deferred` follow-up. Dialogue fires on arrival from ACE's `TryUseItem` callback.
+
+2. **Far-range PickUp.** F-key a ground item ~5 m away. Same shape — single `[B.5] pickup` line, no `pickup-deferred`, item enters inventory.
+
+3. **Close-range Use NPC behind player.** Within 3 m, press R. Body turns 180°. Expected log:
+ ```
+ [B.4b] use deferred (close-range, turn-first) guid=0x...
+ [autowalk-end] reason=arrived
+ [B.4b] use-deferred guid=0x... seq=X
+ ```
+ (Close-range deferred path is unchanged; `use-deferred` is correct here.)
+
+4. **Open inn door from across the room.** ONE `[B.4b] use` line, no retry, door opens once.
+
+5. **User takes manual control mid-auto-walk.** Click far NPC → press W during the walk. Auto-walk cancels (`EndServerAutoWalk("user-input")`). Action does NOT fire on arrival.
+
+**STOP and wait for user confirmation that scenarios 1–5 pass.**
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/GameWindow.cs
+git commit -m "$(cat <<'EOF'
+fix(retail): suppress outbound MoveToState during inbound auto-walk
+
+Phase B.6 — retire the Commit B issue-#63 workarounds by plugging the
+underlying leak that caused them.
+
+ApplyAutoWalkOverlay synthesizes Forward+Run input during inbound
+MoveToObject so the existing motion-interpreter pipeline drives body
+position + animation locally. That synth set MotionStateChanged=true,
+so the outbound wire layer (GameWindow.cs:6410) built a MoveToState
+packet with forwardCommand=RunForward and sent it to ACE. ACE read
+the packet as "user took manual control" and cancelled its own
+CreateMoveToChain (Player_Use.cs:205 → Player_Move.cs:150), so the
+TryUseItem callback never fired on arrival. Our workaround sent Use
+a second time at local-arrival to bypass ACE's cancelled chain.
+
+Fix: one-line guard. The MoveToState send only fires when
+!_playerController.IsServerAutoWalking. AutonomousPosition keeps
+flowing during the walk (so ACE's WithinUseRadius poll sees the
+player approach); ACE's chain runs uninterrupted; callback fires
+when in range. Retail and holtburger (simulation.rs:178-206) follow
+the same pattern — no outbound MoveToState during inbound MoveToObject.
+
+Deletes the retry workarounds:
+- SendUse far-range: `_pendingPostArrivalAction = (guid, false);`
+ + the `(queued for arrival re-send pending #63)` log
+- SendPickUp far-range: same shape
+
+Close-range turn-first deferred path (separate code, retail-faithful
+pre-callback rotation) is unchanged.
+
+Spec: docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md
+Plan: docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 2: Visual checkpoint — confirm Task 1's fix before touching cadence
+
+This is not a code task. The Step 6 visual verification in Task 1 establishes that ACE's `MoveToChain` callback fires correctly with the MoveToState suppression in place. Only proceed to Task 3 if all five scenarios pass. If any regresses, STOP and revert Task 1's commit before continuing.
+
+---
+
+## Task 3: Revert AP cadence to retail's narrow gate
+
+**Files:**
+- Modify: `src/AcDream.App/Input/PlayerMovementController.cs` line ~254 (field block), line ~441-449 (`NotePositionSent`), line ~1240-1275 (the gate logic), line ~289-296 (`AutoWalkArrived` doc-comment cleanup).
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs` line ~6450 (MTS NotePositionSent call), line ~6476 (AP NotePositionSent call).
+
+- [ ] **Step 1: Add `_lastSentContactPlane` field**
+
+In `PlayerMovementController.cs`, find the diff-tracking field block (around line 535-540 — search for `_lastSentPos`):
+
+```csharp
+ private System.Numerics.Vector3 _lastSentPos;
+ private uint _lastSentCellId;
+ private float _lastSentTime;
+ private bool _lastSentInitialized;
+```
+
+Add a new field immediately after `_lastSentCellId`:
+
+```csharp
+ private System.Numerics.Vector3 _lastSentPos;
+ private uint _lastSentCellId;
+ private System.Numerics.Plane _lastSentContactPlane;
+ private float _lastSentTime;
+ private bool _lastSentInitialized;
+```
+
+- [ ] **Step 2: Extend `NotePositionSent` to accept the contact plane**
+
+Find `NotePositionSent` in `PlayerMovementController.cs` (around line 441). Replace:
+
+```csharp
+ public void NotePositionSent(System.Numerics.Vector3 worldPos,
+ uint cellId,
+ float nowSeconds)
+ {
+ _lastSentPos = worldPos;
+ _lastSentCellId = cellId;
+ _lastSentTime = nowSeconds;
+ _lastSentInitialized = true;
+ }
+```
+
+With:
+
+```csharp
+ ///
+ /// 2026-05-16 (Phase B.6). Called by the network outbound layer
+ /// after every AutonomousPosition or MoveToState that carries the
+ /// player's position. Resets the diff-driven heartbeat clock so the
+ /// next evaluation requires a fresh
+ /// state change. Mirrors retail's SendPositionEvent
+ /// (acclient_2013_pseudo_c.txt:700345-700348) which writes
+ /// `last_sent_position`, `last_sent_position_time`, and
+ /// `last_sent_contact_plane` after every send.
+ ///
+ public void NotePositionSent(System.Numerics.Vector3 worldPos,
+ uint cellId,
+ System.Numerics.Plane contactPlane,
+ float nowSeconds)
+ {
+ _lastSentPos = worldPos;
+ _lastSentCellId = cellId;
+ _lastSentContactPlane = contactPlane;
+ _lastSentTime = nowSeconds;
+ _lastSentInitialized = true;
+ }
+```
+
+- [ ] **Step 3: Replace the per-frame gate with retail's narrow gate**
+
+Find the cadence block in `PlayerMovementController.cs` (around line 1240-1275 — search for `retail diff-driven AP cadence`). Replace the block starting at the `// 2026-05-16 — retail diff-driven AP cadence` comment through the `HeartbeatDue =` line with:
+
+```csharp
+ // 2026-05-16 (Phase B.6) — retail-faithful AP cadence per
+ // CommandInterpreter::ShouldSendPositionEvent at
+ // acclient_2013_pseudo_c.txt:700233-700285. Two-branch:
+ //
+ // Branch 1 — interval NOT yet elapsed (< 1 sec since
+ // last send): send only if cell changed OR contact-plane
+ // changed (mid-walk events that matter).
+ //
+ // Branch 2 — interval HAS elapsed (>= 1 sec): send only
+ // if cell OR position frame changed. Truly idle = no
+ // send (retail's `last_sent.frame == player.frame` check
+ // at line 700248-700265).
+ //
+ // SendPositionEvent (line 700327) gates the actual send on
+ // (state & 1) != 0 && (state & 2) != 0 — Contact AND
+ // OnWalkable both set. We mirror that gate here so airborne
+ // and wall-contact-without-walkable states suppress AP
+ // entirely; MoveToState carries jump/fall snapshots while
+ // airborne.
+ //
+ // Effective rates:
+ // - Truly idle (grounded, no movement) : 0 Hz
+ // - Smooth movement (no cell/plane changes) : ~1 Hz (interval-driven)
+ // - Cell crossings + stair/hill steps : per-event
+ // - Airborne : 0 Hz
+ //
+ // Bootstrap: when NotePositionSent has never been called
+ // (_lastSentInitialized=false), treat every frame as
+ // "anything to send" so the first AP gets a chance to fire.
+
+ bool intervalElapsed = !_lastSentInitialized
+ || (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
+
+ bool cellChanged = !_lastSentInitialized
+ || _lastSentCellId != CellId;
+ bool planeChanged = !_lastSentInitialized
+ || !_lastSentContactPlane.Equals(_body.ContactPlane);
+ bool frameChanged = !_lastSentInitialized
+ || !ApproxPositionEqual(_lastSentPos, _body.Position);
+
+ bool sendThisFrame = intervalElapsed
+ ? (cellChanged || frameChanged)
+ : (cellChanged || planeChanged);
+
+ // Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327
+ // (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be
+ // set simultaneously, NOT a bitwise-OR mask test.
+ bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
+
+ HeartbeatDue = groundedOnWalkable && sendThisFrame;
+```
+
+- [ ] **Step 4: Update the MTS site to pass `contactPlane`**
+
+In `GameWindow.cs`, find the MoveToState `NotePositionSent` call (around line 6450). Replace:
+
+```csharp
+ _playerController.NotePositionSent(
+ worldPos: _playerController.Position,
+ cellId: _playerController.CellId,
+ nowSeconds: _playerController.SimTimeSeconds);
+```
+
+With:
+
+```csharp
+ _playerController.NotePositionSent(
+ worldPos: _playerController.Position,
+ cellId: _playerController.CellId,
+ contactPlane: _playerController.PhysicsBody.ContactPlane,
+ nowSeconds: _playerController.SimTimeSeconds);
+```
+
+If `_playerController.PhysicsBody` doesn't exist as a public accessor, search:
+
+```
+grep -n "public.*_body\|public PhysicsBody\|public.*Body" src/AcDream.App/Input/PlayerMovementController.cs
+```
+
+If no public accessor, add one in `PlayerMovementController.cs` near the existing public properties (around line 130-160):
+
+```csharp
+ /// 2026-05-16. Read-only access to the controller's
+ /// physics body — needed by the network outbound layer to stamp
+ /// the contact plane into NotePositionSent.
+ public AcDream.Core.Physics.PhysicsBody PhysicsBody => _body;
+```
+
+(Verify the field name is `_body` first — search `private.*PhysicsBody`.)
+
+- [ ] **Step 5: Update the AP site to pass `contactPlane`**
+
+Find the AutonomousPosition `NotePositionSent` call (around line 6476). Apply the same edit:
+
+```csharp
+ _playerController.NotePositionSent(
+ worldPos: _playerController.Position,
+ cellId: _playerController.CellId,
+ contactPlane: _playerController.PhysicsBody.ContactPlane,
+ nowSeconds: _playerController.SimTimeSeconds);
+```
+
+- [ ] **Step 6: Build**
+
+```
+dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
+```
+
+Expected: 0 errors. Any compile error here is a wiring mistake — the field name (`_body` vs `_physicsBody`), the property accessor, or the `Plane` namespace.
+
+- [ ] **Step 7: Run existing tests**
+
+```
+dotnet test tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj -c Debug --nologo
+dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --nologo
+```
+
+Expected: Core.Net 294/294, Core 1073/1081 (baseline unchanged).
+
+- [ ] **Step 8: Visual verification (user-driven)**
+
+User restarts the client (graceful close + ~3 sec wait + launch). Runs scenarios:
+
+1. **Idle test.** Stand still on flat ground in Holtburg for 10 sec. Watch the dev console / `[autowalk-up]` lines or any outbound packet trace.
+ - Old behavior: 1 AP/sec heartbeat.
+ - New behavior: ZERO outbound packets while truly idle.
+
+2. **Smooth-running test.** Hold W and run in a straight line for 5 sec on flat ground (no cell crossings).
+ - Old behavior: ~60 AP/sec (per-frame while position changed).
+ - New behavior: ~1 AP/sec (interval-driven; cell/plane don't change every frame).
+ - **The character should still appear to remote observers as moving smoothly** — ACE's dead-reckoning fills in the gaps between sparse APs. If remote view becomes jittery, the cadence is too sparse and we'll need to tune.
+
+3. **Cell-crossing test.** Run across a landblock boundary (every ~192 m). Should see a burst of AP packets at the crossing — both the `cellChanged` path and the `intervalElapsed && frameChanged` path can fire here.
+
+4. **Far-range Use re-test.** Repeat Task 1 Step 6 scenario 1 (far-range NPC Use). Should still work — ACE's `MoveToChain` callback fires on arrival, dialogue plays, single Use packet.
+
+5. **Hill / stair test.** Walk up a slope or stairs. Contact-plane changes per step → sub-interval AP sends fire on plane change. Behavior should look smooth to remote observers.
+
+**STOP and wait for user confirmation that scenarios 1–5 pass.** If scenario 2 produces visible remote-jitter, the spec's `ApproxPositionEqual` epsilon may need tightening, or we may need a higher heartbeat rate; document the finding and tune before continuing.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/AcDream.App/Input/PlayerMovementController.cs src/AcDream.App/Rendering/GameWindow.cs
+git commit -m "$(cat <<'EOF'
+fix(retail): revert AP cadence to retail's narrow gate
+
+Phase B.6 — closes #74. With the MoveToState suppression fix in
+place, the per-frame "send while moving" cadence is no longer needed
+to compensate for ACE's MoveToChain cancellation. Reverts to retail's
+two-branch gate per CommandInterpreter::ShouldSendPositionEvent at
+acclient_2013_pseudo_c.txt:700233-700285:
+
+ Interval NOT elapsed (< 1 sec): send if cell or contact-plane changed.
+ Interval elapsed (>= 1 sec): send if cell or position frame changed.
+
+Bootstrap fires every frame until the first NotePositionSent.
+Grounded-on-walkable gate (Contact && OnWalkable) unchanged from
+700327.
+
+Effective rates:
+ Truly idle (grounded, no movement) : 0 Hz (was 1 Hz)
+ Smooth straight-line run : ~1 Hz (was ~60 Hz)
+ Cell crossings + stair/hill steps : per-event
+ Airborne : 0 Hz (unchanged)
+
+Adds _lastSentContactPlane field + extends NotePositionSent to accept
+System.Numerics.Plane. Adds PhysicsBody public accessor so the wire
+layer can read _body.ContactPlane to pass into NotePositionSent. Both
+outbound sites (MoveToState at GameWindow.cs:6450, AP at ~6476)
+updated to pass the plane.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 4: Visual checkpoint — confirm Task 3's cadence revert before closing issues
+
+Same as Task 2. Only proceed to Task 5 if all five scenarios in Task 3 Step 8 pass cleanly.
+
+---
+
+## Task 5: Close issues #63 and #74
+
+**Files:**
+- Modify: `docs/ISSUES.md`.
+
+- [ ] **Step 1: Move issue #63 to "Recently closed"**
+
+Find `## #63 — Server-initiated auto-walk (MoveToObject) not honored` in `docs/ISSUES.md` (around line 425). Currently `Status: OPEN`. Cut the entire `#63` block from the active issues section and paste it into the "Recently closed" section near the bottom of the file with these changes:
+
+1. Change the title line from:
+ ```markdown
+ ## #63 — Server-initiated auto-walk (MoveToObject) not honored
+ ```
+ to:
+ ```markdown
+ ## #63 — [DONE 2026-05-16 · ``] Server-initiated auto-walk (MoveToObject) not honored
+ ```
+ (Replace `` with the actual commit SHA from Task 1. Get it via `git log --oneline -5`.)
+
+2. Change `Status: OPEN` to `Status: DONE`.
+
+3. Append a "Resolution" paragraph after the existing "Acceptance":
+ ```markdown
+ **Resolution (2026-05-16):** B.6 slice 2 (2026-05-14) shipped the inbound-MoveToObject auto-walk handling. The remaining "MoveToChain callback never fires on arrival" half was tracked to ApplyAutoWalkOverlay's synthesized Forward+Run input leaking to the wire as an outbound MoveToState packet (forwardCommand=RunForward), which ACE read as "user took manual control" and used to cancel its own MoveToChain. Fix in `` adds a single guard at `GameWindow.cs:6410`: outbound MoveToState only fires when `!_playerController.IsServerAutoWalking`. With ACE's chain running uninterrupted, the `TryUseItem` callback (Player_Use.cs:205) fires server-side on arrival; no client retry needed. Retired the `_pendingPostArrivalAction` retry workarounds from SendUse + SendPickUp far-range paths.
+ ```
+
+- [ ] **Step 2: Move issue #74 to "Recently closed"**
+
+Find `## #74 — AP cadence is per-frame-while-moving, more chatty than retail`. Same shape: cut the block, paste in "Recently closed", change title to:
+
+```markdown
+## #74 — [DONE 2026-05-16 · ``] AP cadence is per-frame-while-moving, more chatty than retail
+```
+
+Change `Status: OPEN` to `Status: DONE`. Append:
+
+```markdown
+**Resolution (2026-05-16):** With #63 closed (MoveToState no longer cancels ACE's MoveToChain), the per-frame-while-moving cadence workaround is unnecessary. Reverted to retail's two-branch ShouldSendPositionEvent gate per `acclient_2013_pseudo_c.txt:700233-700285` in ``. Effective rate during smooth motion drops from ~60 Hz to ~1 Hz; truly idle drops from 1 Hz to 0 Hz. Cell crossings + contact-plane changes still fire mid-interval. Matches retail bit-for-bit.
+```
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/ISSUES.md
+git commit -m "$(cat <<'EOF'
+docs: close #63 (MoveToObject not honored) + #74 (AP chattier than retail)
+
+Both retired by Phase B.6. #63 fixed in (MoveToState
+suppression during inbound auto-walk + retry workaround retirement).
+#74 fixed in (AP cadence reverted to retail's two-branch
+ShouldSendPositionEvent gate now that the workaround that needed
+per-frame APs is gone).
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+(Replace `` and `` with the actual SHAs.)
+
+---
+
+## Self-review checklist
+
+**Spec coverage:**
+
+| Spec section | Plan task |
+|---|---|
+| Wire-level changes: `IsServerAutoWalking` guard at `GameWindow.cs:6410` | Task 1 Step 1 ✅ |
+| Far-range Use retry deletion | Task 1 Step 2 ✅ |
+| Far-range PickUp retry deletion | Task 1 Step 3 ✅ |
+| Log string cleanup | Task 1 Steps 2+3 ✅ |
+| `_lastSentContactPlane` field + `NotePositionSent` signature | Task 3 Steps 1+2 ✅ |
+| Retail-narrow gate | Task 3 Step 3 ✅ |
+| MTS site contactPlane wiring | Task 3 Step 4 ✅ |
+| AP site contactPlane wiring | Task 3 Step 5 ✅ |
+| Testing plan (visual scenarios) | Task 1 Step 6 + Task 3 Step 8 ✅ |
+| Close `#63` + `#74` | Task 5 ✅ |
+| Out-of-scope `#75` (status messages) | Filed as deferred — not in this plan ✅ |
+
+**Placeholder scan:** No "TBD" / "implement later" / vague phrases. Every step has actual code or actual commands.
+
+**Type consistency:**
+- `IsServerAutoWalking` referenced Task 1 Step 1 — already exists in code (verified at PlayerMovementController.cs:273). ✅
+- `_lastSentContactPlane : System.Numerics.Plane` defined Task 3 Step 1, used Task 3 Steps 2+3. ✅
+- `NotePositionSent(Vector3, uint, Plane, float)` defined Task 3 Step 2, called Task 3 Steps 4+5. ✅
+- `_playerController.PhysicsBody` property defined Task 3 Step 4 (conditional add if missing), used Task 3 Steps 4+5. ✅
+- `_lastSentPos`, `_lastSentCellId`, `_lastSentTime`, `_lastSentInitialized` — pre-existing from Commit B. ✅
+
+**Risk / rollback:**
+
+- Task 1 commit: simple revert restores the workaround.
+- Task 3 commit: simple revert restores the per-frame cadence.
+- Task 5 commit: docs-only; trivial.
+
+Each task is independently revertable. If Task 3 introduces remote-view jitter (scenario 2), revert Task 3 and re-evaluate (e.g., dial down `HeartbeatInterval` from 1 s to 0.5 s).
+
+---
+
+## Execution handoff
+
+Plan saved to `docs/superpowers/plans/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk.md`.
+
+Two options for the controller:
+
+1. **Subagent-Driven (recommended)** — Dispatch fresh subagent per task. Two-stage review (spec compliance + code quality) between tasks. ~3 implementer + 6 reviewer dispatches.
+
+2. **Inline Execution** — Execute tasks in this session using `superpowers:executing-plans`. Two visual-verify checkpoints (between Task 1 & Task 3, between Task 3 & Task 5).
+
+Given the small scope (~30 LOC of behavior change + docs) and the two mandatory user-driven visual checkpoints, inline execution may be simpler — the subagent overhead exceeds the implementation time for tasks this small.
diff --git a/docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md b/docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md
index 36dd6b11..f4abfa41 100644
--- a/docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md
+++ b/docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md
@@ -18,8 +18,9 @@
- `0x006b4770` `SendPositionEvent` — `transient_state & (CONTACT_TS | ON_WALKABLE_TS)` gate.
- `0x00588a80` `ItemHolder::UseObject` — single fire-and-forget `Event_UseEvent`.
- `0x00564900` `Handle_Item__UseDone` — server signals "use done" inbound.
-- `0x0054c740` `Render::GfxObjUnderSelectionRay` — retail picker uses per-part `drawing_sphere` + polygon refine (we use `Setup.SelectionSphere` as a simpler equivalent for Stage A).
+- `0x0054c740` `Render::GfxObjUnderSelectionRay` — retail picker uses per-part `drawing_sphere` + polygon refine. We approximate with a screen-space rect hit-test that uses the exact rect the target indicator already draws (guarantees click area = visible bracket area, zero corner dead zones).
- `0x00518b80` `CPartArray::GetSelectionSphere` — scale formula.
+- `0x00452e20` `SmartBox::GetObjectBoundingBox` — projects `CSetup.SelectionSphere` to a screen-aligned rect. The indicator AND the new picker call into the same projection helper.
---
@@ -40,10 +41,11 @@
| File | Responsibility |
|---|---|
| `src/AcDream.App/Input/PlayerMovementController.cs` | Replace `_heartbeatAccum` with diff-driven `HeartbeatDue` (position/cell change + 1s heartbeat + `IsGrounded` gate). Add `NotePositionSent(pos, cellId, now)` + `SimTimeSeconds` accessor. Delete `TinyMargin` from `ApplyAutoWalkOverlay`. Add `ApproxPositionEqual` helper. |
-| `src/AcDream.App/Rendering/GameWindow.cs` | Delete `OnAutoWalkArrivedReSendAction`, `SendAutonomousPositionNow`, `IsTallSceneryGuid`, the `isRetryAfterArrival` parameter on `SendUse`/`SendPickUp`. Simplify `_pendingPostArrivalAction` to close-range-deferred-Use only (no retry). Add `OnAutoWalkArrivedSendDeferredAction` (FIRST send, not retry). Call `_playerController.NotePositionSent(...)` after each `SendMoveToState` / `SendAutonomousPosition`. Wire `WorldPicker.Pick` to use `TryGetEntitySelectionSphere` (already exists at line ~9605); drop per-type `radiusForGuid` / `verticalOffsetForGuid` callbacks. |
-| `src/AcDream.Core/Selection/WorldPicker.cs` | Add new `Pick(...)` overload taking `Func sphereForEntity`. Keep existing per-radius-callback overload for now (no callers after B8). |
-| `src/AcDream.App/UI/TargetIndicatorPanel.cs` | Trim `EntityHeightFor` per-type branches to a single 1.5 m × scale defensive default. |
-| `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (new or modify existing) | Unit tests for the new sphere-resolver overload. |
+| `src/AcDream.App/Rendering/GameWindow.cs` | Delete `OnAutoWalkArrivedReSendAction`, `SendAutonomousPositionNow`, `IsTallSceneryGuid`, the `isRetryAfterArrival` parameter on `SendUse`/`SendPickUp`. Simplify `_pendingPostArrivalAction` to close-range-deferred-Use only (no retry). Add `OnAutoWalkArrivedSendDeferredAction` (FIRST send, not retry). Call `_playerController.NotePositionSent(...)` after each `SendMoveToState` / `SendAutonomousPosition`. Wire `WorldPicker.Pick` to the new screen-rect overload using `TryGetEntitySelectionSphere` (already exists at line ~9605); drop per-type `radiusForGuid` / `verticalOffsetForGuid` callbacks. |
+| `src/AcDream.Core/Selection/ScreenProjection.cs` (new) | Shared math: `TryProjectSphereToScreenRect(worldCenter, worldRadius, view, projection, viewport, out rectMin, out rectMax, out depth, minSidePixels)`. Factored out of `TargetIndicatorPanel.TryComputeScreenRectFromSphere` so the picker AND the indicator project identically. |
+| `src/AcDream.Core/Selection/WorldPicker.cs` | Add new `Pick(mouseX, mouseY, view, projection, viewport, candidates, skipGuid, sphereForEntity, inflatePixels=8f)` screen-rect-hit-test overload. Keep existing ray-sphere overload for now (no callers after B8). |
+| `src/AcDream.App/UI/TargetIndicatorPanel.cs` | Trim `EntityHeightFor` per-type branches to a single 1.5 m × scale defensive default. Replace private `TryComputeScreenRectFromSphere` with a call into `ScreenProjection.TryProjectSphereToScreenRect`. |
+| `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (new or modify existing) | Unit tests for the new rect-hit-test overload + tests for `ScreenProjection.TryProjectSphereToScreenRect`. |
| `docs/ISSUES.md` | File 3 deferred follow-ups (Triangle apex/size UX; Stage B polygon refine; cdb-probe to verify `omega.z = π/2`). |
---
@@ -620,10 +622,14 @@ Replace with:
// - When interval NOT elapsed: send only if position or cell
// differs from last_sent (Frame::is_equal check at
// acclient_2013_pseudo_c.txt:700248-700265).
- // - SendPositionEvent gates on transient_state &
- // (CONTACT_TS | ON_WALKABLE_TS) — i.e., grounded on a
- // walkable surface. Airborne suppresses AP entirely.
- // MoveToState carries jump/fall snapshots while airborne.
+ // - SendPositionEvent (acclient_2013_pseudo_c.txt:700327)
+ // gates on `((state & 1) != 0 && (state & 2) != 0)` —
+ // Contact (CONTACT_TS bit 0) AND OnWalkable (ON_WALKABLE_TS
+ // bit 1) both set. Two independent `& != 0` tests joined by
+ // `&&`, NOT a single bitwise-OR mask test. Airborne (neither
+ // bit) and wall-contact-without-walkable (Contact only)
+ // both suppress AP. MoveToState carries jump/fall snapshots
+ // while airborne.
//
// Effective rate: per-frame while moving on the ground, 1 Hz at-rest
// heartbeat, 0 Hz airborne. Retires the 1 Hz / 10 Hz flat model.
@@ -641,20 +647,17 @@ Replace with:
|| _lastSentCellId != CellId
|| !ApproxPositionEqual(_lastSentPos, _body.Position);
- // Grounded-on-walkable. Retail's CONTACT_TS + ON_WALKABLE_TS
- // (acclient.h:3688). Our equivalent: PhysicsBody.IsGrounded.
- bool groundedOnWalkable = _body.IsGrounded;
+ // Grounded-on-walkable. Retail's `Contact AND OnWalkable`
+ // (acclient_2013_pseudo_c.txt:700327). PhysicsBody exposes the
+ // two transient-state bits as InContact + OnWalkable; combine
+ // with `&&` to match retail's two-`& != 0`-tests-joined-by-`&&`
+ // pattern (NOT a single bitwise-OR mask test).
+ bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
HeartbeatDue = groundedOnWalkable && (positionChanged || intervalElapsed);
```
-If `PhysicsBody.IsGrounded` doesn't exist, search the codebase for the equivalent predicate:
-
-```
-grep -n "IsGrounded\|OnGround\|HasContact\|GroundContact" src/AcDream.Core/Physics/PhysicsBody.cs
-```
-
-Use whichever exists. If neither, derive from `_body.ContactPlane.Normal.Z > 0.5f` (humanoid walkable-normal threshold per retail FloorZ ≈ 0.66). Document the choice in a comment at the call site.
+`PhysicsBody.InContact` and `PhysicsBody.OnWalkable` are existing convenience properties that wrap `TransientState.HasFlag(TransientStateFlags.Contact)` / `.OnWalkable`. The same `Contact && OnWalkable` "grounded" pattern is used elsewhere in our physics layer (e.g., `MotionInterpreter.cs:808-809` for jump-start gating), so this matches the codebase convention.
- [ ] **Step 3: Add `ApproxPositionEqual` helper**
@@ -1010,15 +1013,204 @@ Expected: 0 errors. If there are remaining "isRetryAfterArrival" references, fix
---
-### Task B7: Add `WorldPicker.Pick` overload taking sphere-resolver
+### Task B7: Factor screen-rect projection into shared helper + add screen-rect picker overload
+
+**Goal:** Retail's picker uses two-tier sphere-then-polygon selection (`Render::GfxObjUnderSelectionRay` 0x0054c740). Our Stage A approximates Stage 1 (sphere reject) — but the indicator draws a SCREEN-SPACE rect, not a 3D sphere. User feedback 2026-05-16: "the range we can click + the area of the object that's clickable should be faithful to retail" — meaning the click hit-area must match what the indicator visibly bounds. A pure world-space ray-sphere picker can't make that guarantee (rect corners are sphere dead zones).
+
+**Approach:** Picker hit-tests against the SAME screen-space rect the indicator draws. The projection math is factored into a shared `ScreenProjection` helper so the indicator and the picker can't drift apart.
**Files:**
+- Create: `src/AcDream.Core/Selection/ScreenProjection.cs`
- Modify: `src/AcDream.Core/Selection/WorldPicker.cs`
+- Modify: `src/AcDream.App/UI/TargetIndicatorPanel.cs` (use shared helper; private copy deleted)
+- Test: `tests/AcDream.Core.Tests/Selection/ScreenProjectionTests.cs` (new file)
- Test: `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (modify or create)
-- [ ] **Step 1: Write the failing test**
+- [ ] **Step 1: Write failing tests for `ScreenProjection`**
-Find or create `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs`. Add:
+Create `tests/AcDream.Core.Tests/Selection/ScreenProjectionTests.cs`:
+
+```csharp
+using System.Numerics;
+using AcDream.Core.Selection;
+
+namespace AcDream.Core.Tests.Selection;
+
+public sealed class ScreenProjectionTests
+{
+ // Standard right-handed perspective + identity view. Sphere centered
+ // at z=+10 in front of camera, radius 1, viewport 800x600.
+ private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
+ {
+ var view = Matrix4x4.Identity;
+ var proj = Matrix4x4.CreatePerspectiveFieldOfView(
+ MathF.PI * 0.5f /*fovY 90°*/, 800f / 600f, 0.1f, 100f);
+ var viewport = new Vector2(800, 600);
+ return (view, proj, viewport);
+ }
+
+ [Fact]
+ public void TryProject_SphereInFront_ReturnsSquareRect()
+ {
+ // System.Numerics CreatePerspectiveFieldOfView is right-handed
+ // (looks down -Z). Place sphere at -10 along Z so it sits in
+ // front of the camera.
+ var (view, proj, viewport) = StdCam();
+ bool ok = ScreenProjection.TryProjectSphereToScreenRect(
+ new Vector3(0, 0, -10), worldRadius: 1f,
+ view, proj, viewport,
+ out var rMin, out var rMax, out var depth,
+ minSidePixels: 0f);
+
+ Assert.True(ok);
+ // 90° FOV at depth 10 -> screen half-extent = 1 unit projects to
+ // viewport.Y/2 pixels per world-unit at near plane = 1/tan(45°).
+ // The rect should be a square (width == height).
+ Assert.Equal(rMax.X - rMin.X, rMax.Y - rMin.Y, precision: 3);
+ // Rect should be centered (approximately) on the screen center.
+ Assert.InRange((rMin.X + rMax.X) * 0.5f, 399f, 401f);
+ Assert.InRange((rMin.Y + rMax.Y) * 0.5f, 299f, 301f);
+ Assert.True(depth > 0f);
+ }
+
+ [Fact]
+ public void TryProject_SphereBehindCamera_ReturnsFalse()
+ {
+ var (view, proj, viewport) = StdCam();
+ bool ok = ScreenProjection.TryProjectSphereToScreenRect(
+ new Vector3(0, 0, +10) /* behind RH camera at origin */,
+ worldRadius: 1f,
+ view, proj, viewport,
+ out _, out _, out _,
+ minSidePixels: 0f);
+ Assert.False(ok);
+ }
+
+ [Fact]
+ public void TryProject_FarSphereClampsToMinSide()
+ {
+ var (view, proj, viewport) = StdCam();
+ bool ok = ScreenProjection.TryProjectSphereToScreenRect(
+ new Vector3(0, 0, -90) /* very far */, worldRadius: 0.01f /* tiny */,
+ view, proj, viewport,
+ out var rMin, out var rMax, out _,
+ minSidePixels: 12f);
+ Assert.True(ok);
+ Assert.True(rMax.X - rMin.X >= 12f);
+ Assert.True(rMax.Y - rMin.Y >= 12f);
+ }
+}
+```
+
+- [ ] **Step 2: Build the test project — confirm test failure**
+
+```
+dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ScreenProjectionTests" --no-build
+```
+Expected: build failure (`ScreenProjection` doesn't exist yet).
+
+- [ ] **Step 3: Create `src/AcDream.Core/Selection/ScreenProjection.cs`**
+
+```csharp
+using System.Numerics;
+
+namespace AcDream.Core.Selection;
+
+///
+/// Shared screen-space projection math for the target indicator and the
+/// world picker. Both call into
+/// so the click hit-area is guaranteed to match the visible indicator
+/// rect — "what you see is what you click".
+///
+///
+/// Retail equivalent: SmartBox::GetObjectBoundingBox at
+/// 0x00452e20, which uses
+/// Render::GetViewerBBox(selection_sphere, &corner1, &corner2)
+/// to compute a camera-aligned bbox of the sphere and projects the two
+/// corner points. We use the mathematical equivalent (project center,
+/// compute screen radius analytically) — both produce identical pixel
+/// rects for a standard right-handed perspective.
+///
+///
+public static class ScreenProjection
+{
+ ///
+ /// Project a world-space sphere to a screen-space axis-aligned square
+ /// rectangle.
+ ///
+ /// Sphere center in world space.
+ /// Sphere radius in world space.
+ /// View matrix (System.Numerics row-vector convention).
+ /// Projection matrix. M22 = cot(fovY/2)
+ /// for a standard right-handed perspective.
+ /// Viewport size in pixels (X = width, Y = height).
+ /// Out: top-left corner of the rect in viewport pixels.
+ /// Out: bottom-right corner of the rect in viewport pixels.
+ /// Out: camera-space depth (clip.W) of the sphere
+ /// center — use this for nearest-first sorting when multiple rects overlap.
+ /// Minimum side length of the rect. Distant
+ /// entities clamp to this so they remain pickable / visible. 12 px
+ /// matches the indicator's clamp floor.
+ ///
+ /// true if the sphere is in front of the camera and the rect was
+ /// produced; false if the center is behind the camera
+ /// (clip.W <= 0) or the rect is more than a screen offset
+ /// from the viewport (obviously off-screen).
+ ///
+ public static bool TryProjectSphereToScreenRect(
+ Vector3 worldCenter, float worldRadius,
+ Matrix4x4 view, Matrix4x4 projection, Vector2 viewport,
+ out Vector2 rectMin, out Vector2 rectMax, out float depth,
+ float minSidePixels = 12f)
+ {
+ rectMin = default;
+ rectMax = default;
+ depth = 0f;
+
+ var viewProj = view * projection;
+ var clip = Vector4.Transform(new Vector4(worldCenter, 1f), viewProj);
+ if (clip.W <= 0.001f) return false;
+
+ depth = clip.W;
+
+ float ndcX = clip.X / clip.W;
+ float ndcY = clip.Y / clip.W;
+ float screenX = (ndcX * 0.5f + 0.5f) * viewport.X;
+ float screenY = (1f - (ndcY * 0.5f + 0.5f)) * viewport.Y;
+
+ // Screen-space radius. projection.M22 = cot(fovY/2). clip.W is
+ // the camera-space distance.
+ float scaleY = projection.M22;
+ if (scaleY <= 0f) return false;
+ float screenRadius = worldRadius * scaleY * viewport.Y / (2f * clip.W);
+
+ // Cull obviously-off-screen entities (more than a screen away).
+ if (screenX + screenRadius < -viewport.X || screenX - screenRadius > 2f * viewport.X) return false;
+ if (screenY + screenRadius < -viewport.Y || screenY - screenRadius > 2f * viewport.Y) return false;
+
+ // Floor at minSidePixels so distant entities still get a visible /
+ // clickable rect. The picker must apply the same floor as the
+ // indicator or distant clicks won't match the visible bracket.
+ if (screenRadius < minSidePixels * 0.5f) screenRadius = minSidePixels * 0.5f;
+
+ rectMin = new Vector2(screenX - screenRadius, screenY - screenRadius);
+ rectMax = new Vector2(screenX + screenRadius, screenY + screenRadius);
+ return true;
+ }
+}
+```
+
+- [ ] **Step 4: Run `ScreenProjection` tests — confirm pass**
+
+```
+dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug
+dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ScreenProjectionTests" --no-build
+```
+Expected: 3/3 pass.
+
+- [ ] **Step 5: Write failing tests for the new `WorldPicker.Pick` rect overload**
+
+In `tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs` (create if missing):
```csharp
using System.Numerics;
@@ -1027,166 +1219,280 @@ using AcDream.Core.World;
namespace AcDream.Core.Tests.Selection;
-public sealed class WorldPickerSphereOverloadTests
+public sealed class WorldPickerRectOverloadTests
{
- [Fact]
- public void Pick_SphereResolver_ReturnsNearestHit()
+ // Same right-handed perspective as ScreenProjectionTests.
+ private static (Matrix4x4 view, Matrix4x4 proj, Vector2 viewport) StdCam()
{
- // Two entities along the +Y axis. Sphere-resolver gives each a
- // tight world-space sphere centered on the entity. A ray from
- // origin pointing along +Y should hit the closer entity first.
- var e1 = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 5, 0), Scale = 1f };
- var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 15, 0), Scale = 1f };
+ var view = Matrix4x4.Identity;
+ var proj = Matrix4x4.CreatePerspectiveFieldOfView(
+ MathF.PI * 0.5f, 800f / 600f, 0.1f, 100f);
+ var viewport = new Vector2(800, 600);
+ return (view, proj, viewport);
+ }
- var origin = new Vector3(0, 0, 0);
- var dir = new Vector3(0, 1, 0);
- Vector3 SphereCenter(WorldEntity e) => e.Position + new Vector3(0, 0, 0.9f);
+ [Fact]
+ public void Pick_RectHitTest_ReturnsHitWhenMouseInsideRect()
+ {
+ var (view, proj, viewport) = StdCam();
+ var e = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -10), Scale = 1f };
+ // Mouse at screen center; entity at world (0,0,-10) projects to
+ // screen center. Rect contains screen center → hit.
uint? picked = WorldPicker.Pick(
- origin, dir, new[] { e1, e2 },
+ mouseX: 400f, mouseY: 300f,
+ view, proj, viewport,
+ new[] { e },
skipServerGuid: 0u,
- sphereForEntity: e => ((Vector3, float)?)(SphereCenter(e), 1.0f));
+ sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
+ inflatePixels: 0f);
Assert.Equal(0x10001u, picked);
}
[Fact]
- public void Pick_SphereResolver_NullSkipsCandidates()
+ public void Pick_RectHitTest_ReturnsNullWhenMouseOutsideRect()
{
- // Resolver returning null should make the picker skip the
- // candidate (matches retail "no Setup → not pickable" behaviour).
- var e1 = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 5, 0), Scale = 1f };
- var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 10, 0), Scale = 1f };
+ var (view, proj, viewport) = StdCam();
+ var e = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -10), Scale = 1f };
- var origin = new Vector3(0, 0, 0);
- var dir = new Vector3(0, 1, 0);
+ // Mouse far from entity rect → no hit.
+ uint? picked = WorldPicker.Pick(
+ mouseX: 50f, mouseY: 50f,
+ view, proj, viewport,
+ new[] { e },
+ skipServerGuid: 0u,
+ sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
+ inflatePixels: 0f);
+
+ Assert.Null(picked);
+ }
+
+ [Fact]
+ public void Pick_RectHitTest_PicksNearerWhenRectsOverlap()
+ {
+ var (view, proj, viewport) = StdCam();
+ // Two entities both at screen center but different depths.
+ var near = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -8), Scale = 1f };
+ var far = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 0, -15), Scale = 1f };
uint? picked = WorldPicker.Pick(
- origin, dir, new[] { e1, e2 },
+ mouseX: 400f, mouseY: 300f,
+ view, proj, viewport,
+ new[] { far, near } /* deliberately reversed */,
skipServerGuid: 0u,
- sphereForEntity: e => e.ServerGuid == 0x10001u
+ sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
+ inflatePixels: 0f);
+
+ Assert.Equal(0x10001u, picked);
+ }
+
+ [Fact]
+ public void Pick_RectHitTest_NullResolverSkipsCandidates()
+ {
+ var (view, proj, viewport) = StdCam();
+ var e1 = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -10), Scale = 1f };
+ var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 0, -20), Scale = 1f };
+
+ uint? picked = WorldPicker.Pick(
+ mouseX: 400f, mouseY: 300f,
+ view, proj, viewport,
+ new[] { e1, e2 },
+ skipServerGuid: 0u,
+ sphereForEntity: x => x.ServerGuid == 0x10001u
? ((Vector3, float)?)null
- : ((Vector3, float)?)(e.Position + new Vector3(0, 0, 0.9f), 1.0f));
+ : ((Vector3, float)?)(x.Position, 1.0f),
+ inflatePixels: 0f);
Assert.Equal(0x10002u, picked);
}
[Fact]
- public void Pick_SphereResolver_RespectsSkipServerGuid()
+ public void Pick_RectHitTest_RespectsSkipServerGuid()
{
- var e1 = new WorldEntity { ServerGuid = 0x50000001u, Position = new Vector3(0, 5, 0), Scale = 1f };
- var e2 = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 10, 0), Scale = 1f };
-
- var origin = new Vector3(0, 0, 0);
- var dir = new Vector3(0, 1, 0);
+ var (view, proj, viewport) = StdCam();
+ var player = new WorldEntity { ServerGuid = 0x5000000Au, Position = new Vector3(0, 0, -10), Scale = 1f };
+ var npc = new WorldEntity { ServerGuid = 0x10002u, Position = new Vector3(0, 0, -15), Scale = 1f };
uint? picked = WorldPicker.Pick(
- origin, dir, new[] { e1, e2 },
- skipServerGuid: 0x50000001u, // skip player
- sphereForEntity: e => ((Vector3, float)?)(e.Position + new Vector3(0, 0, 0.9f), 1.0f));
+ mouseX: 400f, mouseY: 300f,
+ view, proj, viewport,
+ new[] { player, npc },
+ skipServerGuid: 0x5000000Au,
+ sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
+ inflatePixels: 0f);
Assert.Equal(0x10002u, picked);
}
+
+ [Fact]
+ public void Pick_RectHitTest_InflateExpandsClickableArea()
+ {
+ var (view, proj, viewport) = StdCam();
+ var e = new WorldEntity { ServerGuid = 0x10001u, Position = new Vector3(0, 0, -10), Scale = 1f };
+
+ // First: with inflate=0, a mouse 30 px outside the rect misses.
+ uint? withoutInflate = WorldPicker.Pick(
+ mouseX: 400f + 200f, mouseY: 300f,
+ view, proj, viewport,
+ new[] { e },
+ skipServerGuid: 0u,
+ sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
+ inflatePixels: 0f);
+ Assert.Null(withoutInflate);
+
+ // Then: same mouse position, with a 250 px inflate, now hits.
+ uint? withInflate = WorldPicker.Pick(
+ mouseX: 400f + 200f, mouseY: 300f,
+ view, proj, viewport,
+ new[] { e },
+ skipServerGuid: 0u,
+ sphereForEntity: x => ((Vector3, float)?)(x.Position, 1.0f),
+ inflatePixels: 250f);
+ Assert.Equal(0x10001u, withInflate);
+ }
}
```
-NOTE: If `WorldEntity` is `init`-only / immutable, adjust the test constructor calls accordingly — check `src/AcDream.Core/World/WorldEntity.cs` for the actual constructor / object-initializer pattern. The tests assume property initializers are allowed; if not, switch to whatever constructor the type exposes.
+NOTE: If `WorldEntity` is `init`-only / immutable, adjust the test object-initializer calls accordingly — check `src/AcDream.Core/World/WorldEntity.cs` for the actual constructor / property pattern.
-- [ ] **Step 2: Run tests to verify they fail**
+- [ ] **Step 6: Run `WorldPicker` rect tests — confirm failure**
-Run:
```
-dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerSphereOverloadTests" --no-build
+dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerRectOverloadTests" --no-build
```
-Expected: build failure ("sphereForEntity parameter not found") or runtime failure.
+Expected: build failure (the new `Pick` overload doesn't exist yet).
-- [ ] **Step 3: Add the new `Pick` overload to `WorldPicker.cs`**
+- [ ] **Step 7: Add the new `Pick` overload to `WorldPicker.cs`**
-Append to `src/AcDream.Core/Selection/WorldPicker.cs` (do NOT delete the existing `Pick` overload — it stays for back-compat; its consumers are removed in Task B8):
+Append to `src/AcDream.Core/Selection/WorldPicker.cs` (do NOT delete the existing ray-sphere overload — its caller is migrated in B8, but the API stays for back-compat):
```csharp
///
- /// 2026-05-16. Retail-faithful picker overload. Caller supplies a
- /// per-entity world-space sphere via
- /// — typically scaled by entity
- /// scale and rotated into world space (mirroring retail
- /// CPartArray::GetSelectionSphere at 0x00518b80). Resolver returning
- /// null skips the candidate (matches retail "no Setup → not pickable").
+ /// 2026-05-16. Screen-space rect-hit-test picker overload. Each
+ /// candidate's world-space sphere (via )
+ /// projects to a screen-space rectangle through
+ /// . The
+ /// rect is inflated by on every side
+ /// (matches the indicator's TriangleSize outer brackets) and
+ /// hit-tested against the mouse pixel. Among rects that contain the
+ /// mouse, the entity with the nearest camera-space depth wins.
///
///
- /// Replaces the older
- /// overload with the per-type-radius / vertical-offset heuristics.
- /// Those heuristics existed because we didn't have the dat-supplied
- /// SelectionSphere plumbed through. With this overload, the click
- /// geometry matches what the target indicator draws — what you see
- /// is what you click.
+ /// Why screen-space instead of world-space ray-sphere: the indicator
+ /// draws a screen-space RECT. A world-space sphere projects to a
+ /// screen CIRCLE inscribed in that rect — leaving the four rect
+ /// corners as click dead zones. Per user feedback 2026-05-16, the
+ /// click area must match the visible indicator extent exactly. By
+ /// sharing the helper with
+ /// TargetIndicatorPanel, the click rect and the drawn rect
+ /// cannot drift.
///
///
///
- /// Stage A of the picker port. Retail also does a polygon-accurate
- /// refine via CPolygon::polygon_hits_ray when the sphere
- /// hits (decomp 0x0054c889) — that's Stage B, deferred until visual
- /// testing surfaces a sphere-only miss (issue #71).
+ /// Resolver returning null skips the candidate (matches retail
+ /// "no Setup → not pickable" behavior). Entities with
+ /// ServerGuid == 0 (atlas-tier scenery) and the player's own
+ /// guid are also skipped.
+ ///
+ ///
+ ///
+ /// Stage A of the picker port. Stage B (polygon refine via
+ /// CPolygon::polygon_hits_ray 0x0054c889) remains deferred
+ /// per issue #71 — only needed if visual testing surfaces a Stage A
+ /// over-pick on entities whose visible mesh is well inside the
+ /// indicator rect.
///
///
+ /// Pixel inflate on each side of the
+ /// projected rect. Pass the indicator's TriangleSize (8 px)
+ /// so the click area extends to where the visible bracket corners
+ /// sit — the user perceives the inflated rect as the clickable area.
public static uint? Pick(
- System.Numerics.Vector3 origin, System.Numerics.Vector3 direction,
+ float mouseX, float mouseY,
+ System.Numerics.Matrix4x4 view,
+ System.Numerics.Matrix4x4 projection,
+ System.Numerics.Vector2 viewport,
IEnumerable candidates,
uint skipServerGuid,
Func sphereForEntity,
- float maxDistance = 50f)
+ float inflatePixels = 8f)
{
- if (direction.LengthSquared() < 1e-10f) return null;
+ uint? bestGuid = null;
+ float bestDepth = float.PositiveInfinity;
- uint? bestGuid = null;
- float bestT = float.PositiveInfinity;
foreach (var entity in candidates)
{
- if (entity.ServerGuid == 0u) continue;
- if (entity.ServerGuid == skipServerGuid) continue;
+ if (entity.ServerGuid == 0u) continue;
+ if (entity.ServerGuid == skipServerGuid) continue;
var sphere = sphereForEntity(entity);
if (sphere is null) continue;
-
var (center, radius) = sphere.Value;
if (radius <= 0f) continue;
- // Geometric ray-sphere (same math as the older overload).
- var oc = origin - center;
- float b = System.Numerics.Vector3.Dot(oc, direction);
- float c = System.Numerics.Vector3.Dot(oc, oc) - radius * radius;
- float d = b * b - c;
- if (d < 0f) continue;
- float sqrtD = MathF.Sqrt(d);
- float t = -b - sqrtD;
- if (t < 0f) t = -b + sqrtD; // ray origin inside sphere → use far exit
- if (t < 0f) continue; // both roots behind ray
- if (t >= maxDistance) continue;
- if (t < bestT)
+ if (!ScreenProjection.TryProjectSphereToScreenRect(
+ center, radius, view, projection, viewport,
+ out var rMin, out var rMax, out var depth))
+ continue;
+
+ // Inflate by inflatePixels on each side — extend hit area to
+ // where the indicator brackets sit.
+ float minX = rMin.X - inflatePixels;
+ float minY = rMin.Y - inflatePixels;
+ float maxX = rMax.X + inflatePixels;
+ float maxY = rMax.Y + inflatePixels;
+
+ if (mouseX < minX || mouseX > maxX) continue;
+ if (mouseY < minY || mouseY > maxY) continue;
+
+ if (depth < bestDepth)
{
- bestT = t;
- bestGuid = entity.ServerGuid;
+ bestDepth = depth;
+ bestGuid = entity.ServerGuid;
}
}
return bestGuid;
}
```
-- [ ] **Step 4: Run tests to verify they pass**
+- [ ] **Step 8: Refactor `TargetIndicatorPanel` to use the shared helper**
-Run:
+In `src/AcDream.App/UI/TargetIndicatorPanel.cs`:
+
+1. Delete the private `TryComputeScreenRectFromSphere` method (lines 321-356 inclusive — keep `TryProjectToScreen` since the fallback branch still uses it).
+2. Change the call site at line 217 from `TryComputeScreenRectFromSphere(sphereCenter, sphereRadius, view, projection, viewport, out var rMin, out var rMax)` to:
+
+```csharp
+ && AcDream.Core.Selection.ScreenProjection.TryProjectSphereToScreenRect(
+ sphereCenter, sphereRadius, view, projection, viewport,
+ out var rMin, out var rMax, out _,
+ minSidePixels: 12f))
```
-dotnet build tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug
-dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerSphereOverloadTests" --no-build
+
+(The `out _` discards the depth — the indicator doesn't need it. `minSidePixels: 12f` preserves the existing clamp.)
+
+- [ ] **Step 9: Build all touched projects**
+
```
-Expected: 3/3 pass.
+dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug
+dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
+```
+Expected: 0 errors on both.
+
+- [ ] **Step 10: Run all picker + projection tests**
+
+```
+dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~ScreenProjectionTests|FullyQualifiedName~WorldPickerRectOverloadTests" --no-build
+```
+Expected: 9/9 pass (3 ScreenProjection + 6 WorldPicker rect-overload).
---
-### Task B8: Switch `GameWindow` picker call to the sphere-resolver overload
+### Task B8: Switch `GameWindow` picker call to the screen-rect overload
**Files:**
-- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — find the `WorldPicker.Pick` call.
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs` — `PickAndStoreSelection` method around line 9006.
- [ ] **Step 1: Locate the existing call**
@@ -1194,34 +1500,93 @@ Run:
```
grep -n "WorldPicker.Pick(" src/AcDream.App/Rendering/GameWindow.cs
```
+Expected: one match at ~line 9018.
-- [ ] **Step 2: Replace the call with the sphere-resolver overload**
+- [ ] **Step 2: Replace the ray-build + ray-pick chain with the screen-rect picker**
-Replace the existing `WorldPicker.Pick(...)` invocation (which uses `radiusForGuid` + `verticalOffsetForGuid` callbacks) with:
+The existing block reads (paraphrased; lines 9011-9072):
```csharp
+ var camera = _cameraController.Active;
+ var (origin, direction) = AcDream.Core.Selection.WorldPicker.BuildRay(
+ mouseX: _lastMouseX, mouseY: _lastMouseY,
+ viewportW: _window.Size.X, viewportH: _window.Size.Y,
+ view: camera.View, projection: camera.Projection);
+
+ if (direction.LengthSquared() < 1e-6f) return; // degenerate ray
+
var picked = AcDream.Core.Selection.WorldPicker.Pick(
origin, direction,
_entitiesByServerGuid.Values,
skipServerGuid: _playerServerGuid,
- sphereForEntity: e =>
- TryGetEntitySelectionSphere(e.ServerGuid, out var c, out var r)
- ? ((System.Numerics.Vector3, float)?)(c, r)
- : ((System.Numerics.Vector3, float)?)null,
- maxDistance: 50f);
+ maxDistance: 50f,
+ radiusForGuid: g => { /* per-type heuristics ... */ },
+ verticalOffsetForGuid: g => { /* per-type heuristics ... */ });
```
+Replace the entire block with:
+
+```csharp
+ // 2026-05-16 — retail-faithful screen-rect picker. The hit area
+ // is the same screen-space rect the target indicator draws
+ // (computed via the shared AcDream.Core.Selection.ScreenProjection
+ // helper). Per user feedback 2026-05-16: clicking the indicator
+ // brackets — including the rect corners — must select the entity.
+ // The per-type radius/offset heuristics retired here (1.0/1.6/2.0
+ // m radii, 0.2/0.9/1.0/1.5 m vertical offsets, IsTallSceneryGuid)
+ // existed to make a 3D ray-sphere picker approximate the visible
+ // rect; the new picker doesn't need them.
+ var camera = _cameraController.Active;
+ var viewport = new System.Numerics.Vector2(_window.Size.X, _window.Size.Y);
+
+ var picked = AcDream.Core.Selection.WorldPicker.Pick(
+ mouseX: _lastMouseX, mouseY: _lastMouseY,
+ view: camera.View, projection: camera.Projection,
+ viewport: viewport,
+ candidates: _entitiesByServerGuid.Values,
+ skipServerGuid: _playerServerGuid,
+ sphereForEntity: e =>
+ {
+ // Authoritative: Setup's SelectionSphere (matches the
+ // indicator's input).
+ if (TryGetEntitySelectionSphere(e.ServerGuid, out var c, out var r))
+ return ((System.Numerics.Vector3, float)?)(c, r);
+
+ // Fallback for entities whose Setup didn't bake a
+ // SelectionSphere (rare). Synthesize a 1.5 m × scale
+ // sphere centered on body-mid — same intent as B9's
+ // simplified EntityHeightFor fallback, so the picker
+ // and indicator agree even on the fallback path.
+ float scale = 1f;
+ if (_lastSpawnByGuid.TryGetValue(e.ServerGuid, out var s) && s.ObjScale is float es && es > 0f)
+ scale = es;
+ float half = 0.75f * scale;
+ var center = e.Position + new System.Numerics.Vector3(0, 0, half);
+ return ((System.Numerics.Vector3, float)?)(center, half);
+ },
+ // Match the indicator's TriangleSize (8 px) so the click area
+ // extends out to the bracket corners — what the user perceives
+ // as "selectable extent."
+ inflatePixels: 8f);
+```
+
+The local `origin`/`direction` variables and the `if (direction.LengthSquared() < 1e-6f) return;` guard are no longer needed — delete them along with the `BuildRay` call.
+
- [ ] **Step 3: Delete the per-type `radiusForGuid` and `verticalOffsetForGuid` lambda blocks**
-Both lambdas (around line ~9037 and ~9054) become dead with this change. Delete them.
+Both lambdas (the entire `radiusForGuid: g => { ... }` and `verticalOffsetForGuid: g => { ... }` blocks at ~9037 and ~9054) are gone with the rewrite in Step 2. Confirm there are no dangling references via:
+
+```
+grep -n "radiusForGuid\|verticalOffsetForGuid" src/AcDream.App/Rendering/GameWindow.cs
+```
+Expected: 0 matches in `GameWindow.cs` after the edit.
- [ ] **Step 4: Build**
-Run:
```
dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
```
-Expected: 0 errors. The old `WorldPicker.Pick` overload with `radiusForGuid` callback still exists in `WorldPicker.cs` but has no callers — leave it for now; not blocking.
+Expected: 0 errors. The old `WorldPicker.Pick(origin, direction, ...)` ray-sphere overload still exists in `WorldPicker.cs` but has no callers — leave it for now; cleaning it up is a follow-up.
---
@@ -1332,6 +1697,8 @@ User runs the client and confirms:
4. **Pickup item from across the room.** Walks, picks up. ONE `[B.5] pickup` line.
5. **Click the Holtburg sign.** Indicator triangles match the sign size (unchanged from previous ship). Press R. Silent no-op (`SendUse ignored — not useable`).
6. **Click rapidly between NPC and item.** No spurious "I clicked X earlier and now it's firing on Y" cross-contamination. The `_pendingPostArrivalAction` simplification should make this clean.
+7. **Click in the indicator's bracket corners.** Hover the mouse so it sits in the rect-corner region of the indicator brackets (where the four triangle marks sit) — NOT on the entity body. Click. The entity selects. Old behaviour: corners were sphere dead zones and the click missed; new behaviour: click area = visible bracket bounding rect, corner clicks land.
+8. **Click adjacent to an entity but outside the indicator rect.** Mouse just outside the bracket extent. No selection. Old behaviour: sphere over-pick let cursor land far from the visible rect and still select; new behaviour: rect edges are tight.
**If any of (1)-(6) regresses, the cadence fix in Task B2 likely needs tuning. Common cause: `IsGrounded` is too restrictive (suppressing AP on slopes); relax to `ContactPlane.Normal.Z > 0.3f` or similar.**
@@ -1344,11 +1711,13 @@ When user approves:
```bash
git add src/AcDream.App/Input/PlayerMovementController.cs \
src/AcDream.App/Rendering/GameWindow.cs \
+ src/AcDream.Core/Selection/ScreenProjection.cs \
src/AcDream.Core/Selection/WorldPicker.cs \
src/AcDream.App/UI/TargetIndicatorPanel.cs \
+ tests/AcDream.Core.Tests/Selection/ScreenProjectionTests.cs \
tests/AcDream.Core.Tests/Selection/WorldPickerTests.cs
git commit -m "$(cat <<'EOF'
-fix(retail): per-tick AP cadence + sphere picker retires 4 workarounds
+fix(retail): per-tick AP cadence + screen-rect picker retires 4 workarounds
Single coherent commit. Audit findings from 2026-05-16:
@@ -1390,20 +1759,29 @@ Single coherent commit. Audit findings from 2026-05-16:
Renamed to OnAutoWalkArrivedSendDeferredAction to clarify
it's a FIRST send, not a retry.
-3. WorldPicker switched to a Setup.SelectionSphere overload.
- Retail's picker uses CGfxObj.drawing_sphere + polygon refine
- (acclient_2013_pseudo_c.txt:0x0054c740 GfxObjUnderSelectionRay),
- which we approximate via Setup.SelectionSphere (same data
- path as the target indicator since f4f4143). Effect: click
- geometry matches the visible indicator — what you see is what
- you click. Retires the per-type radius (1.0/1.5/2.0 m) and
- vertical-offset (0.9/1.0/1.5 m) heuristic callbacks.
+3. WorldPicker switched to a screen-rect hit-test against the same
+ rect the target indicator draws. Retail's picker uses
+ CGfxObj.drawing_sphere + polygon refine
+ (acclient_2013_pseudo_c.txt:0x0054c740 GfxObjUnderSelectionRay
+ feeding SmartBox::GetObjectBoundingBox at 0x00452e20). Stage A
+ approximates the sphere-reject step by projecting Setup.SelectionSphere
+ to screen and hit-testing the mouse pixel against that rect
+ inflated by 8 px (the indicator's TriangleSize). The new
+ AcDream.Core.Selection.ScreenProjection helper is shared between
+ the picker and the indicator so their rects cannot drift.
+ Effect: click area matches the visible indicator extent
+ bit-for-bit — including the rect corners, which were dead
+ zones under the old ray-sphere picker. Retires the per-type
+ radius (1.0/1.6/2.0 m) and vertical-offset (0.2/0.9/1.0/1.5 m)
+ heuristic callbacks. Old ray-sphere overload remains in
+ WorldPicker for back-compat; not load-bearing.
4. EntityHeightFor fallback trimmed to a single 1.5 m default.
IsTallSceneryGuid deleted entirely — both became dead code
- when the picker switched to SelectionSphere.
+ when the picker switched to screen-rect against SelectionSphere.
-Test suite: 290+ Core.Net unchanged, +3 WorldPickerSphereOverloadTests.
+Test suite: 290+ Core.Net unchanged, +3 ScreenProjectionTests, +6
+WorldPickerRectOverloadTests.
Plan: docs/superpowers/plans/2026-05-16-retail-faithfulness-fixes.md
@@ -1532,7 +1910,7 @@ Deferred follow-ups from the 2026-05-16 retail-faithfulness audit
- Fix #5 (useability fallback probe): Task A4 (flag), A5 step 2 (log lines). ✅
- Fix #2 (per-tick diff-driven AP): Tasks B1 + B2 + B3. ✅
- Fix #6 (delete 4 workarounds): Task B4 (TinyMargin), B5 (handler + SendAutonomousPositionNow), B6 (isRetryAfterArrival). ✅
-- Fix #3 Stage A (sphere picker): Tasks B7 + B8. ✅
+- Fix #3 Stage A (screen-rect picker against indicator rect): Tasks B7 + B8. ✅
- Fix #7 (trim EntityHeightFor): Task B9. ✅
- Fix #8 (delete IsTallSceneryGuid): Task B10. ✅
- Deferred issues (triangle/Stage B/cdb): Tasks DF1, DF2, DF3. ✅
@@ -1544,7 +1922,8 @@ Deferred follow-ups from the 2026-05-16 retail-faithfulness audit
- `NotePositionSent(Vector3, uint, float)` defined B1, called B3. ✅
- `SimTimeSeconds` accessor defined B1, read B3. ✅
- `OnAutoWalkArrivedSendDeferredAction()` defined Task B6 Step 3, subscribed in Task B6 Step 4. ✅
-- `WorldPicker.Pick(...sphereForEntity...)` defined Task B7, called Task B8. ✅
+- `ScreenProjection.TryProjectSphereToScreenRect(...)` defined Task B7 Step 3, called Task B7 Step 7 (WorldPicker.Pick) and Step 8 (TargetIndicatorPanel). ✅
+- `WorldPicker.Pick(mouseX, mouseY, view, projection, viewport, candidates, skipServerGuid, sphereForEntity, inflatePixels)` defined Task B7 Step 7, called Task B8 Step 2. ✅
- `TryGetEntitySelectionSphere` referenced in Task B8 — already exists in `GameWindow.cs` at line ~9605 per audit. ✅
- `ApproxPositionEqual` defined Task B2 Step 3, called Task B2 Step 2. ✅
diff --git a/docs/superpowers/plans/2026-05-18-retail-chase-camera.md b/docs/superpowers/plans/2026-05-18-retail-chase-camera.md
new file mode 100644
index 00000000..1edcbdbd
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-18-retail-chase-camera.md
@@ -0,0 +1,1706 @@
+# Retail-faithful chase camera with dev-tools toggle — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Port retail's `CameraManager` + `CameraSet` chase-cam behavior — exponential damping, slope-aligned heading frame, mouse low-pass — to a new `RetailChaseCamera` class controlled by a `CameraDiagnostics` flag, with sliders in the DebugPanel for live tuning.
+
+**Architecture:** Two `ICamera` implementations coexist (`ChaseCamera` legacy + `RetailChaseCamera` new). `CameraController` carries both and exposes `Active` based on a `CameraDiagnostics.UseRetailChaseCamera` flag. Both update every frame so toggle swaps are instant. `DebugVM` mirrors `CameraDiagnostics` statics as properties; the new "Chase camera" DebugPanel section writes through those mirrors.
+
+**Tech Stack:** C# .NET 10, `System.Numerics`, Silk.NET (windowing), ImGui.NET (panel rendering), xUnit (tests).
+
+**Spec:** [`docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md`](../specs/2026-05-18-retail-chase-camera-design.md)
+
+---
+
+## File structure
+
+**New files:**
+
+| Path | Responsibility |
+|---|---|
+| `src/AcDream.Core/Rendering/CameraDiagnostics.cs` | Static class owning the six tunables (toggle, slope-align, 2× stiffness, low-pass window, adjustment speed). Env-var defaults + setter passthrough. |
+| `src/AcDream.App/Rendering/RetailChaseCamera.cs` | The new camera. Implements `ICamera`. Holds damped state (eye, forward), 5-frame velocity ring, mouse-filter state. Math primitives `internal static` for testability. |
+| `tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs` | Default values + env-var parse + setter passthrough. |
+| `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs` | Five test groups: heading, velocity ring, damping, mouse low-pass, auto-fade. |
+
+**Modified files:**
+
+| Path | What changes |
+|---|---|
+| `src/AcDream.App/Rendering/CameraController.cs` | Carry `RetailChase` alongside `Chase`. `Active` consults flag. `EnterChaseMode` takes both. |
+| `src/AcDream.UI.Abstractions/Input/InputAction.cs` | Four new actions: `CameraZoomIn`, `CameraZoomOut`, `CameraRaise`, `CameraLower`. Default-unbound. |
+| `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` | Six new mirror properties forwarding to `CameraDiagnostics` (one for each tunable). |
+| `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs` | New `DrawChaseCamera(r)` method called from `Render`. Toggle + slope-align checkbox + four sliders. |
+| `src/AcDream.App/Rendering/GameWindow.cs` | Construct both cameras at chase entry, update both per frame with new `playerVelocity` arg, hold-key offset integration via new InputActions, mouse-Y goes through `RetailChaseCamera.FilterMouseDelta` before `AdjustPitch`. |
+| `src/AcDream.App/AcDream.App.csproj` (if not already) | `InternalsVisibleTo("AcDream.App.Tests")` so the test project can call the camera's internal math helpers. |
+
+**Deferred to a follow-up (`#post-camera-toggle` issue):** applying `PlayerTranslucency` to the player mesh via the WB draw dispatcher. The math is computed + tested in this plan; the wire-up is out of scope.
+
+---
+
+## Task 1: `CameraDiagnostics` static class + tests
+
+**Files:**
+- Create: `src/AcDream.Core/Rendering/CameraDiagnostics.cs`
+- Create: `tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs`:
+
+```csharp
+using AcDream.Core.Rendering;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering;
+
+public class CameraDiagnosticsTests
+{
+ // NOTE: These tests assume the env vars ACDREAM_RETAIL_CHASE and
+ // ACDREAM_CAMERA_ALIGN_SLOPE are NOT set when running the test
+ // suite. Static class is initialised on first access; values reflect
+ // the env state at that time.
+
+ [Fact]
+ public void Defaults_AreRetailValues()
+ {
+ // Reset to defaults explicitly (test isolation; another test may
+ // have flipped these earlier in the run).
+ CameraDiagnostics.TranslationStiffness = 0.45f;
+ CameraDiagnostics.RotationStiffness = 0.45f;
+ CameraDiagnostics.MouseLowPassWindowSec = 0.25f;
+ CameraDiagnostics.CameraAdjustmentSpeed = 40.0f;
+ CameraDiagnostics.AlignToSlope = true;
+ CameraDiagnostics.UseRetailChaseCamera = false;
+
+ Assert.Equal(0.45f, CameraDiagnostics.TranslationStiffness);
+ Assert.Equal(0.45f, CameraDiagnostics.RotationStiffness);
+ Assert.Equal(0.25f, CameraDiagnostics.MouseLowPassWindowSec);
+ Assert.Equal(40.0f, CameraDiagnostics.CameraAdjustmentSpeed);
+ Assert.True(CameraDiagnostics.AlignToSlope);
+ Assert.False(CameraDiagnostics.UseRetailChaseCamera);
+ }
+
+ [Fact]
+ public void Setters_PersistRuntimeChanges()
+ {
+ CameraDiagnostics.TranslationStiffness = 0.8f;
+ CameraDiagnostics.UseRetailChaseCamera = true;
+
+ Assert.Equal(0.8f, CameraDiagnostics.TranslationStiffness);
+ Assert.True(CameraDiagnostics.UseRetailChaseCamera);
+
+ // Reset so other tests aren't poisoned.
+ CameraDiagnostics.TranslationStiffness = 0.45f;
+ CameraDiagnostics.UseRetailChaseCamera = false;
+ }
+}
+```
+
+- [ ] **Step 2: Run test, verify failure**
+
+```
+dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CameraDiagnosticsTests"
+```
+
+Expected: compile error — `CameraDiagnostics` doesn't exist.
+
+- [ ] **Step 3: Implement the class**
+
+Create `src/AcDream.Core/Rendering/CameraDiagnostics.cs`:
+
+```csharp
+using System;
+
+namespace AcDream.Core.Rendering;
+
+///
+/// Runtime-tunable knobs for the retail-faithful chase camera. Mirrors
+/// the pattern:
+/// static fields seeded from env vars at process start, runtime-settable
+/// via property setters that the DebugPanel writes to.
+///
+///
+/// Spec: docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.
+///
+///
+public static class CameraDiagnostics
+{
+ ///
+ /// Master toggle. When false (default) the legacy
+ /// AcDream.App.Rendering.ChaseCamera is the active camera;
+ /// when true, the retail-faithful RetailChaseCamera is.
+ /// Initial state from ACDREAM_RETAIL_CHASE=1.
+ ///
+ public static bool UseRetailChaseCamera { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CHASE") == "1";
+
+ ///
+ /// When true (default), the camera basis follows the player's
+ /// 5-frame averaged velocity vector — tilts with the terrain on
+ /// hills. When false, the basis is built from a flat (yaw, 0) vector
+ /// and the camera stays horizontal even on slopes. Initial state
+ /// from ACDREAM_CAMERA_ALIGN_SLOPE; default-on if unset.
+ ///
+ public static bool AlignToSlope { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_CAMERA_ALIGN_SLOPE") != "0";
+
+ ///
+ /// Per-frame translation damping rate. Retail default 0.45. Higher
+ /// (→ 1.0) snaps faster; lower (→ 0.0) lags more. Formula per frame:
+ /// alpha = clamp(TranslationStiffness * dt * 10, 0, 1).
+ ///
+ public static float TranslationStiffness { get; set; } = 0.45f;
+
+ ///
+ /// Per-frame rotation damping rate. Independent of translation —
+ /// can be tuned higher so the camera swings to look at you faster
+ /// than it physically catches up. Retail default 0.45.
+ ///
+ public static float RotationStiffness { get; set; } = 0.45f;
+
+ ///
+ /// Mouse-delta low-pass window (seconds). Mouse deltas spaced
+ /// closer than this are averaged with the previous delta before
+ /// being fed to pitch/yaw adjustments. Smooths out jitter on
+ /// high-DPI mice. Retail default 0.25.
+ ///
+ public static float MouseLowPassWindowSec { get; set; } = 0.25f;
+
+ ///
+ /// Per-second rate that held-key offset adjustments
+ /// (CameraZoomIn/Out, CameraRaise/Lower) integrate into the
+ /// camera's Distance / Pitch. Retail default 40.0.
+ ///
+ public static float CameraAdjustmentSpeed { get; set; } = 40.0f;
+}
+```
+
+- [ ] **Step 4: Run tests, verify pass**
+
+```
+dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CameraDiagnosticsTests"
+```
+
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Verify full solution builds**
+
+```
+dotnet build
+```
+
+Expected: build succeeded, 0 errors.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/AcDream.Core/Rendering/CameraDiagnostics.cs tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs
+git commit -m "$(cat <<'EOF'
+feat(camera): add CameraDiagnostics static tunable owner
+
+Six knobs for the upcoming retail chase camera: UseRetailChaseCamera
+master toggle (env ACDREAM_RETAIL_CHASE), AlignToSlope (env
+ACDREAM_CAMERA_ALIGN_SLOPE, default on), TranslationStiffness +
+RotationStiffness (both 0.45 retail default), MouseLowPassWindowSec
+(0.25), CameraAdjustmentSpeed (40.0). DebugPanel mirror lands later;
+this commit just stands up the static surface + defaults + tests.
+
+Per spec docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 2: `RetailChaseCamera` math primitives (TDD)
+
+**Files:**
+- Create: `src/AcDream.App/Rendering/RetailChaseCamera.cs` (skeleton + 5 internal static math helpers)
+- Create: `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs` (math tests)
+- Modify: `src/AcDream.App/AcDream.App.csproj` — add `InternalsVisibleTo`
+
+**Strategy:** Five math primitives, each written test-first. Math is pure — no instance state needed for these methods. The state-machine (velocity ring, damped pose, mouse-filter timestamps) lands in Task 3 when we assemble `Update()`.
+
+- [ ] **Step 1: Verify `InternalsVisibleTo` exists in `AcDream.App.csproj`**
+
+Look at `src/AcDream.App/AcDream.App.csproj` for an existing `` element or an `[assembly: InternalsVisibleTo("AcDream.App.Tests")]` attribute in an `AssemblyInfo.cs` / `GlobalUsings.cs` / similar. If missing, add to the csproj:
+
+```xml
+
+
+
+```
+
+(Use the existing `` if there's a logical place, otherwise create a new one near the bottom.)
+
+- [ ] **Step 2: Write the failing tests** for the math helpers
+
+Create `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs`:
+
+```csharp
+using System;
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.Core.Rendering;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+public class RetailChaseCameraTests
+{
+ // ── Heading source ────────────────────────────────────────────────
+
+ [Fact]
+ public void Heading_StationaryWithSlopeAlign_FallsBackToYawVector()
+ {
+ var avgVel = Vector3.Zero;
+ float yaw = MathF.PI / 4f; // 45°
+
+ var h = RetailChaseCamera.ComputeHeading(avgVel, yaw, alignToSlope: true);
+
+ Assert.Equal(MathF.Cos(yaw), h.X, 5);
+ Assert.Equal(MathF.Sin(yaw), h.Y, 5);
+ Assert.Equal(0f, h.Z, 5);
+ }
+
+ [Fact]
+ public void Heading_MovingHorizontal_MatchesNormalizedVelocity()
+ {
+ var avgVel = new Vector3(3f, 0f, 0f);
+ var h = RetailChaseCamera.ComputeHeading(avgVel, yaw: 0f, alignToSlope: true);
+ Assert.Equal(1f, h.X, 5);
+ Assert.Equal(0f, h.Y, 5);
+ Assert.Equal(0f, h.Z, 5);
+ }
+
+ [Fact]
+ public void Heading_MovingUphill_HasPositiveZ()
+ {
+ var avgVel = new Vector3(1f, 0f, 0.5f);
+ var h = RetailChaseCamera.ComputeHeading(avgVel, yaw: 0f, alignToSlope: true);
+ Assert.True(h.Z > 0f, $"expected positive Z component, got {h.Z}");
+ }
+
+ [Fact]
+ public void Heading_SlopeAlignDisabled_IgnoresVelocity()
+ {
+ var avgVel = new Vector3(0f, 0f, 1f); // pure upward; would dominate if slope-align were on
+ float yaw = 0f;
+
+ var h = RetailChaseCamera.ComputeHeading(avgVel, yaw, alignToSlope: false);
+
+ Assert.Equal(1f, h.X, 5); // (cos 0, sin 0, 0) = (1, 0, 0)
+ Assert.Equal(0f, h.Y, 5);
+ Assert.Equal(0f, h.Z, 5);
+ }
+
+ // ── Basis from heading ────────────────────────────────────────────
+
+ [Fact]
+ public void Basis_HorizontalHeading_IsOrthonormalAndRightHanded()
+ {
+ var (forward, right, up) = RetailChaseCamera.BuildBasis(new Vector3(1f, 0f, 0f));
+
+ Assert.Equal(1f, forward.Length(), 5);
+ Assert.Equal(1f, right.Length(), 5);
+ Assert.Equal(1f, up.Length(), 5);
+
+ // Orthogonal
+ Assert.Equal(0f, Vector3.Dot(forward, right), 5);
+ Assert.Equal(0f, Vector3.Dot(forward, up), 5);
+ Assert.Equal(0f, Vector3.Dot(right, up), 5);
+
+ // forward = (1,0,0), world up = (0,0,1) → right = (0,-1,0), camera-up = (0,0,1)
+ Assert.Equal(0f, up.X, 5);
+ Assert.Equal(0f, up.Y, 5);
+ Assert.True(up.Z > 0f);
+ }
+
+ [Fact]
+ public void Basis_NearVerticalHeading_UsesXFallbackForRight()
+ {
+ // forward nearly straight up (rare; airborne edge case). Must not produce
+ // a zero-length right vector from cross(forward, worldUp).
+ var (_, right, up) = RetailChaseCamera.BuildBasis(new Vector3(0f, 0f, 1f));
+
+ Assert.Equal(1f, right.Length(), 5);
+ Assert.Equal(1f, up.Length(), 5);
+ }
+
+ // ── Velocity ring & averaging ────────────────────────────────────
+
+ [Fact]
+ public void VelocityRing_AveragesLastN()
+ {
+ var ring = new Vector3[5];
+ int count = 0;
+
+ ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
+ ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
+ ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
+ ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
+ ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(3, 0, 0));
+
+ Assert.Equal(5, count);
+ var avg = RetailChaseCamera.AverageVelocity(ring, count);
+ Assert.Equal(1.8f, avg.X, 5);
+ Assert.Equal(0f, avg.Y, 5);
+ Assert.Equal(0f, avg.Z, 5);
+ }
+
+ [Fact]
+ public void VelocityRing_FifoEvictsOldest()
+ {
+ var ring = new Vector3[5];
+ int count = 0;
+
+ // Push 6 entries; oldest (the first 1,0,0) should be evicted.
+ for (int i = 0; i < 5; i++)
+ ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(1, 0, 0));
+ ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(10, 0, 0));
+
+ Assert.Equal(5, count); // still capped at 5
+ // Sum of newest 5 entries: 4*(1,0,0) + (10,0,0) = (14,0,0), avg = 2.8
+ var avg = RetailChaseCamera.AverageVelocity(ring, count);
+ Assert.Equal(2.8f, avg.X, 5);
+ }
+
+ [Fact]
+ public void VelocityRing_PartialFillUsesActualCount()
+ {
+ var ring = new Vector3[5];
+ int count = 0;
+
+ ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(2, 0, 0));
+ ring = RetailChaseCamera.PushVelocity(ring, ref count, new Vector3(4, 0, 0));
+
+ Assert.Equal(2, count);
+ var avg = RetailChaseCamera.AverageVelocity(ring, count);
+ Assert.Equal(3f, avg.X, 5); // (2+4)/2, not (2+4)/5
+ }
+
+ // ── Damping alpha ────────────────────────────────────────────────
+
+ [Fact]
+ public void DampingAlpha_RetailDefault_ProducesSevenAndAHalfPercent()
+ {
+ // stiffness=0.45, dt=1/60 → 0.45 * (1/60) * 10 ≈ 0.075
+ float alpha = RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 1f / 60f);
+ Assert.Equal(0.075f, alpha, 4);
+ }
+
+ [Fact]
+ public void DampingAlpha_LargeDtClampsToOne()
+ {
+ float alpha = RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 1f);
+ Assert.Equal(1f, alpha);
+ }
+
+ [Fact]
+ public void DampingAlpha_NegativeOrZero_ClampsToZero()
+ {
+ Assert.Equal(0f, RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.45f, dt: 0f));
+ Assert.Equal(0f, RetailChaseCamera.ComputeDampingAlpha(stiffness: 0.0f, dt: 1f));
+ }
+
+ // ── Mouse low-pass ───────────────────────────────────────────────
+
+ [Fact]
+ public void MouseFilter_BeyondWindow_OutputsRaw()
+ {
+ float lastDelta = 5f;
+ float lastTime = 0f;
+ float windowSec = 0.25f;
+
+ float result = RetailChaseCamera.FilterMouseAxis(
+ raw: 10f, weight: 0.5f, nowSec: 1.0f,
+ ref lastDelta, ref lastTime, windowSec);
+
+ // Beyond window, blended == raw, so out = raw * 0.5 + raw * 0.5 = raw.
+ Assert.Equal(10f, result, 5);
+ }
+
+ [Fact]
+ public void MouseFilter_WithinWindow_AveragesWithPrevious()
+ {
+ float lastDelta = 10f;
+ float lastTime = 0f;
+ float windowSec = 0.25f;
+
+ float result = RetailChaseCamera.FilterMouseAxis(
+ raw: 20f, weight: 0.5f, nowSec: 0.1f,
+ ref lastDelta, ref lastTime, windowSec);
+
+ // Within window: avg = (10 + 20)/2 = 15.
+ // Output: 20 * 0.5 + 15 * 0.5 = 17.5
+ Assert.Equal(17.5f, result, 5);
+ }
+
+ [Fact]
+ public void MouseFilter_WeightZero_OutputsRaw()
+ {
+ float lastDelta = 10f;
+ float lastTime = 0f;
+ float windowSec = 0.25f;
+
+ float result = RetailChaseCamera.FilterMouseAxis(
+ raw: 20f, weight: 0f, nowSec: 0.1f,
+ ref lastDelta, ref lastTime, windowSec);
+
+ Assert.Equal(20f, result, 5);
+ }
+
+ [Fact]
+ public void MouseFilter_WeightOne_OutputsAveraged()
+ {
+ float lastDelta = 10f;
+ float lastTime = 0f;
+ float windowSec = 0.25f;
+
+ float result = RetailChaseCamera.FilterMouseAxis(
+ raw: 20f, weight: 1f, nowSec: 0.1f,
+ ref lastDelta, ref lastTime, windowSec);
+
+ // weight=1 → out = avg = 15
+ Assert.Equal(15f, result, 5);
+ }
+
+ [Fact]
+ public void MouseFilter_UpdatesLastDeltaAndTime()
+ {
+ float lastDelta = 10f;
+ float lastTime = 0f;
+ float windowSec = 0.25f;
+
+ float result = RetailChaseCamera.FilterMouseAxis(
+ raw: 20f, weight: 0.5f, nowSec: 0.1f,
+ ref lastDelta, ref lastTime, windowSec);
+
+ Assert.Equal(result, lastDelta); // last is updated to output
+ Assert.Equal(0.1f, lastTime, 5); // last time advances
+ }
+
+ // ── Auto-fade translucency ───────────────────────────────────────
+
+ [Fact]
+ public void Translucency_AtFarThreshold_IsZero()
+ {
+ Assert.Equal(0f, RetailChaseCamera.ComputeTranslucency(distance: 0.45f), 5);
+ Assert.Equal(0f, RetailChaseCamera.ComputeTranslucency(distance: 1.00f), 5);
+ }
+
+ [Fact]
+ public void Translucency_MidwayBetweenThresholds_IsHalf()
+ {
+ // Midpoint between 0.20 and 0.45 = 0.325
+ // t = 1 - (0.20 - 0.325) / (0.20 - 0.45)
+ // = 1 - (-0.125) / (-0.25)
+ // = 1 - 0.5 = 0.5
+ Assert.Equal(0.5f, RetailChaseCamera.ComputeTranslucency(distance: 0.325f), 4);
+ }
+
+ [Fact]
+ public void Translucency_AtNearThreshold_IsOne()
+ {
+ Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.20f), 5);
+ Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.10f), 5);
+ Assert.Equal(1f, RetailChaseCamera.ComputeTranslucency(distance: 0.0f), 5);
+ }
+}
+```
+
+- [ ] **Step 3: Run tests, verify they fail to compile** (class doesn't exist)
+
+```
+dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"
+```
+
+Expected: compile error.
+
+- [ ] **Step 4: Implement the math primitives** in `RetailChaseCamera`
+
+Create `src/AcDream.App/Rendering/RetailChaseCamera.cs`:
+
+```csharp
+using System;
+using System.Numerics;
+using AcDream.Core.Rendering;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Retail-faithful chase camera. Ports the chase-cam behavior from the
+/// 2013 acclient (CameraManager + CameraSet, decomp at
+/// docs/research/named-retail/acclient_2013_pseudo_c.txt:95505):
+/// exponential damping toward a target pose, 5-frame velocity-averaged
+/// slope-aligned heading frame, mouse-input low-pass filter.
+///
+///
+/// Sits behind
+/// next to the legacy ; both update every
+/// frame so toggling the flag swaps cameras instantly. Visible behavior
+/// vs legacy: lag-then-catch-up on turn/stop, tilt-with-terrain on
+/// hills, jump-feedback without the legacy _trackedZ hack.
+///
+///
+///
+/// Spec: docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.
+///
+///
+public sealed class RetailChaseCamera : ICamera
+{
+ // ICamera surface — filled in by Task 3.
+ public Vector3 Position { get; private set; }
+ public float Aspect { get; set; } = 16f / 9f;
+ public float FovY { get; set; } = MathF.PI / 3f;
+ public Matrix4x4 View { get; private set; } = Matrix4x4.Identity;
+ public Matrix4x4 Projection =>
+ Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
+
+ // Math primitives — pure, internal-static for unit-testability.
+
+ ///
+ /// Pick the heading vector that drives the camera basis. Slope-
+ /// aligned when velocity is non-trivial and the toggle is on; flat
+ /// fallback otherwise. Matches retail's target_status &
+ /// ALIGN_WITH_PLANE path with the contact-plane branch
+ /// collapsed into the flat fallback.
+ ///
+ internal static Vector3 ComputeHeading(Vector3 avgVelocity, float yaw, bool alignToSlope)
+ {
+ if (alignToSlope && avgVelocity.LengthSquared() > 1e-4f)
+ return Vector3.Normalize(avgVelocity);
+
+ return new Vector3(MathF.Cos(yaw), MathF.Sin(yaw), 0f);
+ }
+
+ ///
+ /// Build an orthonormal basis with forward = heading. World
+ /// up is (0, 0, 1); if heading is near-parallel to it
+ /// the right axis falls back to world +X so the cross
+ /// product doesn't collapse.
+ ///
+ internal static (Vector3 forward, Vector3 right, Vector3 up) BuildBasis(Vector3 heading)
+ {
+ Vector3 forward = Vector3.Normalize(heading);
+ Vector3 worldUp = new(0f, 0f, 1f);
+
+ Vector3 right;
+ if (MathF.Abs(forward.Z) > 0.99f)
+ {
+ // Near-vertical forward — use world +X as the secondary axis.
+ right = Vector3.Normalize(Vector3.Cross(forward, new Vector3(1f, 0f, 0f)));
+ }
+ else
+ {
+ right = Vector3.Normalize(Vector3.Cross(forward, worldUp));
+ }
+ Vector3 up = Vector3.Cross(right, forward); // already unit (forward + right orthonormal)
+ return (forward, right, up);
+ }
+
+ ///
+ /// FIFO-push a velocity sample into the 5-entry ring. Returns the
+ /// updated ring (mutates the input array; the return is for fluent
+ /// usage in tests). grows from 0 toward 5
+ /// and stays at 5 once the ring is full.
+ ///
+ internal static Vector3[] PushVelocity(Vector3[] ring, ref int count, Vector3 sample)
+ {
+ if (ring.Length != 5)
+ throw new ArgumentException("velocity ring must have 5 entries", nameof(ring));
+
+ // Shift left by 1 (oldest is overwritten), append new sample at the tail.
+ for (int i = 0; i < 4; i++) ring[i] = ring[i + 1];
+ ring[4] = sample;
+ if (count < 5) count++;
+ return ring;
+ }
+
+ ///
+ /// Average the most-recent entries of the
+ /// ring (entries [5-count .. 5)). Returns
+ /// when count is zero.
+ ///
+ internal static Vector3 AverageVelocity(Vector3[] ring, int count)
+ {
+ if (count == 0) return Vector3.Zero;
+ Vector3 sum = Vector3.Zero;
+ for (int i = 5 - count; i < 5; i++) sum += ring[i];
+ return sum / count;
+ }
+
+ ///
+ /// Exponential-damping rate per frame.
+ /// alpha = clamp(stiffness * dt * 10, 0, 1). At
+ /// stiffness=0.45, dt=1/60 → ~0.075
+ /// (~150 ms half-life). Matches retail's
+ /// x_1 = stiffness * dt * 10 formulation.
+ ///
+ internal static float ComputeDampingAlpha(float stiffness, float dt)
+ {
+ float a = stiffness * dt * 10f;
+ if (a <= 0f) return 0f;
+ if (a >= 1f) return 1f;
+ return a;
+ }
+
+ ///
+ /// Low-pass filter for a single mouse axis. Mirrors retail's
+ /// CameraSet::FilterMouseInput: if last sample was within
+ /// , blend output with the average of
+ /// (previous, raw); otherwise pass-through. Final output =
+ /// raw * (1 - weight) + blended * weight. Updates
+ /// and
+ /// to the new state.
+ ///
+ internal static float FilterMouseAxis(
+ float raw,
+ float weight,
+ float nowSec,
+ ref float lastDelta,
+ ref float lastTimeSec,
+ float windowSec)
+ {
+ float avg;
+ if (nowSec - lastTimeSec < windowSec)
+ avg = (lastDelta + raw) * 0.5f;
+ else
+ avg = raw;
+
+ float output = raw * (1f - weight) + avg * weight;
+ lastDelta = output;
+ lastTimeSec = nowSec;
+ return output;
+ }
+
+ ///
+ /// Player-mesh translucency as a function of camera-to-pivot
+ /// distance. 0 = fully opaque, 1 = fully transparent.
+ /// Opaque at and beyond 0.45 m; fully transparent at and within
+ /// 0.20 m; linear ramp between. Matches retail's CameraSet::
+ /// UpdateCamera distance check (decomp :97703–97725).
+ ///
+ internal static float ComputeTranslucency(float distance)
+ {
+ const float Far = 0.45f;
+ const float Near = 0.20f;
+
+ if (distance >= Far) return 0f;
+ if (distance <= Near) return 1f;
+ // Linear: t = 1 - (Near - distance) / (Near - Far)
+ return 1f - (Near - distance) / (Near - Far);
+ }
+}
+```
+
+- [ ] **Step 5: Run tests, verify all pass**
+
+```
+dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"
+```
+
+Expected: PASS (18 tests across heading / basis / ring / damping / mouse / fade groups).
+
+- [ ] **Step 6: Verify full solution builds**
+
+```
+dotnet build
+```
+
+Expected: 0 errors.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/RetailChaseCamera.cs src/AcDream.App/AcDream.App.csproj tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
+git commit -m "$(cat <<'EOF'
+feat(camera): add RetailChaseCamera math primitives
+
+Five pure-math helpers in the new RetailChaseCamera class:
+ComputeHeading (slope-align with flat fallback), BuildBasis (heading
+→ orthonormal frame, near-vertical fallback), PushVelocity +
+AverageVelocity (5-entry FIFO ring), ComputeDampingAlpha (retail's
+stiffness*dt*10), FilterMouseAxis (0.25s low-pass), ComputeTranslucency
+(linear ramp 0.20..0.45 m). 18 tests, all pass. State machine + Update()
+land in the next commit.
+
+Per spec docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 3: `RetailChaseCamera.Update()` integration + damping state tests
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/RetailChaseCamera.cs` — add state fields, `Update()`, `Distance` / `Pitch` / `YawOffset` / `PivotHeight` properties, `AdjustPitch` / `AdjustDistance`, `PlayerTranslucency`, public `FilterMouseDelta` wrapper.
+- Modify: `tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs` — add integration tests for first-frame snap + lerp-toward-target + translucency-on-actual-distance.
+
+- [ ] **Step 1: Write failing integration tests** — append to `RetailChaseCameraTests.cs`:
+
+```csharp
+ // ── Update() integration ─────────────────────────────────────────
+
+ [Fact]
+ public void FirstUpdate_SnapsToTarget()
+ {
+ var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
+ CameraDiagnostics.AlignToSlope = false; // deterministic: heading = yaw vec
+
+ cam.Update(
+ playerPosition: new Vector3(10f, 20f, 30f),
+ playerYaw: 0f, // forward = +X
+ playerVelocity: Vector3.Zero,
+ dt: 1f / 60f);
+
+ // Expected target eye:
+ // pivot = (10, 20, 30+1.5=31.5)
+ // forward (yaw=0)= (1, 0, 0)
+ // right = (0, -1, 0) since (1,0,0) × (0,0,1) = (0, -1, 0)
+ // up = right × forward = (0,-1,0) × (1,0,0) = (0,0,1)
+ // viewer_offset = (0, -5, 0) (Distance=5, Pitch=0 → -Distance*cos = -5, sin = 0)
+ // eye = pivot + right*0 + forward*-5 + up*0
+ // = (10 - 5, 20, 31.5) = (5, 20, 31.5)
+ Assert.Equal(5f, cam.Position.X, 4);
+ Assert.Equal(20f, cam.Position.Y, 4);
+ Assert.Equal(31.5f, cam.Position.Z, 4);
+ }
+
+ [Fact]
+ public void SecondUpdate_LerpsTowardTarget()
+ {
+ var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f };
+ CameraDiagnostics.AlignToSlope = false;
+ CameraDiagnostics.TranslationStiffness = 0.45f;
+ CameraDiagnostics.RotationStiffness = 0.45f;
+
+ // First update at origin: dampedEye = (-5, 0, 1.5).
+ cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
+ var firstEye = cam.Position;
+
+ // Teleport the player one frame later. Target eye now at (10-5, 0, 1.5) = (5, 0, 1.5).
+ // alpha = 0.45 * (1/60) * 10 = 0.075.
+ // New eye = firstEye + 0.075 * (target - firstEye)
+ // = (-5,0,1.5) + 0.075 * ((5,0,1.5) - (-5,0,1.5))
+ // = (-5,0,1.5) + 0.075 * (10,0,0)
+ // = (-4.25, 0, 1.5)
+ cam.Update(new Vector3(10f, 0f, 0f), playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
+
+ Assert.Equal(-4.25f, cam.Position.X, 3);
+ Assert.Equal(0f, cam.Position.Y, 4);
+ Assert.Equal(1.5f, cam.Position.Z, 4);
+ }
+
+ [Fact]
+ public void Translucency_PropertyReflectsCurrentDampedDistance()
+ {
+ var cam = new RetailChaseCamera { Distance = 5f, Pitch = 0f, PivotHeight = 1.5f };
+ CameraDiagnostics.AlignToSlope = false;
+
+ // Far from pivot — translucency should be 0.
+ cam.Update(Vector3.Zero, playerYaw: 0f, playerVelocity: Vector3.Zero, dt: 1f / 60f);
+ Assert.Equal(0f, cam.PlayerTranslucency, 5);
+ }
+
+ [Fact]
+ public void AdjustDistance_ClampsToRange()
+ {
+ var cam = new RetailChaseCamera { Distance = 5f };
+ cam.AdjustDistance(-100f);
+ Assert.Equal(RetailChaseCamera.DistanceMin, cam.Distance);
+
+ cam.AdjustDistance(+200f);
+ Assert.Equal(RetailChaseCamera.DistanceMax, cam.Distance);
+ }
+
+ [Fact]
+ public void AdjustPitch_ClampsToRange()
+ {
+ var cam = new RetailChaseCamera { Pitch = 0f };
+ cam.AdjustPitch(-10f);
+ Assert.Equal(RetailChaseCamera.PitchMin, cam.Pitch);
+
+ cam.AdjustPitch(+10f);
+ Assert.Equal(RetailChaseCamera.PitchMax, cam.Pitch);
+ }
+```
+
+- [ ] **Step 2: Run tests, verify failure**
+
+```
+dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"
+```
+
+Expected: compile errors (missing `Update`, `Distance`, `Pitch`, `PivotHeight`, `PlayerTranslucency`, `AdjustDistance`, `AdjustPitch`, `DistanceMin`/`Max`, `PitchMin`/`Max` symbols).
+
+- [ ] **Step 3: Add state + properties + `Update()`** in `RetailChaseCamera.cs`
+
+Inside the `RetailChaseCamera` class, **above** the math primitives, add:
+
+```csharp
+ // ── Public tunables (per-instance) ──────────────────────────────
+
+ /// Length of the viewer_offset vector. Retail default ≈ 2.61.
+ public float Distance { get; set; } = 2.61f;
+
+ /// Angle of the camera above the heading-frame XY plane. Retail default ≈ 0.291 rad (16.7°).
+ public float Pitch { get; set; } = 0.291f;
+
+ ///
+ /// Yaw offset added on top of player yaw when slope-align is off
+ /// or velocity is too small to derive a heading. Used by hold-RMB
+ /// orbit to swing the camera around the player without rotating
+ /// the character.
+ ///
+ public float YawOffset { get; set; } = 0f;
+
+ /// Height of look-at anchor above the player's feet (m). Retail default 1.5.
+ public float PivotHeight { get; set; } = 1.5f;
+
+ /// Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow.
+ public float PlayerTranslucency { get; private set; }
+
+ /// Clamp bounds carried over from legacy ChaseCamera.
+ public const float DistanceMin = 2f;
+ public const float DistanceMax = 40f;
+ public const float PitchMin = -0.7f;
+ public const float PitchMax = 1.4f;
+
+ // ── Damped state ────────────────────────────────────────────────
+
+ private readonly Vector3[] _velocityRing = new Vector3[5];
+ private int _velocityCount;
+ private Vector3 _dampedEye;
+ private Vector3 _dampedForward = new(1f, 0f, 0f);
+ private bool _initialised;
+
+ // Mouse-filter state — shared by FilterMouseDelta entrypoint.
+ private float _lastMouseDeltaX;
+ private float _lastMouseDeltaY;
+ private float _lastFilterTimeSec;
+```
+
+Then add the `Update` + adjustment methods + `FilterMouseDelta` between the state fields and the math primitives section:
+
+```csharp
+ ///
+ /// Advance the camera one frame. Caller passes the player's current
+ /// pose + velocity (in world space) + the frame's dt in
+ /// seconds. After this returns, ,
+ /// , and reflect
+ /// the new state.
+ ///
+ public void Update(Vector3 playerPosition, float playerYaw, Vector3 playerVelocity, float dt)
+ {
+ // 1. Push velocity into 5-frame ring, get average.
+ PushVelocity(_velocityRing, ref _velocityCount, playerVelocity);
+ Vector3 avgVel = AverageVelocity(_velocityRing, _velocityCount);
+
+ // 2. Heading vector — slope-aligned when fast enough, flat fallback otherwise.
+ Vector3 heading = ComputeHeading(avgVel, playerYaw + YawOffset, CameraDiagnostics.AlignToSlope);
+
+ // 3. Orthonormal heading-frame basis.
+ var (forward, right, up) = BuildBasis(heading);
+
+ // 4. Target pose.
+ Vector3 pivotWorld = playerPosition + new Vector3(0f, 0f, PivotHeight);
+ float horizontal = Distance * MathF.Cos(Pitch);
+ float vertical = Distance * MathF.Sin(Pitch);
+ // viewer_offset = (0, -horizontal, vertical) in (right, forward, up) basis.
+ Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical;
+ Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye);
+
+ // 5. Exponential damping (independent translation + rotation rates).
+ if (!_initialised)
+ {
+ _dampedEye = targetEye;
+ _dampedForward = targetForward;
+ _initialised = true;
+ }
+ else
+ {
+ float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
+ float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
+ _dampedEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
+ _dampedForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
+ }
+
+ // 6. Publish renderer surface.
+ Position = _dampedEye;
+ View = Matrix4x4.CreateLookAt(_dampedEye, _dampedEye + _dampedForward, new Vector3(0f, 0f, 1f));
+
+ // 7. Auto-fade translucency.
+ float d = Vector3.Distance(_dampedEye, pivotWorld);
+ PlayerTranslucency = ComputeTranslucency(d);
+ }
+
+ ///
+ /// Adjust the camera distance (zoom) by a delta, clamped to
+ /// ... Mirrors
+ /// legacy ChaseCamera.AdjustDistance.
+ ///
+ public void AdjustDistance(float delta) =>
+ Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
+
+ ///
+ /// Adjust the camera pitch by a delta (radians), clamped to
+ /// ... Mirrors legacy
+ /// ChaseCamera.AdjustPitch.
+ ///
+ public void AdjustPitch(float delta) =>
+ Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
+
+ ///
+ /// Public entry point for the mouse-input low-pass filter. Calls
+ /// on each axis with shared state.
+ ///
+ public (float outX, float outY) FilterMouseDelta(float rawX, float rawY, float weight, float nowSec)
+ {
+ float x = FilterMouseAxis(rawX, weight, nowSec,
+ ref _lastMouseDeltaX, ref _lastFilterTimeSec, CameraDiagnostics.MouseLowPassWindowSec);
+ // Reset timestamp before the Y call so the within-window check still uses the original `nowSec - prev`
+ // (no — we want both axes to share the same timestamp; X already advanced it. That's fine: Y sees
+ // nowSec - nowSec = 0 < windowSec, so it blends. To keep Y honest we explicitly skip the timestamp
+ // overwrite on the second call by passing a throwaway `_yTimeShadow`.)
+ float yTimeShadow = _lastFilterTimeSec - 1f; // ensure within-window path is taken
+ float y = FilterMouseAxis(rawY, weight, nowSec,
+ ref _lastMouseDeltaY, ref yTimeShadow, CameraDiagnostics.MouseLowPassWindowSec);
+ return (x, y);
+ }
+```
+
+- [ ] **Step 4: Run tests, verify pass**
+
+```
+dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~RetailChaseCameraTests"
+```
+
+Expected: PASS (23 tests).
+
+- [ ] **Step 5: Verify full solution builds**
+
+```
+dotnet build
+```
+
+Expected: 0 errors.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/RetailChaseCamera.cs tests/AcDream.App.Tests/Rendering/RetailChaseCameraTests.cs
+git commit -m "$(cat <<'EOF'
+feat(camera): wire RetailChaseCamera Update() + tunables + state
+
+Adds the per-frame Update(playerPos, yaw, velocity, dt) entrypoint
+that composes the math primitives into a renderable View matrix +
+PlayerTranslucency. State: 5-frame velocity ring, damped eye + forward
+unit vector, first-frame snap flag, mouse-filter shared state.
+Public surface: Distance/Pitch/YawOffset/PivotHeight tunables,
+AdjustDistance/Pitch (with clamps), FilterMouseDelta entry, View +
+Position + PlayerTranslucency outputs. 5 new integration tests, all
+pass; total RetailChaseCamera test count 23.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 4: `CameraController` carries both cameras
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/CameraController.cs`
+- Modify: `tests/AcDream.App.Tests/Rendering/` — new `CameraControllerTests.cs` if no existing CameraController test exists; otherwise extend.
+
+- [ ] **Step 1: Search for existing CameraController tests**
+
+```
+dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~CameraController" --list-tests
+```
+
+If no results: create `tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs`. If existing: append to the existing file.
+
+- [ ] **Step 2: Write the failing tests**
+
+```csharp
+using System.Numerics;
+using AcDream.App.Rendering;
+using AcDream.Core.Rendering;
+using Xunit;
+
+namespace AcDream.App.Tests.Rendering;
+
+public class CameraControllerTests
+{
+ private static (CameraController ctl, ChaseCamera legacy, RetailChaseCamera retail) MakeChaseFixture()
+ {
+ var orbit = new OrbitCamera();
+ var fly = new FlyCamera();
+ var ctl = new CameraController(orbit, fly);
+ var legacy = new ChaseCamera();
+ var retail = new RetailChaseCamera();
+ ctl.EnterChaseMode(legacy, retail);
+ return (ctl, legacy, retail);
+ }
+
+ [Fact]
+ public void ChaseMode_WhenFlagOff_ActiveIsLegacy()
+ {
+ CameraDiagnostics.UseRetailChaseCamera = false;
+ var (ctl, legacy, _) = MakeChaseFixture();
+ Assert.Same(legacy, ctl.Active);
+ Assert.True(ctl.IsChaseMode);
+ }
+
+ [Fact]
+ public void ChaseMode_WhenFlagOn_ActiveIsRetail()
+ {
+ CameraDiagnostics.UseRetailChaseCamera = true;
+ var (ctl, _, retail) = MakeChaseFixture();
+ Assert.Same(retail, ctl.Active);
+ Assert.True(ctl.IsChaseMode);
+
+ // Reset.
+ CameraDiagnostics.UseRetailChaseCamera = false;
+ }
+
+ [Fact]
+ public void ChaseMode_FlagFlipped_ActiveSwaps()
+ {
+ CameraDiagnostics.UseRetailChaseCamera = false;
+ var (ctl, legacy, retail) = MakeChaseFixture();
+ Assert.Same(legacy, ctl.Active);
+
+ CameraDiagnostics.UseRetailChaseCamera = true;
+ Assert.Same(retail, ctl.Active);
+
+ CameraDiagnostics.UseRetailChaseCamera = false;
+ Assert.Same(legacy, ctl.Active);
+ }
+
+ [Fact]
+ public void ExitChaseMode_ClearsBothCameras()
+ {
+ CameraDiagnostics.UseRetailChaseCamera = false;
+ var (ctl, _, _) = MakeChaseFixture();
+ ctl.ExitChaseMode();
+
+ Assert.Null(ctl.Chase);
+ Assert.Null(ctl.RetailChase);
+ Assert.False(ctl.IsChaseMode);
+ }
+}
+```
+
+- [ ] **Step 3: Run tests, verify failure**
+
+```
+dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~CameraControllerTests"
+```
+
+Expected: compile errors — `RetailChase` property + `EnterChaseMode(ChaseCamera, RetailChaseCamera)` overload missing.
+
+- [ ] **Step 4: Modify `CameraController.cs`**
+
+Replace the existing class body with:
+
+```csharp
+using AcDream.Core.Rendering;
+
+namespace AcDream.App.Rendering;
+
+public sealed class CameraController
+{
+ public OrbitCamera Orbit { get; }
+ public FlyCamera Fly { get; }
+ public ChaseCamera? Chase { get; private set; }
+ public RetailChaseCamera? RetailChase { get; private set; }
+
+ ///
+ /// The renderer-facing active camera. In chase mode, returns
+ /// when
+ /// is true,
+ /// otherwise . In fly mode returns
+ /// ; default is .
+ ///
+ public ICamera Active
+ {
+ get
+ {
+ if (_mode == Mode.Fly) return Fly;
+ if (_mode == Mode.Chase)
+ {
+ if (CameraDiagnostics.UseRetailChaseCamera && RetailChase is not null)
+ return RetailChase;
+ if (Chase is not null) return Chase;
+ }
+ return Orbit;
+ }
+ }
+
+ public bool IsFlyMode => _mode == Mode.Fly;
+ public bool IsChaseMode => _mode == Mode.Chase;
+
+ public event Action? ModeChanged;
+
+ private enum Mode { Orbit, Fly, Chase }
+ private Mode _mode = Mode.Orbit;
+
+ public CameraController(OrbitCamera orbit, FlyCamera fly)
+ {
+ Orbit = orbit;
+ Fly = fly;
+ }
+
+ public void ToggleFly()
+ {
+ _mode = IsFlyMode ? Mode.Orbit : Mode.Fly;
+ ModeChanged?.Invoke(IsFlyMode);
+ }
+
+ ///
+ /// Enter chase mode with both candidate cameras. Both are held;
+ /// picks based on
+ /// .
+ ///
+ public void EnterChaseMode(ChaseCamera legacy, RetailChaseCamera retail)
+ {
+ Chase = legacy;
+ RetailChase = retail;
+ _mode = Mode.Chase;
+ ModeChanged?.Invoke(IsChaseMode);
+ }
+
+ public void ExitChaseMode()
+ {
+ Chase = null;
+ RetailChase = null;
+ _mode = Mode.Fly;
+ ModeChanged?.Invoke(IsFlyMode);
+ }
+
+ public void SetAspect(float aspect)
+ {
+ Orbit.Aspect = aspect;
+ Fly.Aspect = aspect;
+ }
+}
+```
+
+**Caller-update note:** This breaks `CameraController.EnterChaseMode(ChaseCamera)` callers — search for them and update. From earlier exploration there are exactly two: `GameWindow.cs:9857` and `GameWindow.cs:9965`. We update them in Task 7 (GameWindow wiring). For this task to compile, change those two call sites to a temporary stub:
+
+In `GameWindow.cs`, find the two `EnterChaseMode(_chaseCamera)` calls and replace with:
+
+```csharp
+_cameraController.EnterChaseMode(_chaseCamera, new RetailChaseCamera { Aspect = _chaseCamera.Aspect });
+```
+
+(Task 7 replaces this with proper construction + per-frame updates.)
+
+- [ ] **Step 5: Run tests, verify pass**
+
+```
+dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~CameraControllerTests"
+```
+
+Expected: PASS (4 tests).
+
+- [ ] **Step 6: Verify full solution builds**
+
+```
+dotnet build
+```
+
+Expected: 0 errors.
+
+- [ ] **Step 7: Run the full test suite to confirm nothing else broke**
+
+```
+dotnet test
+```
+
+Expected: all tests pass (a handful of legacy chase-cam tests may exist; the temporary GameWindow stub should keep them green).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/CameraController.cs src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/Rendering/CameraControllerTests.cs
+git commit -m "$(cat <<'EOF'
+feat(camera): CameraController carries both legacy + retail chase cams
+
+EnterChaseMode now takes (ChaseCamera, RetailChaseCamera); Active
+consults CameraDiagnostics.UseRetailChaseCamera to pick which to
+expose. Flag flip at runtime swaps cameras instantly (both are kept
+warm). GameWindow's two EnterChaseMode call sites get a temporary
+stub RetailChaseCamera; Task 7 wires proper construction +
+per-frame updates.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 5: `InputAction` additions + `DebugVM` mirror properties
+
+**Files:**
+- Modify: `src/AcDream.UI.Abstractions/Input/InputAction.cs`
+- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs`
+
+- [ ] **Step 1: Add four new enum entries**
+
+Open `src/AcDream.UI.Abstractions/Input/InputAction.cs`. Find the existing `Acdream*` section near the bottom (or the last block of entries). Insert these four entries at the end of the enum, before the closing brace:
+
+```csharp
+ // ── AcdreamCameraCommands ─────────────────────────────
+ /// Camera zoom in (held key, integrates Distance−= adjSpeed·dt). Default unbound.
+ CameraZoomIn,
+ /// Camera zoom out (held key, integrates Distance+= adjSpeed·dt). Default unbound.
+ CameraZoomOut,
+ /// Camera raise (held key, integrates Pitch+= adjSpeed·dt·0.02). Default unbound.
+ CameraRaise,
+ /// Camera lower (held key, integrates Pitch−= adjSpeed·dt·0.02). Default unbound.
+ CameraLower,
+```
+
+- [ ] **Step 2: Add `CameraDiagnostics` mirror properties to `DebugVM`**
+
+Open `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs`. Add a `using` at the top if not present:
+
+```csharp
+using AcDream.Core.Rendering;
+```
+
+Locate the existing `ProbeAutoWalk` property (last of the Probe* group). Below it (still inside the class), add a new "Chase camera tunables" section:
+
+```csharp
+ // ── Chase camera tunables (forward to CameraDiagnostics) ──────────
+
+ /// Runtime mirror of .
+ public bool UseRetailChaseCamera
+ {
+ get => CameraDiagnostics.UseRetailChaseCamera;
+ set => CameraDiagnostics.UseRetailChaseCamera = value;
+ }
+
+ /// Runtime mirror of .
+ public bool CameraAlignToSlope
+ {
+ get => CameraDiagnostics.AlignToSlope;
+ set => CameraDiagnostics.AlignToSlope = value;
+ }
+
+ /// Runtime mirror of .
+ public float CameraTranslationStiffness
+ {
+ get => CameraDiagnostics.TranslationStiffness;
+ set => CameraDiagnostics.TranslationStiffness = value;
+ }
+
+ /// Runtime mirror of .
+ public float CameraRotationStiffness
+ {
+ get => CameraDiagnostics.RotationStiffness;
+ set => CameraDiagnostics.RotationStiffness = value;
+ }
+
+ /// Runtime mirror of .
+ public float CameraMouseLowPassWindowSec
+ {
+ get => CameraDiagnostics.MouseLowPassWindowSec;
+ set => CameraDiagnostics.MouseLowPassWindowSec = value;
+ }
+
+ /// Runtime mirror of .
+ public float CameraAdjustmentSpeed
+ {
+ get => CameraDiagnostics.CameraAdjustmentSpeed;
+ set => CameraDiagnostics.CameraAdjustmentSpeed = value;
+ }
+```
+
+- [ ] **Step 3: Verify build**
+
+```
+dotnet build
+```
+
+Expected: 0 errors. (No tests for this task — the mirror properties are trivial passthroughs already covered by Task 1's `CameraDiagnostics` tests + the integration verification in Task 7.)
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/AcDream.UI.Abstractions/Input/InputAction.cs src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
+git commit -m "$(cat <<'EOF'
+feat(camera): InputAction + DebugVM surface for retail chase camera
+
+Four new InputAction entries for held-key offset integration
+(CameraZoomIn/Out, CameraRaise/Lower; default unbound). Six new
+DebugVM mirror properties forwarding to CameraDiagnostics so the
+upcoming "Chase camera" DebugPanel section can drive them live.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 6: `DebugPanel` "Chase camera" section
+
+**Files:**
+- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs`
+
+- [ ] **Step 1: Add the section call** in `DebugPanel.Render`
+
+Find `Render(...)` in `DebugPanel.cs` (around line 74). Insert a new `DrawChaseCamera(renderer)` call between `DrawPlayerInfo(renderer)` and `DrawPerformance(renderer)`:
+
+```csharp
+ public void Render(PanelContext ctx, IPanelRenderer renderer)
+ {
+ if (!renderer.Begin(Title))
+ {
+ renderer.End();
+ return;
+ }
+
+ DrawPlayerInfo(renderer);
+ DrawChaseCamera(renderer); // ← NEW
+ DrawPerformance(renderer);
+ DrawCompass(renderer);
+ DrawHelp(renderer);
+ DrawCombatEvents(renderer);
+ DrawRecentToasts(renderer);
+ DrawDiagnostics(renderer);
+
+ renderer.End();
+ }
+```
+
+- [ ] **Step 2: Add the `DrawChaseCamera` method** — insert below `DrawPlayerInfo`:
+
+```csharp
+ private void DrawChaseCamera(IPanelRenderer r)
+ {
+ if (!r.CollapsingHeader("Chase camera", defaultOpen: true)) return;
+
+ bool useRetail = _vm.UseRetailChaseCamera;
+ bool alignSlope = _vm.CameraAlignToSlope;
+ float tStiff = _vm.CameraTranslationStiffness;
+ float rStiff = _vm.CameraRotationStiffness;
+ float lpWindow = _vm.CameraMouseLowPassWindowSec;
+ float adjSpeed = _vm.CameraAdjustmentSpeed;
+
+ if (r.Checkbox("Use retail chase camera (env: ACDREAM_RETAIL_CHASE)", ref useRetail))
+ _vm.UseRetailChaseCamera = useRetail;
+
+ if (r.Checkbox("Align to slope (env: ACDREAM_CAMERA_ALIGN_SLOPE)", ref alignSlope))
+ _vm.CameraAlignToSlope = alignSlope;
+
+ if (r.SliderFloat("Translation stiffness", ref tStiff, 0.05f, 1.0f))
+ _vm.CameraTranslationStiffness = tStiff;
+ if (r.SliderFloat("Rotation stiffness", ref rStiff, 0.05f, 1.0f))
+ _vm.CameraRotationStiffness = rStiff;
+ if (r.SliderFloat("Mouse low-pass window (s)", ref lpWindow, 0.0f, 0.5f))
+ _vm.CameraMouseLowPassWindowSec = lpWindow;
+ if (r.SliderFloat("Adjustment speed (units/s)", ref adjSpeed, 10f, 80f))
+ _vm.CameraAdjustmentSpeed = adjSpeed;
+ }
+```
+
+- [ ] **Step 3: Verify build**
+
+```
+dotnet build
+```
+
+Expected: 0 errors.
+
+- [ ] **Step 4: Run the existing DebugPanel tests** (if any) to confirm no regression:
+
+```
+dotnet test tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj
+```
+
+Expected: all pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
+git commit -m "$(cat <<'EOF'
+feat(camera): DebugPanel "Chase camera" section with live tunables
+
+New CollapsingHeader between Player Info and Performance: toggle +
+slope-align checkbox + four sliders (translation stiffness, rotation
+stiffness, mouse low-pass window, adjustment speed). All controls
+write through DebugVM mirror properties to CameraDiagnostics statics;
+changes take effect on the next frame.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 7: `GameWindow` wiring — construct both, update both, route inputs
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (4 sites)
+
+- [ ] **Step 1: Add the retail chase field** alongside the existing chase camera field
+
+Find `private AcDream.App.Rendering.ChaseCamera? _chaseCamera;` (around line 629). Add directly below:
+
+```csharp
+ private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
+```
+
+- [ ] **Step 2: Construct both cameras at chase-mode entry**
+
+Find the `_chaseCamera = new AcDream.App.Rendering.ChaseCamera { ... }` block (around line 9956). Replace the block + the immediately-following `_cameraController?.EnterChaseMode(_chaseCamera);` line with:
+
+```csharp
+ _chaseCamera = new AcDream.App.Rendering.ChaseCamera
+ {
+ Aspect = _window!.Size.X / (float)_window.Size.Y,
+ };
+ _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
+ {
+ Aspect = _window!.Size.X / (float)_window.Size.Y,
+ };
+ // K.1b: _playerMouseDeltaX is no longer consumed by
+ // MovementInput, but we still reset it here so any stale
+ // accumulated value from a previous session doesn't leak
+ // into a future code path that re-enables mouse-yaw.
+ _playerMouseDeltaX = 0f;
+ _cameraController?.EnterChaseMode(_chaseCamera, _retailChaseCamera);
+```
+
+- [ ] **Step 3: Update the chase camera each frame** — both cameras get the same inputs
+
+Find the existing `_chaseCamera.Update(result.RenderPosition, _playerController.Yaw, isOnGround: result.IsOnGround, dt: (float)dt);` call (around line 6390). Replace with both updates:
+
+```csharp
+ // Update chase camera(s). The CameraController exposes whichever
+ // is currently selected via CameraDiagnostics.UseRetailChaseCamera;
+ // both update every frame so toggling the flag swaps instantly
+ // with the new camera already warm.
+ //
+ // Legacy ChaseCamera: pre-K-fix12 args (isOnGround pins Z during
+ // jumps as a workaround for the visual feel retail gets from
+ // low-stiffness damping).
+ _chaseCamera.Update(result.RenderPosition, _playerController.Yaw,
+ isOnGround: result.IsOnGround,
+ dt: (float)dt);
+ // RetailChaseCamera: takes world velocity for slope-aligned heading;
+ // jump-feedback falls out of damping naturally, no isOnGround needed.
+ _retailChaseCamera!.Update(result.RenderPosition, _playerController.Yaw,
+ playerVelocity: _playerController.BodyVelocity,
+ dt: (float)dt);
+```
+
+- [ ] **Step 4: Route mouse-Y (RMB-orbit pitch) through the low-pass filter**
+
+Find the existing mouse-move handler (around line 996 — the `_chaseCamera.AdjustPitch(dy * 0.003f * sens);` call). Replace the two `AdjustPitch` call sites (one at ~line 996 in the non-RMB branch, one at ~1007-1008 in the RMB branch) with conditional dispatch:
+
+```csharp
+ // Filter mouse Y through the retail camera's low-pass
+ // filter only when retail is the active mode; legacy
+ // path passes through raw (no behavior change for
+ // legacy users).
+ if (CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
+ {
+ float nowSec = (float)_runtimeClock.ElapsedSeconds;
+ var (_, filteredDy) = _retailChaseCamera.FilterMouseDelta(
+ rawX: 0f, rawY: dy, weight: 0.5f, nowSec: nowSec);
+ _retailChaseCamera.AdjustPitch(filteredDy * 0.003f * sens);
+ }
+ else
+ {
+ _chaseCamera.AdjustPitch(dy * 0.003f * sens);
+ }
+```
+
+(Make the same swap in both branches — the second site additionally has the `YawOffset -= dx * 0.004f * sens;` line; apply that to whichever chase camera is active. The legacy `_chaseCamera.YawOffset` and `_retailChaseCamera!.YawOffset` are both float setters with the same semantics.)
+
+**Note:** the file may not have `_runtimeClock.ElapsedSeconds` accessible directly. If it doesn't, use `(float)(_lastFrameTimestamp - _firstFrameTimestamp)` or whatever the existing wall-clock plumbing is. Grep for an existing `ElapsedSeconds` / `Stopwatch.GetElapsedTime` pattern in `GameWindow.cs`; if none, use `(float)Environment.TickCount64 / 1000f` as a sufficient monotonic source for the 0.25 s window comparison.
+
+- [ ] **Step 5: Mouse wheel zoom** — same conditional routing
+
+Find the `_chaseCamera.AdjustDistance(-dir * 0.8f);` call (around line 10112). Replace with:
+
+```csharp
+ if (_playerMode && _cameraController.IsChaseMode)
+ {
+ if (CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
+ _retailChaseCamera.AdjustDistance(-dir * 0.8f);
+ else if (_chaseCamera is not null)
+ _chaseCamera.AdjustDistance(-dir * 0.8f);
+ }
+```
+
+- [ ] **Step 6: Held-key offset integration** — in the player-mode tick loop (the same place where movement input is polled, around line 6314), add at the top of the `_playerMode && _playerController is not null && _chaseCamera is not null` branch:
+
+```csharp
+ // Retail-style held-key offset integration. Only active when
+ // retail chase is selected; legacy camera ignores these.
+ if (CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null
+ && _inputDispatcher is not null)
+ {
+ float adj = CameraDiagnostics.CameraAdjustmentSpeed * (float)dt;
+ if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomIn))
+ _retailChaseCamera.AdjustDistance(-adj);
+ if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomOut))
+ _retailChaseCamera.AdjustDistance(+adj);
+ if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraRaise))
+ _retailChaseCamera.AdjustPitch(+adj * 0.02f);
+ if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraLower))
+ _retailChaseCamera.AdjustPitch(-adj * 0.02f);
+ }
+```
+
+- [ ] **Step 7: Null-out the retail chase reference** in the cleanup path
+
+Find the existing `_chaseCamera = null;` line (around line 8965). Add directly below:
+
+```csharp
+ _retailChaseCamera = null;
+```
+
+Same for line 9803 if there's another cleanup site.
+
+- [ ] **Step 8: Remove the temporary stub** added in Task 4
+
+Search `GameWindow.cs` for `_cameraController.EnterChaseMode(_chaseCamera, new RetailChaseCamera`. There should be **zero** results after Step 2 (the stub is replaced by the proper construction). Confirm clean.
+
+- [ ] **Step 9: Verify build**
+
+```
+dotnet build
+```
+
+Expected: 0 errors. If there's a missing using for `CameraDiagnostics` in `GameWindow.cs`, add `using AcDream.Core.Rendering;` to the top of the file.
+
+- [ ] **Step 10: Run the full test suite**
+
+```
+dotnet test
+```
+
+Expected: all pass (the existing chase-camera-using-legacy tests continue to work; the retail camera tests added in Tasks 2-3 still pass; the controller tests from Task 4 still pass).
+
+- [ ] **Step 11: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/GameWindow.cs
+git commit -m "$(cat <<'EOF'
+feat(camera): wire RetailChaseCamera through GameWindow
+
+GameWindow now constructs both ChaseCamera + RetailChaseCamera at
+player-mode entry, updates both per frame (legacy with isOnGround,
+retail with BodyVelocity), and routes mouse/wheel/held-key input to
+whichever the CameraDiagnostics flag selects. Mouse-Y goes through
+RetailChaseCamera.FilterMouseDelta before AdjustPitch when retail is
+active; legacy path is unchanged. Held-key bindings (CameraZoomIn/Out,
+CameraRaise/Lower; default-unbound) integrate distance/pitch at
+CameraDiagnostics.CameraAdjustmentSpeed per second.
+
+Default behavior: ACDREAM_RETAIL_CHASE unset → legacy camera as before.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 8: Build, test, visual verification handoff
+
+- [ ] **Step 1: Clean rebuild from scratch**
+
+```
+dotnet build --no-incremental
+```
+
+Expected: 0 errors, 0 new warnings.
+
+- [ ] **Step 2: Run the full test suite**
+
+```
+dotnet test
+```
+
+Expected: all tests pass, including the 27 new tests (2 CameraDiagnosticsTests + 23 RetailChaseCameraTests + 4 CameraControllerTests).
+
+- [ ] **Step 3: Launch the client** (only after the user is ready — they have ACE running)
+
+Run the canonical launch command from `CLAUDE.md` §"Running the client":
+
+```powershell
+$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
+$env:ACDREAM_LIVE = "1"
+$env:ACDREAM_TEST_HOST = "127.0.0.1"
+$env:ACDREAM_TEST_PORT = "9000"
+$env:ACDREAM_TEST_USER = "testaccount"
+$env:ACDREAM_TEST_PASS = "testpassword"
+$env:ACDREAM_DEVTOOLS = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
+ Tee-Object -FilePath "launch.log"
+```
+
+- [ ] **Step 4: Manual visual verification** — confirm with the user
+
+Ask the user to verify (from CLAUDE.md acceptance criteria):
+
+1. With Ctrl+F1 (debug panel) → "Chase camera" → toggle **OFF** (default): camera feels identical to before this branch.
+2. Toggle **ON**: camera now visibly **lags** behind a turn, **coasts** after a stop, and **tilts with the slope** when crossing a hill crest.
+3. Toggling at runtime swaps instantly with no snap or crash.
+4. Jumping in retail mode shows the player rise above the camera *without* the legacy `_trackedZ` hack (which still exists in the legacy camera but is bypassed when retail is active).
+5. Sliders work: bumping `Translation stiffness` to 1.0 turns the camera into the legacy rigid follow; dropping to 0.10 produces ultra-floaty motion.
+
+- [ ] **Step 5: If acceptance criteria pass — close handoff**
+
+No further commit. Spec + plan are committed; the implementation commits are the deliverable. Update the todos in the executing session: mark all tasks done.
+
+If the user reports a behavioral issue: file as an issue in `docs/ISSUES.md` (e.g. "retail chase camera: tilt-with-terrain too aggressive on N-grade hills"), do NOT patch in this session — that's a separate investigation.
+
+---
+
+## Self-review
+
+**Spec coverage:**
+- Architecture (CameraDiagnostics, RetailChaseCamera, dual-controller, DebugVM mirrors, DebugPanel section, GameWindow wiring) → all covered by Tasks 1-7.
+- Math (8 steps in the update loop, including velocity ring, basis, target pose, damping, view matrix, mouse low-pass, auto-fade) → covered by Tasks 2-3.
+- Continuous-key offset integration → covered by Task 5 (enum) + Task 7 (held-key wiring).
+- Tests (5 groups in spec) → covered by Tasks 1-4 with explicit test bodies.
+- Acceptance criteria → covered by Task 8 visual handoff.
+- Out of scope (in-head, look-down, map mode, camera-vs-wall, default-flip, legacy deletion) → not in any task. ✓ matches spec.
+- **Q1 (PlayerTranslucency application):** computation lands in Task 3; application is explicitly deferred to a follow-up issue. ✓ matches spec escape clause.
+- **Q2 (player velocity source):** confirmed `_playerController.BodyVelocity` exists at `PlayerMovementController.cs:155`; used in Task 7 Step 3.
+- **Q3 (IPanelRenderer.SliderFloat):** confirmed present (`IPanelRenderer.cs:100`) — Task 6 uses it directly.
+
+**Placeholder scan:**
+- Step 4 of Task 7 has a fallback note for `_runtimeClock.ElapsedSeconds` if it doesn't exist — concrete fallback options are given. Not a placeholder; an implementation pivot.
+- No TBD, TODO, or "implement later" markers.
+
+**Type consistency:**
+- `CameraDiagnostics.UseRetailChaseCamera` used in Tasks 1, 4, 5, 6, 7 — same name throughout.
+- `RetailChaseCamera.Distance` / `Pitch` / `YawOffset` / `PivotHeight` / `PlayerTranslucency` / `Aspect` / `Position` / `View` / `Projection` — all defined in Task 3, used consistently in Tasks 4 + 7.
+- `Update(playerPosition, playerYaw, playerVelocity, dt)` signature defined in Task 3, called with the same arg order in Task 7.
+- `EnterChaseMode(ChaseCamera, RetailChaseCamera)` signature defined in Task 4, called in Task 7.
+- `FilterMouseDelta(rawX, rawY, weight, nowSec) → (outX, outY)` defined in Task 3, called in Task 7.
+- Math helpers: `ComputeHeading`, `BuildBasis`, `PushVelocity`, `AverageVelocity`, `ComputeDampingAlpha`, `FilterMouseAxis`, `ComputeTranslucency` — same names in Tasks 2 and 3.
+
+Plan is internally consistent.
diff --git a/docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md b/docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md
new file mode 100644
index 00000000..53c35ef5
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-indoor-cell-rendering-phase1-diagnostics.md
@@ -0,0 +1,964 @@
+# Indoor Cell Rendering Fix — Phase 1 Diagnostics Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add five toggleable diagnostic probes that pinpoint where the EnvCell rendering chain breaks, so Phase 2's fix can target the actual failure point.
+
+**Architecture:** Single `RenderingDiagnostics` static class in `AcDream.Core.Rendering` exposes five bool flags + a master toggle (env-var-initialized, runtime-settable). DebugVM mirrors them as live-toggle properties; DebugPanel exposes them as checkboxes. Probe call sites in `WbMeshAdapter` and `WbDrawDispatcher` emit one structured `[indoor-*]` line per event when the corresponding flag is on. The Holtburg Inn floor-missing bug is the test case — log output identifies which of six hypotheses (H1–H6 in the spec) the failure matches.
+
+**Tech Stack:** C# .NET 10, xUnit (test framework), Silk.NET OpenGL (rendering), Chorizite.OpenGLSDLBackend (WB ObjectMeshManager).
+
+**Spec:** [`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`](../specs/2026-05-19-indoor-cell-rendering-fix-design.md)
+
+---
+
+## File Structure
+
+| File | Status | Responsibility |
+|---|---|---|
+| `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` | NEW | Static class with five `bool` properties + master toggle. Env-var read at startup; runtime-settable. |
+| `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs` | NEW | Verify default values and get/set behavior of the diagnostic flags. |
+| `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` | MODIFY | Add five mirror properties that forward to `RenderingDiagnostics`. |
+| `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs` | MODIFY | Add an "Indoor rendering" subsection in `DrawDiagnostics` with six checkboxes. |
+| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | MODIFY | Emit `[indoor-upload] requested` on first `IncrementRefCount` for an EnvCell id; emit `[indoor-upload] completed` in `Tick()` when WB's staged drain produces that id's `ObjectMeshData`. |
+| `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` | MODIFY | Emit `[indoor-walk]` + `[indoor-cull]` in `WalkVisibleEntities` per cell entity; emit `[indoor-lookup]` and `[indoor-xform]` in `DrawAccumulated` per cell-entity render-data lookup + composed transform. |
+
+---
+
+## Task 1: Create `RenderingDiagnostics` static class
+
+**Files:**
+- Create: `src/AcDream.Core/Rendering/RenderingDiagnostics.cs`
+
+- [ ] **Step 1: Write the file**
+
+The class mirrors `AcDream.Core.Physics.PhysicsDiagnostics` exactly — same env-var-init pattern, same get/set, same XML comments style. Five individual probe flags + one `IndoorAll` master. The master setter cascades to all five.
+
+```csharp
+using System;
+
+namespace AcDream.Core.Rendering;
+
+///
+/// 2026-05-19 — runtime-toggleable diagnostic flags for the indoor cell
+/// rendering pipeline. Initialized from env vars at process start;
+/// flippable at runtime via the DebugPanel mirror. Log call sites read
+/// these statics so a checkbox toggle takes effect on the next frame
+/// without relaunching.
+///
+///
+/// Mirrors the L.2a
+/// pattern. The master toggle is the user's
+/// common case — flipping it cascades to all five probe flags.
+///
+///
+///
+/// Spec: docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md.
+///
+///
+public static class RenderingDiagnostics
+{
+ ///
+ /// When true, WbDrawDispatcher.WalkVisibleEntities emits one
+ /// [indoor-walk] line per visible cell entity per second:
+ /// entity id, world position, parent cell id, landblock visible flag,
+ /// AABB-visible flag, "in visible cells" flag, drew flag.
+ /// Initial state from ACDREAM_PROBE_INDOOR_WALK=1.
+ ///
+ public static bool ProbeIndoorWalkEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_WALK") == "1"
+ || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
+
+ ///
+ /// When true, WbDrawDispatcher emits one [indoor-lookup]
+ /// line per visible cell entity per second: render-data hit/miss,
+ /// IsSetup flag, SetupParts count, parts-hit / parts-miss tallies.
+ /// Initial state from ACDREAM_PROBE_INDOOR_LOOKUP=1.
+ ///
+ public static bool ProbeIndoorLookupEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_LOOKUP") == "1"
+ || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
+
+ ///
+ /// When true, WbMeshAdapter emits two lines per EnvCell id:
+ /// [indoor-upload] requested on first IncrementRefCount and
+ /// [indoor-upload] completed when WB's staged drain produces
+ /// its ObjectMeshData. Missing "completed" lines indicate WB
+ /// silently returned null (hypothesis H1).
+ /// Initial state from ACDREAM_PROBE_INDOOR_UPLOAD=1.
+ ///
+ public static bool ProbeIndoorUploadEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_UPLOAD") == "1"
+ || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
+
+ ///
+ /// When true, WbDrawDispatcher emits one [indoor-xform]
+ /// line per visible cell entity per second: cell-geometry SetupPart's
+ /// composed world matrix translation. Disambiguates transform
+ /// double-apply (hypothesis H5).
+ /// Initial state from ACDREAM_PROBE_INDOOR_XFORM=1.
+ ///
+ public static bool ProbeIndoorXformEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_XFORM") == "1"
+ || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
+
+ ///
+ /// When true, WbDrawDispatcher.WalkVisibleEntities emits one
+ /// [indoor-cull] line per cell entity that gets culled, with
+ /// the reason (visibleCellIds-miss, frustum, landblock). Disambiguates
+ /// cull bugs (hypothesis H3).
+ /// Initial state from ACDREAM_PROBE_INDOOR_CULL=1.
+ ///
+ public static bool ProbeIndoorCullEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_CULL") == "1"
+ || Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_ALL") == "1";
+
+ ///
+ /// Master toggle. Reading reflects the AND of all five flags
+ /// (true only when every probe is on). Writing cascades — setting
+ /// to turns ALL five flags on; setting to
+ /// turns ALL five off.
+ ///
+ public static bool IndoorAll
+ {
+ get => ProbeIndoorWalkEnabled
+ && ProbeIndoorLookupEnabled
+ && ProbeIndoorUploadEnabled
+ && ProbeIndoorXformEnabled
+ && ProbeIndoorCullEnabled;
+ set
+ {
+ ProbeIndoorWalkEnabled = value;
+ ProbeIndoorLookupEnabled = value;
+ ProbeIndoorUploadEnabled = value;
+ ProbeIndoorXformEnabled = value;
+ ProbeIndoorCullEnabled = value;
+ }
+ }
+
+ ///
+ /// Helper for probe call sites. Returns when
+ /// the low 16 bits of are ≥ 0x0100 — the AC
+ /// convention for EnvCell (indoor) cells, as opposed to outdoor cells
+ /// in the 8×8 landblock grid (0x0001–0x0040).
+ ///
+ public static bool IsEnvCellId(ulong id) => (id & 0xFFFFu) >= 0x0100u;
+}
+```
+
+- [ ] **Step 2: Build**
+
+Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj -c Debug`
+Expected: 0 errors, 0 warnings.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/AcDream.Core/Rendering/RenderingDiagnostics.cs
+git commit -m "$(cat <<'EOF'
+feat(diagnostics): RenderingDiagnostics static class for indoor probes
+
+Five toggleable bool flags + master IndoorAll cascade, mirroring the
+L.2a PhysicsDiagnostics pattern. Env vars at startup, runtime-settable
+via DebugPanel mirrors (added next task). Probe call sites and DebugVM
+wiring follow in subsequent tasks.
+
+Spec: docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 2: Unit-test `RenderingDiagnostics`
+
+**Files:**
+- Create: `tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+```csharp
+using AcDream.Core.Rendering;
+using Xunit;
+
+namespace AcDream.Core.Tests.Rendering;
+
+public sealed class RenderingDiagnosticsTests
+{
+ [Fact]
+ public void IndoorAll_True_TurnsAllFlagsOn()
+ {
+ // Reset all flags off first to make the test deterministic
+ // regardless of env-var state on the test runner.
+ RenderingDiagnostics.ProbeIndoorWalkEnabled = false;
+ RenderingDiagnostics.ProbeIndoorLookupEnabled = false;
+ RenderingDiagnostics.ProbeIndoorUploadEnabled = false;
+ RenderingDiagnostics.ProbeIndoorXformEnabled = false;
+ RenderingDiagnostics.ProbeIndoorCullEnabled = false;
+
+ RenderingDiagnostics.IndoorAll = true;
+
+ Assert.True(RenderingDiagnostics.ProbeIndoorWalkEnabled);
+ Assert.True(RenderingDiagnostics.ProbeIndoorLookupEnabled);
+ Assert.True(RenderingDiagnostics.ProbeIndoorUploadEnabled);
+ Assert.True(RenderingDiagnostics.ProbeIndoorXformEnabled);
+ Assert.True(RenderingDiagnostics.ProbeIndoorCullEnabled);
+ Assert.True(RenderingDiagnostics.IndoorAll);
+ }
+
+ [Fact]
+ public void IndoorAll_False_TurnsAllFlagsOff()
+ {
+ RenderingDiagnostics.IndoorAll = true; // start from all-on
+ RenderingDiagnostics.IndoorAll = false;
+
+ Assert.False(RenderingDiagnostics.ProbeIndoorWalkEnabled);
+ Assert.False(RenderingDiagnostics.ProbeIndoorLookupEnabled);
+ Assert.False(RenderingDiagnostics.ProbeIndoorUploadEnabled);
+ Assert.False(RenderingDiagnostics.ProbeIndoorXformEnabled);
+ Assert.False(RenderingDiagnostics.ProbeIndoorCullEnabled);
+ Assert.False(RenderingDiagnostics.IndoorAll);
+ }
+
+ [Fact]
+ public void IndoorAll_OneOff_ReadsAsFalse()
+ {
+ RenderingDiagnostics.IndoorAll = true;
+ RenderingDiagnostics.ProbeIndoorCullEnabled = false; // flip one off
+ Assert.False(RenderingDiagnostics.IndoorAll);
+ }
+
+ [Theory]
+ [InlineData(0x00000029ul, false)] // outdoor cell 0x29 in 8x8 grid
+ [InlineData(0xA9B40029ul, false)] // outdoor cell with landblock prefix
+ [InlineData(0x00000100ul, true)] // indoor cell minimum
+ [InlineData(0x00000105ul, true)] // typical Holtburg Inn interior
+ [InlineData(0xA9B40105ul, true)] // indoor with landblock prefix
+ [InlineData(0xA9B401FFul, true)] // indoor near top of range
+ public void IsEnvCellId_DistinguishesOutdoorVsIndoorByLow16Bits(ulong id, bool expected)
+ {
+ Assert.Equal(expected, RenderingDiagnostics.IsEnvCellId(id));
+ }
+}
+```
+
+- [ ] **Step 2: Run tests — expect failure on first build**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~RenderingDiagnostics" -c Debug --nologo`
+
+Expected: Build green (Task 1 already implemented the class). All 7 tests pass (1 cascade-on + 1 cascade-off + 1 partial-off + 4 IsEnvCellId rows).
+
+If any test fails, the implementation in Task 1 has a bug — go back and fix.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/AcDream.Core.Tests/Rendering/RenderingDiagnosticsTests.cs
+git commit -m "$(cat <<'EOF'
+test(diagnostics): RenderingDiagnostics cascade + IsEnvCellId rows
+
+Covers the master IndoorAll cascade (both directions) and the IsEnvCellId
+helper's 0x0100 boundary check across outdoor cells, indoor cells, and
+landblock-prefixed forms.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 3: Mirror `RenderingDiagnostics` into `DebugVM`
+
+**Files:**
+- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs`
+
+- [ ] **Step 1: Read DebugVM and find the existing `ProbeBuilding` mirror block**
+
+Find the `ProbeBuilding` property (around line 270) — that's an existing live-mirror to `PhysicsDiagnostics.ProbeBuildingEnabled`. New mirrors go immediately AFTER `ProbeAutoWalk` (next property in the file), in a new clearly-commented block.
+
+- [ ] **Step 2: Add `using AcDream.Core.Rendering;` at the top of `DebugVM.cs`**
+
+If the using statement is already present, skip. Otherwise insert alphabetically after `using AcDream.Core.Physics;`.
+
+- [ ] **Step 3: Append the five mirror properties to the file**
+
+Find the closing brace of the last existing property block (after `ProbeAutoWalk` or the last `Probe*` property). Insert this block before the class's closing brace:
+
+```csharp
+ // ── Indoor rendering diagnostics (2026-05-19) ───────────────────
+ // Mirror RenderingDiagnostics statics so DebugPanel checkbox toggles
+ // take effect on the next render frame without relaunching.
+
+ ///
+ /// Runtime mirror of RenderingDiagnostics.ProbeIndoorWalkEnabled
+ /// (env var ACDREAM_PROBE_INDOOR_WALK).
+ ///
+ public bool ProbeIndoorWalk
+ {
+ get => RenderingDiagnostics.ProbeIndoorWalkEnabled;
+ set => RenderingDiagnostics.ProbeIndoorWalkEnabled = value;
+ }
+
+ ///
+ /// Runtime mirror of RenderingDiagnostics.ProbeIndoorLookupEnabled
+ /// (env var ACDREAM_PROBE_INDOOR_LOOKUP).
+ ///
+ public bool ProbeIndoorLookup
+ {
+ get => RenderingDiagnostics.ProbeIndoorLookupEnabled;
+ set => RenderingDiagnostics.ProbeIndoorLookupEnabled = value;
+ }
+
+ ///
+ /// Runtime mirror of RenderingDiagnostics.ProbeIndoorUploadEnabled
+ /// (env var ACDREAM_PROBE_INDOOR_UPLOAD).
+ ///
+ public bool ProbeIndoorUpload
+ {
+ get => RenderingDiagnostics.ProbeIndoorUploadEnabled;
+ set => RenderingDiagnostics.ProbeIndoorUploadEnabled = value;
+ }
+
+ ///
+ /// Runtime mirror of RenderingDiagnostics.ProbeIndoorXformEnabled
+ /// (env var ACDREAM_PROBE_INDOOR_XFORM).
+ ///
+ public bool ProbeIndoorXform
+ {
+ get => RenderingDiagnostics.ProbeIndoorXformEnabled;
+ set => RenderingDiagnostics.ProbeIndoorXformEnabled = value;
+ }
+
+ ///
+ /// Runtime mirror of RenderingDiagnostics.ProbeIndoorCullEnabled
+ /// (env var ACDREAM_PROBE_INDOOR_CULL).
+ ///
+ public bool ProbeIndoorCull
+ {
+ get => RenderingDiagnostics.ProbeIndoorCullEnabled;
+ set => RenderingDiagnostics.ProbeIndoorCullEnabled = value;
+ }
+
+ ///
+ /// Runtime mirror of RenderingDiagnostics.IndoorAll — toggles all
+ /// five indoor probes together.
+ ///
+ public bool ProbeIndoorAll
+ {
+ get => RenderingDiagnostics.IndoorAll;
+ set => RenderingDiagnostics.IndoorAll = value;
+ }
+```
+
+- [ ] **Step 4: Build**
+
+Run: `dotnet build src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj -c Debug`
+Expected: 0 errors. The `using AcDream.Core.Rendering;` resolves; new properties compile.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
+git commit -m "$(cat <<'EOF'
+feat(debugvm): mirror RenderingDiagnostics indoor probes
+
+Live-toggle wrappers for the five indoor-rendering probe flags plus the
+ProbeIndoorAll master cascade. Pattern matches existing ProbeResolve /
+ProbeCell / ProbeBuilding / ProbeAutoWalk mirrors so a checkbox flip in
+the DebugPanel takes effect on the next frame.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 4: Expose probes in `DebugPanel` Diagnostics group
+
+**Files:**
+- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs`
+
+- [ ] **Step 1: Find `DrawDiagnostics(IPanelRenderer r)` method**
+
+Open the file. Find the method at approximately line 226. The existing pattern reads probe values into locals at the top of the method, then conditionally re-assigns through checkboxes. The new indoor probes follow the same shape, appended after the last existing probe checkbox.
+
+- [ ] **Step 2: Read the locals + checkboxes at the bottom of the existing block**
+
+Find the line that says `if (r.Checkbox("Probe auto-walk (ACDREAM_PROBE_AUTOWALK)", ref probeAutoWalk)) _vm.ProbeAutoWalk = probeAutoWalk;` or similar last existing probe checkbox in `DrawDiagnostics`. New checkboxes go immediately AFTER this line, before the method's closing brace.
+
+- [ ] **Step 3: Insert the new checkboxes**
+
+Before the closing brace of `DrawDiagnostics`, insert:
+
+```csharp
+
+ // ── Indoor rendering diagnostics (2026-05-19) ───────────────
+ // Pinpoint where the EnvCell rendering chain breaks for
+ // hypothesis-driven Phase 2 fix. Spec:
+ // docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md
+ r.Separator();
+ r.Text("Indoor rendering (envCell):");
+
+ bool probeIndoorAll = _vm.ProbeIndoorAll;
+ bool probeIndoorWalk = _vm.ProbeIndoorWalk;
+ bool probeIndoorLookup = _vm.ProbeIndoorLookup;
+ bool probeIndoorUpload = _vm.ProbeIndoorUpload;
+ bool probeIndoorXform = _vm.ProbeIndoorXform;
+ bool probeIndoorCull = _vm.ProbeIndoorCull;
+
+ if (r.Checkbox("Indoor: ALL (ACDREAM_PROBE_INDOOR_ALL)", ref probeIndoorAll)) _vm.ProbeIndoorAll = probeIndoorAll;
+ if (r.Checkbox("Indoor: walk (ACDREAM_PROBE_INDOOR_WALK)", ref probeIndoorWalk)) _vm.ProbeIndoorWalk = probeIndoorWalk;
+ if (r.Checkbox("Indoor: lookup (ACDREAM_PROBE_INDOOR_LOOKUP)", ref probeIndoorLookup)) _vm.ProbeIndoorLookup = probeIndoorLookup;
+ if (r.Checkbox("Indoor: upload (ACDREAM_PROBE_INDOOR_UPLOAD)", ref probeIndoorUpload)) _vm.ProbeIndoorUpload = probeIndoorUpload;
+ if (r.Checkbox("Indoor: xform (ACDREAM_PROBE_INDOOR_XFORM)", ref probeIndoorXform)) _vm.ProbeIndoorXform = probeIndoorXform;
+ if (r.Checkbox("Indoor: cull (ACDREAM_PROBE_INDOOR_CULL)", ref probeIndoorCull)) _vm.ProbeIndoorCull = probeIndoorCull;
+```
+
+Note: `r.Separator()` and `r.Text(string)` are the existing `IPanelRenderer` API methods used elsewhere in the file. If they don't exist, drop those two lines (the checkboxes still work standalone).
+
+- [ ] **Step 4: Build**
+
+Run: `dotnet build src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj -c Debug`
+Expected: 0 errors.
+
+If `r.Separator()` / `r.Text()` aren't on `IPanelRenderer`, the build will fail. Remove those two lines and re-build.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
+git commit -m "$(cat <<'EOF'
+feat(debugpanel): "Indoor rendering" probe checkboxes
+
+Six checkboxes (ALL master + five individual probes) in the existing
+DrawDiagnostics block. Toggling flips the corresponding
+RenderingDiagnostics.Probe* flag live via DebugVM forwarding.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 5: Instrument `WbMeshAdapter` with `[indoor-upload]` probes
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`
+
+The upload probe has TWO emission points:
+1. `IncrementRefCount` — emits `requested` on the first call for an EnvCell id (gated by the existing `_metadataPopulated.Add(id)` first-call check).
+2. `Tick()` — emits `completed` when WB's `StagedMeshData` drain produces an `ObjectMeshData` whose `ObjectId` is in our pending-EnvCell set.
+
+- [ ] **Step 1: Add the pending-EnvCell tracking field + `using` statement**
+
+Open `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`. Add `using AcDream.Core.Rendering;` near the top with the other `using` statements (after `using AcDream.Core.Meshing;`).
+
+Find the field declarations near the top of the class (around line 34 — `_metadataPopulated`). Add immediately after:
+
+```csharp
+ ///
+ /// EnvCell ids we've requested via PrepareMeshDataAsync but not yet
+ /// seen completion for in Tick(). Used by the [indoor-upload] probe
+ /// to log requested + completed pairs. Cleared per completion;
+ /// missing completions after a few seconds indicate WB silently
+ /// returned null (hypothesis H1 in the design spec).
+ ///
+ private readonly HashSet _pendingEnvCellRequests = new();
+```
+
+- [ ] **Step 2: Emit `[indoor-upload] requested` in `IncrementRefCount`**
+
+Find the `IncrementRefCount(ulong id)` method (around line 116). Inside the `if (_metadataPopulated.Add(id))` block, immediately AFTER the `_ = _meshManager.PrepareMeshDataAsync(id, isSetup: false);` line, add:
+
+```csharp
+ // [indoor-upload] requested probe — only for EnvCell ids.
+ if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
+ {
+ _pendingEnvCellRequests.Add(id);
+ Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}");
+ }
+```
+
+- [ ] **Step 3: Emit `[indoor-upload] completed` in `Tick`**
+
+Find the `Tick()` method (around line 167). Replace the existing drain loop:
+
+```csharp
+ while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
+ {
+ _meshManager.UploadMeshData(meshData);
+ }
+```
+
+with:
+
+```csharp
+ while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
+ {
+ // [indoor-upload] completed probe — check BEFORE upload so we
+ // see what WB actually produced (vertex counts, parts) before
+ // any post-upload mutation.
+ bool isPendingEnvCell = RenderingDiagnostics.ProbeIndoorUploadEnabled
+ && _pendingEnvCellRequests.Remove(meshData.ObjectId);
+
+ var renderData = _meshManager.UploadMeshData(meshData);
+
+ if (isPendingEnvCell)
+ {
+ int parts = meshData.SetupParts?.Count ?? 0;
+ bool hasGeom = meshData.EnvCellGeometry is not null;
+ int cellGeomVerts = meshData.EnvCellGeometry?.Vertices?.Length ?? 0;
+ bool uploadOk = renderData is not null;
+ Console.WriteLine(
+ $"[indoor-upload] completed cellId=0x{meshData.ObjectId:X8} " +
+ $"isSetup={meshData.IsSetup} parts={parts} " +
+ $"hasEnvCellGeom={hasGeom} cellGeomVerts={cellGeomVerts} " +
+ $"uploadOk={uploadOk}");
+ }
+ }
+```
+
+- [ ] **Step 4: Build**
+
+Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
+Expected: 0 errors.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
+git commit -m "$(cat <<'EOF'
+feat(wb): [indoor-upload] probe for EnvCell mesh requests + completions
+
+Instruments WbMeshAdapter at two sites:
+- IncrementRefCount: on first call for an EnvCell id (low 16 bits ≥
+ 0x0100), tag the id in _pendingEnvCellRequests and log
+ [indoor-upload] requested.
+- Tick: when WB's StagedMeshData drains an ObjectMeshData whose
+ ObjectId matches a pending EnvCell, log [indoor-upload] completed
+ with parts count, EnvCellGeometry vertex count, and upload result.
+
+Missing "completed" lines after "requested" identify hypothesis H1
+(WB silently returns null from PrepareEnvCellMeshData).
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 6: Instrument `WbDrawDispatcher` walk + cull probes
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+
+The `WalkVisibleEntities` method (around line 280) does landblock visibility, per-entity AABB cull, and the `visibleCellIds` filter. Cell entities (entities whose `MeshRefs[0].GfxObjId` low-16-bits ≥ 0x0100) need probes at three decision sites: passed-all, culled-by-aabb, culled-by-visibleCellIds.
+
+To rate-limit, maintain a per-cellId last-log frame counter as a class-level field.
+
+- [ ] **Step 1: Add the rate-limit tracking field + `using` statement**
+
+Add `using AcDream.Core.Rendering;` near the top with the other `using` statements (after `using AcDream.Core.Meshing;`).
+
+Find the class field declarations. Add:
+
+```csharp
+ ///
+ /// Per-cell-entity last-log frame number for rate-limiting the
+ /// [indoor-walk] / [indoor-lookup] / [indoor-xform] / [indoor-cull]
+ /// probes. Defaults to 30 frames at 30Hz = 1 sec.
+ ///
+ private readonly Dictionary _lastIndoorProbeFrame = new();
+ private int _indoorProbeFrameCounter;
+ private const int IndoorProbeRateLimitFrames = 30;
+
+ ///
+ /// Returns true at most once per
+ /// frames per cellId. Caller must already have checked that an indoor
+ /// probe flag is enabled.
+ ///
+ private bool ShouldEmitIndoorProbe(ulong cellId)
+ {
+ if (!_lastIndoorProbeFrame.TryGetValue(cellId, out int last)
+ || _indoorProbeFrameCounter - last >= IndoorProbeRateLimitFrames)
+ {
+ _lastIndoorProbeFrame[cellId] = _indoorProbeFrameCounter;
+ return true;
+ }
+ return false;
+ }
+```
+
+- [ ] **Step 2: Bump the frame counter at the top of `Draw(...)`**
+
+Find the `Draw` method (around line 339). At its very top, after the existing `_shader.Use();` line, add:
+
+```csharp
+ _indoorProbeFrameCounter++;
+```
+
+- [ ] **Step 3: Replace the per-entity filter block in `WalkVisibleEntities`**
+
+Find the per-entity loop in `WalkVisibleEntities` (around lines 313-335). The current shape (simplified):
+
+```csharp
+ foreach (var entity in entry.Entities)
+ {
+ if (entity.MeshRefs.Count == 0) continue;
+
+ if (entity.ParentCellId.HasValue && visibleCellIds is not null
+ && !visibleCellIds.Contains(entity.ParentCellId.Value))
+ continue;
+
+ bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
+ if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
+ {
+ if (entity.AabbDirty) entity.RefreshAabb();
+ if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax))
+ continue;
+ }
+
+ result.EntitiesWalked++;
+ for (int i = 0; i < entity.MeshRefs.Count; i++)
+ scratch.Add((entity, i, entry.LandblockId));
+ }
+```
+
+Replace the entire `foreach (var entity in entry.Entities)` body with this instrumented version:
+
+```csharp
+ foreach (var entity in entry.Entities)
+ {
+ if (entity.MeshRefs.Count == 0) continue;
+
+ // Detect cell entity for indoor probes — first MeshRef.GfxObjId
+ // is an EnvCell id (low 16 bits ≥ 0x0100). Cheap to compute;
+ // result reused for all four probe checks below.
+ ulong cellProbeId = (ulong)entity.MeshRefs[0].GfxObjId;
+ bool isCellEntity = RenderingDiagnostics.IsEnvCellId(cellProbeId);
+
+ bool cellInVis = !(entity.ParentCellId.HasValue
+ && visibleCellIds is not null
+ && !visibleCellIds.Contains(entity.ParentCellId.Value));
+ if (!cellInVis)
+ {
+ if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
+ && ShouldEmitIndoorProbe(cellProbeId))
+ {
+ Console.WriteLine(
+ $"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
+ $"reason=visibleCellIds-miss " +
+ $"parentCell=0x{entity.ParentCellId!.Value:X8}");
+ }
+ continue;
+ }
+
+ bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
+ bool aabbVisible = true;
+ if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
+ {
+ if (entity.AabbDirty) entity.RefreshAabb();
+ aabbVisible = FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax);
+ }
+
+ if (!aabbVisible)
+ {
+ if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
+ && ShouldEmitIndoorProbe(cellProbeId))
+ {
+ Console.WriteLine(
+ $"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
+ $"reason=frustum " +
+ $"aabbMin=({entity.AabbMin.X:F1},{entity.AabbMin.Y:F1},{entity.AabbMin.Z:F1}) " +
+ $"aabbMax=({entity.AabbMax.X:F1},{entity.AabbMax.Y:F1},{entity.AabbMax.Z:F1})");
+ }
+ continue;
+ }
+
+ // Passed all filters — emit walk probe.
+ if (isCellEntity && RenderingDiagnostics.ProbeIndoorWalkEnabled
+ && ShouldEmitIndoorProbe(cellProbeId))
+ {
+ Console.WriteLine(
+ $"[indoor-walk] cellEnt=0x{entity.Id:X8} " +
+ $"pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) " +
+ $"parentCell=0x{(entity.ParentCellId ?? 0u):X8} " +
+ $"meshRef0=0x{cellProbeId:X8} " +
+ $"meshRefCount={entity.MeshRefs.Count} " +
+ $"landblockVisible=true aabbVisible=true cellInVis=true");
+ }
+
+ result.EntitiesWalked++;
+ for (int i = 0; i < entity.MeshRefs.Count; i++)
+ scratch.Add((entity, i, entry.LandblockId));
+ }
+```
+
+Important: `ShouldEmitIndoorProbe(cellProbeId)` is intentionally called only once per probe-decision-site per cellId, so each cellId emits at most ONE line per frame across all four probe sites (whichever fires first).
+
+- [ ] **Step 4: Build**
+
+Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
+Expected: 0 errors. The `using AcDream.Core.Rendering;` resolves; the new field + helper compile; the instrumented loop builds cleanly.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+git commit -m "$(cat <<'EOF'
+feat(dispatcher): [indoor-walk] + [indoor-cull] probes
+
+Instruments WalkVisibleEntities to identify whether cell entities (first
+MeshRef.GfxObjId low-16-bits ≥ 0x0100) pass all visibility filters or
+get culled. Three emission paths:
+
+- [indoor-cull] reason=visibleCellIds-miss — when the ParentCellId
+ filter rejects the entity.
+- [indoor-cull] reason=frustum — when AABB frustum cull rejects.
+- [indoor-walk] — when the entity passes all filters and reaches the
+ draw list.
+
+Rate-limited to once per cellId per ~1 sec (30 frames at 30 Hz) via
+_lastIndoorProbeFrame dictionary. Bumped from Draw()'s top.
+
+Disambiguates hypothesis H3 (cull bug — cell entity dropped before
+draw).
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 7: Instrument `WbDrawDispatcher` lookup + xform probes
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs`
+
+These probes fire deeper in the per-MeshRef draw loop, where the render-data lookup happens and the `IsSetup` branch composes per-part transforms. The dispatcher's per-MeshRef body is around line 590-627.
+
+- [ ] **Step 1: Find the per-MeshRef body and the IsSetup branch**
+
+Open the file. Find the line `var renderData = _meshAdapter.TryGetRenderData(gfxObjId);` (or similar TryGetRenderData lookup inside the per-MeshRef draw loop). The relevant block is the if/else at line 607 (the `IsSetup` branch).
+
+- [ ] **Step 2: Add the `[indoor-lookup]` probe at the lookup site**
+
+Find the line that fetches the renderData (likely `var renderData = _meshAdapter.TryGetRenderData(gfxObjId);` or equivalent). Immediately AFTER that lookup and BEFORE the existing null/miss handling at line 595 (`if (diag) _meshesMissing++; continue;`), insert:
+
+```csharp
+ // [indoor-lookup] probe — emit once per cell entity per sec.
+ ulong _lookupCellId = (ulong)gfxObjId;
+ if (RenderingDiagnostics.IsEnvCellId(_lookupCellId)
+ && RenderingDiagnostics.ProbeIndoorLookupEnabled
+ && ShouldEmitIndoorProbe(_lookupCellId))
+ {
+ bool hit = renderData is not null;
+ bool isSetup = hit && renderData!.IsSetup;
+ int partCount = isSetup ? renderData!.SetupParts.Count : 0;
+
+ int partsHit = 0, partsMiss = 0;
+ if (isSetup)
+ {
+ foreach (var (partId, _) in renderData!.SetupParts)
+ {
+ if (_meshAdapter.TryGetRenderData(partId) is not null) partsHit++;
+ else partsMiss++;
+ }
+ }
+
+ bool hasEnvCellGeom = isSetup
+ && renderData!.SetupParts.Exists(t => (t.GfxObjId & 0x1_0000_0000UL) != 0);
+
+ Console.WriteLine(
+ $"[indoor-lookup] cellId=0x{_lookupCellId:X8} " +
+ $"hit={hit} isSetup={isSetup} partCount={partCount} " +
+ $"hasEnvCellGeom={hasEnvCellGeom} partsHit={partsHit} partsMiss={partsMiss}");
+ }
+```
+
+Note: this probe emits BEFORE the null-renderData early-`continue`, so a null lookup still emits `hit=false`. That's intentional — it tells us if the lookup itself failed (hypothesis H1 fallout).
+
+- [ ] **Step 3: Add the `[indoor-xform]` probe inside the IsSetup branch**
+
+Find the `if (renderData.IsSetup && renderData.SetupParts.Count > 0)` block (line 607 in current code). Inside the `foreach (var (partGfxObjId, partTransform) in renderData.SetupParts)` loop, AFTER the `var model = ComposePartWorldMatrix(...)` line, insert:
+
+```csharp
+ // [indoor-xform] probe — only for the cell's synthetic
+ // geometry part (bit 32 set, per WB's PrepareEnvCellMeshData
+ // line 1247). One line per cell per sec.
+ if ((partGfxObjId & 0x1_0000_0000UL) != 0
+ && RenderingDiagnostics.ProbeIndoorXformEnabled
+ && ShouldEmitIndoorProbe(partGfxObjId))
+ {
+ Console.WriteLine(
+ $"[indoor-xform] cellGeomId=0x{partGfxObjId:X16} " +
+ $"entityWorldT=({entityWorld.Translation.X:F2},{entityWorld.Translation.Y:F2},{entityWorld.Translation.Z:F2}) " +
+ $"meshRefT=({meshRef.PartTransform.Translation.X:F2},{meshRef.PartTransform.Translation.Y:F2},{meshRef.PartTransform.Translation.Z:F2}) " +
+ $"partT=({partTransform.Translation.X:F2},{partTransform.Translation.Y:F2},{partTransform.Translation.Z:F2}) " +
+ $"composedT=({model.Translation.X:F2},{model.Translation.Y:F2},{model.Translation.Z:F2})");
+ }
+```
+
+- [ ] **Step 4: Build**
+
+Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
+Expected: 0 errors.
+
+- [ ] **Step 5: Test (existing tests, sanity)**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj -c Debug --filter "FullyQualifiedName~Rendering" --no-build --nologo`
+Expected: All Rendering tests (including new RenderingDiagnosticsTests) pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+git commit -m "$(cat <<'EOF'
+feat(dispatcher): [indoor-lookup] + [indoor-xform] probes
+
+Instruments the per-MeshRef draw loop in WbDrawDispatcher:
+
+- [indoor-lookup]: per cell entity, dumps render-data hit/miss,
+ IsSetup, parts count, and a partsHit/partsMiss tally over the
+ SetupParts. Disambiguates hypothesis H2 (WB produces empty
+ ObjectRenderData with zero parts) and H6 (dispatcher fails to
+ traverse Setup).
+
+- [indoor-xform]: only fires for the cell's synthetic geometry part
+ (the SetupPart whose GfxObjId has bit 32 set, per WB's
+ PrepareEnvCellMeshData cellGeomId convention). Logs the three
+ composed transform translations: entityWorld, meshRef.PartTransform,
+ partTransform, and the final composed matrix translation. Disambiguates
+ hypothesis H5 (transform double-apply — composedT lands at 2 ×
+ cellOrigin).
+
+Rate-limited via existing _lastIndoorProbeFrame map.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 8: Build + visual capture procedure
+
+**Files:** none modified. Build verification + runtime data capture.
+
+- [ ] **Step 1: Full solution build**
+
+Run: `dotnet build AcDream.slnx -c Debug --nologo 2>&1 | tail -10`
+Expected: 0 errors, 0 warnings. All projects compile.
+
+- [ ] **Step 2: Run full test suite**
+
+Run: `dotnet test AcDream.slnx -c Debug --nologo --no-build 2>&1 | tail -15`
+Expected: New RenderingDiagnostics tests pass. Pre-existing failures in `DispatcherToMovementIntegrationTests`, `BSPStepUpTests`, and `MotionInterpreterTests` (8 total) remain — those are unrelated to this work. No NEW failures.
+
+- [ ] **Step 3: Gracefully close any prior AcDream.App instance**
+
+```powershell
+$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
+if ($proc) {
+ $proc | ForEach-Object { $_.CloseMainWindow() | Out-Null }
+ $proc | ForEach-Object { if (-not $_.WaitForExit(5000)) { Stop-Process -Id $_.Id -Force } }
+ Start-Sleep -Seconds 3
+}
+```
+
+- [ ] **Step 4: Launch with all indoor probes enabled**
+
+```powershell
+$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
+$env:ACDREAM_LIVE = "1"
+$env:ACDREAM_TEST_HOST = "127.0.0.1"
+$env:ACDREAM_TEST_PORT = "9000"
+$env:ACDREAM_TEST_USER = "testaccount"
+$env:ACDREAM_TEST_PASS = "testpassword"
+$env:ACDREAM_DEVTOOLS = "1"
+$env:ACDREAM_PROBE_INDOOR_ALL = "1"
+$logPath = "launch.log"
+Remove-Item $logPath -ErrorAction SilentlyContinue
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath $logPath
+```
+
+Run this in the background (the launching tool supports `run_in_background: true`).
+
+- [ ] **Step 5: User reproduces the bug**
+
+In the running client:
+- Wait until in-world at Holtburg (8-12 s after launch).
+- Walk to Holtburg Inn (north of spawn — Fispur's Foodstuffs is visible).
+- Stand at the doorway. Then step inside. Look at the floor.
+- Walk around the inn interior.
+- Close the client window (graceful close — close button, NOT taskkill).
+
+- [ ] **Step 6: Grep the log for probe output**
+
+```bash
+grep -E "\[indoor-" launch.log | head -100
+```
+
+Expected: a mix of `[indoor-upload] requested`, `[indoor-upload] completed`, `[indoor-walk]`, `[indoor-lookup]`, `[indoor-xform]`, `[indoor-cull]` lines for the Holtburg Inn cell IDs (0xA9B40100-ish range).
+
+- [ ] **Step 7: Identify which hypothesis matches**
+
+Compare the captured log against the hypothesis table in the spec (§3 of `2026-05-19-indoor-cell-rendering-fix-design.md`):
+
+| Hypothesis | Probe pattern in log |
+|---|---|
+| H1 — WB silently returns null | `[indoor-upload] requested` lines exist but NO matching `completed` lines for cell ids |
+| H2 — Empty batches | `[indoor-upload] completed ... cellGeomVerts=0` |
+| H3 — Cull bug | `[indoor-cull]` lines for cell entity ids with `reason=visibleCellIds-miss` |
+| H4 — Double-spawn | `[indoor-lookup] partCount=N` where N includes static object IDs that ALSO appear in the entity walk — cross-check against `[indoor-walk]` lines |
+| H5 — Transform double-apply | `[indoor-xform] composedT` translation roughly 2× the cell's known world origin |
+| H6 — MeshRefs structure | `[indoor-lookup] hit=true isSetup=true partCount>0 partsHit=0` (all parts missing) |
+
+- [ ] **Step 8: Document the captured data + matched hypothesis**
+
+Create a short investigation note at `docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md` summarizing:
+- The exact `[indoor-*]` log lines captured (or a representative subset).
+- The matched hypothesis number.
+- A one-line proposed fix sketch.
+
+This file will be referenced by Phase 2's spec.
+
+- [ ] **Step 9: Commit the capture note**
+
+```bash
+git add docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md
+git commit -m "$(cat <<'EOF'
+docs(research): Phase 1 indoor probe capture — identifies hypothesis HX
+
+[Replace HX with the matched hypothesis number, and summarize the
+captured log evidence in 1-2 sentences.]
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+- [ ] **Step 10: Hand off to Phase 2 design**
+
+The captured data is now the input to Phase 2's design. Either:
+- Amend `docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md` with a Phase 2 section, OR
+- Write a new spec `docs/superpowers/specs/YYYY-MM-DD-indoor-cell-rendering-phase2-fix-design.md` targeting the identified hypothesis.
+
+The plan for Phase 2 follows the standard brainstorming → writing-plans → executing-plans flow.
+
+---
+
+## Acceptance Criteria
+
+- [x] All eight tasks complete + committed.
+- [ ] `dotnet build` clean. `dotnet test` clean (no new failures; pre-existing 8 physics/input failures unchanged).
+- [ ] Probe captured at Holtburg Inn produces enough log evidence to identify which of H1-H6 is the root cause.
+- [ ] Capture note written and committed.
+- [ ] Phase 2 design follow-up spec started.
diff --git a/docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md b/docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md
new file mode 100644
index 00000000..6b5d4467
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md
@@ -0,0 +1,1846 @@
+# Indoor Portal-Based Cell Tracking Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Replace Phase D's AABB-based indoor cell tracking with retail-faithful portal-graph traversal, so walls block consistently inside buildings and CellId updates correctly when crossing doors (closes ISSUES.md #87 + remaining wall-collision parts of #84 + #85).
+
+**Architecture:** A new pure-static `CellTransit` class in `AcDream.Core.Physics` ports retail's four functions: `find_cell_list` (top-level driver), `find_transit_cells` sphere variant (indoor portal walk), `check_building_transit` (outdoor→indoor entry), and `add_all_outside_cells` (outdoor neighbour expansion). Cell containment uses the already-ported `BSPQuery.PointInsideCellBsp` against each cell's `CellBSP` (a third BSP tree per cell, separate from `PhysicsBSP` and `DrawingBSP`). The existing `PhysicsEngine.ResolveOutdoorCellId` is renamed to `ResolveCellId`, its body rewritten to call `CellTransit.FindCellList`, and Phase D's `TryFindContainingCell` + AABB fields are deleted entirely.
+
+**Tech Stack:** C# / .NET 10, xUnit, `DatReaderWriter.DBObjs.EnvCell` + `DatReaderWriter.DBObjs.LandBlockInfo` for portal data sources, existing `BSPQuery.PointInsideCellBsp` for point-in-cell tests.
+
+**Spec:** [`docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md`](../specs/2026-05-19-indoor-portal-cell-tracking-design.md)
+
+**Research:** [`docs/research/acclient_indoor_transitions_pseudocode.md`](../../research/acclient_indoor_transitions_pseudocode.md) — the full algorithm in pseudocode, cross-referenced ACE + retail decomp.
+
+---
+
+## File Structure
+
+| File | Action | Responsibility |
+|---|---|---|
+| `src/AcDream.Core/Physics/PortalInfo.cs` | create | `readonly struct PortalInfo(ushort OtherCellId, ushort PolygonId, ushort Flags)` with `PortalSide` property (`(Flags & 2) == 0`). |
+| `src/AcDream.Core/Physics/BuildingPhysics.cs` | create | `sealed class BuildingPhysics` holding `WorldTransform`, `InverseWorldTransform`, and an `IReadOnlyList` for outdoor→indoor entry. Plus `readonly struct BldPortalInfo(uint OtherCellId, ushort OtherPortalId, ushort Flags, bool ExactMatch)`. |
+| `src/AcDream.Core/Physics/CellTransit.cs` | create | Static class with the four retail-ported functions: `FindCellList`, `FindTransitCellsSphere`, `CheckBuildingTransit`, `AddAllOutsideCells`. |
+| `src/AcDream.Core/Physics/PhysicsDataCache.cs` | modify | Extend `CellPhysics` with `CellBSP`, `Portals`, `PortalPolygons`, `VisibleCellIds`. Delete `LocalAabbMin/Max`. Change `CacheCellStruct` signature to accept `EnvCell envCell`. Delete `TryFindContainingCell`. Add `CacheBuilding(uint landcellId, IReadOnlyList, Matrix4x4)` and `GetBuilding(uint)`. |
+| `src/AcDream.Core/Physics/PhysicsEngine.cs` | modify | Rename `ResolveOutdoorCellId` → `ResolveCellId`. Add `sphereRadius` parameter. Body becomes `CellTransit.FindCellList` call. Update 2 internal callers. |
+| `src/AcDream.Core/Physics/TransitionTypes.cs` | modify | Update call at line 1181 to pass sphere radius. |
+| `src/AcDream.App/Rendering/GameWindow.cs` | modify | Update `CacheCellStruct` call at line 5384 to pass the `EnvCell`. Add `CacheBuilding` call inside the `lbInfo.Buildings` loop. |
+| `tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs` | create | `PortalSide` flag-decoding tests. |
+| `tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs` | create | Verify `CacheCellStruct` populates `CellBSP`, `Portals`, `PortalPolygons`, `VisibleCellIds`. |
+| `tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs` | create | Indoor portal-graph walk: sphere overlaps portal → adds neighbour; far → doesn't add; wrong side → doesn't add; exit portal → marks checkOutside. |
+| `tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs` | create | Outdoor sphere overlapping building portal → adds indoor cell. |
+| `tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs` | create | Sphere at 24m landcell boundary edges → correct neighbour set. |
+| `tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs` | create | Integration: indoor → matching indoor cell; outdoor → matching landcell; outdoor near building → entering indoor cell; indoor → outdoor through exit portal. |
+| `tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs` | rename + rewrite | Rename file to `ResolveCellIdTests.cs`; the 4 Phase D AABB tests are ported to use synthetic portal+CellBSP fixtures. |
+
+---
+
+## Plan-Time Reference Facts
+
+These are facts an implementer needs and should NOT have to rediscover.
+
+**Fact 1.** `BSPQuery.PointInsideCellBsp(node, point)` lives at [src/AcDream.Core/Physics/BSPQuery.cs:940](../../src/AcDream.Core/Physics/BSPQuery.cs:940). Signature: `public static bool PointInsideCellBsp(PhysicsBSPNode? node, Vector3 point)`. Returns `true` for null node (treats it as fully solid — must NOT be reached by `FindCellList`; callers must guard `cellPhysics.CellBSP?.Root == null` themselves).
+
+**Fact 2.** The existing `CellPhysics` is a `sealed class` (not record) with `required` + `init`-only properties. New fields can be added as non-required with defaults. Source: PhysicsDataCache.cs:422.
+
+**Fact 3.** `envCell.CellPortals` (from `DatReaderWriter.DBObjs.EnvCell`) is an iterable where each portal has `.OtherCellId` (ushort low-16), `.PolygonId` (ushort), and `.Flags` (some integer type — cast to ushort). Confirmed by GameWindow.cs:5506-5511. The portal's `PolygonId` indexes `cellStruct.Polygons` (visible polys), NOT `cellStruct.PhysicsPolygons`. Confirmed by GameWindow.cs:5685-5689 comment.
+
+**Fact 4.** `cellStruct.CellBSP` field name verification is **Task 0** (first task — must complete before any code changes). The DAT format definitely has this BSP per the retail header and pseudocode doc, but DatReaderWriter's exact C# property name needs confirming. If it's missing from the C# binding, escalate to controller.
+
+**Fact 5.** `LandBlockInfo.Buildings` (from `DatReaderWriter.DBObjs.LandBlockInfo`) carries the building portal data — each Building has a portal list. The exact C# property names need confirming in Task 0 as well.
+
+**Fact 6.** Existing `ResolvePolygons(polys, vertexArray)` helper at PhysicsDataCache.cs lines 231-275 is reusable for both `PhysicsPolygons` and `Polygons` (visible). The function is private static; either widen visibility (recommended) OR copy/paste the logic into a public wrapper.
+
+**Fact 7.** The 3 `ResolveOutdoorCellId` call sites are:
+- `PhysicsEngine.cs:254` (definition itself)
+- `PhysicsEngine.cs:755` (inside `ResolveWithTransition`)
+- `PhysicsEngine.cs:773` (inside `ResolveWithTransition` fallback path)
+- `TransitionTypes.cs:1181` (inside `Transition.FindEnvCollisions`)
+
+Plus 2 test references in `tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs` (the Phase D tests).
+
+---
+
+## Task 0: Verify DatReaderWriter field names (read-only)
+
+**Files:** Read-only investigation. No commits.
+
+- [ ] **Step 1: Confirm `CellStruct.CellBSP` exists**
+
+Run from the worktree root:
+```powershell
+$cellStruct = [DatReaderWriter.Types.CellStruct]
+$cellStruct.GetProperties() | Select-Object Name, PropertyType | Where-Object { $_.Name -match 'BSP|Bsp|Polygon|Portal|Vertex' }
+```
+
+OR find the DatReaderWriter source on disk:
+```bash
+find ~/.nuget/packages/datreaderwriter -name "*.cs" 2>/dev/null | head -5
+find ~/.nuget/packages/datreader* -name "CellStruct*" 2>/dev/null
+```
+
+OR find the type by listing all instance properties at the call site. Add a temporary line to GameWindow.cs around line 5660 (where `cellStruct` is in scope):
+```csharp
+Console.WriteLine(string.Join(",", typeof(DatReaderWriter.Types.CellStruct).GetProperties().Select(p => p.Name)));
+```
+Build + run (don't commit). Read output. Remove the line.
+
+Expected: a property name matching `CellBSP` / `CellBsp` (or similar). If found, note the EXACT name for Task 2.
+
+- [ ] **Step 2: Confirm `LandBlockInfo.Buildings` shape**
+
+Same method as Step 1, but for `DatReaderWriter.DBObjs.LandBlockInfo`. Look for `Buildings` (List or array). Inspect one Building's properties to find:
+- A list of portals (probably `Portals` or `BldPortals`)
+- The building's world transform (probably `Transform` or `Frame`)
+
+For each Portal in a Building, find:
+- `OtherCellId` (uint or ushort)
+- `OtherPortalId` (ushort)
+- `Flags` (ushort or some flags type)
+- Optionally: `ExactMatch` (bool)
+
+Document the exact property names. If a field is named differently than the spec assumed, note the mapping.
+
+- [ ] **Step 3: Report findings**
+
+Note (mentally or in scratch) the EXACT C# property names that will be used in subsequent tasks:
+- `cellStruct.` for the third BSP
+- `lbInfo.Buildings` (or whatever name)
+- Building's portal list property
+- Building's transform property
+- BldPortal field names
+
+If `CellBSP` is missing OR `Buildings` is missing OR the shape is fundamentally different, STOP and report BLOCKED. The plan assumes both exist — if they don't, the controller needs to escalate (e.g., extend DatReaderWriter upstream).
+
+Throughout the rest of this plan, the placeholder `` refers to whatever the actual C# property name turned out to be. Adjust each task accordingly.
+
+---
+
+## Task 1: Add `PortalInfo` struct (TDD)
+
+**Files:**
+- Create: `src/AcDream.Core/Physics/PortalInfo.cs`
+- Create: `tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs`:
+
+```csharp
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public class PortalInfoTests
+{
+ [Fact]
+ public void PortalSide_FlagsBit2Clear_ReturnsTrue()
+ {
+ // (Flags & 2) == 0 → PortalSide is true.
+ var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0);
+ Assert.True(portal.PortalSide);
+ }
+
+ [Fact]
+ public void PortalSide_FlagsBit2Set_ReturnsFalse()
+ {
+ // (Flags & 2) != 0 → PortalSide is false.
+ var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 2);
+ Assert.False(portal.PortalSide);
+ }
+
+ [Fact]
+ public void PortalSide_OtherBitsSet_FollowsOnlyBit2()
+ {
+ var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0xFF & ~2);
+ Assert.True(portal.PortalSide);
+ }
+
+ [Fact]
+ public void OtherCellId_StoredAsLowSixteenBits()
+ {
+ // OtherCellId is a low-16 cell index (or 0xFFFF for exit-to-outdoor).
+ var portal = new PortalInfo(otherCellId: 0xFFFF, polygonId: 5, flags: 0);
+ Assert.Equal((ushort)0xFFFF, portal.OtherCellId);
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PortalInfo"`
+Expected: build fails (type not found).
+
+- [ ] **Step 3: Create the type**
+
+Create `src/AcDream.Core/Physics/PortalInfo.cs`:
+
+```csharp
+namespace AcDream.Core.Physics;
+
+///
+/// Indoor walking Phase 2 (2026-05-19). Portal connection between two
+/// EnvCells. Each carries a list of these,
+/// mirroring retail's CCellStruct.portals array.
+///
+///
+/// is a low-16 cell index (combined with the
+/// owning landblock prefix at lookup time) or 0xFFFF to mean
+/// "exit to outdoor world" (the player crosses this portal to leave
+/// the building).
+///
+///
+///
+/// indexes the OWNING cell's
+/// dict (the visible-polygon
+/// table, NOT which holds physics
+/// polys).
+///
+///
+///
+/// decodes bit 2 of :
+/// (Flags & 2) == 0 → portal's polygon normal points INTO
+/// the owning cell (so dist > 0 in cell-local space means "outside
+/// the cell, beyond the portal"). Used in find_transit_cells's
+/// load-hint path for unloaded neighbours.
+///
+///
+public readonly struct PortalInfo
+{
+ public PortalInfo(ushort otherCellId, ushort polygonId, ushort flags)
+ {
+ OtherCellId = otherCellId;
+ PolygonId = polygonId;
+ Flags = flags;
+ }
+
+ public ushort OtherCellId { get; }
+ public ushort PolygonId { get; }
+ public ushort Flags { get; }
+
+ /// Bit 2 of . See struct docstring.
+ public bool PortalSide => (Flags & 2) == 0;
+}
+```
+
+- [ ] **Step 4: Run tests, expect green**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PortalInfo"`
+Expected: 4 tests passing.
+
+- [ ] **Step 5: No commit yet** — bundle with Task 2 + Task 3 into one data-wiring commit.
+
+---
+
+## Task 2: Extend `CellPhysics` with portal fields (TDD)
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/PhysicsDataCache.cs` (the `CellPhysics` class around line 422)
+- Create: `tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs`
+
+- [ ] **Step 1: Locate `CellPhysics` and confirm shape**
+
+Open `src/AcDream.Core/Physics/PhysicsDataCache.cs` and locate the `CellPhysics` class around line 422. Confirm:
+- It's a `sealed class` with `required` + `init`-only properties.
+- It currently has `BSP`, `PhysicsPolygons`, `Vertices`, `WorldTransform`, `InverseWorldTransform`, `Resolved`, `LocalAabbMin`, `LocalAabbMax`.
+
+- [ ] **Step 2: Add new fields, delete AABB fields**
+
+Replace the class body to:
+1. ADD: `CellBSP` (`PhysicsBSPTree?`, init-only, nullable).
+2. ADD: `Portals` (`IReadOnlyList`, init-only, default empty).
+3. ADD: `PortalPolygons` (`Dictionary?`, init-only, default null).
+4. ADD: `VisibleCellIds` (`IReadOnlySet`, init-only, default empty).
+5. DELETE: `LocalAabbMin` and `LocalAabbMax` (and their XML docs).
+
+Concrete edit shape (the existing class continues to start with `public sealed class CellPhysics { ... }`):
+
+```csharp
+public sealed class CellPhysics
+{
+ // ── Pre-existing fields (unchanged) ────────────────────────────────
+ public PhysicsBSPTree? BSP { get; init; }
+ public Dictionary? PhysicsPolygons { get; init; }
+ public VertexArray? Vertices { get; init; }
+ public required Matrix4x4 WorldTransform { get; init; }
+ public required Matrix4x4 InverseWorldTransform { get; init; }
+ public required Dictionary Resolved { get; init; }
+
+ // ── Indoor walking Phase 2 (2026-05-19): portal-graph fields ───────
+
+ ///
+ /// The cell BSP used for
+ /// (point-in-cell tests). Separate tree from
+ /// (collision) and from the renderer's drawing-BSP.
+ /// Source: cellStruct.<CellBSP-property-name> at cache time.
+ /// Nullable: cells without a CellBSP cannot participate in portal
+ /// containment and are skipped by .
+ ///
+ public PhysicsBSPTree? CellBSP { get; init; }
+
+ ///
+ /// Portal connections to neighbouring cells, in cell-local space.
+ /// Default: empty list. Source: envCell.CellPortals.
+ ///
+ public IReadOnlyList Portals { get; init; } = System.Array.Empty();
+
+ ///
+ /// Resolved VISIBLE polygons (from cellStruct.Polygons),
+ /// keyed by polygon id. Distinct from which
+ /// holds PhysicsPolygons. Portal lookup via
+ /// resolves through this dict.
+ /// Nullable when the cell has no visible polys (rare).
+ ///
+ public Dictionary? PortalPolygons { get; init; }
+
+ ///
+ /// The cell ids visible from this cell (low-16 indexes, combined
+ /// with the owning landblock prefix). Populated from
+ /// envCell.VisibleCells. Unused this phase; reserved for the
+ /// optional find_cell_list visibility filter.
+ ///
+ public IReadOnlySet VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet();
+}
+```
+
+**Important:** the AABB fields (`LocalAabbMin`, `LocalAabbMax`) are DELETED in this edit. Their XML docs go too. Any references to them anywhere in the codebase will fail the build in Task 7 (intentional — the build break shows you missed a deletion site).
+
+- [ ] **Step 3: Write the parity test (RED first)**
+
+Create `tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs`:
+
+```csharp
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public class CellPhysicsPortalWiringTests
+{
+ [Fact]
+ public void NewFields_HaveSensibleDefaults()
+ {
+ // Phase 2 added CellBSP / Portals / PortalPolygons / VisibleCellIds.
+ // Default-initialized values must not crash callers.
+ var cp = new CellPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new System.Collections.Generic.Dictionary(),
+ };
+
+ Assert.Null(cp.CellBSP);
+ Assert.Empty(cp.Portals);
+ Assert.Null(cp.PortalPolygons);
+ Assert.Empty(cp.VisibleCellIds);
+ }
+
+ [Fact]
+ public void NewFields_AcceptInitValues()
+ {
+ var portal = new PortalInfo(otherCellId: 0x0101, polygonId: 5, flags: 0);
+
+ var cp = new CellPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new System.Collections.Generic.Dictionary(),
+ Portals = new[] { portal },
+ VisibleCellIds = new System.Collections.Generic.HashSet { 0xA9B40101 },
+ };
+
+ Assert.Single(cp.Portals);
+ Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId);
+ Assert.Contains(0xA9B40101u, cp.VisibleCellIds);
+ }
+}
+```
+
+- [ ] **Step 4: Run tests, expect green**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellPhysicsPortalWiring"`
+Expected: 2 tests passing.
+
+- [ ] **Step 5: Verify build is still otherwise green (deletion didn't break anything yet)**
+
+Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj`
+
+If references to `LocalAabbMin` / `LocalAabbMax` exist outside the deleted block, you'll see compile errors. Those error locations are EXACTLY what you must fix in Task 7. For now, the AcDream.Core.csproj might not build green yet — that's expected. Note the error locations.
+
+Expected build state: **may have errors** referencing the deleted AABB fields. That's fine; Task 7 closes them.
+
+- [ ] **Step 6: No commit yet** — Task 3 finishes the data wiring.
+
+---
+
+## Task 3: Extend `CacheCellStruct` to populate portal data (TDD)
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/PhysicsDataCache.cs` (the `CacheCellStruct` method around lines 131-180)
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (call site at line 5384)
+- Extend: `tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs`
+
+- [ ] **Step 1: Replace `ResolvePolygons` visibility**
+
+In `src/AcDream.Core/Physics/PhysicsDataCache.cs`, find the existing `private static Dictionary ResolvePolygons(...)` at line 231. Change `private` to `internal`. The function stays as-is otherwise — same signature, same body. This makes it reusable for both `PhysicsPolygons` and the new `PortalPolygons` resolution.
+
+- [ ] **Step 2: Change `CacheCellStruct` signature**
+
+Find the existing `CacheCellStruct` method at PhysicsDataCache.cs:131. Change the signature from:
+
+```csharp
+public void CacheCellStruct(uint envCellId, CellStruct cellStruct, Matrix4x4 worldTransform)
+```
+
+to:
+
+```csharp
+public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell, CellStruct cellStruct, Matrix4x4 worldTransform)
+```
+
+Add the `using DatReaderWriter.DBObjs;` at the top of the file if not already there.
+
+- [ ] **Step 3: Replace the body**
+
+Inside `CacheCellStruct`, replace the body. New body:
+
+```csharp
+public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell,
+ CellStruct cellStruct, Matrix4x4 worldTransform)
+{
+ if (_cellStruct.ContainsKey(envCellId)) return;
+ if (cellStruct.PhysicsBSP?.Root is null) return;
+
+ Matrix4x4.Invert(worldTransform, out var inverseTransform);
+
+ var resolved = ResolvePolygons(cellStruct.PhysicsPolygons, cellStruct.VertexArray);
+
+ // Visible polygons — portals reference these (NOT PhysicsPolygons).
+ var portalPolygons = ResolvePolygons(cellStruct.Polygons, cellStruct.VertexArray);
+
+ // Portal list from envCell.CellPortals.
+ var portals = new System.Collections.Generic.List(envCell.CellPortals.Count);
+ foreach (var p in envCell.CellPortals)
+ {
+ portals.Add(new PortalInfo(
+ otherCellId: p.OtherCellId,
+ polygonId: p.PolygonId,
+ flags: (ushort)p.Flags));
+ }
+
+ // VisibleCells set — populated for future use; not consulted this phase.
+ var visibleCellIds = new System.Collections.Generic.HashSet();
+ if (envCell.VisibleCells is not null)
+ {
+ uint lbPrefix = envCellId & 0xFFFF0000u;
+ foreach (var lowId in envCell.VisibleCells.Keys)
+ visibleCellIds.Add(lbPrefix | lowId);
+ }
+
+ _cellStruct[envCellId] = new CellPhysics
+ {
+ BSP = cellStruct.PhysicsBSP,
+ PhysicsPolygons = cellStruct.PhysicsPolygons,
+ Vertices = cellStruct.VertexArray,
+ WorldTransform = worldTransform,
+ InverseWorldTransform = inverseTransform,
+ Resolved = resolved,
+ // ── Phase 2 portal fields ──
+ CellBSP = cellStruct., // ← REPLACE with actual property name from Task 0
+ Portals = portals,
+ PortalPolygons = portalPolygons,
+ VisibleCellIds = visibleCellIds,
+ };
+
+ if (PhysicsDiagnostics.ProbeCellCacheEnabled)
+ {
+ var root = cellStruct.PhysicsBSP?.Root;
+ int bspRootPolyCount = root?.Polygons?.Count ?? 0;
+ bool bspRootHasChildren = root?.PosNode is not null || root?.NegNode is not null;
+
+ // Recursive walk: count total leaf poly references + how many of
+ // those poly IDs are absent from the resolved dict. If
+ // bspTotalLeafPolys == 0 the BSP has no collidable polys at all.
+ int bspTotalLeafPolys = 0;
+ int bspUnmatchedIds = 0;
+ if (root is not null)
+ {
+ var stack = new System.Collections.Generic.Stack();
+ stack.Push(root);
+ while (stack.Count > 0)
+ {
+ var n = stack.Pop();
+ if (n.Polygons is not null)
+ {
+ foreach (var pid in n.Polygons)
+ {
+ bspTotalLeafPolys++;
+ if (!resolved.ContainsKey(pid)) bspUnmatchedIds++;
+ }
+ }
+ if (n.PosNode is not null) stack.Push(n.PosNode);
+ if (n.NegNode is not null) stack.Push(n.NegNode);
+ }
+ }
+
+ var bs = root?.BoundingSphere;
+ string bsStr = bs is null
+ ? "bsphere=n/a"
+ : System.FormattableString.Invariant(
+ $"bsphere=({bs.Origin.X:F2},{bs.Origin.Y:F2},{bs.Origin.Z:F2}) r={bs.Radius:F2}");
+
+ var worldOrigin = Vector3.Transform(Vector3.Zero, worldTransform);
+
+ // Phase 2: dropped aabbMin/aabbMax (deleted in Task 2). Added
+ // portal/visible counts.
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[cell-cache] envCellId=0x{envCellId:X8} " +
+ $"physicsPolyCount={cellStruct.PhysicsPolygons?.Count ?? 0} " +
+ $"resolvedCount={resolved.Count} " +
+ $"bspTotalLeafPolys={bspTotalLeafPolys} bspUnmatchedIds={bspUnmatchedIds} " +
+ $"{bsStr} " +
+ $"portalCount={portals.Count} " +
+ $"visibleCells={visibleCellIds.Count} " +
+ $"cellBspRoot={(cellStruct.?.Root is null ? "null" : "ok")} " +
+ $"worldOrigin=({worldOrigin.X:F2},{worldOrigin.Y:F2},{worldOrigin.Z:F2})"));
+ }
+}
+```
+
+**Substitution:** `` appears twice in the snippet above — replace BOTH with the actual property name discovered in Task 0 (likely `CellBSP` or `CellBsp`).
+
+- [ ] **Step 4: Update the GameWindow call site**
+
+In `src/AcDream.App/Rendering/GameWindow.cs` at line 5384, find the existing call:
+
+```csharp
+_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform);
+```
+
+Replace with:
+
+```csharp
+_physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform);
+```
+
+(The `envCell` variable is already in scope at this site — it's the loop variable from the surrounding `foreach` over EnvCells.)
+
+- [ ] **Step 5: Extend the parity test with a portal-population check**
+
+Append to `tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs`:
+
+```csharp
+ [Fact]
+ public void CellPhysics_PortalsRoundTrip()
+ {
+ // Two portals: one indoor (OtherCellId=0x0101), one exit (OtherCellId=0xFFFF).
+ var portals = new[]
+ {
+ new PortalInfo(otherCellId: 0x0101, polygonId: 7, flags: 0),
+ new PortalInfo(otherCellId: 0xFFFF, polygonId: 8, flags: 2),
+ };
+
+ var cp = new CellPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new System.Collections.Generic.Dictionary(),
+ Portals = portals,
+ };
+
+ Assert.Equal(2, cp.Portals.Count);
+ Assert.Equal((ushort)0x0101, cp.Portals[0].OtherCellId);
+ Assert.True(cp.Portals[0].PortalSide);
+ Assert.Equal((ushort)0xFFFF, cp.Portals[1].OtherCellId);
+ Assert.False(cp.Portals[1].PortalSide);
+ }
+```
+
+- [ ] **Step 6: Build + test green**
+
+Run: `dotnet build && dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellPhysicsPortalWiring|FullyQualifiedName~PortalInfo"`
+
+Expected: 7 tests passing (4 PortalInfo + 3 CellPhysicsPortalWiring). The full `dotnet build` may still have errors from `TryFindContainingCell` and AABB references that Task 7 cleans up — that's expected. The targeted filter must pass.
+
+- [ ] **Step 7: Commit (data wiring)**
+
+```
+git add src/AcDream.Core/Physics/PortalInfo.cs `
+ src/AcDream.Core/Physics/PhysicsDataCache.cs `
+ src/AcDream.App/Rendering/GameWindow.cs `
+ tests/AcDream.Core.Tests/Physics/PortalInfoTests.cs `
+ tests/AcDream.Core.Tests/Physics/CellPhysicsPortalWiringTests.cs
+
+git commit -m "$(cat <<'EOF'
+feat(physics): Phase 2 — wire CellBSP + Portals into CellPhysics
+
+Adds PortalInfo struct and extends CellPhysics with CellBSP (third BSP for
+point-in-cell tests), Portals (from envCell.CellPortals), PortalPolygons
+(resolved cellStruct.Polygons — portals reference visible polys, not
+PhysicsPolygons), and VisibleCellIds (populated for future use). Deletes
+the Phase D LocalAabbMin/Max fields; CacheCellStruct's AABB compute is
+gone.
+
+Build is intentionally not green yet — references to the deleted AABB
+fields in PhysicsEngine and tests will be removed in the integration commit
+that wires CellTransit.FindCellList. This data-wiring step lands first so
+the new infrastructure is in place before the consumer.
+
+Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
+Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 4: Port `CellTransit.FindTransitCellsSphere` (indoor portal walk, TDD)
+
+**Files:**
+- Create: `src/AcDream.Core/Physics/CellTransit.cs`
+- Create: `tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public class CellTransitFindTransitCellsSphereTests
+{
+ // Synthetic 2-cell scenario:
+ // Cell A at world origin; cell B 5m east of A.
+ // Portal poly at x=2.5 (the wall between A and B), normal pointing +X (out of A).
+ // Both cells are 5x5x5 m AABB.
+
+ private const float EPSILON = 0.02f;
+
+ private static (CellPhysics cellA, CellPhysics cellB) MakeAdjacentCells()
+ {
+ // Cell A: at world origin; portal poly at local x=2.5 (right wall), normal +X.
+ var portalPolyA = new ResolvedPolygon
+ {
+ Vertices = new[]
+ {
+ new Vector3(2.5f, -2.5f, 0f),
+ new Vector3(2.5f, 2.5f, 0f),
+ new Vector3(2.5f, 2.5f, 5f),
+ new Vector3(2.5f, -2.5f, 5f),
+ },
+ Plane = new Plane(new Vector3(1, 0, 0), -2.5f), // x = 2.5
+ NumPoints = 4,
+ SidesType = DatReaderWriter.Enums.CullMode.None,
+ };
+
+ var cellA = new CellPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new Dictionary(),
+ PortalPolygons = new Dictionary { [10] = portalPolyA },
+ Portals = new[]
+ {
+ // Portal to Cell B (low-16 = 0x0101). Flags=0 → PortalSide=true.
+ new PortalInfo(otherCellId: 0x0101, polygonId: 10, flags: 0),
+ },
+ };
+
+ // Cell B: 5m east in world; same portal polygon mirrored.
+ var bWorldT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f));
+ Matrix4x4.Invert(bWorldT, out var bInvT);
+ var cellB = new CellPhysics
+ {
+ WorldTransform = bWorldT,
+ InverseWorldTransform = bInvT,
+ Resolved = new Dictionary(),
+ // Cell B's CellBSP — null means point-in-cell returns "outside" for the test path.
+ // For these tests we don't need the BSP since we're testing the load-hint and
+ // add-by-sphere paths.
+ };
+
+ return (cellA, cellB);
+ }
+
+ [Fact]
+ public void SphereInsideCellA_NearPortal_AddsCellB()
+ {
+ var (cellA, cellB) = MakeAdjacentCells();
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(0xA9B40100u, cellA);
+ cache.RegisterCellStructForTest(0xA9B40101u, cellB);
+
+ // Sphere center near the portal plane (local x=2.0, sphere radius=0.5).
+ // Sphere reaches x=2.5 which is the portal plane → straddles.
+ var worldSphereCenter = new Vector3(2.0f, 0f, 2.5f);
+ float sphereRadius = 0.5f;
+
+ var candidates = new HashSet();
+ CellTransit.FindTransitCellsSphere(
+ cache,
+ currentCell: cellA,
+ currentCellId: 0xA9B40100u,
+ worldSphereCenter,
+ sphereRadius,
+ candidates,
+ out bool exitOutside);
+
+ Assert.Contains(0xA9B40101u, candidates);
+ Assert.False(exitOutside);
+ }
+
+ [Fact]
+ public void SphereInsideCellA_FarFromPortal_DoesNotAddCellB()
+ {
+ var (cellA, cellB) = MakeAdjacentCells();
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(0xA9B40100u, cellA);
+ cache.RegisterCellStructForTest(0xA9B40101u, cellB);
+
+ // Sphere far from portal (local x=-1.0, sphere radius=0.5).
+ // Sphere reach extends only to x=-0.5 — nowhere near portal at x=2.5.
+ var worldSphereCenter = new Vector3(-1.0f, 0f, 2.5f);
+
+ var candidates = new HashSet();
+ CellTransit.FindTransitCellsSphere(
+ cache,
+ currentCell: cellA,
+ currentCellId: 0xA9B40100u,
+ worldSphereCenter,
+ sphereRadius: 0.5f,
+ candidates,
+ out bool exitOutside);
+
+ Assert.DoesNotContain(0xA9B40101u, candidates);
+ }
+
+ [Fact]
+ public void ExitPortal_SphereStraddlesPortalPlane_FlagsCheckOutside()
+ {
+ // Modify cell A to have its second portal be an EXIT (OtherCellId = 0xFFFF).
+ var (cellA, _) = MakeAdjacentCells();
+ var exitOnly = new CellPhysics
+ {
+ WorldTransform = cellA.WorldTransform,
+ InverseWorldTransform = cellA.InverseWorldTransform,
+ Resolved = cellA.Resolved,
+ PortalPolygons = cellA.PortalPolygons,
+ Portals = new[]
+ {
+ new PortalInfo(otherCellId: 0xFFFF, polygonId: 10, flags: 0),
+ },
+ };
+
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(0xA9B40100u, exitOnly);
+
+ var worldSphereCenter = new Vector3(2.0f, 0f, 2.5f);
+ var candidates = new HashSet();
+
+ CellTransit.FindTransitCellsSphere(
+ cache,
+ currentCell: exitOnly,
+ currentCellId: 0xA9B40100u,
+ worldSphereCenter,
+ sphereRadius: 0.5f,
+ candidates,
+ out bool exitOutside);
+
+ Assert.True(exitOutside);
+ }
+}
+```
+
+- [ ] **Step 2: Run, expect failure**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransitFindTransitCellsSphere"`
+Expected: build fails (`CellTransit` not found).
+
+- [ ] **Step 3: Implement `CellTransit.FindTransitCellsSphere`**
+
+Create `src/AcDream.Core/Physics/CellTransit.cs`:
+
+```csharp
+using System.Collections.Generic;
+using System.Numerics;
+using DatReaderWriter.Types;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Indoor walking Phase 2 (2026-05-19). Portal-graph cell traversal,
+/// ported from retail's CObjCell::find_cell_list family
+/// (sphere variant for the player's single foot sphere).
+///
+///
+/// Replaces Phase D's AABB containment. Uses the cell BSP for retail-
+/// faithful point-in-cell tests via
+/// . Walks the portal graph
+/// starting from a given current cell to find which cells a moving
+/// sphere overlaps.
+///
+///
+///
+/// Reference pseudocode:
+/// docs/research/acclient_indoor_transitions_pseudocode.md
+/// (2026-04-13). Retail decomp: CEnvCell::find_transit_cells
+/// (sphere variant) at acclient_2013_pseudo_c.txt.
+///
+///
+public static class CellTransit
+{
+ ///
+ /// Small radius padding matching retail's EPSILON usage in the
+ /// sphere-plane distance test (research doc §"EnvCell.find_transit_cells").
+ ///
+ private const float EPSILON = 0.02f;
+
+ ///
+ /// Indoor portal-neighbour expansion. For each portal of
+ /// , test whether the sphere overlaps
+ /// the portal polygon's plane in cell-local space. If so, add the
+ /// neighbour cell to .
+ ///
+ ///
+ /// Ported from CEnvCell::find_transit_cells (sphere variant)
+ /// per the pseudocode doc §"EnvCell.find_transit_cells (sphere variant)".
+ ///
+ ///
+ /// The physics data cache (for neighbour lookups).
+ /// The cell whose portals are walked.
+ /// The full id (with landblock prefix) of
+ /// . Used to resolve neighbour ids by
+ /// combining the prefix with .
+ /// Player's foot-sphere center in world space.
+ /// Player's foot-sphere radius.
+ /// Set to add neighbour cell ids to.
+ /// Set to true if the sphere straddles an
+ /// exit portal (OtherCellId == 0xFFFF) — the caller should
+ /// then expand outdoor neighbour cells via .
+ public static void FindTransitCellsSphere(
+ PhysicsDataCache cache,
+ CellPhysics currentCell,
+ uint currentCellId,
+ Vector3 worldSphereCenter,
+ float sphereRadius,
+ HashSet candidates,
+ out bool exitOutside)
+ {
+ exitOutside = false;
+ if (currentCell.PortalPolygons is null) return;
+
+ uint lbPrefix = currentCellId & 0xFFFF0000u;
+ float rad = sphereRadius + EPSILON;
+
+ // Cell-local sphere center.
+ var localCenter = Vector3.Transform(worldSphereCenter, currentCell.InverseWorldTransform);
+
+ foreach (var portal in currentCell.Portals)
+ {
+ if (!currentCell.PortalPolygons.TryGetValue(portal.PolygonId, out var poly))
+ continue;
+
+ // Signed distance from sphere center to portal plane (cell-local).
+ float dist = Vector3.Dot(localCenter, poly.Plane.Normal) + poly.Plane.D;
+
+ if (portal.OtherCellId == 0xFFFF)
+ {
+ // Exit portal. Sphere must straddle the plane.
+ if (dist > -rad && dist < rad)
+ {
+ exitOutside = true;
+ // Don't break — there may be more portals to check.
+ }
+ continue;
+ }
+
+ uint otherId = lbPrefix | portal.OtherCellId;
+ var otherCell = cache.GetCellStruct(otherId);
+
+ if (otherCell is not null)
+ {
+ // Neighbour is loaded. Test containment via PointInsideCellBsp
+ // in the OTHER cell's local space.
+ var otherLocal = Vector3.Transform(worldSphereCenter, otherCell.InverseWorldTransform);
+
+ // We don't yet have sphere_intersects_cell ported. Use the
+ // load-hint sphere-plane heuristic from the research doc's
+ // "otherCell == null" branch as a conservative add — once
+ // the sphere is near the portal plane and on the "exit" side
+ // (per PortalSide), the neighbour is a valid candidate.
+ if (portal.PortalSide ? dist > -rad : dist < rad)
+ {
+ candidates.Add(otherId);
+ }
+ }
+ else
+ {
+ // Load-hint path: neighbour not yet cached. Mirrors retail's
+ // load-hint branch — add by plane-side test only.
+ if (portal.PortalSide ? dist > -rad : dist < rad)
+ {
+ candidates.Add(otherId);
+ }
+ }
+ }
+ }
+}
+```
+
+**Note on `sphere_intersects_cell`:** retail's algorithm uses `CellBSP.sphere_intersects_cell_bsp` (returns `Inside`/`Crossing`/`Outside`) for the neighbour-containment test. Our `BSPQuery` doesn't currently expose that operation — only `PointInsideCellBsp` and the collision-side `FindCollisions`. The plane-side heuristic above is a conservative approximation: it adds a candidate whenever the sphere is close to the portal and on the right side. Combined with `FindCellList`'s subsequent point-in-cell test (Task 6) which filters to a single winning cell via `PointInsideCellBsp`, the conservative add is safe.
+
+A more retail-faithful implementation would add `BSPQuery.SphereIntersectsCellBsp` (~80 LOC). Deferred to a follow-up if our heuristic produces visible bugs.
+
+- [ ] **Step 4: Run tests, expect green**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransitFindTransitCellsSphere"`
+Expected: 3 tests passing.
+
+- [ ] **Step 5: No commit yet** — bundle with Task 5 + 6 + 7 into one CellTransit commit.
+
+---
+
+## Task 5: Port `CellTransit.AddAllOutsideCells` (TDD)
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/CellTransit.cs`
+- Create: `tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public class CellTransitAddAllOutsideCellsTests
+{
+ // Outdoor landcells are 24×24m. Cell ids low-16 are 0x01..0x40 in row-major
+ // order (8 cells wide, 8 cells tall per landblock). Cell offset within
+ // landblock: cellY = (low - 1) % 8, cellX = (low - 1) / 8 (per the AC2D
+ // reference; verify with research doc).
+
+ [Fact]
+ public void SphereWellInsideCell_AddsOneCell()
+ {
+ // Player at world (12, 12, 0) — middle of cell (0, 0) of landblock 0xA9B40000.
+ // Cell low-16 id = 0x0001 (first outdoor cell).
+ var candidates = new HashSet();
+ CellTransit.AddAllOutsideCells(
+ worldSphereCenter: new Vector3(12f, 12f, 0f),
+ sphereRadius: 0.5f,
+ currentCellId: 0xA9B40001u,
+ candidates);
+
+ Assert.Single(candidates);
+ Assert.Contains(0xA9B40001u, candidates);
+ }
+
+ [Fact]
+ public void SphereAtCellEastBoundary_AddsTwoCells()
+ {
+ // Player at world (23.6, 12, 0) — at the +X edge of cell (0,0). Sphere
+ // radius 0.5 → reaches X=24.1 which is in cell (1,0).
+ var candidates = new HashSet();
+ CellTransit.AddAllOutsideCells(
+ worldSphereCenter: new Vector3(23.6f, 12f, 0f),
+ sphereRadius: 0.5f,
+ currentCellId: 0xA9B40001u,
+ candidates);
+
+ Assert.Equal(2, candidates.Count);
+ Assert.Contains(0xA9B40001u, candidates);
+ // Cell at (1, 0): low-16 id = 0x0009 (cell index = 8 → low+8 = 0x0009).
+ Assert.Contains(0xA9B40009u, candidates);
+ }
+
+ // Additional boundary tests (corner: 4 cells; +Y edge: 2 cells) intentionally
+ // omitted from the plan to keep the commit small. Add them once the basic
+ // shape works.
+}
+```
+
+- [ ] **Step 2: Add the function to `CellTransit`**
+
+Append to `src/AcDream.Core/Physics/CellTransit.cs`:
+
+```csharp
+ ///
+ /// Outdoor neighbour expansion. Ported from
+ /// CLandCell::add_all_outside_cells (sphere variant) per the
+ /// pseudocode doc §"LandCell.add_all_outside_cells (sphere variant)".
+ ///
+ ///
+ /// The 24×24m landcell grid: a landblock is 8×8 cells. Cell index
+ /// within a landblock is computed from local X/Y mod 24. The sphere
+ /// adds the primary cell plus up to 3 neighbours when the radius
+ /// reaches a cell boundary.
+ ///
+ ///
+ public static void AddAllOutsideCells(
+ Vector3 worldSphereCenter,
+ float sphereRadius,
+ uint currentCellId,
+ HashSet candidates)
+ {
+ const float CellSize = 24f;
+
+ uint lbPrefix = currentCellId & 0xFFFF0000u;
+
+ // Compute the landblock's world XY origin from the landblock id.
+ // Landblock byte X = (lbPrefix >> 24) & 0xFF, Y = (lbPrefix >> 16) & 0xFF.
+ // Each landblock is 192×192m at world (lbX * 192, lbY * 192).
+ // The "current landblock" depends on world position — if the sphere
+ // crosses landblock boundaries we'd need to adjust the prefix; for
+ // now assume the sphere stays within the current landblock.
+ float lbXf = ((lbPrefix >> 24) & 0xFFu) * 192f;
+ float lbYf = ((lbPrefix >> 16) & 0xFFu) * 192f;
+ float localX = worldSphereCenter.X - lbXf;
+ float localY = worldSphereCenter.Y - lbYf;
+
+ // Within-cell local 2D.
+ float cellLocalX = localX % CellSize;
+ float cellLocalY = localY % CellSize;
+ float minRad = sphereRadius;
+ float maxRad = CellSize - sphereRadius;
+
+ // Grid coordinates.
+ int gridX = (int)(localX / CellSize);
+ int gridY = (int)(localY / CellSize);
+ if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
+
+ AddOutsideCell(candidates, lbPrefix, gridX, gridY);
+
+ // Boundary checks (matches research doc's check_add_cell_boundary).
+ if (cellLocalX > maxRad)
+ {
+ AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY);
+ if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY + 1);
+ if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX + 1, gridY - 1);
+ }
+ if (cellLocalX < minRad)
+ {
+ AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY);
+ if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY + 1);
+ if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX - 1, gridY - 1);
+ }
+ if (cellLocalY > maxRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY + 1);
+ if (cellLocalY < minRad) AddOutsideCell(candidates, lbPrefix, gridX, gridY - 1);
+ }
+
+ private static void AddOutsideCell(HashSet candidates, uint lbPrefix, int gridX, int gridY)
+ {
+ if (gridX < 0 || gridX >= 8 || gridY < 0 || gridY >= 8) return;
+
+ // Cell index within landblock: row-major (X * 8 + Y) + 1, per AC's convention.
+ uint low = (uint)(gridX * 8 + gridY + 1);
+ candidates.Add(lbPrefix | low);
+ }
+```
+
+- [ ] **Step 3: Run tests, expect green**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransitAddAllOutsideCells"`
+Expected: 2 tests passing.
+
+- [ ] **Step 4: No commit yet.**
+
+---
+
+## Task 6: Port `CellTransit.FindCellList` (top-level driver, TDD)
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/CellTransit.cs`
+- Create: `tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public class CellTransitFindCellListTests
+{
+ [Fact]
+ public void IndoorSeed_PointInsideCellBsp_ReturnsCurrentCell()
+ {
+ var cache = new PhysicsDataCache();
+ // Synthetic cell with no portals; CellBSP is null so PointInsideCellBsp
+ // would return true — but we guard CellBSP?.Root == null and treat
+ // missing BSP as "not findable" → fall through.
+ var cell = new CellPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new Dictionary(),
+ };
+ cache.RegisterCellStructForTest(0xA9B40100u, cell);
+
+ uint result = CellTransit.FindCellList(
+ cache,
+ worldSphereCenter: Vector3.Zero,
+ sphereRadius: 0.5f,
+ currentCellId: 0xA9B40100u);
+
+ // No CellBSP → falls back to the input cell id.
+ Assert.Equal(0xA9B40100u, result);
+ }
+
+ [Fact]
+ public void OutdoorSeed_Returns_OutdoorLandcell()
+ {
+ var cache = new PhysicsDataCache();
+ // Outdoor seed: low-16 < 0x0100. No CellPhysics needed for landcells.
+ uint result = CellTransit.FindCellList(
+ cache,
+ worldSphereCenter: new Vector3(12f, 12f, 0f),
+ sphereRadius: 0.5f,
+ currentCellId: 0xA9B40001u);
+
+ Assert.Equal(0xA9B40001u, result);
+ }
+}
+```
+
+- [ ] **Step 2: Add `FindCellList` to `CellTransit`**
+
+Append to `src/AcDream.Core/Physics/CellTransit.cs`:
+
+```csharp
+ ///
+ /// Top-level cell-tracking driver, ported from retail's
+ /// CObjCell::find_cell_list (sphere variant).
+ ///
+ ///
+ /// Walks the portal graph from ,
+ /// finds the cell whose contains
+ /// the sphere center, and returns its full id (landblock-prefixed).
+ /// Falls back to when no candidate
+ /// matches.
+ ///
+ ///
+ ///
+ /// Pseudocode reference:
+ /// docs/research/acclient_indoor_transitions_pseudocode.md
+ /// §"Overall Driver: find_cell_list".
+ ///
+ ///
+ public static uint FindCellList(
+ PhysicsDataCache cache,
+ Vector3 worldSphereCenter,
+ float sphereRadius,
+ uint currentCellId)
+ {
+ var candidates = new HashSet();
+ uint currentLow = currentCellId & 0xFFFFu;
+
+ if (currentLow >= 0x0100u)
+ {
+ // Indoor seed.
+ var currentCell = cache.GetCellStruct(currentCellId);
+ if (currentCell is null) return currentCellId;
+
+ candidates.Add(currentCellId);
+
+ // BFS the portal graph (one hop per pass — usually 1-2 passes is enough).
+ var pending = new Queue();
+ pending.Enqueue(currentCellId);
+ int maxIterations = 16; // hard cap; portal graphs are small
+ while (pending.Count > 0 && maxIterations-- > 0)
+ {
+ uint cellId = pending.Dequeue();
+ var cell = cache.GetCellStruct(cellId);
+ if (cell is null) continue;
+
+ var sizeBefore = candidates.Count;
+ FindTransitCellsSphere(
+ cache, cell, cellId, worldSphereCenter, sphereRadius,
+ candidates, out bool exitOutside);
+
+ // For each NEW candidate, enqueue it.
+ if (candidates.Count > sizeBefore)
+ {
+ // Snapshot the new candidates to avoid mutating during iteration.
+ foreach (var c in candidates)
+ {
+ if (c != cellId) // skip seed
+ pending.Enqueue(c);
+ }
+ }
+
+ if (exitOutside)
+ {
+ // Add neighbour outdoor cells too.
+ AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
+ }
+ }
+ }
+ else
+ {
+ // Outdoor seed.
+ AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
+ // TODO: check_building_transit hookup at Task 7.
+ }
+
+ // Containment test: for each candidate, transform worldSphereCenter to
+ // local and test PointInsideCellBsp.
+ foreach (uint candId in candidates)
+ {
+ var cand = cache.GetCellStruct(candId);
+ if (cand?.CellBSP?.Root is null) continue;
+
+ var local = Vector3.Transform(worldSphereCenter, cand.InverseWorldTransform);
+ if (BSPQuery.PointInsideCellBsp(cand.CellBSP.Root, local))
+ return candId;
+ }
+
+ // No cell contained the sphere center. Stay in the input cell.
+ return currentCellId;
+ }
+```
+
+- [ ] **Step 3: Run tests, expect green**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellTransitFindCellList"`
+Expected: 2 tests passing.
+
+- [ ] **Step 4: No commit yet.**
+
+---
+
+## Task 7: Wire `CellTransit.FindCellList` into `ResolveCellId`, delete AABB containment
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/PhysicsEngine.cs`
+- Modify: `src/AcDream.Core/Physics/PhysicsDataCache.cs`
+- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs`
+- Rename + rewrite: `tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs` → `tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs`
+
+- [ ] **Step 1: Rename `ResolveOutdoorCellId` and rewrite body**
+
+In `src/AcDream.Core/Physics/PhysicsEngine.cs`, find `ResolveOutdoorCellId` at line 254. Rename to `ResolveCellId` and replace the body. New definition:
+
+```csharp
+///
+/// Indoor walking Phase 2 (2026-05-19). Resolves the cell id for a
+/// given world position via retail's portal-graph traversal. Delegates
+/// to .
+///
+///
+/// Replaces Phase D's ResolveOutdoorCellId which used AABB
+/// containment — see docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
+/// for the design.
+///
+///
+internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
+{
+ if (fallbackCellId == 0) return 0;
+ if (DataCache is null) return fallbackCellId;
+
+ return CellTransit.FindCellList(DataCache, worldPos, sphereRadius, fallbackCellId);
+}
+```
+
+- [ ] **Step 2: Update the two internal callers in `PhysicsEngine.cs`**
+
+Find lines 755 and 773 (or thereabouts after the rename). The current calls are:
+
+```csharp
+ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId),
+```
+
+Plumb the sphere radius through. The caller is inside `ResolveWithTransition`, and `sp.GlobalSphere[0].Radius` is accessible via the `sp` (SpherePath) variable. Update both lines:
+
+```csharp
+ResolveCellId(sp.CheckPos, sp.GlobalSphere[0].Radius, sp.CheckCellId),
+```
+
+- [ ] **Step 3: Update the `TransitionTypes.cs` call**
+
+At `src/AcDream.Core/Physics/TransitionTypes.cs:1181`, the existing call:
+
+```csharp
+uint resolvedOutdoorCellId = engine.ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId);
+```
+
+Replace with:
+
+```csharp
+uint resolvedOutdoorCellId = engine.ResolveCellId(sp.CheckPos, sphereRadius, sp.CheckCellId);
+```
+
+The `sphereRadius` local is already in scope at line 1186 (`float sphereRadius = sp.GlobalSphere[0].Radius;`). The replacement uses it directly.
+
+- [ ] **Step 4: Delete `TryFindContainingCell` from `PhysicsDataCache`**
+
+In `src/AcDream.Core/Physics/PhysicsDataCache.cs`, find the `TryFindContainingCell` method (around line 295). Delete the entire method including its XML doc.
+
+- [ ] **Step 5: Rebuild — should be green now**
+
+Run: `dotnet build`
+
+If errors remain referencing `LocalAabbMin` / `LocalAabbMax` / `TryFindContainingCell` outside the deleted code, fix them. Most likely candidates: the WorldPicker tests if they constructed CellPhysics with those fields, or any other consumer that snuck in.
+
+- [ ] **Step 6: Rename + rewrite the Phase D test file**
+
+Rename `tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs` to `tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs` (file rename — use `git mv`).
+
+Replace the class body. The old AABB-based tests assert containment behavior that no longer exists. New tests verify the portal-traversal-based behavior:
+
+```csharp
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public class ResolveCellIdTests
+{
+ [Fact]
+ public void ResolveCellId_FallbackZero_ReturnsZero()
+ {
+ var engine = new PhysicsEngine();
+ uint result = engine.ResolveCellId(Vector3.Zero, sphereRadius: 0.5f, fallbackCellId: 0u);
+ Assert.Equal(0u, result);
+ }
+
+ [Fact]
+ public void ResolveCellId_NoDataCache_ReturnsFallback()
+ {
+ // Build a PhysicsEngine without setting DataCache (default: null).
+ var engine = new PhysicsEngine { DataCache = null };
+ uint result = engine.ResolveCellId(Vector3.Zero, sphereRadius: 0.5f, fallbackCellId: 0x00000001u);
+ Assert.Equal(0x00000001u, result);
+ }
+
+ [Fact]
+ public void ResolveCellId_OutdoorSeedNoLandblock_ReturnsFallback()
+ {
+ var engine = new PhysicsEngine();
+ uint result = engine.ResolveCellId(
+ new Vector3(100, 100, 0),
+ sphereRadius: 0.5f,
+ fallbackCellId: 0xA9B40001u);
+ // No cells cached, no landblock added → AddAllOutsideCells produces 1
+ // candidate (the input cell) but PointInsideCellBsp on null CellBSP skips
+ // → returns fallback.
+ Assert.Equal(0xA9B40001u, result);
+ }
+}
+```
+
+(The original Phase D tests covered specific behaviors that are now better covered by `CellTransitFindCellListTests` and `CellTransitFindTransitCellsSphereTests`. Don't try to port them 1:1.)
+
+- [ ] **Step 7: Build + full test sweep**
+
+Run: `dotnet build && dotnet test`
+
+Expected:
+- Build green (0 errors).
+- All new tests pass.
+- 8 pre-existing failures unchanged (baseline match).
+- Old `ResolveOutdoorCellIdTests` is gone (file renamed); the rewritten `ResolveCellIdTests` (3 tests) passes.
+
+- [ ] **Step 8: Commit (the cellTransit + integration commit)**
+
+```
+git add src/AcDream.Core/Physics/CellTransit.cs `
+ src/AcDream.Core/Physics/PhysicsEngine.cs `
+ src/AcDream.Core/Physics/PhysicsDataCache.cs `
+ src/AcDream.Core/Physics/TransitionTypes.cs `
+ tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsSphereTests.cs `
+ tests/AcDream.Core.Tests/Physics/CellTransitAddAllOutsideCellsTests.cs `
+ tests/AcDream.Core.Tests/Physics/CellTransitFindCellListTests.cs `
+ tests/AcDream.Core.Tests/Physics/ResolveCellIdTests.cs
+
+git rm tests/AcDream.Core.Tests/Physics/ResolveOutdoorCellIdTests.cs
+
+git commit -m "$(cat <<'EOF'
+feat(physics): Phase 2 — port CellTransit + delete AABB containment
+
+New CellTransit static class ports retail's portal-graph cell traversal:
+- FindTransitCellsSphere — indoor portal-neighbour walk
+- AddAllOutsideCells — outdoor 24m grid expansion
+- FindCellList — top-level driver (BFS through portals;
+ PointInsideCellBsp for final containment)
+
+PhysicsEngine.ResolveOutdoorCellId renamed to ResolveCellId. Body
+rewritten to delegate to CellTransit.FindCellList. Signature extended
+with sphereRadius parameter (needed by the sphere-vs-portal-plane test).
+Three call sites updated (PhysicsEngine ×2, TransitionTypes ×1).
+
+Deletes PhysicsDataCache.TryFindContainingCell + the corresponding AABB
+compute. The previous Phase D AABB-containment tests are dropped; new
+tests under CellTransit*Tests cover the equivalent scenarios via portal
+traversal.
+
+Outdoor→indoor entry (check_building_transit) is wired but no-op until
+the BuildingPhysics infrastructure lands in a follow-up commit.
+
+Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
+Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 8: Add `BuildingPhysics` + `CheckBuildingTransit` (TDD)
+
+**Files:**
+- Create: `src/AcDream.Core/Physics/BuildingPhysics.cs`
+- Modify: `src/AcDream.Core/Physics/PhysicsDataCache.cs`
+- Modify: `src/AcDream.Core/Physics/CellTransit.cs`
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
+- Create: `tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs`
+
+- [ ] **Step 1: Create the `BuildingPhysics` type**
+
+Create `src/AcDream.Core/Physics/BuildingPhysics.cs`:
+
+```csharp
+using System.Collections.Generic;
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Indoor walking Phase 2 (2026-05-19). Cached building portal data
+/// for outdoor→indoor cell entry. One per outdoor landcell that contains
+/// a building stab. Mirrors retail's BuildingObj.Portals array
+/// (per the pseudocode doc §"LandCell.find_transit_cells").
+///
+public sealed class BuildingPhysics
+{
+ public required Matrix4x4 WorldTransform { get; init; }
+ public required Matrix4x4 InverseWorldTransform { get; init; }
+ public required IReadOnlyList Portals { get; init; }
+}
+
+///
+/// One building portal: the connection from a SortCell's BuildingObj to
+/// an interior EnvCell.
+///
+public readonly struct BldPortalInfo
+{
+ public BldPortalInfo(uint otherCellId, ushort otherPortalId, ushort flags, bool exactMatch)
+ {
+ OtherCellId = otherCellId;
+ OtherPortalId = otherPortalId;
+ Flags = flags;
+ ExactMatch = exactMatch;
+ }
+
+ /// Full id of the interior EnvCell this portal connects to.
+ public uint OtherCellId { get; }
+ /// The portal id within the destination EnvCell.
+ public ushort OtherPortalId { get; }
+ public ushort Flags { get; }
+ public bool ExactMatch { get; }
+}
+```
+
+- [ ] **Step 2: Add `CacheBuilding` + `GetBuilding` to `PhysicsDataCache`**
+
+In `src/AcDream.Core/Physics/PhysicsDataCache.cs`, near the existing `CacheCellStruct` method (around line 180), add:
+
+```csharp
+// ── Phase 2: building portal cache for outdoor→indoor entry ───────────
+
+private readonly System.Collections.Concurrent.ConcurrentDictionary _buildings = new();
+
+///
+/// Indoor walking Phase 2 (2026-05-19). Cache the building portal list
+/// for an outdoor landcell that contains a building stab. Used by
+/// .
+///
+public void CacheBuilding(uint landcellId, IReadOnlyList portals, Matrix4x4 worldTransform)
+{
+ if (_buildings.ContainsKey(landcellId)) return;
+ Matrix4x4.Invert(worldTransform, out var inverse);
+ _buildings[landcellId] = new BuildingPhysics
+ {
+ WorldTransform = worldTransform,
+ InverseWorldTransform = inverse,
+ Portals = portals,
+ };
+}
+
+public BuildingPhysics? GetBuilding(uint landcellId)
+ => _buildings.TryGetValue(landcellId, out var b) ? b : null;
+
+public IReadOnlyCollection BuildingIds => (IReadOnlyCollection)_buildings.Keys;
+
+/// Test helper, mirrors .
+public void RegisterBuildingForTest(uint landcellId, BuildingPhysics b) => _buildings[landcellId] = b;
+```
+
+- [ ] **Step 3: Write the failing test**
+
+Create `tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs`:
+
+```csharp
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+
+namespace AcDream.Core.Tests.Physics;
+
+public class CellTransitCheckBuildingTransitTests
+{
+ [Fact]
+ public void SphereOverlapsBuildingPortal_AddsInteriorCell()
+ {
+ // Building at world origin. One portal to interior cell 0xA9B40100.
+ var building = new BuildingPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Portals = new[]
+ {
+ new BldPortalInfo(
+ otherCellId: 0xA9B40100u,
+ otherPortalId: 0,
+ flags: 0,
+ exactMatch: false),
+ },
+ };
+
+ // Interior cell whose CellBSP returns "inside" for the sphere center.
+ // For this test we use a null CellBSP — PointInsideCellBsp on null
+ // returns true, so the cell registers as containing the point.
+ var interiorCell = new CellPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new Dictionary(),
+ };
+
+ var cache = new PhysicsDataCache();
+ cache.RegisterBuildingForTest(0xA9B40001u, building);
+ cache.RegisterCellStructForTest(0xA9B40100u, interiorCell);
+
+ var candidates = new HashSet();
+ CellTransit.CheckBuildingTransit(
+ cache,
+ building,
+ worldSphereCenter: new Vector3(0, 0, 0),
+ sphereRadius: 0.5f,
+ candidates);
+
+ Assert.Contains(0xA9B40100u, candidates);
+ }
+}
+```
+
+- [ ] **Step 4: Add `CheckBuildingTransit` to `CellTransit`**
+
+Append to `src/AcDream.Core/Physics/CellTransit.cs`:
+
+```csharp
+ ///
+ /// Outdoor→indoor entry path. Ported from retail's
+ /// BuildingObj::find_building_transit_cells +
+ /// EnvCell::check_building_transit. For each portal of the
+ /// outdoor building, look up the destination interior cell and test
+ /// whether the sphere overlaps it via .
+ /// If so, add the interior cell to .
+ ///
+ public static void CheckBuildingTransit(
+ PhysicsDataCache cache,
+ BuildingPhysics building,
+ Vector3 worldSphereCenter,
+ float sphereRadius,
+ HashSet candidates)
+ {
+ foreach (var portal in building.Portals)
+ {
+ if (portal.OtherCellId == 0xFFFFFFFFu) continue;
+
+ var otherCell = cache.GetCellStruct(portal.OtherCellId);
+ if (otherCell is null) continue;
+
+ // Sphere center in the OTHER cell's local space.
+ var localCenter = Vector3.Transform(worldSphereCenter, otherCell.InverseWorldTransform);
+
+ // Use PointInsideCellBsp if available; else fall through.
+ if (otherCell.CellBSP?.Root is null) continue;
+ if (BSPQuery.PointInsideCellBsp(otherCell.CellBSP.Root, localCenter))
+ {
+ candidates.Add(portal.OtherCellId);
+ }
+ }
+ }
+```
+
+- [ ] **Step 5: Wire `CheckBuildingTransit` into `FindCellList`**
+
+In `src/AcDream.Core/Physics/CellTransit.cs`, find the outdoor seed branch of `FindCellList`:
+
+```csharp
+else
+{
+ // Outdoor seed.
+ AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
+ // TODO: check_building_transit hookup at Task 7.
+}
+```
+
+Replace the TODO with the building loop:
+
+```csharp
+else
+{
+ // Outdoor seed.
+ AddAllOutsideCells(worldSphereCenter, sphereRadius, currentCellId, candidates);
+
+ // For each landcell candidate, check if it has a building stab.
+ var landcellSnapshot = new List(candidates);
+ foreach (uint landcellId in landcellSnapshot)
+ {
+ var building = cache.GetBuilding(landcellId);
+ if (building is null) continue;
+ CheckBuildingTransit(cache, building, worldSphereCenter, sphereRadius, candidates);
+ }
+}
+```
+
+- [ ] **Step 6: Wire `CacheBuilding` at landblock load**
+
+In `src/AcDream.App/Rendering/GameWindow.cs`, find the existing `lbInfo.Buildings` iteration (around line 5641 where portal planes are extracted). Add a call to `_physicsDataCache.CacheBuilding` inside the loop, using the actual property names from Task 0. Example shape (the exact property names need substitution):
+
+```csharp
+foreach (var building in lbInfo.Buildings)
+{
+ // building.Portals → IReadOnlyList
+ // building.Frame → world transform (or building.Transform — check Task 0)
+ var portals = new System.Collections.Generic.List(building.Portals.Count);
+ foreach (var bp in building.Portals)
+ {
+ portals.Add(new AcDream.Core.Physics.BldPortalInfo(
+ otherCellId: bp.OtherCellId,
+ otherPortalId: bp.OtherPortalId,
+ flags: (ushort)bp.Flags,
+ exactMatch: bp.ExactMatch));
+ }
+
+ // Compute the building's world transform.
+ var buildingTransform = System.Numerics.Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
+ * System.Numerics.Matrix4x4.CreateTranslation(building.Frame.Origin + origin);
+
+ // Building lives in a specific outdoor landcell. The dat encoding is
+ // typically a "SortCell" id — derive it from building.Frame.Origin or
+ // the building's containing-cell field (verify in Task 0).
+ uint landcellId = (lb.LandblockId & 0xFFFF0000u)
+ | (uint)(((int)(building.Frame.Origin.X / 24f) * 8 + (int)(building.Frame.Origin.Y / 24f)) + 1);
+
+ _physicsDataCache.CacheBuilding(landcellId, portals, buildingTransform);
+}
+```
+
+(The landcell-id derivation may need adjusting based on retail's exact SortCell rules — confirm during Task 0 investigation. If the building's containing-cell is stored explicitly in the DAT, use that instead of the X/Y math.)
+
+- [ ] **Step 7: Run all tests**
+
+Run: `dotnet build && dotnet test`
+Expected: build green, all new tests pass, pre-existing 8 failures unchanged.
+
+- [ ] **Step 8: Commit**
+
+```
+git add src/AcDream.Core/Physics/BuildingPhysics.cs `
+ src/AcDream.Core/Physics/PhysicsDataCache.cs `
+ src/AcDream.Core/Physics/CellTransit.cs `
+ src/AcDream.App/Rendering/GameWindow.cs `
+ tests/AcDream.Core.Tests/Physics/CellTransitCheckBuildingTransitTests.cs
+
+git commit -m "$(cat <<'EOF'
+feat(physics): Phase 2 — port BuildingPhysics + CheckBuildingTransit
+
+Adds outdoor→indoor cell entry via building portals. Ported from
+retail's BuildingObj::find_building_transit_cells +
+CEnvCell::check_building_transit.
+
+New BuildingPhysics type holds the per-SortCell BldPortal list +
+building world transform. CacheBuilding wires from GameWindow at
+landblock load. CellTransit.FindCellList's outdoor branch now expands
+each landcell candidate's building portals (if any) via
+CheckBuildingTransit, which point-in-cell tests the destination
+interior cell. Closes the "walk into a building from outside" path.
+
+Spec: docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
+Plan: docs/superpowers/plans/2026-05-19-indoor-portal-cell-tracking.md
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 9: Capture session (user-driven)
+
+**Goal:** verify the four acceptance criteria from the spec live in the Holtburg cottage.
+
+- [ ] **Step 1: Build green**
+
+Run: `dotnet build`
+Expected: 0 errors.
+
+- [ ] **Step 2: Launch the client**
+
+User runs:
+
+```powershell
+$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
+$env:ACDREAM_LIVE = "1"
+$env:ACDREAM_TEST_HOST = "127.0.0.1"
+$env:ACDREAM_TEST_PORT = "9000"
+$env:ACDREAM_TEST_USER = "testaccount"
+$env:ACDREAM_TEST_PASS = "testpassword"
+$env:ACDREAM_PROBE_INDOOR_BSP = "1"
+$env:ACDREAM_PROBE_CELL = "1"
+$env:ACDREAM_PROBE_CELL_CACHE = "1"
+$env:ACDREAM_DEVTOOLS = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug *>&1 |
+ Tee-Object -FilePath "launch-phase2-verify.log"
+```
+
+- [ ] **Step 3: Test the four acceptance scenarios**
+
+1. **Indoor walking** — Enter the Holtburg cottage. Walk around freely. Walls must block from inside. Furniture must still collide.
+2. **Outdoor→indoor** — Walk toward the cottage door from outside. The door must let you through; the walls beside the door must block.
+3. **Indoor→outdoor** — Walk back out through the door. Outdoor terrain collision must resume; you're no longer trapped inside.
+4. **Indoor→indoor** — If the cottage has multiple rooms, walk between them.
+
+Close the window when done.
+
+- [ ] **Step 4: Verify the log shows clean transitions**
+
+Run:
+```bash
+rg -c "^\[cell-transit\]" launch-phase2-verify.log
+rg -c "^\[indoor-bsp\].*result=Collided" launch-phase2-verify.log
+```
+
+Expected:
+- Multiple `[cell-transit]` lines including indoor↔outdoor crossings with proper landblock prefixes.
+- `[indoor-bsp] result=Collided` lines firing when the player walks into walls.
+
+If neither shows up: portal traversal isn't firing. Re-launch with debugging or escalate.
+
+- [ ] **Step 5: Save log artifact**
+
+The log at `launch-phase2-verify.log` will be cited in the Phase F handoff doc. No commit needed yet.
+
+---
+
+## Task 10: Docs cleanup + handoff
+
+**Files:**
+- Modify: `docs/ISSUES.md`
+- Modify: `docs/plans/2026-04-11-roadmap.md`
+- Modify: `CLAUDE.md`
+- Create: `docs/research/-portal-cell-tracking-shipped-handoff.md`
+
+- [ ] **Step 1: Close issues**
+
+In `docs/ISSUES.md`:
+
+- Move **#87** (Indoor cell tracking uses AABB containment...) to "Recently closed". Status DONE. Closed date + commit SHAs.
+- Update **#84** (blocked by air indoors): the remaining wall-pass-through symptom is closed by Phase 2. Move to "Recently closed".
+- Update **#85** (pass through walls outside→in): the outdoor→indoor entry via `BuildingObj` portals closes this. Move to "Recently closed".
+
+- [ ] **Step 2: Update roadmap**
+
+In `docs/plans/2026-04-11-roadmap.md`, add a "Recently shipped" row for "Indoor portal-based cell tracking" with date + commit SHAs. Remove any forward entry for this work.
+
+- [ ] **Step 3: Update CLAUDE.md**
+
+Update the "Currently in Phase..." paragraph to reflect Phase 2 shipped. Next phase is Claude's choice per work-order autonomy.
+
+- [ ] **Step 4: Write the shipped-handoff doc**
+
+Create `docs/research/-portal-cell-tracking-shipped-handoff.md`. Mirror the format of `docs/research/2026-05-19-cluster-a-shipped-handoff.md`. Cover:
+
+- Commits list with SHAs
+- One-paragraph summary of what shipped
+- Per-issue resolution (#87, #84, #85 all closed)
+- Probe evidence from `launch-phase2-verify.log`
+- Diagnostic infrastructure that persists (`[cell-cache]`, `[indoor-bsp]`)
+- Follow-up items: `BSPQuery.SphereIntersectsCellBsp` for retail-faithful neighbour-add; parts/AABB variant of `find_transit_cells` for remote entities; `VisibleCells` cleanup filter.
+
+- [ ] **Step 5: Final build + test sweep**
+
+Run: `dotnet build && dotnet test`
+Expected: 0 errors, all new tests pass, 8 pre-existing failures unchanged.
+
+- [ ] **Step 6: Commit the docs**
+
+```
+git add docs/ISSUES.md `
+ docs/plans/2026-04-11-roadmap.md `
+ CLAUDE.md `
+ docs/research/-portal-cell-tracking-shipped-handoff.md
+
+git commit -m "$(cat <<'EOF'
+docs(phase): Indoor portal-based cell tracking shipped
+
+Closes ISSUES.md #87, #84, #85. Portal-graph cell traversal replaces
+Phase D's AABB containment; player can now walk freely inside
+buildings, walls block consistently, doors update CellId correctly,
+walking into a building from outside works.
+
+ISSUES.md: #84/#85/#87 → Recently closed.
+Roadmap: Indoor portal cell tracking added to shipped table.
+CLAUDE.md: current-phase paragraph updated.
+
+Follow-up items (filed inline in handoff doc):
+- BSPQuery.SphereIntersectsCellBsp port for retail-faithful neighbour-add
+ (currently uses a plane-side heuristic)
+- parts/AABB variant of find_transit_cells for remote-entity cell tracking
+- VisibleCells cleanup filter at end of find_cell_list
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Self-review checklist (run after writing the plan)
+
+1. **Spec coverage:** every spec section maps to a task. ✓ §1-2 → background; §3-4 architecture/components → Tasks 1-7; §5 data flow → Tasks 3 + 7; §6 commit shape → Tasks 3 + 7 + 8 + 10; §7 files → File Structure table; §9 testing → per-task unit tests + Task 9 live; §10 acceptance → Task 9.
+2. **Placeholder scan:** Two intentional `` substitutions remain in Task 3 — these get resolved at Task 0. The landcell-id derivation in Task 8 Step 6 may need tweaking based on Task 0 findings — flagged inline. No "TBD", "TODO", or unspecified behavior.
+3. **Type consistency:**
+ - `PortalInfo(ushort, ushort, ushort)` is consistent across Tasks 1, 2, 3, 4.
+ - `BldPortalInfo(uint, ushort, ushort, bool)` consistent across Tasks 8.
+ - `CellTransit.FindCellList(cache, worldSphereCenter, sphereRadius, currentCellId) → uint` consistent across Tasks 6, 7.
+ - `CellTransit.FindTransitCellsSphere(cache, currentCell, currentCellId, ws, r, candidates, out exitOutside)` consistent across Tasks 4, 6.
+ - `CellTransit.AddAllOutsideCells(ws, r, currentCellId, candidates)` consistent across Tasks 5, 6, 7.
+ - `CellTransit.CheckBuildingTransit(cache, building, ws, r, candidates)` consistent across Task 8.
+4. **Acceptance:** matches spec §10. Visual verification by user in Task 9.
diff --git a/docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md b/docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md
new file mode 100644
index 00000000..b9010fc4
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md
@@ -0,0 +1,1249 @@
+# Indoor Walkable-Plane BSP Port — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Spec:** [docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md](../specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md) (committed `165f67a`)
+
+**Goal:** Route `Transition.TryFindIndoorWalkablePlane` through the existing retail-faithful BSP walkable-finder (`BSPQuery.FindWalkableInternal`) via a thin new public wrapper, so indoor walkable-plane synthesis picks the polygon closest to the foot along the up vector instead of the first walkable polygon in dictionary order. Fixes cellar descent, 2nd-floor walking, and the suspected invisible-obstacle cascade indoors.
+
+**Architecture:** One small extension (`FindWalkableInternal` gains a `ref ushort hitPolyId` so the dictionary key surfaces to the caller), one new ~30-line public wrapper (`BSPQuery.FindWalkableSphere`), one refactored helper body (`Transition.TryFindIndoorWalkablePlane`), one deleted dead helper (`Transition.PointInPolygonXY`), one extended diagnostic probe line (`[indoor-walkable]`). All other physics code paths unchanged.
+
+**Tech Stack:** C# / .NET 10, `Silk.NET` (not touched here), xUnit for tests, existing `PhysicsDiagnostics` for probes.
+
+**Build / test invocations (used throughout):**
+- Full build: `dotnet build -c Debug`
+- Targeted test run: `dotnet test --filter "FullyQualifiedName~BSPQueryTests"` for BSP tests, `dotnet test --filter "FullyQualifiedName~TransitionTypesTests"` for transition tests
+- Full suite: `dotnet test`
+- The 8 pre-existing failures (MotionInterpreter / BSPStepUp baseline) must remain exactly 8 — no new failures.
+
+---
+
+## File Structure
+
+| File | Action | Lines (approx) | Purpose |
+|---|---|---|---|
+| `src/AcDream.Core/Physics/BSPQuery.cs` | Modify | Extends `FindWalkableInternal` signature (+1 ref param) and updates 4 internal recursion sites + 2 external callers (StepSphereDown, Path 4 Collide). Adds new `FindWalkableSphere` public wrapper. | The walkable-finder lives here; one self-contained file. |
+| `src/AcDream.Core/Physics/TransitionTypes.cs` | Modify | Refactors `TryFindIndoorWalkablePlane` body, adds `sphereRadius` parameter, deletes `PointInPolygonXY` helper. Adds `[indoor-walkable]` probe line in `FindEnvCollisions`. | The indoor walkable-plane synthesis lives here. |
+| `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs` | Modify (append) | Adds 4 new unit tests for `FindWalkableSphere` (closest-below-foot, closest-above-foot-when-only-above, no-walkable-in-range, steep-poly-rejected). | Existing BSP test suite. |
+| `tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs` | Create OR Modify (append) | Adds 1 integration test asserting `TryFindIndoorWalkablePlane` routes through `BSPQuery.FindWalkableSphere` and preserves `WalkableAllowance`. | New or existing transition test file (check during Task 4). |
+| `docs/plans/2026-04-11-roadmap.md` | Modify | Adds "Indoor walkable-plane BSP port" row to the shipped table after visual verification passes. | Strategic roadmap. |
+| `docs/ISSUES.md` | Modify | Closes #83 after visual verification of scenarios 4 & 5. | Tactical issues list. |
+
+---
+
+## Task 1: Extend `BSPQuery.FindWalkableInternal` to track polyId
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/BSPQuery.cs:647-705` (FindWalkableInternal body)
+- Modify: `src/AcDream.Core/Physics/BSPQuery.cs:1085-1128` (StepSphereDown caller)
+- Modify: `src/AcDream.Core/Physics/BSPQuery.cs:1486-1532` (Path 4 Collide caller)
+
+This is a pure mechanical signature extension. Since `FindWalkableInternal` already iterates `foreach (ushort polyId in node.Polygons)` in its leaf branch (line 665), the polyId is already in scope — we just expose it to the caller. No behavior change for the two existing callers; they pass a discard. The new `FindWalkableSphere` wrapper (Task 2) will use the new param.
+
+- [ ] **Step 1.1: Modify `FindWalkableInternal` signature and body**
+
+Change `BSPQuery.cs:647-705`:
+
+```csharp
+private static void FindWalkableInternal(
+ PhysicsBSPNode? node,
+ Dictionary resolved,
+ SpherePath path,
+ CollisionSphere validPos,
+ Vector3 movement,
+ Vector3 up,
+ ref ResolvedPolygon? hitPoly,
+ ref ushort hitPolyId,
+ ref bool changed)
+{
+ if (node is null) return;
+ if (!NodeIntersects(node, validPos)) return;
+
+ // Leaf.
+ if (node.Type == BSPNodeType.Leaf)
+ {
+ if (node.Polygons.Count == 0) return;
+
+ foreach (ushort polyId in node.Polygons)
+ {
+ if (!resolved.TryGetValue(polyId, out var poly)) continue;
+
+ bool walkable = WalkableHitsSphere(poly, path, validPos, up);
+ bool adjusted = walkable && AdjustSphereToPlane(poly, path, validPos, movement);
+
+ if (walkable && adjusted)
+ {
+ changed = true;
+ hitPoly = poly;
+ hitPolyId = polyId;
+ }
+ }
+ return;
+ }
+
+ // Internal: classify against splitting plane.
+ float dist = Vector3.Dot(node.SplittingPlane.Normal, validPos.Center)
+ + node.SplittingPlane.D;
+ float reach = validPos.Radius - PhysicsGlobals.EPSILON;
+
+ if (dist >= reach)
+ {
+ FindWalkableInternal(node.PosNode, resolved, path, validPos, movement, up,
+ ref hitPoly, ref hitPolyId, ref changed);
+ return;
+ }
+
+ if (dist <= -reach)
+ {
+ FindWalkableInternal(node.NegNode, resolved, path, validPos, movement, up,
+ ref hitPoly, ref hitPolyId, ref changed);
+ return;
+ }
+
+ // Straddles.
+ FindWalkableInternal(node.PosNode, resolved, path, validPos, movement, up,
+ ref hitPoly, ref hitPolyId, ref changed);
+ FindWalkableInternal(node.NegNode, resolved, path, validPos, movement, up,
+ ref hitPoly, ref hitPolyId, ref changed);
+}
+```
+
+- [ ] **Step 1.2: Update `StepSphereDown` caller**
+
+Change `BSPQuery.cs:1085-1128`:
+
+```csharp
+private static TransitionState StepSphereDown(
+ PhysicsBSPNode root,
+ Dictionary resolved,
+ Transition transition,
+ CollisionSphere checkPos,
+ Vector3 up,
+ float scale,
+ Quaternion localToWorld = default,
+ Vector3 worldOrigin = default)
+{
+ if (localToWorld == default) localToWorld = Quaternion.Identity;
+
+ var path = transition.SpherePath;
+ var collisions = transition.CollisionInfo;
+
+ float stepDownAmount = -(path.StepDownAmt * path.WalkInterp);
+ var movement = up * stepDownAmount * (1f / scale);
+
+ var validPos = new CollisionSphere(checkPos);
+ bool changed = false;
+ ResolvedPolygon? polyHit = null;
+ ushort _polyId = 0; // step-down doesn't need the id, but the signature requires it
+
+ FindWalkableInternal(root, resolved, path, validPos, movement, up,
+ ref polyHit, ref _polyId, ref changed);
+
+ if (changed && polyHit is not null)
+ {
+ // ACE: path.LocalSpacePos.LocalToGlobalVec(adjusted) * scale
+ var adjusted = validPos.Center - checkPos.Center;
+ var offset = Vector3.Transform(adjusted, localToWorld) * scale;
+ path.AddOffsetToCheckPos(offset);
+
+ var worldNormal = TransformNormal(polyHit.Plane.Normal, localToWorld);
+ var worldVertices = TransformVertices(polyHit.Vertices, localToWorld, scale, worldOrigin);
+ var worldPlane = BuildWorldPlane(worldNormal, worldVertices);
+ collisions.SetContactPlane(worldPlane, path.CheckCellId, false);
+
+ path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ);
+
+ return TransitionState.Adjusted;
+ }
+
+ return TransitionState.OK;
+}
+```
+
+- [ ] **Step 1.3: Update Path 4 (Collide) caller**
+
+In `BSPQuery.cs:1486-1496` change the call site:
+
+```csharp
+ if (path.Collide)
+ {
+ var validPos = new CollisionSphere(sphere0);
+ ResolvedPolygon? hitPoly = null;
+ ushort _hitPolyId = 0; // Path 4 doesn't need the id
+ bool changed = false;
+
+ FindWalkableInternal(root, resolved, path, validPos, movement, localSpaceZ,
+ ref hitPoly, ref _hitPolyId, ref changed);
+
+ if (changed && hitPoly is not null)
+ {
+ // ACE: var offset = LocalToGlobalVec(validPos.Center - localSphere.Center) * scale
+```
+
+(Only lines 1488-1493 change — the discard local addition and the new `ref _hitPolyId` argument.)
+
+- [ ] **Step 1.4: Build and verify no regression**
+
+Run:
+```
+dotnet build -c Debug
+```
+
+Expected: clean build (zero errors, zero warnings introduced).
+
+Run:
+```
+dotnet test --filter "FullyQualifiedName~BSPQueryTests"
+```
+
+Expected: all existing BSPQueryTests pass (no behavior change yet).
+
+Run:
+```
+dotnet test
+```
+
+Expected: same 8 pre-existing failures as before (MotionInterpreter / BSPStepUp baseline). No new failures.
+
+- [ ] **Step 1.5: Commit**
+
+```bash
+git add src/AcDream.Core/Physics/BSPQuery.cs
+git commit -m "$(cat <<'EOF'
+refactor(physics): expose hitPolyId from FindWalkableInternal
+
+Adds a ref ushort hitPolyId parameter to FindWalkableInternal so callers
+can identify which polygon was hit. The leaf branch already iterates
+foreach (ushort polyId in node.Polygons); this surfaces it.
+
+No behavior change. Existing callers (StepSphereDown, Path 4 Collide)
+pass a discard local. The new BSPQuery.FindWalkableSphere wrapper
+(next commit) will consume it.
+
+Prep for indoor walkable-plane BSP port — see spec
+docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
+EOF
+)"
+```
+
+---
+
+## Task 2: Add `BSPQuery.FindWalkableSphere` public wrapper + unit tests
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/BSPQuery.cs` — add new public method adjacent to `StepSphereDown` (~line 1085)
+- Modify: `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs` — append 4 new unit tests
+
+TDD order: write the 4 failing tests first, then implement.
+
+- [ ] **Step 2.1: Append test helpers and Test 1 to BSPQueryTests.cs (failing)**
+
+Append at the end of `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs` (inside the existing `BSPQueryTests` class, before the closing brace):
+
+```csharp
+ // =========================================================================
+ // FindWalkableSphere — indoor walkable-plane finder (spec 2026-05-19)
+ // =========================================================================
+
+ ///
+ /// Build a single-leaf BSP rooted at one node containing two horizontal
+ /// walkable polygons (id 0 at Z=lowerZ, id 1 at Z=upperZ), both covering
+ /// the unit square X[0..1] × Y[0..1]. Bounding sphere is sized to enclose
+ /// both polys.
+ ///
+ private static (PhysicsBSPNode root, Dictionary resolved)
+ BuildTwoFloorsBsp(float lowerZ, float upperZ)
+ {
+ var center = new Vector3(0.5f, 0.5f, (lowerZ + upperZ) * 0.5f);
+ float halfHeight = MathF.Abs(upperZ - lowerZ) * 0.5f + 1.0f;
+ float radius = MathF.Sqrt(0.5f * 0.5f + 0.5f * 0.5f + halfHeight * halfHeight);
+
+ var root = new PhysicsBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ BoundingSphere = new Sphere { Origin = center, Radius = radius },
+ };
+ root.Polygons.Add(0);
+ root.Polygons.Add(1);
+
+ Vector3[] lowerVerts =
+ {
+ new Vector3(0f, 0f, lowerZ),
+ new Vector3(1f, 0f, lowerZ),
+ new Vector3(1f, 1f, lowerZ),
+ new Vector3(0f, 1f, lowerZ),
+ };
+ Vector3[] upperVerts =
+ {
+ new Vector3(0f, 0f, upperZ),
+ new Vector3(1f, 0f, upperZ),
+ new Vector3(1f, 1f, upperZ),
+ new Vector3(0f, 1f, upperZ),
+ };
+
+ var resolved = new Dictionary
+ {
+ [0] = new ResolvedPolygon
+ {
+ Vertices = lowerVerts,
+ Plane = new Plane(Vector3.UnitZ, -lowerZ),
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ },
+ [1] = new ResolvedPolygon
+ {
+ Vertices = upperVerts,
+ Plane = new Plane(Vector3.UnitZ, -upperZ),
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ },
+ };
+
+ return (root, resolved);
+ }
+
+ ///
+ /// Build a Transition with WalkableAllowance set to FloorZ — what the
+ /// indoor walkable-plane synthesis uses.
+ ///
+ private static Transition BuildFloorZTransition()
+ {
+ var transition = new Transition();
+ transition.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
+ transition.SpherePath.WalkInterp = 1.0f;
+ return transition;
+ }
+
+ [Fact]
+ public void FindWalkableSphere_TwoFloors_FootBetween_PicksLowerFloor()
+ {
+ // Two floors at Z=0 and Z=3. Foot sphere center at Z=1.0 (radius 0.48).
+ // Downward probe of 0.5m → sphere swept from Z=1.0 down to Z=0.5.
+ // Lower floor is at Z=0; sphere will collide with it (top of sphere
+ // reaches Z=1.48; with downward sweep, bottom reaches Z=0.02). Upper
+ // floor at Z=3 is unreachable in either direction within probe range.
+ // Expect: pick lower floor (id 0).
+ var (root, resolved) = BuildTwoFloorsBsp(lowerZ: 0f, upperZ: 3f);
+ var transition = BuildFloorZTransition();
+
+ var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 1.0f), Radius = 0.48f };
+
+ bool found = BSPQuery.FindWalkableSphere(
+ root, resolved, transition,
+ sphere,
+ probeDistance: 0.5f,
+ up: Vector3.UnitZ,
+ out var hitPoly,
+ out var hitPolyId,
+ out var adjustedCenter);
+
+ Assert.True(found);
+ Assert.Equal((ushort)0, hitPolyId);
+ Assert.NotNull(hitPoly);
+ Assert.Equal(0f, hitPoly!.Plane.Normal.Z, precision: 3); // sanity
+ }
+```
+
+- [ ] **Step 2.2: Run test, verify it fails with "FindWalkableSphere not defined"**
+
+Run:
+```
+dotnet test --filter "FullyQualifiedName~FindWalkableSphere_TwoFloors_FootBetween" --no-restore
+```
+
+Expected: compile error or test failure citing `BSPQuery.FindWalkableSphere` not found.
+
+- [ ] **Step 2.3: Implement `BSPQuery.FindWalkableSphere`**
+
+Insert into `src/AcDream.Core/Physics/BSPQuery.cs` immediately AFTER `StepSphereDown` (after line ~1128, before the `// step_sphere_up` section header at line ~1130):
+
+```csharp
+ // -------------------------------------------------------------------------
+ // find_walkable_sphere — "stand here, find my contact plane"
+ // Indoor walkable-plane synthesis entry point (Phase 2 follow-up 2026-05-19).
+ // -------------------------------------------------------------------------
+
+ ///
+ /// "Stand here, find my contact plane" entry point over the BSPNode/BSPLeaf
+ /// find_walkable BSP traversal. Probes downward by
+ /// along and returns the closest walkable polygon the
+ /// sphere would rest on, with the sphere's center adjusted to lie on that plane.
+ ///
+ ///
+ /// Wraps the existing private — which already
+ /// implements the retail-faithful walkable-finder
+ /// (BSPNODE::find_walkable + BSPLEAF::find_walkable +
+ /// CPolygon::walkable_hits_sphere + CPolygon::adjust_sphere_to_plane,
+ /// acclient_2013_pseudo_c.txt:326211, :326793, :323006, :322032).
+ ///
+ ///
+ ///
+ /// Intended call site: indoor walkable-plane synthesis in
+ /// Transition.TryFindIndoorWalkablePlane when the indoor cell-BSP
+ /// collision returns OK (no wall hit) and the resolver still needs a
+ /// ContactPlane to feed ValidateWalkable. Outdoor terrain has its own path
+ /// () and does not use this.
+ ///
+ ///
+ ///
+ /// The caller is responsible for setting transition.SpherePath.WalkableAllowance
+ /// to the desired walkability threshold (typically )
+ /// before calling, and restoring it after.
+ ///
+ ///
+ /// Root of the cell's PhysicsBSP.
+ /// Pre-resolved polygon dictionary from PhysicsDataCache.
+ /// Current transition (read for WalkableAllowance / walk_interp).
+ /// Foot sphere in cell-local space.
+ /// Downward probe distance in meters. Typical: 0.5f.
+ /// Up vector in cell-local space (typically Vector3.UnitZ).
+ /// Output: the walkable polygon found, or null on miss.
+ /// Output: polygon id (dictionary key) of the hit. Zero on miss.
+ ///
+ /// Output: sphere center adjusted onto the polygon plane. Equal to input
+ /// sphere.Origin on miss.
+ ///
+ /// True if a walkable polygon was found; false otherwise.
+ public static bool FindWalkableSphere(
+ PhysicsBSPNode? root,
+ Dictionary resolved,
+ Transition transition,
+ Sphere sphere,
+ float probeDistance,
+ Vector3 up,
+ out ResolvedPolygon? hitPoly,
+ out ushort hitPolyId,
+ out Vector3 adjustedCenter)
+ {
+ adjustedCenter = sphere.Origin;
+ hitPoly = null;
+ hitPolyId = 0;
+
+ if (root is null) return false;
+
+ var validPos = new CollisionSphere(sphere.Origin, sphere.Radius);
+ var movement = -up * probeDistance;
+ bool changed = false;
+ ushort polyId = 0;
+ ResolvedPolygon? poly = null;
+
+ FindWalkableInternal(root, resolved, transition.SpherePath, validPos,
+ movement, up, ref poly, ref polyId, ref changed);
+
+ if (changed && poly is not null)
+ {
+ hitPoly = poly;
+ hitPolyId = polyId;
+ adjustedCenter = validPos.Center;
+ return true;
+ }
+
+ return false;
+ }
+```
+
+- [ ] **Step 2.4: Run test, verify it passes**
+
+Run:
+```
+dotnet test --filter "FullyQualifiedName~FindWalkableSphere_TwoFloors_FootBetween"
+```
+
+Expected: PASS.
+
+- [ ] **Step 2.5: Append Test 2 (foot above only-upper-floor picks upper)**
+
+Append to BSPQueryTests.cs after Test 1:
+
+```csharp
+ [Fact]
+ public void FindWalkableSphere_OnlyUpperFloor_FootAbove_PicksUpperFloor()
+ {
+ // One floor at Z=3. Foot sphere at Z=3.5 with downward probe 0.5m.
+ // Sphere sweeps from Z=3.5 to Z=3.0; upper-floor surface at Z=3 is
+ // within reach. Expect: pick the upper floor (id 1).
+ var (root, resolved) = BuildTwoFloorsBsp(lowerZ: 0f, upperZ: 3f);
+ var transition = BuildFloorZTransition();
+
+ var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 3.5f), Radius = 0.48f };
+
+ bool found = BSPQuery.FindWalkableSphere(
+ root, resolved, transition,
+ sphere,
+ probeDistance: 0.5f,
+ up: Vector3.UnitZ,
+ out var hitPoly,
+ out var hitPolyId,
+ out _);
+
+ Assert.True(found);
+ Assert.Equal((ushort)1, hitPolyId);
+ Assert.NotNull(hitPoly);
+ }
+
+ [Fact]
+ public void FindWalkableSphere_NoWalkableInProbeRange_ReturnsFalse()
+ {
+ // Two floors at Z=0 and Z=3. Foot at Z=10 with probe 0.5m — out of
+ // range of both polygons even considering sphere radius (sweep covers
+ // Z=9.52..10.48, no overlap with Z=0 or Z=3).
+ var (root, resolved) = BuildTwoFloorsBsp(lowerZ: 0f, upperZ: 3f);
+ var transition = BuildFloorZTransition();
+
+ var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 10f), Radius = 0.48f };
+
+ bool found = BSPQuery.FindWalkableSphere(
+ root, resolved, transition,
+ sphere,
+ probeDistance: 0.5f,
+ up: Vector3.UnitZ,
+ out var hitPoly,
+ out var hitPolyId,
+ out var adjustedCenter);
+
+ Assert.False(found);
+ Assert.Null(hitPoly);
+ Assert.Equal((ushort)0, hitPolyId);
+ Assert.Equal(sphere.Origin, adjustedCenter);
+ }
+
+ [Fact]
+ public void FindWalkableSphere_SteepPoly_RejectedByWalkableAllowance()
+ {
+ // One polygon with a steep normal (Z component = 0.5 < FloorZ ≈ 0.6664).
+ // walkable_hits_sphere should reject it: dp = N·up = 0.5,
+ // WalkableAllowance = FloorZ, so dp <= WalkableAllowance is true → not walkable.
+ var center = new Vector3(0.5f, 0.5f, 0f);
+ var root = new PhysicsBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ BoundingSphere = new Sphere { Origin = center, Radius = 2f },
+ };
+ root.Polygons.Add(0);
+
+ // Plane tilted: normal has Z = 0.5 (52° slope).
+ var steepNormal = Vector3.Normalize(new Vector3(0f, MathF.Sqrt(0.75f), 0.5f));
+ Vector3[] verts =
+ {
+ new Vector3(0f, 0f, 0f),
+ new Vector3(1f, 0f, 0f),
+ new Vector3(1f, 1f, MathF.Sqrt(0.75f) / 0.5f),
+ new Vector3(0f, 1f, MathF.Sqrt(0.75f) / 0.5f),
+ };
+ var resolved = new Dictionary
+ {
+ [0] = new ResolvedPolygon
+ {
+ Vertices = verts,
+ Plane = new Plane(steepNormal, -Vector3.Dot(steepNormal, verts[0])),
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ },
+ };
+
+ var transition = BuildFloorZTransition();
+ var sphere = new Sphere { Origin = new Vector3(0.5f, 0.5f, 0.5f), Radius = 0.48f };
+
+ bool found = BSPQuery.FindWalkableSphere(
+ root, resolved, transition,
+ sphere,
+ probeDistance: 0.5f,
+ up: Vector3.UnitZ,
+ out _,
+ out _,
+ out _);
+
+ Assert.False(found);
+ }
+```
+
+- [ ] **Step 2.6: Run all 4 FindWalkableSphere tests, verify they pass**
+
+Run:
+```
+dotnet test --filter "FullyQualifiedName~FindWalkableSphere"
+```
+
+Expected: 4 tests pass.
+
+- [ ] **Step 2.7: Full BSPQuery test run — no regressions**
+
+Run:
+```
+dotnet test --filter "FullyQualifiedName~BSPQueryTests"
+```
+
+Expected: all existing tests pass plus 4 new ones.
+
+Run:
+```
+dotnet test
+```
+
+Expected: same 8 pre-existing failures (MotionInterpreter / BSPStepUp baseline). 4 new tests pass. No new failures.
+
+- [ ] **Step 2.8: Commit**
+
+```bash
+git add src/AcDream.Core/Physics/BSPQuery.cs tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs
+git commit -m "$(cat <<'EOF'
+feat(physics): add BSPQuery.FindWalkableSphere wrapper
+
+Thin public wrapper over the existing retail-faithful
+FindWalkableInternal (BSPNODE::find_walkable + BSPLEAF::find_walkable
+port). Probes downward by probeDistance along up, returns the closest
+walkable polygon the sphere would rest on plus the adjusted center.
+
+Will replace Transition.TryFindIndoorWalkablePlane's linear first-match
+scan (next commit). The wrapper is callable from any "stand here, find
+my floor" use case; current intent is indoor walkable-plane synthesis.
+
+4 unit tests covering: two-floors-foot-between, only-upper-floor-foot-above,
+no-walkable-in-probe-range, steep-poly-rejected-by-WalkableAllowance.
+
+Spec: docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
+EOF
+)"
+```
+
+---
+
+## Task 3: Refactor `TryFindIndoorWalkablePlane` to use `FindWalkableSphere`
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs:1192-1253` (helper body + delete PointInPolygonXY)
+- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs:1358` (single callsite — thread sphereRadius)
+
+TDD: write the integration test first (asserts BSPQuery is called AND WalkableAllowance is restored), then implement.
+
+- [ ] **Step 3.1: Create or locate the transition tests file**
+
+Check whether `tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs` exists:
+
+```
+ls tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
+```
+
+If it doesn't exist, create it with this header:
+
+```csharp
+using System.Numerics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
+using AcDream.Core.Physics;
+using Xunit;
+using Plane = System.Numerics.Plane;
+
+namespace AcDream.Core.Tests.Physics;
+
+public class TransitionTypesTests
+{
+ // (tests will be appended here)
+}
+```
+
+If it exists, skip the file creation; just append the test in step 3.2.
+
+- [ ] **Step 3.2: Append integration test (failing)**
+
+Append inside the `TransitionTypesTests` class:
+
+```csharp
+ [Fact]
+ public void TryFindIndoorWalkablePlane_TwoOverlappingFloors_PicksClosestBelowFoot_PreservesAllowance()
+ {
+ // Build a CellPhysics with two horizontal walkable polygons at
+ // local Z=0 and Z=3, both covering the unit square X[0..1] × Y[0..1].
+ // Foot at local Z=1.0 → should find the Z=0 polygon (closer below).
+ // Sentinel WalkableAllowance value must be preserved across the call.
+
+ var cellPhysics = BuildTwoFloorCellPhysics(lowerZ: 0f, upperZ: 3f);
+
+ var transition = new Transition();
+ const float sentinelAllowance = 0.42f;
+ transition.SpherePath.WalkableAllowance = sentinelAllowance;
+ transition.SpherePath.WalkInterp = 1.0f;
+
+ bool found = transition.TryFindIndoorWalkablePlane(
+ cellPhysics,
+ localFootCenter: new Vector3(0.5f, 0.5f, 1.0f),
+ sphereRadius: 0.48f,
+ out var worldPlane,
+ out var worldVertices,
+ out var hitPolyId);
+
+ Assert.True(found);
+ // The lower polygon's local plane Normal.Z = 1.0; identity world transform
+ // means the world Normal.Z is also 1.0.
+ Assert.Equal(1.0f, worldPlane.Normal.Z, precision: 3);
+ // World vertices should match the lower polygon (Z=0 in world space).
+ Assert.Equal(4, worldVertices.Length);
+ Assert.Equal(0f, worldVertices[0].Z, precision: 3);
+ // hitPolyId is the dictionary key — lower polygon was inserted as key 0.
+ Assert.Equal(0u, hitPolyId);
+ // WalkableAllowance must be restored to the sentinel.
+ Assert.Equal(sentinelAllowance, transition.SpherePath.WalkableAllowance);
+ }
+
+ ///
+ /// Build a minimal CellPhysics with two horizontal walkable polygons at
+ /// local Z=lowerZ and Z=upperZ. Identity world transform so world == local.
+ ///
+ private static CellPhysics BuildTwoFloorCellPhysics(float lowerZ, float upperZ)
+ {
+ Vector3[] lowerVerts =
+ {
+ new Vector3(0f, 0f, lowerZ),
+ new Vector3(1f, 0f, lowerZ),
+ new Vector3(1f, 1f, lowerZ),
+ new Vector3(0f, 1f, lowerZ),
+ };
+ Vector3[] upperVerts =
+ {
+ new Vector3(0f, 0f, upperZ),
+ new Vector3(1f, 0f, upperZ),
+ new Vector3(1f, 1f, upperZ),
+ new Vector3(0f, 1f, upperZ),
+ };
+
+ var resolved = new Dictionary
+ {
+ [0] = new ResolvedPolygon
+ {
+ Vertices = lowerVerts,
+ Plane = new Plane(Vector3.UnitZ, -lowerZ),
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ },
+ [1] = new ResolvedPolygon
+ {
+ Vertices = upperVerts,
+ Plane = new Plane(Vector3.UnitZ, -upperZ),
+ NumPoints = 4,
+ SidesType = CullMode.None,
+ },
+ };
+
+ var center = new Vector3(0.5f, 0.5f, (lowerZ + upperZ) * 0.5f);
+ float halfHeight = MathF.Abs(upperZ - lowerZ) * 0.5f + 1.0f;
+ float radius = MathF.Sqrt(0.5f * 0.5f + 0.5f * 0.5f + halfHeight * halfHeight);
+
+ var root = new PhysicsBSPNode
+ {
+ Type = BSPNodeType.Leaf,
+ BoundingSphere = new Sphere { Origin = center, Radius = radius },
+ };
+ root.Polygons.Add(0);
+ root.Polygons.Add(1);
+
+ var bsp = new PhysicsBSPTree { Root = root };
+
+ return new CellPhysics
+ {
+ BSP = bsp,
+ Resolved = resolved,
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ // Other CellPhysics fields are not consulted by TryFindIndoorWalkablePlane.
+ };
+ }
+```
+
+**Note on the helper:** `CellPhysics` is `required`-property-heavy. If the build fails on "missing required member", check `src/AcDream.Core/Physics/PhysicsDataCache.cs` around line 410 (the CellPhysics record) and add minimal init values for any required properties (typically empty collections or null where the type permits). Do not extend test fixtures beyond what TryFindIndoorWalkablePlane reads — keep the fixture minimal.
+
+- [ ] **Step 3.3: Run test, verify it fails on signature mismatch**
+
+Run:
+```
+dotnet test --filter "FullyQualifiedName~TryFindIndoorWalkablePlane_TwoOverlappingFloors"
+```
+
+Expected: compile error citing `TryFindIndoorWalkablePlane` doesn't take `sphereRadius` parameter, OR an instance-vs-static method mismatch (the spec changes `internal static` to `internal`).
+
+- [ ] **Step 3.4: Refactor `TryFindIndoorWalkablePlane`**
+
+Replace `src/AcDream.Core/Physics/TransitionTypes.cs:1192-1232` (the full body of the existing `TryFindIndoorWalkablePlane` method) with:
+
+```csharp
+ ///
+ /// Synthesize the indoor walkable contact plane for the player's current
+ /// position when the cell BSP returns OK (no wall collision).
+ ///
+ ///
+ /// Routes through the retail-faithful BSP walkable-finder
+ /// () — which traverses the cell
+ /// PhysicsBSP and picks the polygon closest to the foot along the up vector.
+ /// Phase 2 commit eb0f772 introduced a linear first-match XY scan as a
+ /// stop-gap; that scan picked the wrong floor whenever two polygons
+ /// overlapped in XY at different Z (cellars, 2nd floors, balconies).
+ ///
+ ///
+ ///
+ /// Returns false if no walkable floor poly is found under the
+ /// player. The caller falls through to outdoor terrain in that case
+ /// (defensive backstop — should not normally happen inside a sealed cell).
+ ///
+ ///
+ ///
+ /// Retail oracle: BSPLEAF::find_walkable (acclient_2013_pseudo_c.txt:326793),
+ /// BSPNODE::find_walkable (:326211), CPolygon::walkable_hits_sphere (:323006),
+ /// CPolygon::adjust_sphere_to_plane (:322032).
+ ///
+ ///
+ internal bool TryFindIndoorWalkablePlane(
+ CellPhysics cellPhysics,
+ Vector3 localFootCenter,
+ float sphereRadius,
+ out System.Numerics.Plane worldPlane,
+ out Vector3[] worldVertices,
+ out uint hitPolyId)
+ {
+ worldPlane = default;
+ worldVertices = System.Array.Empty();
+ hitPolyId = 0;
+
+ if (cellPhysics.BSP?.Root is null) return false;
+
+ // Build foot sphere in cell-local space. Caller passes localFootCenter
+ // already transformed into cell-local space and the resolver's
+ // foot-sphere radius.
+ var localSphere = new DatReaderWriter.Types.Sphere
+ {
+ Origin = localFootCenter,
+ Radius = sphereRadius,
+ };
+
+ // Save/restore WalkableAllowance: CPolygon::walkable_hits_sphere reads
+ // path.WalkableAllowance (acclient_2013_pseudo_c.txt:323010). For
+ // "standing here, find my floor" we want the walkability slope
+ // threshold FloorZ. The outer resolver may have set it to LandingZ
+ // (airborne→ground transition) or another value; we must not leak our
+ // change back to the resolver. try/finally so an exception inside
+ // FindWalkableSphere doesn't leak the modified state.
+ float savedWalkableAllowance = this.SpherePath.WalkableAllowance;
+ this.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
+
+ ResolvedPolygon? hitPoly = null;
+ ushort hitId = 0;
+ Vector3 adjustedCenter;
+ bool found;
+
+ try
+ {
+ found = BSPQuery.FindWalkableSphere(
+ cellPhysics.BSP.Root,
+ cellPhysics.Resolved,
+ this,
+ localSphere,
+ INDOOR_WALKABLE_PROBE_DISTANCE,
+ Vector3.UnitZ, // local Z is up for indoor cells (identity transform)
+ out hitPoly,
+ out hitId,
+ out adjustedCenter);
+ }
+ finally
+ {
+ this.SpherePath.WalkableAllowance = savedWalkableAllowance;
+ }
+
+ if (!found || hitPoly is null) return false;
+
+ // Transform hit polygon's plane + vertices to world space. Math is
+ // unchanged from the previous TryFindIndoorWalkablePlane implementation.
+ var worldNormal = Vector3.TransformNormal(hitPoly.Plane.Normal, cellPhysics.WorldTransform);
+ worldNormal = Vector3.Normalize(worldNormal);
+ var worldV0 = Vector3.Transform(hitPoly.Vertices[0], cellPhysics.WorldTransform);
+ float worldD = -Vector3.Dot(worldNormal, worldV0);
+ worldPlane = new System.Numerics.Plane(worldNormal, worldD);
+
+ worldVertices = new Vector3[hitPoly.Vertices.Length];
+ for (int i = 0; i < hitPoly.Vertices.Length; i++)
+ worldVertices[i] = Vector3.Transform(hitPoly.Vertices[i], cellPhysics.WorldTransform);
+
+ hitPolyId = hitId;
+ return true;
+ }
+
+ ///
+ /// Downward probe distance used by
+ /// when scanning for the indoor walkable contact plane. 50 cm.
+ /// Larger than the +0.02f cell-origin Z-bump and larger than any realistic
+ /// step riser; smaller than a full cell height so we don't reach through
+ /// a thin floor into the cell above/below.
+ ///
+ private const float INDOOR_WALKABLE_PROBE_DISTANCE = 0.5f;
+```
+
+**Notes on the change:**
+- Method went from `internal static` to `internal` (instance method) — `this.SpherePath` access requires it. The single callsite at line 1358 is already inside a `Transition` instance method.
+- Removed `static` keyword.
+- Added `float sphereRadius` parameter.
+- Replaced the linear scan body and the `PointInPolygonXY` call with a `BSPQuery.FindWalkableSphere` call wrapped in try/finally for allowance save/restore.
+
+- [ ] **Step 3.5: Delete `PointInPolygonXY` helper**
+
+Remove `src/AcDream.Core/Physics/TransitionTypes.cs:1234-1253` (the entire `PointInPolygonXY` method including its leading `` doc comment block). Verify no other callers via:
+
+```
+grep -rn PointInPolygonXY src/ tests/
+```
+
+Expected: zero matches after deletion.
+
+- [ ] **Step 3.6: Update the single callsite in `FindEnvCollisions`**
+
+At `src/AcDream.Core/Physics/TransitionTypes.cs:1358`, change:
+
+```csharp
+ if (TryFindIndoorWalkablePlane(cellPhysics, localCenter,
+ out var indoorPlane,
+ out var indoorVertices,
+ out uint _))
+```
+
+to:
+
+```csharp
+ if (TryFindIndoorWalkablePlane(cellPhysics, localCenter, sphereRadius,
+ out var indoorPlane,
+ out var indoorVertices,
+ out uint _))
+```
+
+`sphereRadius` is already bound at line 1268 in scope of this method.
+
+- [ ] **Step 3.7: Build and run integration test**
+
+Run:
+```
+dotnet build -c Debug
+```
+
+Expected: clean build.
+
+Run:
+```
+dotnet test --filter "FullyQualifiedName~TryFindIndoorWalkablePlane_TwoOverlappingFloors"
+```
+
+Expected: PASS.
+
+- [ ] **Step 3.8: Full suite — no regressions**
+
+Run:
+```
+dotnet test
+```
+
+Expected: same 8 pre-existing failures. 5 new tests pass (4 from Task 2 + 1 from this task). No new failures.
+
+- [ ] **Step 3.9: Commit**
+
+```bash
+git add src/AcDream.Core/Physics/TransitionTypes.cs tests/AcDream.Core.Tests/Physics/TransitionTypesTests.cs
+git commit -m "$(cat <<'EOF'
+fix(physics): route indoor walkable-plane synthesis through retail BSP walker
+
+TryFindIndoorWalkablePlane (Phase 2 commit eb0f772) used a linear
+first-match XY scan of cellPhysics.Resolved with no Z-proximity test.
+For any cell with two walkable polys overlapping in XY at different Z
+(cellars, 2nd floors, balconies, stairs spanning floors), it returned
+whichever polygon came first in dictionary order — typically the upper
+floor when descending, causing the player to be reported below the
+synthesized plane → ValidateWalkable fails → falling-stuck. Symptoms
+reported by user 2026-05-19: cannot descend into cellar; cannot walk
+on 2nd floor; "invisible obstacles at certain spots" (suspected
+cascade from wrong-Z ContactPlane misrouting the resolver state).
+
+Fix: route through BSPQuery.FindWalkableSphere (added previous commit),
+which wraps the existing retail-faithful FindWalkableInternal
+(BSPNODE::find_walkable + BSPLEAF::find_walkable port). Adds a
+sphereRadius parameter to TryFindIndoorWalkablePlane so the foot
+sphere is built with the actual entity radius rather than a guess.
+WalkableAllowance is save/restored via try/finally so the slope
+threshold used by walkable_hits_sphere doesn't leak back to the
+resolver. Method becomes an instance method (was static) to access
+this.SpherePath.
+
+Deletes the now-dead PointInPolygonXY helper.
+
+Integration test covers two-overlapping-floors selection AND
+WalkableAllowance preservation.
+
+Closes (pending visual verification): ISSUES #83.
+Spec: docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
+EOF
+)"
+```
+
+---
+
+## Task 4: Extend the `[indoor-bsp]` probe with an `[indoor-walkable]` line
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs` — at the `TryFindIndoorWalkablePlane` callsite inside `FindEnvCollisions` (around line 1358)
+
+Adds one diagnostic line per call when `PhysicsDiagnostics.ProbeIndoorBspEnabled` is set. No new flag; no new env var. Cost-when-disabled: a single bool check.
+
+- [ ] **Step 4.1: Add `[indoor-walkable]` probe line**
+
+In `src/AcDream.Core/Physics/TransitionTypes.cs`, modify the block around line 1358 (the `TryFindIndoorWalkablePlane` callsite) to emit a probe line tracking hit/miss.
+
+Existing code (around line 1357-1377):
+
+```csharp
+ if (TryFindIndoorWalkablePlane(cellPhysics, localCenter, sphereRadius,
+ out var indoorPlane,
+ out var indoorVertices,
+ out uint _))
+ {
+ return ValidateWalkable(
+ footCenter,
+ sphereRadius,
+ indoorPlane,
+ isWater: false,
+ waterDepth: 0f,
+ cellId: sp.CheckCellId,
+ walkableVertices: indoorVertices);
+ }
+```
+
+Replace with:
+
+```csharp
+ bool walkableHit = TryFindIndoorWalkablePlane(
+ cellPhysics, localCenter, sphereRadius,
+ out var indoorPlane,
+ out var indoorVertices,
+ out uint hitPolyId);
+
+ if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
+ {
+ if (walkableHit)
+ {
+ float dz = footCenter.Z + indoorPlane.D / indoorPlane.Normal.Z;
+ // dz = signed gap between foot and synthesized plane.
+ // Plane: N·p + D = 0 ⇒ pZ_on_plane = -D/N.z (for upward-facing planes)
+ // gap = foot.Z - pZ_on_plane = foot.Z - (-D/N.z) = foot.Z + D/N.z
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[indoor-walkable] cell=0x{sp.CheckCellId:X8} "
+ + $"wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) "
+ + $"probe={0.5f:F2} result=HIT poly=0x{hitPolyId:X4} "
+ + $"wn=({indoorPlane.Normal.X:F3},{indoorPlane.Normal.Y:F3},{indoorPlane.Normal.Z:F3}) "
+ + $"wD={indoorPlane.D:F3} dz={dz:+0.00;-0.00;+0.00}"));
+ }
+ else
+ {
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[indoor-walkable] cell=0x{sp.CheckCellId:X8} "
+ + $"wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) "
+ + $"probe={0.5f:F2} result=MISS"));
+ }
+ }
+
+ if (walkableHit)
+ {
+ return ValidateWalkable(
+ footCenter,
+ sphereRadius,
+ indoorPlane,
+ isWater: false,
+ waterDepth: 0f,
+ cellId: sp.CheckCellId,
+ walkableVertices: indoorVertices);
+ }
+```
+
+- [ ] **Step 4.2: Build and run all tests**
+
+Run:
+```
+dotnet build -c Debug
+```
+
+Expected: clean build.
+
+Run:
+```
+dotnet test
+```
+
+Expected: same 8 pre-existing failures. 5 new tests pass. No new failures.
+
+- [ ] **Step 4.3: Commit**
+
+```bash
+git add src/AcDream.Core/Physics/TransitionTypes.cs
+git commit -m "$(cat <<'EOF'
+feat(physics): add [indoor-walkable] probe line
+
+Extends the existing [indoor-bsp] probe surface in FindEnvCollisions
+with a per-call [indoor-walkable] line gated on
+PhysicsDiagnostics.ProbeIndoorBspEnabled (no new flag). Logs the
+synthesized contact plane, the polyId hit, and the signed Z gap (dz)
+between foot and plane.
+
+Lets the visual-verification step distinguish "FindWalkableSphere
+picked the right polygon" from "FindWalkableSphere returned a miss
+and we fell through to outdoor-terrain backstop", which is critical
+for triaging any remaining indoor collision oddities after the BSP
+port lands.
+
+Runtime-toggleable via the existing DebugPanel "Indoor BSP probe"
+checkbox; zero cost when disabled.
+
+Spec: docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
+EOF
+)"
+```
+
+---
+
+## Task 5: Visual verification + roadmap/ISSUES update
+
+**This is the acceptance gate — the user runs the client and reports results. The agent's job here is to (a) build a clean launch artifact, (b) document what to test, (c) update the roadmap + ISSUES.md after user confirmation.**
+
+Visual verification cannot be automated. The agent must NOT mark this task complete until the user has reported scenarios 1–5 as passing.
+
+- [ ] **Step 5.1: Final clean build**
+
+Run:
+```
+dotnet build -c Debug
+```
+
+Expected: clean build, no warnings introduced.
+
+Run:
+```
+dotnet test
+```
+
+Expected: same 8 pre-existing failures, 5 new tests passing.
+
+- [ ] **Step 5.2: Present the verification scenario list to the user**
+
+Tell the user what to test, in this exact form (paste into chat):
+
+> **Indoor walkable-plane BSP port — visual verification scenarios.**
+>
+> Launch the client (PowerShell snippet from CLAUDE.md), enable the
+> `ACDREAM_PROBE_INDOOR_BSP=1` probe to capture `[indoor-walkable]` lines,
+> connect, walk to a building, and test:
+>
+> 1. Walk into Holtburg cottage, walk around single-floor interior. **Acceptance: no regression — still walks freely.**
+> 2. Walk between cottage rooms via doorways. **Acceptance: no regression.**
+> 3. Walk back outside through cottage door. **Acceptance: no regression.**
+> 4. **Find any building with a cellar entry; descend the stairs.** Pre-fix: stuck/bounces at top. Acceptance: smooth descent onto cellar floor.
+> 5. **Find any 2-story building; climb stairs; walk around upper floor.** Pre-fix: snaps back to 1st floor or hits "invisible obstacles". Acceptance: stays on 2nd floor, free movement.
+> 6. (Observational, not gating) Walk near previously-reported "invisible obstacle" spots — report PASS if gone, FAIL if persist.
+> 7. (Observational, not gating) Watch bookshelves / open furnaces #88 — report whether vibration is reduced.
+>
+> Capture the launch log so I can grep `[indoor-walkable]` for evidence
+> of correct polyId selection across the cellar-descent and 2nd-floor
+> scenarios. Report back PASS/FAIL on each numbered scenario.
+
+- [ ] **Step 5.3: WAIT for user verification report**
+
+Do not proceed past this checkbox until the user has reported results for scenarios 1–5. If any of 1–5 fails, file a follow-up phase rather than marking this task done. If 6 or 7 fail, file separate issues but treat the phase as shipped (cascade hypothesis was the maybe, not the must).
+
+- [ ] **Step 5.4: Update `docs/plans/2026-04-11-roadmap.md` — add shipped row**
+
+Locate the "shipped" table near the top of the doc. Add a new row in date order (after the Indoor walking Phase 2 row):
+
+```
+| 2026-05-19 | Indoor walkable-plane BSP port — routes TryFindIndoorWalkablePlane through retail-faithful BSPQuery.FindWalkableInternal via new FindWalkableSphere wrapper. Fixes cellar descent + 2nd-floor walking + suspected invisible-obstacle cascade. Closes #83. Spec: 2026-05-19-indoor-walkable-plane-bsp-port-design.md. Plan: 2026-05-19-indoor-walkable-plane-bsp-port.md. |
+```
+
+(Adjust column format to match the existing table — date, description with links if the existing rows use links.)
+
+- [ ] **Step 5.5: Update `docs/ISSUES.md` — close #83**
+
+Locate the `## #83 — Walking up stairs broken` section (around line 285). Change the header to indicate closure and add a resolution block, mirroring the format used for #84/#85/#87 in the same file:
+
+```markdown
+## #83 — [DONE 2026-05-19 · ] Walking up stairs broken (actual symptom: walking DOWN in multi-floor cells)
+
+**Status:** DONE
+**Closed:** 2026-05-19
+**Commits:** , , ,
+**Filed:** 2026-05-19
+**Component:** physics, movement
+
+**Resolution (2026-05-19 · Indoor walkable-plane BSP port):**
+The issue title was misleading — walking UP stairs in houses always worked
+(step_up routes through DoStepDown → TransitionalInsert → BSPQuery.FindCollisions
+Path 3 = StepSphereDown, which already uses the retail-faithful
+FindWalkableInternal BSP walker). The actual symptom was walking DOWN
+in multi-floor cells (cellar descent, 2nd-floor walking, plus suspected
+invisible-obstacle cascade). Root cause: Phase 2 commit eb0f772 added
+TryFindIndoorWalkablePlane as a stop-gap walkable-plane synthesis when the
+indoor BSP returns OK; its body did a linear first-match XY scan with no
+Z-proximity test → wrong floor selected → wrong contact plane → resolver
+misclassifies grounded vs airborne state.
+
+Fix: route TryFindIndoorWalkablePlane through the existing retail-faithful
+BSPQuery.FindWalkableInternal via a thin new public wrapper
+(BSPQuery.FindWalkableSphere). Extends FindWalkableInternal's signature
+with a ref ushort hitPolyId. Threads foot-sphere radius through
+TryFindIndoorWalkablePlane's signature. Deletes the dead PointInPolygonXY
+helper. WalkableAllowance is save/restored via try/finally. Adds
+[indoor-walkable] probe line on the existing PhysicsDiagnostics flag.
+
+Visual-verified by user: cellar descent works, 2nd-floor walking works,
+no regression on Phase 2 single-floor cottage scenarios.
+
+**Original description:** ... [keep the original text below]
+```
+
+(Replace the `` placeholders with the actual SHAs from `git log --oneline -5` after Tasks 1–4 land.)
+
+- [ ] **Step 5.6: Commit docs + roadmap update**
+
+```bash
+git add docs/plans/2026-04-11-roadmap.md docs/ISSUES.md
+git commit -m "$(cat <<'EOF'
+docs: ship Indoor walkable-plane BSP port — close #83
+
+Roadmap shipped-table row + ISSUES #83 resolution block.
+
+Visual-verified by user at : cellar descent works,
+2nd-floor walking works, no regression on Phase 2 single-floor cottage
+scenarios.
+
+Spec: docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
+Plan: docs/superpowers/plans/2026-05-19-indoor-walkable-plane-bsp-port.md
+EOF
+)"
+```
+
+---
+
+## Self-Review
+
+**1. Spec coverage:** Re-checked spec §3 (Architecture), §4 (Implementation surface), §5 (Diagnostics), §6 (Testing), §8 (Acceptance criteria). Coverage map:
+
+| Spec section | Plan task |
+|---|---|
+| §3 / §4.1 — extend `FindWalkableInternal` + add `FindWalkableSphere` | Task 1 + Task 2 |
+| §4.2 — refactor `TryFindIndoorWalkablePlane` body | Task 3 (steps 3.4) |
+| §4.3 — `INDOOR_WALKABLE_PROBE_DISTANCE` constant, sphereRadius parameter | Task 3 (steps 3.4, 3.6) |
+| §4.4 — callsite update | Task 3 (step 3.6) |
+| §4.5 — delete `PointInPolygonXY` | Task 3 (step 3.5) |
+| §5 — `[indoor-walkable]` probe line | Task 4 |
+| §6.1 — 4 unit tests + 1 integration test | Task 2 (steps 2.1, 2.5) + Task 3 (step 3.2) |
+| §6.3 — visual verification scenarios | Task 5 (steps 5.2, 5.3) |
+| §8 — acceptance criteria (build/test/visual/roadmap/ISSUES) | Task 5 (all steps) |
+
+All sections covered.
+
+**2. Placeholder scan:** No "TBD", "TODO", or vague "add error handling" instructions. Every code step has full code; every command has expected output. The `` placeholders in Task 5.5 are documented as "replace after Tasks 1–4 land" — that is concrete, not vague.
+
+**3. Type consistency:**
+- `FindWalkableInternal` signature: `ref ResolvedPolygon? hitPoly, ref ushort hitPolyId, ref bool changed` — consistent across all 6 call sites (4 internal recursions in Task 1.1, StepSphereDown in Task 1.2, Path 4 in Task 1.3, FindWalkableSphere in Task 2.3).
+- `FindWalkableSphere` signature: `(root, resolved, transition, sphere, probeDistance, up, out hitPoly, out hitPolyId, out adjustedCenter)` — consistent in Task 2.3 implementation, Task 2.1/2.5 tests (calls), and Task 3.4 caller.
+- `TryFindIndoorWalkablePlane` signature: `(cellPhysics, localFootCenter, sphereRadius, out worldPlane, out worldVertices, out hitPolyId)` — consistent in Task 3.2 test, Task 3.4 implementation, Task 3.6 callsite, Task 4.1 callsite.
+
+**4. Bite-sized tasks:** Each step is one action (write a test, run a command, modify a block of code, commit). Steps 2.1/2.5 are larger (full test bodies) but each defines exactly one test method per code block. No batched edits across files in a single step.
+
+No spec requirements missed; no contradictions found. Plan committed below.
diff --git a/docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md b/docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md
new file mode 100644
index 00000000..7284d915
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md
@@ -0,0 +1,1297 @@
+# Indoor Walking Phase 1 — BSP Cluster (#84 / #85 / #86) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Surface the root causes for ISSUES.md #84 (blocked by air indoors), #85 (pass through walls outside→in), and #86 (click selection penetrates walls) via a single diagnostic-driven capture session, then ship one surgical fix commit per issue.
+
+**Architecture:** Add an `[indoor-bsp]` probe to `TransitionTypes.FindEnvCollisions`' cell-BSP branch (the indoor-collision code path already exists at lines 1188-1241 but emits no diagnostics). Capture one Holtburg Inn walkaround session that exercises all three issues. Read the log, pin each root cause to a specific code site, ship a separate surgical commit per issue. #86 has no probe dependency — its cause is already pinned by code-reading (WorldPicker.Pick has no cell-BSP test) — so its fix is structural.
+
+**Tech Stack:** C# / .NET 10, xUnit, Silk.NET, Möller-Trumbore ray-triangle intersection. Uses existing physics + selection types. Probe writes to `Console.WriteLine` per the established `[indoor-*]` / `[resolve]` / `[cell-transit]` convention.
+
+**Spec:** [`docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md`](../specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md)
+
+---
+
+## File Structure
+
+| File | Action | Responsibility |
+|---|---|---|
+| `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` | modify | Add `ProbeIndoorBspEnabled` static property. |
+| `src/AcDream.Core/Physics/BSPQuery.cs` | modify | Have all 8 `LastBspHitPoly = hitPoly` write sites fire when `ProbeIndoorBspEnabled` is true (currently only fires for `ProbeBuildingEnabled`). |
+| `src/AcDream.Core/Physics/TransitionTypes.cs` | modify | Emit `[indoor-bsp]` log around the cell-BSP `FindCollisions` call at line 1222. |
+| `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` | modify | Add `ProbeIndoorBsp` runtime mirror property. |
+| `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs` | modify | Add checkbox row beneath the existing indoor-render probes. |
+| `src/AcDream.Core/Selection/CellBspRayOccluder.cs` | create | Pure Möller-Trumbore ray-triangle test against a set of `CellPhysics`. Returns nearest-wall `t` along ray. |
+| `src/AcDream.Core/Selection/WorldPicker.cs` | modify | Both `Pick` overloads accept an optional `cellOccluder` callback. Production callers pass it; tests can pass `null`. |
+| `src/AcDream.App/Rendering/GameWindow.cs` | modify | Wire `CellBspRayOccluder.NearestWallT` into the screen-rect `Pick` call at line 9134. |
+| `tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs` | create | Direct unit tests for Möller-Trumbore semantics. |
+| `tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs` | create | Integration test: synthetic wall poly between ray origin and entity → no hit. |
+| `tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs` | modify | Add a parity test for the new `ProbeIndoorBsp` mirror. |
+| `docs/ISSUES.md` | modify | Move #84/#85/#86 to "Recently closed" with commit SHAs. |
+| `docs/plans/2026-04-11-roadmap.md` | modify | Add shipped-table entry for the phase. |
+
+---
+
+## Probe pre-work: code-shape facts
+
+These facts are referenced in many tasks below. Read them once.
+
+**Fact 1.** `PhysicsDiagnostics.cs` already has six toggles (`ProbeResolveEnabled`, `ProbeCellEnabled`, `ProbeBuildingEnabled`, `ProbeAutoWalkEnabled`, `ProbeUseabilityFallbackEnabled`, `DumpSteepRoofEnabled`) plus the `LastBspHitPoly` diagnostic side-channel. Pattern is `public static bool Foo { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_FOO") == "1";`.
+
+**Fact 2.** `BSPQuery.cs` has 8 sites that write `PhysicsDiagnostics.LastBspHitPoly = hitPoly;`, each gated by `if (PhysicsDiagnostics.ProbeBuildingEnabled)`. The sites are at lines 1219, 1232, 1239, 1555, 1589, 1673, 1683, 1713, 1722. (Verify line numbers before editing — file evolves.)
+
+**Fact 3.** `TransitionTypes.FindEnvCollisions` cell-BSP branch lives at TransitionTypes.cs:1188-1241. The `BSPQuery.FindCollisions` call is at line 1222. Pre-call, the engine has `cellPhysics`, `localSphere`, `localCurrCenter`, `sp.CheckCellId`, `footCenter`. Post-call, `cellState` carries the outcome and `PhysicsDiagnostics.LastBspHitPoly` carries the hit poly (if our probe and the indoor flag fire together).
+
+**Fact 4.** Only ONE production `WorldPicker.Pick` call exists, at GameWindow.cs:9134 — the screen-rect overload. The legacy ray-sphere overload at WorldPicker.cs:88-160 is test-only.
+
+**Fact 5.** Cell physics caching site is `_physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform)` at GameWindow.cs:5384 — applied to ALL loaded EnvCells. Access via `_physicsDataCache.GetCellStruct(envCellId)` returning a `CellPhysics?` with `BSP`, `Resolved`, `WorldTransform`, `InverseWorldTransform`.
+
+**Fact 6.** `CellPhysics.Resolved` is a `Dictionary` — keys are poly ids, values include `Vertices` (already-resolved world-positions, but they're in LOCAL space — multiply by `WorldTransform` to get world), `Plane`, `NumPoints`, `SidesType`. (Confirm by reading `PhysicsDataCache.ResolvePolygons` lines 155-204.)
+
+**Fact 7.** Three loaded sets the picker needs:
+- `_physicsDataCache.GetCellStruct(id)` — looks up one cell's BSP.
+- The set of currently-loaded EnvCell ids — enumerate via `_cellVisibility._cellLookup` (if that's accessible from GameWindow) or by iterating `_pendingCellMeshes.Keys` / a similar field. **Confirm during Task 10** which collection is the authoritative list of loaded EnvCells in GameWindow.
+
+---
+
+## Task 1: Add `PhysicsDiagnostics.ProbeIndoorBspEnabled`
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`
+
+- [ ] **Step 1: Add the new toggle property at the bottom of the existing toggle list**
+
+In `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`, immediately before the closing `}` of the class (after `DumpSteepRoofEnabled` at line 168), add:
+
+```csharp
+ ///
+ /// Indoor walking Phase 1 (2026-05-19). When true, emits one
+ /// [indoor-bsp] line per
+ /// call made from 's indoor
+ /// cell-BSP branch. Captures the cell id, sphere local position,
+ /// resulting , and the hit poly's id,
+ /// local-normal, and side-type — pinpoints why indoor collision
+ /// returns spurious collisions (#84) and helps cross-check the
+ /// outdoor-in approach path (#85).
+ ///
+ ///
+ /// While true, this also un-gates the diagnostic
+ /// side-channel inside
+ /// — see the OR'd condition at every poly
+ /// write site. Zero-cost when off.
+ ///
+ ///
+ ///
+ /// Initial state from ACDREAM_PROBE_INDOOR_BSP=1.
+ /// Runtime-toggleable via DebugPanel.
+ ///
+ ///
+ ///
+ /// Spec: docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md.
+ ///
+ ///
+ public static bool ProbeIndoorBspEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_INDOOR_BSP") == "1";
+```
+
+- [ ] **Step 2: Verify build**
+
+Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj`
+Expected: Build succeeds, 0 errors.
+
+- [ ] **Step 3: No commit yet** — bundle with Task 2 and Task 3 into one probe-feature commit.
+
+---
+
+## Task 2: OR `ProbeIndoorBspEnabled` into BSPQuery's `LastBspHitPoly` write sites
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/BSPQuery.cs`
+
+**Why:** `BSPQuery` currently writes `PhysicsDiagnostics.LastBspHitPoly` only when `ProbeBuildingEnabled` is true. The indoor probe needs the same side-channel; un-gate it for either flag.
+
+- [ ] **Step 1: Find every write site**
+
+Run: `rg -n "PhysicsDiagnostics.LastBspHitPoly = hitPoly" src/AcDream.Core/Physics/BSPQuery.cs`
+
+Expected: 8 lines, each immediately preceded by `if (PhysicsDiagnostics.ProbeBuildingEnabled)`.
+
+- [ ] **Step 2: Replace the gate at each site**
+
+For each of the 8 occurrences, edit the gate from:
+```csharp
+if (PhysicsDiagnostics.ProbeBuildingEnabled)
+ PhysicsDiagnostics.LastBspHitPoly = hitPoly;
+```
+to:
+```csharp
+if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
+ PhysicsDiagnostics.LastBspHitPoly = hitPoly;
+```
+
+(The local variable name varies: `hitPoly`, `hitPoly0`, `hitPoly1`. Preserve the local name at each site.)
+
+Use the Edit tool one site at a time. If two sites have identical surrounding text, use `replace_all: true` on the gate-only string `if (PhysicsDiagnostics.ProbeBuildingEnabled)` since the OR transformation is identical for every site that this exact gate immediately precedes a `LastBspHitPoly` write. But verify no other call uses the same gate before doing replace_all.
+
+Confirm via:
+`rg -n "PhysicsDiagnostics.ProbeBuildingEnabled\b" src/AcDream.Core/Physics/BSPQuery.cs`
+Expected: every match is followed on the next line by a `LastBspHitPoly` write.
+
+- [ ] **Step 3: Verify build**
+
+Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj`
+Expected: Build succeeds.
+
+- [ ] **Step 4: Run BSPQuery tests**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~BSPQuery"`
+Expected: all pass; behavior unchanged when both flags are false.
+
+- [ ] **Step 5: No commit yet** — bundle with Task 1 and Task 3.
+
+---
+
+## Task 3: Emit `[indoor-bsp]` log line in `FindEnvCollisions`
+
+**Files:**
+- Modify: `src/AcDream.Core/Physics/TransitionTypes.cs`
+
+**Why:** One log line per cell-BSP collision query, with enough fields to diagnose poly Z-bump misalignment (#84) and out-of-cell asymmetry (#85). Bracketed prefix matches the existing `[indoor-*]` convention.
+
+- [ ] **Step 1: Locate the insertion site**
+
+Verify the cell branch is still at TransitionTypes.cs:1188-1241 (line numbers may drift). Find the exact `var cellState = BSPQuery.FindCollisions(` line — currently 1222.
+
+- [ ] **Step 2: Add `using System.Globalization;` if not already present**
+
+Check the file header. If missing, add `using System.Globalization;` to the using block at the top of the file.
+
+- [ ] **Step 3: Wrap the FindCollisions call with the probe**
+
+Replace the existing block at lines ~1220-1239:
+
+```csharp
+ // Use the full 6-path BSP dispatcher for retail-faithful collision.
+ // Use pre-resolved polygons (vertices+planes computed at cache time).
+ var cellState = BSPQuery.FindCollisions(
+ cellPhysics.BSP.Root,
+ cellPhysics.Resolved,
+ this,
+ localSphere,
+ localSphere1,
+ localCurrCenter,
+ Vector3.UnitZ, // local space Z is up
+ 1.0f, // scale = 1.0 for cell geometry
+ Quaternion.Identity,
+ engine); // engine needed for Path 5 step-up
+
+ if (cellState != TransitionState.OK)
+ {
+ if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
+ ci.CollidedWithEnvironment = true;
+ return cellState;
+ }
+```
+
+with:
+
+```csharp
+ // Indoor walking Phase 1 (2026-05-19): clear the LastBspHitPoly
+ // side-channel before the call so a missed write (no collision)
+ // is greppable as "poly=n/a" in the probe line below.
+ if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
+ PhysicsDiagnostics.LastBspHitPoly = null;
+
+ // Use the full 6-path BSP dispatcher for retail-faithful collision.
+ // Use pre-resolved polygons (vertices+planes computed at cache time).
+ var cellState = BSPQuery.FindCollisions(
+ cellPhysics.BSP.Root,
+ cellPhysics.Resolved,
+ this,
+ localSphere,
+ localSphere1,
+ localCurrCenter,
+ Vector3.UnitZ, // local space Z is up
+ 1.0f, // scale = 1.0 for cell geometry
+ Quaternion.Identity,
+ engine); // engine needed for Path 5 step-up
+
+ if (PhysicsDiagnostics.ProbeIndoorBspEnabled)
+ {
+ var hit = PhysicsDiagnostics.LastBspHitPoly;
+ string polyDesc = hit is null
+ ? "poly=n/a"
+ : System.FormattableString.Invariant(
+ $"poly=0x{0:X4} n=({hit.Plane.Normal.X:F3},{hit.Plane.Normal.Y:F3},{hit.Plane.Normal.Z:F3}) sides={hit.SidesType}");
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[indoor-bsp] cell=0x{sp.CheckCellId:X8} " +
+ $"wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) " +
+ $"lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) " +
+ $"lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) " +
+ $"r={sphereRadius:F3} result={cellState} {polyDesc}"));
+ }
+
+ if (cellState != TransitionState.OK)
+ {
+ if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
+ ci.CollidedWithEnvironment = true;
+ return cellState;
+ }
+```
+
+**Note:** `LastBspHitPoly` is a `ResolvedPolygon?` (a struct or class — check). The format string assumes `hit.Plane.Normal` works. The id field is not stored on `ResolvedPolygon` directly (only the value lives in the dict). The probe substitutes `0x0000` for the id field — if poly-id is needed for triage, extend `ResolvedPolygon` to carry its key in a follow-up. Capture diagnoses don't usually need the id; the normal + side-type + local-z is enough.
+
+- [ ] **Step 4: Verify build**
+
+Run: `dotnet build src/AcDream.Core/AcDream.Core.csproj`
+Expected: Build succeeds.
+
+- [ ] **Step 5: Run physics tests**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Transition"`
+Expected: all pass — probe is zero-cost when off, and the toggle defaults off.
+
+---
+
+## Task 4: Add DebugVM mirror + DebugPanel checkbox
+
+**Files:**
+- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs`
+- Modify: `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs`
+
+- [ ] **Step 1: Add `ProbeIndoorBsp` property to DebugVM**
+
+In `DebugVM.cs`, after the existing `ProbeIndoorCull` property (around line 346), add:
+
+```csharp
+ ///
+ /// Indoor walking Phase 1 (2026-05-19). Runtime mirror of
+ /// PhysicsDiagnostics.ProbeIndoorBspEnabled (env var
+ /// ACDREAM_PROBE_INDOOR_BSP). Toggling here flips the
+ /// [indoor-bsp] probe live — no relaunch required.
+ /// Physics-side companion to the five render-side
+ /// ProbeIndoor* mirrors directly above.
+ ///
+ public bool ProbeIndoorBsp
+ {
+ get => PhysicsDiagnostics.ProbeIndoorBspEnabled;
+ set => PhysicsDiagnostics.ProbeIndoorBspEnabled = value;
+ }
+```
+
+- [ ] **Step 2: Add checkbox row to DebugPanel**
+
+In `DebugPanel.cs`, locate the existing indoor-probe block (the six `if (r.Checkbox("Indoor: ...", ...))` lines around 271-276).
+
+After the last existing checkbox (`Indoor: cull`), add:
+
+```csharp
+ bool probeIndoorBsp = _vm.ProbeIndoorBsp;
+ if (r.Checkbox("Indoor: BSP collision (ACDREAM_PROBE_INDOOR_BSP)", ref probeIndoorBsp)) _vm.ProbeIndoorBsp = probeIndoorBsp;
+```
+
+Also update the local-variable block above (around line 264-269) to include the new local. Insert under `bool probeIndoorCull = _vm.ProbeIndoorCull;`:
+
+```csharp
+ // probeIndoorBsp added below (physics-side; not part of IndoorAll cascade)
+```
+
+(Placement comment for human readers — no functional impact.)
+
+- [ ] **Step 3: Verify build**
+
+Run: `dotnet build src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj`
+Expected: Build succeeds.
+
+---
+
+## Task 5: Add DebugVM parity test for `ProbeIndoorBsp`
+
+**Files:**
+- Modify: `tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs`
+
+- [ ] **Step 1: Read the existing parity test for `ProbeBuilding`**
+
+Run: `rg -n "ProbeBuilding" tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs`
+
+Find the test that verifies `vm.ProbeBuilding = true` flips `PhysicsDiagnostics.ProbeBuildingEnabled`. Use it as a template.
+
+- [ ] **Step 2: Add the parity test**
+
+Add a new test method after the existing `ProbeBuilding` test:
+
+```csharp
+[Fact]
+public void ProbeIndoorBsp_ForwardsToPhysicsDiagnostics()
+{
+ var originalEnabled = PhysicsDiagnostics.ProbeIndoorBspEnabled;
+ try
+ {
+ var vm = MakeVm(); // Use the existing test factory in this file.
+
+ vm.ProbeIndoorBsp = true;
+ Assert.True(PhysicsDiagnostics.ProbeIndoorBspEnabled);
+ Assert.True(vm.ProbeIndoorBsp);
+
+ vm.ProbeIndoorBsp = false;
+ Assert.False(PhysicsDiagnostics.ProbeIndoorBspEnabled);
+ Assert.False(vm.ProbeIndoorBsp);
+ }
+ finally
+ {
+ PhysicsDiagnostics.ProbeIndoorBspEnabled = originalEnabled;
+ }
+}
+```
+
+**Note:** Check whether the test class has a `MakeVm()` helper. If not, look at how `ProbeBuilding_ForwardsToPhysicsDiagnostics` (or similar) constructs the VM and mirror that pattern.
+
+- [ ] **Step 3: Verify the test runs and passes**
+
+Run: `dotnet test tests/AcDream.UI.Abstractions.Tests/AcDream.UI.Abstractions.Tests.csproj --filter "FullyQualifiedName~ProbeIndoorBsp"`
+Expected: 1 test passing.
+
+- [ ] **Step 4: Full test sweep**
+
+Run: `dotnet build` then `dotnet test`
+Expected: all tests green.
+
+---
+
+## Task 6: Commit the probe feature
+
+- [ ] **Step 1: Stage files**
+
+```bash
+git add src/AcDream.Core/Physics/PhysicsDiagnostics.cs \
+ src/AcDream.Core/Physics/BSPQuery.cs \
+ src/AcDream.Core/Physics/TransitionTypes.cs \
+ src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs \
+ src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs \
+ tests/AcDream.UI.Abstractions.Tests/Panels/Debug/DebugVMTests.cs
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git commit -m "$(cat <<'EOF'
+feat(physics): Cluster A — indoor BSP collision probe
+
+Adds the [indoor-bsp] probe + ProbeIndoorBspEnabled toggle for the
+Indoor walking Phase 1 BSP-cluster investigation. Mirrors the existing
+[resolve] / [cell-transit] / [indoor-*] pattern: one log line per
+BSPQuery.FindCollisions call from FindEnvCollisions' cell branch,
+capturing cell id, sphere local-pos, result TransitionState, and the
+hit poly's normal + side-type via the LastBspHitPoly side-channel
+(already wired for ProbeBuildingEnabled, now also fires for the indoor
+flag).
+
+Toggle via ACDREAM_PROBE_INDOOR_BSP=1 env var or DebugPanel checkbox.
+Zero-cost when off.
+
+Predecessor for the three fix commits that will close ISSUES.md
+#84/#85/#86 after the capture session.
+
+Spec: docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md
+Plan: docs/superpowers/plans/2026-05-19-indoor-walking-phase1-bsp-cluster.md
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+- [ ] **Step 3: Verify commit**
+
+```bash
+git log -1 --oneline
+git status
+```
+Expected: One new commit on `claude/competent-robinson-dec1f4`; working tree clean.
+
+---
+
+## ━━━ CAPTURE GATE — runs once, between Task 6 and Task 7 ━━━
+
+This gate requires the user to run the client. Do not attempt to fully automate it; the user is the test subject.
+
+### Task 7: Capture session
+
+**Goal:** Produce `launch.log` lines that pin the root cause of #84 and inform #85.
+
+- [ ] **Step 1: Confirm `dotnet build` is green**
+
+Run: `dotnet build`
+Expected: 0 errors, 0 warnings.
+
+- [ ] **Step 2: Hand the user the launch command**
+
+Print the following block back to the user verbatim so they can paste it into PowerShell:
+
+```powershell
+$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
+$env:ACDREAM_LIVE = "1"
+$env:ACDREAM_TEST_HOST = "127.0.0.1"
+$env:ACDREAM_TEST_PORT = "9000"
+$env:ACDREAM_TEST_USER = "testaccount"
+$env:ACDREAM_TEST_PASS = "testpassword"
+$env:ACDREAM_PROBE_INDOOR_BSP = "1"
+$env:ACDREAM_PROBE_RESOLVE = "1"
+$env:ACDREAM_PROBE_CELL = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
+ Tee-Object -FilePath "launch-cluster-a-capture.log"
+```
+
+- [ ] **Step 3: Hand the user the capture script (three scenarios)**
+
+Ask the user to perform, in order, while the client is in-world:
+
+1. **Inside Inn walkaround.** Walk into Holtburg Inn through the front door. Once inside, walk slowly around the common room (front-to-back, then a circuit along the walls). Stop wherever an invisible block happens — note the on-screen position before retrying. ~30 seconds.
+2. **Outside-in approach.** Exit the Inn. Stand 5–10 m from the Inn's west exterior wall in open ground. Sprint at the wall (`W` + Shift held). Observe whether you pass through. ~30 seconds.
+3. **Inside-out sanity.** Re-enter the Inn through the door. Walk into one interior wall directly. Confirm the wall blocks (this is the working direction). ~15 seconds.
+
+Total: ~80 seconds of walking. One launch.
+
+- [ ] **Step 4: Wait for the user to confirm capture is done**
+
+After the user closes the client, the log is at `launch-cluster-a-capture.log` in the worktree root.
+
+---
+
+## Task 8: Diagnose #84 from captured log
+
+**Files:** Read-only.
+
+- [ ] **Step 1: Confirm the log exists and has indoor-bsp lines**
+
+Run: `rg -c "^\[indoor-bsp\]" launch-cluster-a-capture.log`
+Expected: a positive number (the count of probe lines).
+
+If 0: the probe didn't fire — either the user wasn't actually in an indoor cell, or the flag wasn't set. Re-check launch command and re-capture.
+
+- [ ] **Step 2: Find the most-frequent "Collided" cell + poly**
+
+Run:
+```bash
+rg "^\[indoor-bsp\]" launch-cluster-a-capture.log | rg "result=Collided" | head -200
+```
+
+Identify recurrent patterns. Look for:
+- Same `cell=0x...` appearing repeatedly even when the player visually wasn't near a wall.
+- `lpos=` Z component that's slightly negative (`-0.02`-ish) → +0.02f Z-bump hypothesis.
+- Polys with `n=(0,0,1)` (floor up-normals) firing far from visible floor edges → bogus floor poly hypothesis.
+- Polys with `sides=Back` or unusual side-types → one-sided handling hypothesis.
+
+- [ ] **Step 3: Cross-ref with the user's reported invisible-block positions**
+
+Use the world position from each `wpos=` field to identify which probe lines correspond to actual user-reported invisible blocks. The user reports them by approximate location; the log gives exact Z + cell. If user reports "near the back wall but a meter shy", filter `[indoor-bsp]` to that cell's lines and identify what poly fired.
+
+- [ ] **Step 4: Identify ONE specific root cause**
+
+Pin to one of:
+- **(a) Z-bump asymmetry**: `lpos.Z` consistently slightly below 0 while `n=(0,0,1)` polys collide. Fix: remove the +0.02f from the physics path's `cellTransform` while keeping it for render, OR bump player Z by +0.02f when in an indoor cell.
+- **(b) Bogus physics-only polys**: collisions fire at world positions where the user reports no visible wall, AND the contacted poly's normal points in a direction inconsistent with any visible geometry. Fix: filter polys by side-type at cache time OR ignore polys whose plane doesn't intersect the cell's visible volume.
+- **(c) Step-up regression at cell boundary**: collisions fire as the player crosses a cell boundary (preceded by a `[cell-transit]` line). Fix: ensure the cell-BSP path handles the cell-id-change case correctly.
+- **(d) Something the data shows that we didn't predict.** Write a one-paragraph note in the eventual commit message.
+
+- [ ] **Step 5: Write a one-paragraph diagnosis to `docs/research/2026-05-19-cluster-a-diagnosis.md`**
+
+This doc is the evidence file for the upcoming commits. Format:
+
+```markdown
+# Cluster A — captured diagnosis (2026-05-19)
+
+**Capture:** `launch-cluster-a-capture.log`.
+
+## #84 root cause
+
+
+
+Sample probe line:
+```
+[indoor-bsp] cell=0x... wpos=... lpos=... result=Collided poly=... n=... sides=...
+```
+
+## #85 root cause
+
+(filled by Task 9)
+```
+
+- [ ] **Step 6: No commit yet — proceed to fix.**
+
+---
+
+## Task 9: Apply #84 fix
+
+**Files:** TBD based on Task 8's diagnosis. Most likely candidates:
+- `src/AcDream.App/Rendering/GameWindow.cs` (the `+0.02f` Z-bump at line 5362).
+- `src/AcDream.Core/Physics/TransitionTypes.cs` (the cell-BSP branch).
+- `src/AcDream.Core/Physics/PhysicsDataCache.cs` (the polygon resolve step).
+- `src/AcDream.Core/Physics/BSPQuery.cs` (the BSP query dispatcher).
+
+- [ ] **Step 1: Apply the surgical fix**
+
+Per the diagnosis. Code samples for the most likely two cases:
+
+**If (a) Z-bump asymmetry**: at GameWindow.cs:5360-5365, split the bumped transform into a render-only Z-bump while keeping physics aligned to terrain. Replace:
+
+```csharp
+ var cellOrigin = envCell.Position.Origin + lbOffset
+ + new System.Numerics.Vector3(0f, 0f, 0.02f);
+ var cellTransform =
+ System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation) *
+ System.Numerics.Matrix4x4.CreateTranslation(cellOrigin);
+
+ var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransform);
+```
+
+with:
+
+```csharp
+ // Two cellOrigins: render is bumped +0.02 m on Z to
+ // prevent z-fight with terrain; physics stays aligned
+ // with terrain so the player's foot-Z (from terrain
+ // sample) matches the cell BSP's local floor.
+ // (Cluster A #84 — capture identified the bump as the
+ // source of "blocked by air" at cell boundaries.)
+ var cellOriginPhysics = envCell.Position.Origin + lbOffset;
+ var cellOriginRender = cellOriginPhysics
+ + new System.Numerics.Vector3(0f, 0f, 0.02f);
+
+ var orientationMat =
+ System.Numerics.Matrix4x4.CreateFromQuaternion(envCell.Position.Orientation);
+
+ var cellTransformRender =
+ orientationMat *
+ System.Numerics.Matrix4x4.CreateTranslation(cellOriginRender);
+ var cellTransformPhysics =
+ orientationMat *
+ System.Numerics.Matrix4x4.CreateTranslation(cellOriginPhysics);
+
+ var cellMeshRef = new AcDream.Core.World.MeshRef(envCellId, cellTransformRender);
+```
+
+Then at line 5384, change the physics cache call to pass `cellTransformPhysics`:
+
+```csharp
+ _physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransformPhysics);
+```
+
+And at line 5381 (the `BuildLoadedCell` call), evaluate whether to pass render or physics (whichever the visibility code path uses). Inspect `BuildLoadedCell` and adjust if needed.
+
+**If (b) bogus physics-only polys**: filter in `PhysicsDataCache.ResolvePolygons` at line 155-204 by skipping polys whose `SidesType` value indicates a back-face-only or physics-stub. Reference: check `DatReaderWriter.Types.SidesType` enum. Add a `continue` for any side-type identified in capture.
+
+- [ ] **Step 2: Build**
+
+Run: `dotnet build`
+Expected: Build succeeds.
+
+- [ ] **Step 3: Run physics tests**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Physics"`
+Expected: all green.
+
+- [ ] **Step 4: Re-launch the client + Inside-Inn walkaround**
+
+Same launch command as Task 7 Step 2. User walks the same inside-Inn loop. Verify no invisible blocks. `rg "^\[indoor-bsp\]" launch.log | rg result=Collided | wc -l` should be ~0 except at actual walls.
+
+- [ ] **Step 5: Commit the fix**
+
+```bash
+git add
+git commit -m "$(cat <<'EOF'
+fix(physics): Cluster A #84 —
+
+<3-5 line description citing the probe-line evidence>
+
+Closes #84.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+Replace `` and the body with the actual evidence from Task 8 / Step 5's diagnosis doc.
+
+---
+
+## Task 10: Diagnose #85 from captured log
+
+**Files:** Read-only.
+
+- [ ] **Step 1: Confirm scenario 2 lines are present**
+
+Run:
+```bash
+rg "^\[resolve\]" launch-cluster-a-capture.log | head -100
+```
+
+Look for lines emitted during the outside-in approach. Time-correlate via line ordering — scenario 2 follows scenario 1.
+
+- [ ] **Step 2: Identify what (if anything) was hit during outside-in approach**
+
+If `[resolve]` lines during scenario 2 show `obj=0x...` for the building stab, the outdoor BSP IS being consulted — the question is why it doesn't block. Inspect that stab's polys via:
+```bash
+rg "0xA9B47900" launch-cluster-a-capture.log # adjust to the obj id observed
+```
+
+If no `[resolve]` lines have `obj=...` for the building during scenario 2, the outdoor BSP isn't being engaged at all — the question is why `FindObjCollisions` doesn't iterate the building's stab.
+
+- [ ] **Step 3: Identify ONE specific root cause**
+
+Pin to one of:
+- **(α) Building stab BSP exists but polys are one-sided**. Approach from outside fails the BSP traversal direction test. Fix: change side-type handling in `BSPQuery.FindCollisions` for the outdoor-stab path OR mark stab polys two-sided at cache time.
+- **(β) Building stab is in the loaded set but never iterated**. The `FindObjCollisions` loop skips it for some reason (cell mismatch, scale mismatch, etc.). Fix: ensure the building stab's shadow-entry registration covers the outdoor cells the player walks through.
+- **(γ) Building stab has no wall polys**. Retail's building shells are partial — they cover floor/roof, with interior walls in the EnvCell. Fix: port retail's cross-cell BSP probing (when sphere overlaps an EnvCell's world AABB from an outdoor cell, query that EnvCell's BSP too).
+- **(δ) Risk path: if (γ) is the root cause and the port is large**, promote #85 to its own phase. Pause this plan and write a new phase spec for the cross-cell BSP work, then return to this plan for #86.
+
+- [ ] **Step 4: Add #85 diagnosis to `docs/research/2026-05-19-cluster-a-diagnosis.md`**
+
+Mirror the format from Task 8 Step 5.
+
+- [ ] **Step 5: If route (δ) triggers — split out #85**
+
+Stop the plan here. Write a new spec `docs/superpowers/specs/2026-05-DD-cluster-a-cross-cell-bsp-design.md` for #85's cross-cell port; come back to this plan and skip to Task 12 (#86 fix) immediately.
+
+---
+
+## Task 11: Apply #85 fix
+
+**Files:** TBD based on Task 10's diagnosis. Most likely candidates:
+- `src/AcDream.Core/Physics/BSPQuery.cs` (side-type handling).
+- `src/AcDream.Core/Physics/PhysicsDataCache.cs` (stab caching).
+- `src/AcDream.Core/Physics/TransitionTypes.cs` (`FindObjCollisions` or cell-BSP cross-probe).
+- `src/AcDream.App/Rendering/GameWindow.cs` (cell-stab registration).
+
+- [ ] **Step 1: Apply the surgical fix**
+
+Per the Task 10 diagnosis. The actual code is determined by which root cause (α / β / γ) the capture pinned. The diagnosis doc records the evidence; the commit body cites it.
+
+- [ ] **Step 2: Build + test**
+
+Run: `dotnet build && dotnet test`
+Expected: all green.
+
+- [ ] **Step 3: Re-launch + outside-in scenario**
+
+User stands 5+ m west of the Inn, sprints at the wall. Verify: player blocks at the wall plane.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add
+git commit -m "fix(physics): Cluster A #85 —
+
+
+
+Closes #85.
+
+Co-Authored-By: Claude Opus 4.7 (1M context) "
+```
+
+---
+
+## Task 12: Create `CellBspRayOccluder` for #86
+
+**Files:**
+- Create: `src/AcDream.Core/Selection/CellBspRayOccluder.cs`
+- Create: `tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs`
+
+- [ ] **Step 1: Write the test file FIRST (TDD)**
+
+Create `tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs`:
+
+```csharp
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.Selection;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.Core.Tests.Selection;
+
+public class CellBspRayOccluderTests
+{
+ // Build a CellPhysics with a single triangular poly at world-Y=10.
+ // Triangle vertices in local space, then world transform = identity.
+ private static CellPhysics MakeWallCell()
+ {
+ var verts = new[]
+ {
+ new Vector3(-5, 10, 0),
+ new Vector3( 5, 10, 0),
+ new Vector3( 0, 10, 5),
+ };
+ var poly = new ResolvedPolygon
+ {
+ Vertices = verts,
+ Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
+ NumPoints = 3,
+ SidesType = SidesType.Front,
+ };
+ return new CellPhysics
+ {
+ BSP = null, // Occluder doesn't use BSP — direct poly iteration.
+ Resolved = new() { [0] = poly },
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ };
+ }
+
+ [Fact]
+ public void NearestWallT_RayHitsTriangle_ReturnsHitDistance()
+ {
+ var cell = MakeWallCell();
+ var origin = new Vector3(0, 0, 1);
+ var direction = Vector3.UnitY; // travels +Y toward the wall at Y=10
+ float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
+ Assert.True(t > 9.9f && t < 10.1f, $"expected ~10, got {t}");
+ }
+
+ [Fact]
+ public void NearestWallT_RayMisses_ReturnsPositiveInfinity()
+ {
+ var cell = MakeWallCell();
+ var origin = new Vector3(0, 0, 1);
+ var direction = -Vector3.UnitY; // travels AWAY from the wall
+ float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { cell });
+ Assert.True(float.IsPositiveInfinity(t), $"expected +inf, got {t}");
+ }
+
+ [Fact]
+ public void NearestWallT_EmptyCellList_ReturnsPositiveInfinity()
+ {
+ var origin = Vector3.Zero;
+ var direction = Vector3.UnitY;
+ float t = CellBspRayOccluder.NearestWallT(origin, direction, System.Array.Empty());
+ Assert.True(float.IsPositiveInfinity(t));
+ }
+
+ [Fact]
+ public void NearestWallT_TwoCells_ReturnsNearer()
+ {
+ var nearCell = MakeWallCell(); // wall at Y=10
+ var farCell = MakeWallCell();
+ // Move farCell's transform to push it to Y=20.
+ farCell.WorldTransform = Matrix4x4.CreateTranslation(0, 10, 0);
+ Matrix4x4.Invert(farCell.WorldTransform, out var inv);
+ farCell.InverseWorldTransform = inv;
+
+ var origin = new Vector3(0, 0, 1);
+ var direction = Vector3.UnitY;
+ float t = CellBspRayOccluder.NearestWallT(origin, direction, new[] { farCell, nearCell });
+ Assert.True(t < 11f, $"expected near-cell hit ~10, got {t}");
+ }
+}
+```
+
+- [ ] **Step 2: Run the test — expect failure**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellBspRayOccluder"`
+Expected: FAIL (`CellBspRayOccluder` not found).
+
+- [ ] **Step 3: Implement the occluder**
+
+Create `src/AcDream.Core/Selection/CellBspRayOccluder.cs`:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+
+namespace AcDream.Core.Selection;
+
+///
+/// Indoor walking Phase 1 (2026-05-19). Pure ray-vs-cell-BSP-polygon
+/// occlusion test. Given a ray and a set of
+/// (currently-loaded EnvCells with resolved polygon planes), returns
+/// the nearest world-space t along the ray that hits any cell
+/// polygon — or if the ray clears
+/// all cells.
+///
+///
+/// Used by to filter entities that sit
+/// behind a wall from the camera's POV (issue #86). Möller-Trumbore
+/// ray-triangle intersection; one test per triangle. Cells are
+/// transformed via their
+/// so the ray runs in cell-local space and the resolved-polygon
+/// vertices don't need re-transformation per query.
+///
+///
+///
+/// No BSP traversal — iterates every polygon in every cell. Cell count
+/// in a Holtburg-radius-4 streaming window is ~80 cells × ~50 polys
+/// each = ~4K triangles. Möller-Trumbore is ~40 ns per triangle on
+/// modern hardware; one Pick call is well under 1 ms.
+///
+///
+public static class CellBspRayOccluder
+{
+ ///
+ /// Returns the nearest positive t such that
+ /// origin + t * direction intersects a polygon in any cell.
+ /// Returns if no cell polygon
+ /// is intersected.
+ ///
+ /// Need not be normalized; returned t
+ /// scales with direction length the same as a parametric ray.
+ public static float NearestWallT(
+ Vector3 origin,
+ Vector3 direction,
+ IEnumerable loadedCells)
+ {
+ if (loadedCells is null) return float.PositiveInfinity;
+
+ float bestT = float.PositiveInfinity;
+ foreach (var cell in loadedCells)
+ {
+ if (cell?.Resolved is null) continue;
+
+ // Bring the ray into cell-local space ONCE per cell.
+ var localOrigin = Vector3.Transform(origin, cell.InverseWorldTransform);
+ var localDirection = Vector3.TransformNormal(direction, cell.InverseWorldTransform);
+
+ foreach (var (_, poly) in cell.Resolved)
+ {
+ // Triangulate the (possibly polygonal) face into a fan.
+ int n = poly.NumPoints;
+ if (n < 3 || poly.Vertices is null || poly.Vertices.Length < n)
+ continue;
+
+ for (int i = 1; i < n - 1; i++)
+ {
+ if (TryRayTriangle(
+ localOrigin, localDirection,
+ poly.Vertices[0], poly.Vertices[i], poly.Vertices[i + 1],
+ out var t)
+ && t < bestT)
+ {
+ bestT = t;
+ }
+ }
+ }
+ }
+ return bestT;
+ }
+
+ ///
+ /// Möller-Trumbore ray-triangle intersection. Returns true with
+ /// t in if the ray hits the triangle
+ /// at a positive distance.
+ ///
+ private static bool TryRayTriangle(
+ Vector3 origin, Vector3 direction,
+ Vector3 v0, Vector3 v1, Vector3 v2,
+ out float t)
+ {
+ const float Epsilon = 1e-7f;
+
+ var edge1 = v1 - v0;
+ var edge2 = v2 - v0;
+ var pvec = Vector3.Cross(direction, edge2);
+ float det = Vector3.Dot(edge1, pvec);
+
+ // No two-sided handling here — picker should be permissive so
+ // a wall blocks regardless of which side the camera is on.
+ if (det > -Epsilon && det < Epsilon) { t = 0f; return false; }
+ float invDet = 1f / det;
+
+ var tvec = origin - v0;
+ float u = Vector3.Dot(tvec, pvec) * invDet;
+ if (u < 0f || u > 1f) { t = 0f; return false; }
+
+ var qvec = Vector3.Cross(tvec, edge1);
+ float v = Vector3.Dot(direction, qvec) * invDet;
+ if (v < 0f || u + v > 1f) { t = 0f; return false; }
+
+ t = Vector3.Dot(edge2, qvec) * invDet;
+ return t > Epsilon;
+ }
+}
+```
+
+- [ ] **Step 4: Run the tests — expect green**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~CellBspRayOccluder"`
+Expected: 4 tests passing.
+
+---
+
+## Task 13: Wire `WorldPicker.Pick` to use the occluder
+
+**Files:**
+- Modify: `src/AcDream.Core/Selection/WorldPicker.cs`
+- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
+- Create: `tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs`
+
+- [ ] **Step 1: Write the failing integration test**
+
+Create `tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs`:
+
+```csharp
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.Selection;
+using AcDream.Core.World;
+using DatReaderWriter.Types;
+using Xunit;
+
+namespace AcDream.Core.Tests.Selection;
+
+public class WorldPickerCellOcclusionTests
+{
+ private static CellPhysics MakeWallAtY10()
+ {
+ var verts = new[]
+ {
+ new Vector3(-5, 10, -5),
+ new Vector3( 5, 10, -5),
+ new Vector3( 5, 10, 5),
+ new Vector3(-5, 10, 5),
+ };
+ var poly = new ResolvedPolygon
+ {
+ Vertices = verts,
+ Plane = new System.Numerics.Plane(new Vector3(0, -1, 0), 10f),
+ NumPoints = 4,
+ SidesType = SidesType.Front,
+ };
+ return new CellPhysics
+ {
+ BSP = null,
+ Resolved = new() { [0] = poly },
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ };
+ }
+
+ private static WorldEntity MakeEntity(uint guid, Vector3 pos) => new()
+ {
+ Id = guid,
+ ServerGuid = guid,
+ SourceGfxObjOrSetupId = 0,
+ Position = pos,
+ Rotation = Quaternion.Identity,
+ MeshRefs = System.Array.Empty(),
+ };
+
+ [Fact]
+ public void Pick_RaySphere_EntityBehindWall_OccludedByCellBsp()
+ {
+ var wall = MakeWallAtY10();
+ var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0)); // entity at Y=20, wall at Y=10
+
+ var result = WorldPicker.Pick(
+ origin: Vector3.Zero,
+ direction: Vector3.UnitY,
+ candidates: new[] { entity },
+ skipServerGuid: 0u,
+ cellOccluder: (origin, direction) =>
+ CellBspRayOccluder.NearestWallT(origin, direction, new[] { wall }));
+
+ Assert.Null(result);
+ }
+
+ [Fact]
+ public void Pick_RaySphere_NoWall_HitsEntity()
+ {
+ var entity = MakeEntity(0xABCDu, new Vector3(0, 20, 0));
+
+ var result = WorldPicker.Pick(
+ origin: Vector3.Zero,
+ direction: Vector3.UnitY,
+ candidates: new[] { entity },
+ skipServerGuid: 0u,
+ cellOccluder: null); // null occluder = no occlusion
+
+ Assert.Equal(0xABCDu, result);
+ }
+}
+```
+
+- [ ] **Step 2: Run the test — expect compile failure (cellOccluder param doesn't exist yet)**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerCellOcclusion"`
+Expected: build FAIL (`cellOccluder` param not on Pick).
+
+- [ ] **Step 3: Add `cellOccluder` parameter to the legacy ray-sphere `Pick`**
+
+In `src/AcDream.Core/Selection/WorldPicker.cs`, change the legacy `Pick` signature (line 88-95) from:
+
+```csharp
+ public static uint? Pick(
+ Vector3 origin, Vector3 direction,
+ IEnumerable candidates,
+ uint skipServerGuid,
+ float maxDistance = 50f,
+ Func? radiusForGuid = null,
+ Func? verticalOffsetForGuid = null)
+```
+
+to:
+
+```csharp
+ public static uint? Pick(
+ Vector3 origin, Vector3 direction,
+ IEnumerable candidates,
+ uint skipServerGuid,
+ float maxDistance = 50f,
+ Func? radiusForGuid = null,
+ Func? verticalOffsetForGuid = null,
+ Func? cellOccluder = null)
+```
+
+Then, inside the method, BEFORE the foreach loop over candidates, capture the wall-t once:
+
+```csharp
+ float wallT = cellOccluder?.Invoke(origin, direction) ?? float.PositiveInfinity;
+```
+
+And inside the candidate loop, immediately before the `if (t < bestT)` line, add:
+
+```csharp
+ if (t >= wallT) continue; // wall is between camera and entity
+```
+
+- [ ] **Step 4: Run the legacy-overload test — expect green**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~WorldPickerCellOcclusion.Pick_RaySphere"`
+Expected: 2 tests passing.
+
+- [ ] **Step 5: Add cellOccluder to the screen-rect Pick overload**
+
+In `src/AcDream.Core/Selection/WorldPicker.cs`, change the screen-rect `Pick` signature (line 202-211) from:
+
+```csharp
+ public static uint? Pick(
+ float mouseX, float mouseY,
+ Matrix4x4 view,
+ Matrix4x4 projection,
+ Vector2 viewport,
+ IEnumerable candidates,
+ uint skipServerGuid,
+ Func sphereForEntity,
+ float inflatePixels = 8f)
+```
+
+to:
+
+```csharp
+ public static uint? Pick(
+ float mouseX, float mouseY,
+ Matrix4x4 view,
+ Matrix4x4 projection,
+ Vector2 viewport,
+ IEnumerable candidates,
+ uint skipServerGuid,
+ Func sphereForEntity,
+ float inflatePixels = 8f,
+ Func? cellOccluder = null)
+```
+
+Inside the method, BEFORE the foreach over candidates, build the click ray and query the occluder. After computing the ray via the existing `BuildRay` helper (or `Matrix4x4.Invert(vp)` if `BuildRay` isn't directly callable due to viewport plumbing), use:
+
+```csharp
+ var (rayOrigin, rayDir) = BuildRay(mouseX, mouseY, viewport.X, viewport.Y, view, projection);
+ float wallT = cellOccluder?.Invoke(rayOrigin, rayDir) ?? float.PositiveInfinity;
+ // Convert wall t (world-space distance along normalized ray dir)
+ // to camera-space depth for comparison with `depth` from
+ // ScreenProjection.TryProjectSphereToScreenRect.
+ Vector3 wallPoint = float.IsPositiveInfinity(wallT)
+ ? new Vector3(0, 0, 0)
+ : rayOrigin + rayDir * wallT;
+ float wallDepth = float.IsPositiveInfinity(wallT)
+ ? float.PositiveInfinity
+ : Vector3.Transform(wallPoint, view).Z * -1f; // camera looks -Z; depth is positive
+```
+
+Inside the candidate loop, just before `if (depth < bestDepth)`:
+
+```csharp
+ if (depth > wallDepth) continue;
+```
+
+**Note:** The camera-space depth math assumes the engine uses the System.Numerics row-vector convention (`view * projection`). Verify by reading the existing `ScreenProjection.TryProjectSphereToScreenRect` to see how `depth` is computed, and match.
+
+- [ ] **Step 6: Wire the occluder from GameWindow**
+
+In `src/AcDream.App/Rendering/GameWindow.cs` at the picker call (line 9134), add a `cellOccluder` argument that snapshots the currently-loaded cells:
+
+```csharp
+ // Cluster A #86 (2026-05-19): occlude entities behind walls.
+ // Snapshot the currently-loaded EnvCells' physics — picker uses
+ // ray-vs-poly to gate selection through walls.
+ var loadedCellPhysics = new List();
+ foreach (var cellId in EnumerateLoadedEnvCellIds()) // see helper below
+ {
+ var cp = _physicsDataCache.GetCellStruct(cellId);
+ if (cp is not null) loadedCellPhysics.Add(cp);
+ }
+
+ var picked = AcDream.Core.Selection.WorldPicker.Pick(
+ mouseX: _lastMouseX, mouseY: _lastMouseY,
+ view: camera.View, projection: camera.Projection,
+ viewport: viewport,
+ candidates: _entitiesByServerGuid.Values,
+ skipServerGuid: _playerServerGuid,
+ sphereForEntity: e => /* unchanged */ ...,
+ inflatePixels: 8f,
+ cellOccluder: (origin, direction) =>
+ AcDream.Core.Selection.CellBspRayOccluder.NearestWallT(origin, direction, loadedCellPhysics));
+```
+
+Add a small helper above `OnPick` (or wherever fits):
+
+```csharp
+ ///
+ /// Cluster A #86 helper. Returns the EnvCell ids whose physics BSP
+ /// is currently cached and may occlude a picker ray. Authoritative
+ /// source TBD during integration — check whether `_cellVisibility`
+ /// exposes a public set, otherwise iterate `_pendingCellMeshes.Keys`
+ /// or the equivalent.
+ ///
+ private IEnumerable EnumerateLoadedEnvCellIds()
+ {
+ // Confirm authoritative source during integration. _physicsDataCache
+ // already has `_cellStruct` (private). Easiest path: add a public
+ // `IReadOnlyCollection CellStructIds` getter to PhysicsDataCache.
+ return _physicsDataCache.CellStructIds;
+ }
+```
+
+If `PhysicsDataCache` doesn't yet expose `CellStructIds`, add it. Edit `src/AcDream.Core/Physics/PhysicsDataCache.cs` near the existing `CellStructCount` property:
+
+```csharp
+ ///
+ /// Indoor walking Phase 1 (2026-05-19). Snapshot of currently-cached
+ /// EnvCell ids — used by to enumerate
+ /// occluder candidates without exposing the underlying dictionary.
+ ///
+ public IReadOnlyCollection CellStructIds => _cellStruct.Keys;
+```
+
+- [ ] **Step 7: Build + test**
+
+Run: `dotnet build && dotnet test`
+Expected: all green, including the two new `WorldPickerCellOcclusionTests`.
+
+- [ ] **Step 8: Visual verify**
+
+Re-launch the client. Mouse over the Inn's west exterior wall from open ground: cursor should NOT show a selection ring for any indoor entities. Mouse through the Inn's open door at an inside NPC: selection works.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/AcDream.Core/Selection/CellBspRayOccluder.cs \
+ src/AcDream.Core/Selection/WorldPicker.cs \
+ src/AcDream.Core/Physics/PhysicsDataCache.cs \
+ src/AcDream.App/Rendering/GameWindow.cs \
+ tests/AcDream.Core.Tests/Selection/CellBspRayOccluderTests.cs \
+ tests/AcDream.Core.Tests/Selection/WorldPickerCellOcclusionTests.cs
+
+git commit -m "$(cat <<'EOF'
+fix(picker): Cluster A #86 — cell-BSP ray occlusion in WorldPicker
+
+WorldPicker.Pick previously had no occlusion test — any entity along
+the click ray within maxDistance was a candidate, including ones
+behind walls. Adds the CellBspRayOccluder static helper that
+Möller-Trumbore-tests the click ray against every polygon in every
+currently-cached EnvCell BSP, returning the nearest wall-hit `t`.
+Both Pick overloads gate candidate selection by that wall-t (legacy
+ray-sphere via world-space `t`, screen-rect via camera-space depth).
+
+PhysicsDataCache exposes a new CellStructIds snapshot accessor so the
+caller can iterate without needing the private cache dictionary.
+
+Closes #86.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 14: Close out docs
+
+**Files:**
+- Modify: `docs/ISSUES.md`
+- Modify: `docs/plans/2026-04-11-roadmap.md`
+- Modify: `CLAUDE.md` (only the "Currently working toward / current phase" line; otherwise leave alone)
+- Create: `docs/research/-indoor-walking-phase1-shipped-handoff.md`
+
+- [ ] **Step 1: Move issues #84, #85, #86 to "Recently closed" in `docs/ISSUES.md`**
+
+For each issue, change `**Status:** OPEN` to `**Status:** DONE`, add a `**Closed:** YYYY-MM-DD · ` line, and move the block to the "Recently closed" section at the bottom of the file (mirroring the format of other DONE entries).
+
+- [ ] **Step 2: Add shipped entry to `docs/plans/2026-04-11-roadmap.md`**
+
+Add a row to the "Recently shipped" table at the top of the roadmap doc. Format matches the existing "Indoor cell rendering Phase 2" row (which landed earlier today).
+
+- [ ] **Step 3: Update CLAUDE.md "Currently in Phase..." paragraph**
+
+Open `CLAUDE.md`. The block at the start of the "Roadmap discipline" section names the current phase. Update to reflect that Indoor walking Phase 1 shipped, and that the next item is Indoor walking Phase 2 (the visibility cluster — #78) OR a return to the M2 critical path (F.2/F.3/etc.) — pick per CLAUDE.md's work-order autonomy rule and announce in commit message.
+
+- [ ] **Step 4: Write the shipped handoff doc**
+
+Create `docs/research/-indoor-walking-phase1-shipped-handoff.md` (replace placeholder with actual date). Format mirrors `docs/research/2026-05-14-b5-shipped-handoff.md`:
+
+```markdown
+# Indoor walking Phase 1 — shipped handoff
+
+**Date:** YYYY-MM-DD
+**Commits:**
+**Closes:** ISSUES.md #84, #85, #86
+
+## Probe evidence
+
+(paste 3-5 representative `[indoor-bsp]` lines from `launch-cluster-a-capture.log`)
+
+## Root causes
+
+- **#84:** (one paragraph)
+- **#85:** (one paragraph)
+- **#86:** (one paragraph — WorldPicker had no cell-BSP test, pinned by code-reading not by capture)
+
+## Files touched
+
+(short list grouped by commit)
+
+## Follow-up
+
+(any new issues filed during this phase, e.g. an extension of the probe scope, or items deferred to Indoor walking Phase 2)
+```
+
+- [ ] **Step 5: Final build + test sweep**
+
+Run: `dotnet build && dotnet test`
+Expected: 0 errors, all green.
+
+- [ ] **Step 6: Commit the docs**
+
+```bash
+git add docs/ISSUES.md \
+ docs/plans/2026-04-11-roadmap.md \
+ CLAUDE.md \
+ docs/research/-indoor-walking-phase1-shipped-handoff.md
+
+git commit -m "$(cat <<'EOF'
+docs(phase): Indoor walking Phase 1 — shipped
+
+Closes ISSUES.md #84, #85, #86. Adds shipped handoff doc with probe
+evidence + root cause summaries. Roadmap and CLAUDE.md current-phase
+pointer updated.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Self-review checklist (run after writing the plan; the engineer doesn't run this)
+
+1. **Spec coverage**: every spec section maps to a task. ✓ §1-3 → Tasks 1-3 + facts; §4 components → Tasks 1-6 (probe), Task 12-13 (picker); §5 data flow → Tasks 7-11; §6 commit shape → Task 6 + 9 + 11 + 13 + 14; §7 files → File Structure table; §9 testing → Tasks 5, 12, 13; §10 acceptance → Task 14 Step 4 handoff doc.
+2. **Placeholder scan**: post-capture tasks (8-11) intentionally carry parameterized fixes since the exact fix is unknown pre-capture; the runbook structure gives concrete commands + a decision tree. This is honest about the phase shape, not a placeholder.
+3. **Type consistency**: `ProbeIndoorBspEnabled` (PhysicsDiagnostics) ↔ `ProbeIndoorBsp` (DebugVM) ↔ `ACDREAM_PROBE_INDOOR_BSP` (env var) ↔ "Indoor: BSP collision" (DebugPanel label) — verified consistent throughout. `CellBspRayOccluder.NearestWallT` signature consistent across Tasks 12 and 13.
+4. **Acceptance**: matches spec §10 + design §13 risk #2 (the split-out option for #85 if the fix scope explodes).
diff --git a/docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md b/docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md
new file mode 100644
index 00000000..c1abc26b
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-19-phase2-indoor-cell-rendering-fix.md
@@ -0,0 +1,550 @@
+# Indoor Cell Rendering Fix — Phase 2 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Surface WB's silent `PrepareEnvCellMeshData` failures via an exception-capturing continuation in `WbMeshAdapter`, identify the root cause for the 26 missing-completion cells, then implement the targeted fix that lands the indoor floor rendering.
+
+**Architecture:** `WbMeshAdapter.IncrementRefCount` captures the `Task` returned by WB's `PrepareMeshDataAsync` and attaches a `ContinueWith` that logs faulted-task exceptions + clean-null results for EnvCell IDs only. Gated by the existing `ProbeIndoorUploadEnabled` flag — zero cost when off. Component 3 (the actual fix) is data-driven: the captured exception type + message determines the surgical code change.
+
+**Tech Stack:** C# .NET 10, Silk.NET OpenGL, WorldBuilder's `Chorizite.OpenGLSDLBackend.Lib.ObjectMeshManager`. xUnit for any unit tests.
+
+**Spec:** [`docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md`](../specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md).
+**Phase 1 capture:** [`docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`](../../research/2026-05-19-indoor-cell-rendering-probe-capture.md).
+
+---
+
+## File Structure
+
+| File | Status | Responsibility |
+|---|---|---|
+| `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs` | MODIFY (Task 1) | Capture `prepTask` from `PrepareMeshDataAsync`. Attach a `ContinueWith` for EnvCell IDs that emits `[indoor-upload] FAILED` on faulted tasks and `[indoor-upload] NULL_RESULT` on clean-null returns. |
+| `launch.log` (and the user's walk-through) | NEW (Task 2) | Captured probe output. Drives Component 3's fix shape. Not committed. |
+| `docs/research/2026-05-19-indoor-cell-rendering-cause.md` | NEW (Task 3) | One-page report documenting the captured exception type(s) + the chosen fix shape. Becomes Phase 2's "design closure" doc. |
+| TBD-by-data (Component 3) | MODIFY (Task 4) | Fix shape depends on captured cause. Likely candidates: `WbMeshAdapter.PopulateMetadata`, `CellMesh.Build`, a guard at the dat-access call site, or a small WB fork patch. |
+| `docs/research/2026-05-19-indoor-cell-rendering-verification.md` | NEW (Task 5) | Post-fix verification record: previously-missing cells now emit `[indoor-upload] completed`, visual confirmation. |
+| `docs/plans/2026-04-11-roadmap.md` | MODIFY (Task 6) | Roadmap update: Phase 2 shipped, link to spec + research notes. |
+
+---
+
+## Task 1: Add exception-surfacing continuation in `WbMeshAdapter`
+
+**Files:**
+- Modify: `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`
+
+- [ ] **Step 1: Add `using System.Linq;` and `using System.Threading.Tasks;` if missing**
+
+Open `src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs`. Verify both `using System.Linq;` and `using System.Threading.Tasks;` are present at the top. Add them if not.
+
+- [ ] **Step 2: Replace the fire-and-forget call with a captured task + continuation**
+
+Find the `IncrementRefCount` method (around line 116). The current block looks like:
+
+```csharp
+public void IncrementRefCount(ulong id)
+{
+ if (_isUninitialized || _meshManager is null) return;
+ _meshManager.IncrementRefCount(id);
+
+ if (_metadataPopulated.Add(id))
+ {
+ PopulateMetadata(id);
+
+ // WB's IncrementRefCount alone only bumps a usage counter; it does
+ // NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
+ // so the background workers actually decode the GfxObj. The result
+ // auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
+ // which Tick() drains onto the GPU. Until that completes,
+ // TryGetRenderData(id) returns null and the dispatcher silently
+ // skips the entity — standard streaming flicker.
+ //
+ // isSetup: false — acdream's MeshRefs already carry expanded
+ // per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
+ // unused.
+ _ = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
+
+ // [indoor-upload] requested probe — only for EnvCell ids.
+ if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
+ {
+ _pendingEnvCellRequests.Add(id);
+ Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}");
+ }
+ }
+}
+```
+
+Replace the `_metadataPopulated.Add(id)` block body with this exact content (note: the `_ = _meshManager.PrepareMeshDataAsync(...)` line becomes `var prepTask = ...` — capture the task instead of discarding it):
+
+```csharp
+ PopulateMetadata(id);
+
+ // WB's IncrementRefCount alone only bumps a usage counter; it does
+ // NOT trigger mesh loading. We must explicitly call PrepareMeshDataAsync
+ // so the background workers actually decode the GfxObj. The result
+ // auto-enqueues into _stagedMeshData (ObjectMeshManager line 510),
+ // which Tick() drains onto the GPU. Until that completes,
+ // TryGetRenderData(id) returns null and the dispatcher silently
+ // skips the entity — standard streaming flicker.
+ //
+ // isSetup: false — acdream's MeshRefs already carry expanded
+ // per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
+ // unused.
+ var prepTask = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
+
+ // [indoor-upload] requested probe — only for EnvCell ids.
+ if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
+ {
+ _pendingEnvCellRequests.Add(id);
+ Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}");
+
+ // Phase 2 — surface what WB's catch block silently swallows.
+ // ObjectMeshManager.PrepareMeshData has try/catch at line 589
+ // that calls _logger.LogError on exceptions and returns null.
+ // We construct ObjectMeshManager with NullLogger so the log
+ // goes nowhere. This continuation captures the same data
+ // (scoped to EnvCell ids only). Runs on ThreadPool; non-
+ // blocking. Zero cost when probe is off.
+ ulong cellId = id;
+ _ = prepTask.ContinueWith(t =>
+ {
+ if (t.IsFaulted && t.Exception is not null)
+ {
+ var ex = t.Exception.InnerException ?? t.Exception;
+ var stack = (ex.StackTrace ?? "").Split('\n')
+ .Take(3).Select(s => s.Trim()).Where(s => s.Length > 0);
+ Console.WriteLine(
+ $"[indoor-upload] FAILED cellId=0x{cellId:X8} " +
+ $"exception={ex.GetType().Name}: {ex.Message} " +
+ $"stack=[{string.Join(" | ", stack)}]");
+ }
+ else if (t.IsCompletedSuccessfully && t.Result is null)
+ {
+ Console.WriteLine($"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8}");
+ }
+ }, TaskScheduler.Default);
+ }
+```
+
+- [ ] **Step 3: Build**
+
+Run: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`
+Expected: 0 errors, 0 warnings (any new warnings about discarded tasks are fixed by the `_ = prepTask.ContinueWith(...)` assignment).
+
+- [ ] **Step 4: Run tests (sanity)**
+
+Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~Rendering" -c Debug --nologo --no-build`
+Expected: All 130 Rendering tests still pass (the change doesn't touch any tested code path — `WbMeshAdapter.IncrementRefCount` isn't covered by unit tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
+git commit -m "$(cat <<'EOF'
+feat(wb): surface WB-swallowed exceptions for EnvCell upload failures
+
+Phase 1 confirmed 26/123 Holtburg cells silently fail in WB's
+PrepareEnvCellMeshData / PrepareMeshData. WB's catch block at
+ObjectMeshManager.cs:589 calls _logger.LogError(ex, ...) — but we
+construct ObjectMeshManager with NullLogger, so the log is dropped.
+
+Capture the Task from PrepareMeshDataAsync (previously fire-and-forget)
+and attach a ContinueWith that, for EnvCell ids only when the probe
+is on, logs:
+
+ [indoor-upload] FAILED cellId=0x... exception=:
+ stack=[]
+ [indoor-upload] NULL_RESULT cellId=0x...
+
+Runs on ThreadPool — non-blocking. Zero cost when ProbeIndoorUploadEnabled
+is off. AggregateException is unwrapped to InnerException for readability.
+Stack truncated to top 3 frames.
+
+Next: capture procedure, identify cause, target the fix.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 2: Capture procedure — run client, identify cause
+
+This task is operator-driven, not subagent-driven. The user (not a subagent) walks the client. Subagent role is limited to launching + analyzing the log.
+
+**Files:**
+- New: `launch.log` (transient — not committed)
+
+- [ ] **Step 1: Full solution build (sanity)**
+
+Run: `dotnet build AcDream.slnx -c Debug --nologo 2>&1 | tail -10`
+Expected: `Build succeeded. 0 Error(s)`.
+
+- [ ] **Step 2: Gracefully close any prior `AcDream.App` instance**
+
+```powershell
+$proc = Get-Process -Name AcDream.App -ErrorAction SilentlyContinue
+if ($proc) {
+ $proc | ForEach-Object { $_.CloseMainWindow() | Out-Null }
+ $proc | ForEach-Object { if (-not $_.WaitForExit(5000)) { Stop-Process -Id $_.Id -Force } }
+ Start-Sleep -Seconds 3
+}
+```
+
+- [ ] **Step 3: Launch with `ACDREAM_PROBE_INDOOR_UPLOAD=1`**
+
+```powershell
+$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
+$env:ACDREAM_LIVE = "1"
+$env:ACDREAM_TEST_HOST = "127.0.0.1"
+$env:ACDREAM_TEST_PORT = "9000"
+$env:ACDREAM_TEST_USER = "testaccount"
+$env:ACDREAM_TEST_PASS = "testpassword"
+$env:ACDREAM_DEVTOOLS = "1"
+$env:ACDREAM_PROBE_INDOOR_UPLOAD = "1"
+$logPath = "launch.log"
+Remove-Item $logPath -ErrorAction SilentlyContinue
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath $logPath
+```
+
+Run in background via `run_in_background: true`.
+
+- [ ] **Step 4: User walks Holtburg**
+
+User waits for the client to reach in-world (~8-12 s), then:
+- Walks into Holtburg Inn (where the floor was missing in Phase 1).
+- Walks into 2-3 other nearby buildings to capture varied failure causes.
+- Closes the client window with the close button (graceful — NOT taskkill).
+
+- [ ] **Step 5: Analyze the log**
+
+```powershell
+$lines = Get-Content launch.log | Where-Object { $_ -match '\[indoor-upload\] (FAILED|NULL_RESULT)' }
+Write-Host "Total failure lines: $($lines.Count)"
+Write-Host ""
+Write-Host "=== Distinct exception types (FAILED) ==="
+$lines | Where-Object { $_ -match '\[indoor-upload\] FAILED' } |
+ ForEach-Object { if ($_ -match 'exception=(\w+):') { $matches[1] } } |
+ Group-Object | Sort-Object Count -Descending | Format-Table -AutoSize
+
+Write-Host "=== Distinct NULL_RESULT count ==="
+($lines | Where-Object { $_ -match 'NULL_RESULT' }).Count
+
+Write-Host ""
+Write-Host "=== Sample FAILED lines ==="
+$lines | Where-Object { $_ -match '\[indoor-upload\] FAILED' } | Select-Object -First 10
+Write-Host ""
+Write-Host "=== Sample NULL_RESULT lines ==="
+$lines | Where-Object { $_ -match '\[indoor-upload\] NULL_RESULT' } | Select-Object -First 5
+```
+
+Verify the previously-failing cells (from Phase 1: `0xA9B40100`, `0xA9B40111`, `0xA9B40112`, etc.) now appear in either FAILED or NULL_RESULT.
+
+If they DON'T appear:
+- Confirm the probe flag is on (check `$env:ACDREAM_PROBE_INDOOR_UPLOAD` reads `"1"`).
+- Confirm the user actually walked into the failing cells.
+- Possible BUG: the continuation isn't firing — check Task 1's edits for typos.
+
+---
+
+## Task 3: Write the cause report
+
+**Files:**
+- Create: `docs/research/2026-05-19-indoor-cell-rendering-cause.md`
+
+- [ ] **Step 1: Write the report based on Task 2's output**
+
+Create the file with this structure (replace bracketed sections with captured data):
+
+```markdown
+# Indoor Cell Rendering — Phase 2 Cause Report
+
+**Date:** 2026-05-19
+**Predecessor:** Phase 1 capture confirmed H1 (silent failure in WB).
+**Capture method:** Task 1's `ContinueWith` surfaced WB's swallowed exceptions for EnvCell IDs.
+
+## Cause(s)
+
+[Replace this section with the captured findings. Example shape:]
+
+Two distinct failure modes captured at Holtburg:
+
+1. **`KeyNotFoundException` — N cells affected** — Exception thrown from `PrepareCellStructMeshData` line XXX when trying to look up surface `0x08001234`. Affected cells: `0xA9B40100`, `0xA9B40111`, ...
+
+2. **`NULL_RESULT` — M cells affected** — WB's `ResolveId` returned empty for `EnvironmentId 0xD000XXXX`, causing `PrepareEnvCellMeshData` to skip the cellGeometry branch and produce an empty result. Affected cells: ...
+
+[OR if only one cause is observed:]
+
+Single failure mode: [exception type] thrown in [location] for all 26 cells. Root cause: [analysis].
+
+## Sample log lines
+
+```
+[paste 5-10 actual captured FAILED / NULL_RESULT lines here]
+```
+
+## Proposed fix
+
+[Concrete code change for each distinct cause. For example:]
+
+- For `KeyNotFoundException` on surface lookup: add a null-guard in `WbMeshAdapter.PopulateMetadata` AND skip the failing surface in our acdream-side processing.
+- For `NULL_RESULT` from missing `EnvironmentId`: log + skip with a sentinel render-data so the dispatcher gracefully draws nothing instead of failing silently.
+
+Each fix is a single-file change. Task 4 of this plan implements them.
+
+## Verification approach
+
+After Task 4's fix:
+- Re-launch with the same probe flag.
+- Confirm previously-failing cells now emit `[indoor-upload] completed` lines.
+- Visual: floor renders in Holtburg Inn.
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add docs/research/2026-05-19-indoor-cell-rendering-cause.md
+git commit -m "$(cat <<'EOF'
+docs(research): Phase 2 cause report —
+
+Captured at Holtburg with the ContinueWith-based exception surfacer
+from Task 1.
+
+Fix shape decided: . Implemented in next commit.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 4: Apply the targeted fix
+
+**The fix shape is unknown until Task 2 captures.** This task's code is data-driven. The plan below lists the four most likely fix shapes; the implementer picks the matching one(s) and implements them.
+
+### 4a — If the cause is `KeyNotFoundException` / missing dat record
+
+Most likely path: WB's `PrepareCellStructMeshData` calls `_dats.Portal.TryGet(surfaceId, out var surface)`, gets `false`, then crashes when later code assumes non-null.
+
+**Files:**
+- Modify: TBD by exception stack — likely a WB fork patch OR a guard at our acdream call site.
+
+- [ ] **Step 1: Open the throwing file based on the exception stack trace**
+
+The probe line will show:
+```
+stack=[at PrepareCellStructMeshData in ObjectMeshManager.cs:line | at PrepareEnvCellMeshData in ObjectMeshManager.cs:line | ...]
+```
+
+Open that file at that line. Confirm the missing-dat-record assumption.
+
+- [ ] **Step 2: Patch shape (WB fork, if in WB)**
+
+In `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs`, add a null-guard at the throwing line:
+
+```csharp
+// Pre-Phase-2: WB assumed every surface in envCell.Surfaces was
+// resolvable. Some Holtburg cells reference surfaces that aren't in the
+// loaded portal dat, causing a NullRef in the throwing line below.
+// Guard: skip the surface if it doesn't resolve.
+if (!_dats.Portal.TryGet(surfaceId, out var surface))
+{
+ continue; // or: surface = _fallbackSurface; whichever fits
+}
+```
+
+(Exact code depends on the stack. The implementer reads the actual throwing line and adapts.)
+
+- [ ] **Step 3: Build, capture, verify**
+
+```bash
+dotnet build src/AcDream.App/AcDream.App.csproj -c Debug
+```
+
+Then re-run Task 2's launch + capture. Confirm:
+- Previously-failing cells now have `[indoor-upload] completed` lines.
+- No new `[indoor-upload] FAILED` lines for those cells.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs
+# OR whatever file was patched
+git commit -m "$(cat <<'EOF'
+fix(wb): null-guard for missing surface in PrepareCellStructMeshData
+
+Phase 2 capture found Holtburg cells silently failing with
+ thrown at : when WB tried to look up
+surface 0x... that isn't resolvable in the loaded portal dat.
+
+Patch: .
+
+Visual-verified: floor now renders in Holtburg Inn.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+### 4b — If the cause is `NULL_RESULT` (clean null return from WB)
+
+WB's `PrepareMeshData` returns null without throwing. Examined paths in the WB source:
+- Line 568: `_dats.Portal.TryGet(envId, ...)` fails → returns null.
+- Line 583: `type == DBObjType.Unknown` (ResolveId didn't classify the record) → returns null.
+
+**Files:**
+- Modify: probably WbMeshAdapter to detect and log, then either accept the cell as "no geometry" gracefully OR investigate the dat issue.
+
+- [ ] **Step 1: Read which path triggered**
+
+Look at the `NULL_RESULT` cells' EnvironmentId values. If the EnvironmentId looks corrupt or out of range, the dat is the issue. If it looks valid, WB's `ResolveId` is broken for that record.
+
+- [ ] **Step 2: Add a guard at our acdream call site OR patch WB**
+
+Depending on the finding:
+- **If dat is genuinely missing data**: skip the cell with a warning. Don't try to render its mesh. Log once via memory.
+- **If WB's ResolveId mis-classifies**: patch WB or work around by pre-checking with our own `_dats.Get(envCellId)` before calling `IncrementRefCount`.
+
+- [ ] **Step 3: Build, capture, verify, commit** (same pattern as 4a Step 3-4).
+
+### 4c — If the cause is a `NullReferenceException` in our code path
+
+Less likely but possible — if `PopulateMetadata` or `CellMesh.Build` crashes when invoked from a worker thread.
+
+**Files:**
+- Modify: the specific acdream file the stack trace points to.
+
+- [ ] **Step 1: Read the throwing line**
+- [ ] **Step 2: Add the appropriate null-guard**
+- [ ] **Step 3: Build, capture, verify, commit.**
+
+### 4d — If the cause is something else entirely
+
+If the captured exception type doesn't match 4a-4c, **STOP and re-design**. The fix shape needs the implementer's judgment + possibly a fresh brainstorm session. Don't paper over the cause with a generic try/catch.
+
+---
+
+## Task 5: Verification + visualization
+
+**Files:**
+- Create: `docs/research/2026-05-19-indoor-cell-rendering-verification.md`
+
+- [ ] **Step 1: Re-launch with the probe and re-walk Holtburg**
+
+Same as Task 2 Steps 2-4, but expectation flipped: `[indoor-upload] FAILED` / `NULL_RESULT` lines for previously-failing cells should NOT appear; `[indoor-upload] completed` lines should appear instead.
+
+- [ ] **Step 2: Visual verification by user**
+
+User walks into Holtburg Inn AND the other buildings whose cells were previously missing. Expected: floors visible, no missing geometry.
+
+- [ ] **Step 3: Write the verification report**
+
+Create the file documenting:
+
+```markdown
+# Indoor Cell Rendering — Phase 2 Verification
+
+**Date:** 2026-05-19
+**Outcome:** Floor renders in Holtburg Inn.
+
+## Probe re-capture
+
+After Task 4's fix:
+- Previously-failing cells:
+- Now emit `[indoor-upload] completed cellId=0x... isSetup=True hasEnvCellGeom=True cellGeomVerts= uploadOk=True`
+- No new `[indoor-upload] FAILED` or `NULL_RESULT` lines for these cells.
+
+## Visual confirmation
+
+User walked into:
+- Holtburg Inn — floor visible. ✓
+- — floor visible. ✓
+
+## Regressions checked
+
+- Outdoor terrain still renders correctly. ✓
+- NPCs, mobs, scenery still render. ✓
+- No new build warnings, no new test failures.
+
+## Closes
+
+This concludes Phase 2 of the indoor cell rendering fix.
+```
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add docs/research/2026-05-19-indoor-cell-rendering-verification.md
+git commit -m "$(cat <<'EOF'
+docs(research): Phase 2 verification — floor renders in Holtburg Inn
+
+Post-fix re-capture confirms previously-failing cells now emit
+[indoor-upload] completed. Visual verification by user confirms
+floors visible in Holtburg Inn and .
+
+Phase 2 complete.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Task 6: Roadmap update
+
+**Files:**
+- Modify: `docs/plans/2026-04-11-roadmap.md`
+
+- [ ] **Step 1: Read the roadmap's "shipped" section**
+
+Open `docs/plans/2026-04-11-roadmap.md`. Find the section listing recently-shipped phases (likely near the top, in a "shipped" table or chronological list).
+
+- [ ] **Step 2: Add an entry for Phase 2 indoor cell rendering fix**
+
+Add an entry matching the existing pattern of shipped-row entries. Example shape:
+
+```markdown
+| | 2026-05-19 | Indoor cell rendering — Phase 1 (diagnostics) + Phase 2 (fix) | Surfaced + fixed WB's silent failure for 26/123 Holtburg cells. Spec at [phase 1](../superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md) + [phase 2](../superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md). Cause: . Fix: . Visual-verified at Holtburg Inn. |
+```
+
+(Read the actual existing row format and match it.)
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add docs/plans/2026-04-11-roadmap.md
+git commit -m "$(cat <<'EOF'
+docs(roadmap): Phase 2 indoor cell rendering fix shipped
+
+Phase 1 diagnostics + Phase 2 fix landed today. Indoor floor rendering
+restored for Holtburg cells previously missing due to WB silent
+failure. Spec, plan, and verification documents committed.
+
+Co-Authored-By: Claude Opus 4.7 (1M context)
+EOF
+)"
+```
+
+---
+
+## Acceptance Criteria
+
+- [ ] Task 1 commits: `WbMeshAdapter.IncrementRefCount` attaches the continuation. `dotnet build` clean.
+- [ ] Task 2 capture: `[indoor-upload] FAILED` or `NULL_RESULT` lines fire for previously-failing cells. Distinct cause(s) identified.
+- [ ] Task 3 cause report: documented in `docs/research/2026-05-19-indoor-cell-rendering-cause.md`.
+- [ ] Task 4 fix: applied + committed. Build clean. Tests clean (no new failures; pre-existing 8 physics/input failures unchanged).
+- [ ] Task 5 verification: post-fix probe re-capture confirms `[indoor-upload] completed` for previously-failing cells. User visually confirms floor renders in Holtburg Inn.
+- [ ] Task 6 roadmap update: shipped row added.
+
+---
+
+## Subagent dispatch notes
+
+- **Task 1** is mechanical (well-specified code edit) — dispatch to Sonnet.
+- **Task 2** is operator-driven — the controller (parent) drives the launch + capture, not a subagent. The user MUST walk the client.
+- **Task 3** is analytical (interpret captured data) — controller writes inline, or dispatch a Sonnet subagent with the captured log as context.
+- **Task 4** is judgment-intensive (fix shape depends on data) — controller writes inline. If complex, a fresh brainstorm may be needed.
+- **Task 5** is similar to Task 2 (user-driven walk + analysis).
+- **Task 6** is mechanical — dispatch to Sonnet OR controller writes inline.
diff --git a/docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md b/docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md
new file mode 100644
index 00000000..db2b0d1c
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-16-phase-b6-suppress-movetostate-during-inbound-autowalk-design.md
@@ -0,0 +1,156 @@
+# Phase B.6 — Suppress outbound `MoveToState` during inbound MoveToObject auto-walk
+
+**Date:** 2026-05-16
+**Closes:** [#63](../../ISSUES.md) (server-initiated auto-walk not honored — the half about ACE's `MoveToChain` callback never firing), [#74](../../ISSUES.md) (AP cadence chattier than retail)
+**Milestone:** M1 — Walkable + clickable world
+
+## Goal
+
+Make ACE's server-side `MoveToChain` callback fire the Use action on arrival, eliminating the client-side retry workaround that today's `feat(retail): Commit B` (`b5da17d`) restored as an issue-#63 mitigation.
+
+## Background
+
+When the player double-clicks an NPC across the room in our current build:
+
+1. We send `Use(guid)` wire packet immediately at click.
+2. ACE: player not in `WithinUseRadius` → server-side `CreateMoveToChain(item, (success) => TryUseItem(item, success))` ([`Player_Use.cs:205`](../../../references/ACE/Source/ACE.Server/WorldObjects/Player_Use.cs)).
+3. ACE broadcasts `UpdateMotion` with `MovementType=6` (MoveToObject) targeting our player.
+4. Our client honors it via `BeginServerAutoWalk(...)` at [`GameWindow.cs:3389-3414`](../../../src/AcDream.App/Rendering/GameWindow.cs).
+5. The auto-walk overlay at [`PlayerMovementController.ApplyAutoWalkOverlay`](../../../src/AcDream.App/Input/PlayerMovementController.cs) synthesizes `input.Forward = true, input.Run = true` (line 611-619) so the rest of `Update` runs as if the user pressed W.
+6. **Bug:** the rest of `Update` computes `MotionStateChanged = true` and the outbound wire layer at [`GameWindow.cs:6410-6445`](../../../src/AcDream.App/Rendering/GameWindow.cs) builds and sends a `MoveToState` packet with `forwardCommand=RunForward, holdKey=Run`.
+7. ACE reads that as **user took manual control** and cancels its own `MoveToChain` — the `TryUseItem` callback never fires.
+8. We've worked around this by sending `Use` a second time on local-arrival (`_pendingPostArrivalAction` in [`GameWindow.SendUse` far-range path](../../../src/AcDream.App/Rendering/GameWindow.cs)). ACE accepts the second Use because we're now in range. **This is what we're retiring.**
+
+The retail client and holtburger's Rust client don't have this bug: neither synthesizes player-input MoveToState during inbound MoveToObject. Their auto-walk is body-level only; ACE's chain runs uninterrupted; the callback fires on arrival.
+
+### Retail anchor
+
+- ACE `Player_Use.cs:205` — `CreateMoveToChain(item, (success) => TryUseItem(item, success))`.
+- ACE `Player_Move.cs:150` — chain polls and fires `callback(true)` when within use radius.
+- Holtburger [`simulation.rs:178-206`](../../../references/holtburger/crates/holtburger-core/src/client/simulation.rs) — `MoveToObject` handler returns `Ok(Vec::new())`, no outbound packets in response. No retry mechanism anywhere in its codebase.
+
+## Design
+
+Single-flag suppression of the outbound `MoveToState` send while a server-initiated auto-walk is active. The body keeps walking locally (synth still drives the animation cycle + per-frame position update), `AutonomousPosition` keeps flowing (so ACE's `WithinUseRadius` poll sees us approach), but the misleading "user is RunForward" packet is silenced.
+
+### Wire-level changes
+
+**File: [`src/AcDream.App/Input/PlayerMovementController.cs`](../../../src/AcDream.App/Input/PlayerMovementController.cs)**
+
+No new field needed — [`IsServerAutoWalking`](../../../src/AcDream.App/Input/PlayerMovementController.cs:273) already exists, added by B.6 slice 2 (2026-05-14) for this purpose. Backs onto private `_autoWalkActive` (line 254). Set true by `BeginServerAutoWalk`, false by `EndServerAutoWalk`. User-input cancellation at line 478-482 already clears the flag, so manual takeover transitions correctly to "user is in control, send MoveToState normally."
+
+**File: [`src/AcDream.App/Rendering/GameWindow.cs`](../../../src/AcDream.App/Rendering/GameWindow.cs)**
+
+Guard the `MoveToState.Build` block at lines 6410-6445 with a second condition:
+
+```csharp
+if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
+{
+ // ... existing MoveToState build + SendGameAction ...
+}
+```
+
+No other path needs adjustment; the AutonomousPosition path remains unchanged (heartbeat-driven, gated on `Contact && OnWalkable`).
+
+Single-clause add; `IsServerAutoWalking` already documented at line 267-273 as the predicate for "the next Update synthesizes Forward+Run."
+
+### Workaround retirement
+
+After the wire fix is verified, the following Commit B safeguards are deleted:
+
+1. **Far-range Use retry.** In [`GameWindow.SendUse`](../../../src/AcDream.App/Rendering/GameWindow.cs) (~line 9201-9204), remove the `_pendingPostArrivalAction = (guid, false);` assignment from the far-range path and rewrite the comment block to say "ACE auto-fires Use via MoveToChain callback; client sends Use exactly once."
+
+2. **Far-range PickUp retry.** Same shape in [`GameWindow.SendPickUp`](../../../src/AcDream.App/Rendering/GameWindow.cs) (~line 9265).
+
+3. **Log strings.** Drop `(queued for arrival re-send pending #63)` from the same paths.
+
+4. **AP cadence revert.** Replace the per-frame `positionChanged` gate in [`PlayerMovementController.cs:1261-1275`](../../../src/AcDream.App/Input/PlayerMovementController.cs:1261) with retail's narrow gate per `acclient_2013_pseudo_c.txt:700233-700285`:
+
+```csharp
+// During sub-interval: send only on cell-or-contact-plane change.
+// After interval elapses: send only if position/frame changed.
+// Retail-faithful per acclient_2013_pseudo_c.txt:700233 ShouldSendPositionEvent.
+bool intervalElapsed = !_lastSentInitialized
+ || (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
+
+bool cellChanged = _lastSentCellId != CellId;
+bool planeChanged = !_lastSentContactPlane.Equals(_body.ContactPlane);
+bool frameChanged = !ApproxPositionEqual(_lastSentPos, _body.Position);
+
+bool sendThisFrame = intervalElapsed
+ ? (cellChanged || frameChanged)
+ : (cellChanged || planeChanged);
+
+HeartbeatDue = _body.InContact && _body.OnWalkable && sendThisFrame;
+```
+
+`_lastSentContactPlane` requires adding a `System.Numerics.Plane _lastSentContactPlane;` field next to the other `_lastSent*` fields. `System.Numerics.Plane` is a struct with `IEquatable` so direct `!_lastSentContactPlane.Equals(_body.ContactPlane)` works. Extend `NotePositionSent` to accept a `Plane` and stamp it alongside position/cell/time. The outbound layer at `GameWindow.cs:6410-6445` (MoveToState) and `~line 6310` (AutonomousPosition) both gain a `contactPlane: _body.ContactPlane` argument.
+
+### Issues closed
+
+- **#63** — server-initiated auto-walk not honored. (Both halves: B.6 slice 2 shipped the inbound handling on 2026-05-14; this commit fixes the MoveToChain callback-cancellation gap.)
+- **#74** — AP cadence chattier than retail. (Direct revert to retail's narrow gate enabled by the workaround retirement.)
+
+## Out of scope (file as new issues)
+
+- **Retail status messages** ("Approaching {target}" / "Using the {target}" on screen). Client-side UX polish; not wire-critical. Mentioned by user during brainstorm 2026-05-16 as nice-to-have to match retail feel. File as `#75 — Retail-faithful pending-action status messages on screen`.
+
+## Testing plan
+
+### Unit tests
+
+No new unit tests required. The behavioral change is wire-level integration; existing Core.Net 294 baseline must hold. Existing 1073 Core tests baseline must hold (8 pre-existing physics failures unchanged).
+
+### Visual verification (user-driven)
+
+1. **Far-range Use NPC.** Double-click a Pathwarden / Royal Guard ~8-15 m away. Expected log shape:
+ ```
+ [B.4b] use guid=0x... seq=X
+ [autowalk-mt] mt=0x06 isMoveTo=True ...
+ [autowalk-begin] dest=...
+ [autowalk-end] reason=arrived
+ ```
+ **Expected: NO `[B.4b] use-deferred` line.** Dialogue fires from ACE's callback. One Use packet on the wire, not two.
+
+2. **Far-range PickUp item.** F-key a ground item ~5-10 m away. Same shape — one PickUp, no retry.
+
+3. **Close-range Use NPC behind player.** Within 3 m of an NPC facing away, press R. Body turns 180°, then dialogue fires. The close-range deferred path is unchanged by this work; should still produce:
+ ```
+ [B.4b] use deferred (close-range, turn-first)
+ [autowalk-end] reason=arrived
+ [B.4b] use-deferred
+ ```
+ (`use-deferred` is correct here — close-range deferral is retail-faithful turn-first, not a workaround.)
+
+4. **Open inn door from across the room.** Walks, opens. ONE `[B.4b] use` line, no retry, no double-open.
+
+5. **User takes manual control mid-auto-walk.** Click far NPC → during the walk, press W. Auto-walk cancels via `user-input`, normal MoveToState resumes firing. The action does NOT fire on arrival (user cancelled).
+
+6. **Rapid click between two targets.** Click NPC, before arrival click a second NPC. ACE re-broadcasts MoveToObject with the new target. Our overlay re-targets (existing `BeginServerAutoWalk` overwrite). Action fires on arrival at the SECOND target. No stale `_pendingPostArrivalAction` from the first click (close-range path keeps its handling; far-range no longer queues anything).
+
+### Pre-conditions
+
+- ACE running on `127.0.0.1:9000`.
+- `+Acdream` character at Holtburg (test character).
+- `ACDREAM_PROBE_AUTOWALK=1` for the trace lines.
+- Build green: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`.
+
+## Acceptance criteria
+
+- All six visual-verify scenarios pass.
+- No `use-deferred` log line for FAR-range Use (only for close-range turn-first defer, which is correct).
+- Core.Net tests pass (294/294).
+- Core tests baseline holds (1073/1081).
+- Issues #63 and #74 close with this commit's SHA.
+
+## Risk + rollback
+
+**Risk:** If ACE's `MoveToChain` somehow STILL doesn't fire the callback (e.g., a separate ACE bug we haven't identified), Use action breaks for far-range targets. The user would observe "walked to NPC, no dialogue."
+
+**Rollback:** trivial git revert. The fix is one guard clause + workaround deletions. Reverting restores the working-but-chatty Commit B state at `b5da17d`.
+
+**Detection during visual verify:** if scenario 1 fails (no dialogue after arrival), revert before merging.
+
+## Plan handoff
+
+This design is the input for `superpowers:writing-plans`, which produces the task-by-task plan with code blocks ready to paste.
diff --git a/docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md b/docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md
new file mode 100644
index 00000000..14946d5e
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md
@@ -0,0 +1,478 @@
+# Retail-faithful chase camera with dev-tools toggle
+
+**Date:** 2026-05-18
+**Author:** Claude (with @erikn)
+**Phase:** ad-hoc rendering polish (not on the M2 critical path)
+**Status:** brainstormed → ready for plan
+
+## Motivation
+
+`src/AcDream.App/Rendering/ChaseCamera.cs` is a rigid follow-cam: each
+frame `Position` is recomputed as a pure function of
+`playerPosition + yaw + Pitch + Distance + EyeHeight`. The character
+is welded to the camera, which makes movement feel mechanical (no lag,
+no overshoot, no slope-awareness). A `_trackedZ` hack pins camera Z
+during jumps as a workaround for the visual feel that retail's camera
+gets naturally from low-stiffness damping.
+
+The retail 2013 client uses a two-class chase camera
+(`CameraManager` + `CameraSet`, decomp at
+`docs/research/named-retail/acclient_2013_pseudo_c.txt:95505` and
+`:97643`) that:
+
+1. **Exponentially damps both translation and rotation toward a target
+ pose each frame.** Default stiffness 0.45 → ~7.5 % of the gap closed
+ per 60 Hz frame → ~150 ms half-life. This is the dominant "alive"
+ feel.
+2. **Aligns the camera basis to the player's recent velocity vector**
+ (5-frame moving average) instead of pure world-up. The camera tilts
+ with the terrain when running up/down hills.
+3. **Low-passes mouse-look deltas** within a 0.25 s window so
+ high-DPI mice don't make the camera jitter.
+4. **Integrates held-key offset adjustments** (Closer/Farther/Raise/
+ Lower) at a settable rate per frame so zoom/elevation transitions
+ are smooth.
+5. **Fades the player mesh** linearly from opaque at 0.45 m to fully
+ transparent at 0.20 m when the camera approaches the pivot.
+6. **Orbits independently of player yaw** — Rotate inputs spin
+ `viewer_offset` around the pivot's Z-axis, the character's heading
+ isn't touched.
+
+This spec ports all six behaviors as a new `RetailChaseCamera` class,
+controlled by a dev-tools toggle so the user can A/B against the
+existing legacy camera before we make retail the default.
+
+## Architecture
+
+```
+ ┌─ ChaseCamera ────────┐
+ │ (existing, legacy) │
+ └──────────────────────┘
+ ▲
+ │ ICamera
+ │
+CameraDiagnostics ─── flag ─► CameraController ──► renderer (View matrix)
+ (static) │
+ │ ICamera
+ ▼
+ ┌─ RetailChaseCamera ──┐
+ │ (new) │
+ └──────────────────────┘
+ ▲
+ │ per-frame inputs
+ │
+ GameWindow (updates both, picks active)
+```
+
+### Components
+
+**`AcDream.Core.Rendering.CameraDiagnostics`** (new static class)
+Owns the runtime-tunable knobs. Mirrors the `PhysicsDiagnostics` pattern
+(diagnostic owner classes per CLAUDE.md §"Code Structure Rules" rule 5).
+Initial values from env vars at process start; runtime-settable via
+property setters that the DebugPanel writes to.
+
+```csharp
+public static class CameraDiagnostics
+{
+ public static bool UseRetailChaseCamera { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CHASE") == "1";
+
+ public static bool AlignToSlope { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_CAMERA_ALIGN_SLOPE") != "0";
+
+ public static float TranslationStiffness { get; set; } = 0.45f;
+ public static float RotationStiffness { get; set; } = 0.45f;
+ public static float MouseLowPassWindowSec { get; set; } = 0.25f;
+ public static float CameraAdjustmentSpeed { get; set; } = 40.0f;
+}
+```
+
+Lives in `AcDream.Core` (math-relevant tunables don't need the GL/
+window dependency). Defaults match retail's `CameraManager` constructor
+(decomp lines 95957–95988).
+
+**`AcDream.App.Rendering.RetailChaseCamera : ICamera`** (new)
+The retail-faithful camera. Owns per-frame state:
+
+- `_velocityRing[5]` — 5-frame velocity history for the slope-align
+ moving average. Matches retail's `old_velocities[5]`.
+- `_velocityCount` — ring fill level (0..5). Until full, average uses
+ the actual count.
+- `_dampedEye` (Vector3) — current damped camera world position.
+- `_dampedForward` (Vector3, unit-length) — current damped look
+ direction.
+- `_initialised` — first-frame snap flag.
+- `_lastMouseDeltaX`, `_lastMouseDeltaY`, `_lastFilterTimeSec` — mouse
+ low-pass state.
+
+User-tunable properties (independent of `CameraDiagnostics` — those are
+global, these are per-camera-instance settings the controller can
+clamp):
+
+- `Distance` (default 2.61, clamp [2, 40]) — length of `viewer_offset`.
+- `Pitch` (default 0.291 rad ≈ 16.7°, clamp [-0.7, 1.4]) — angle of
+ `viewer_offset` above the heading-frame XY plane.
+- `YawOffset` (default 0) — orbit offset added on top of player yaw
+ when slope-align is off OR when running stationary.
+- `PivotHeight` (default 1.5 m) — height of look-at anchor above the
+ player's feet.
+- `Aspect`, `FovY` (from `ICamera`).
+
+**`AcDream.App.Rendering.CameraController`** (existing, extended)
+Carries both `Chase` and `RetailChase`. `Active` reads the flag and
+returns whichever is selected. `EnterChaseMode(legacy, retail)` takes
+both at once and tracks them in parallel.
+
+### Data flow
+
+1. **Process start** → `CameraDiagnostics` static initialisers read env
+ vars; defaults applied if env unset.
+2. **PlayerMode entry** (existing `EnterPlayerModeIfPossible` at
+ `GameWindow.cs:9776`) — construct both cameras, hand to
+ `CameraController.EnterChaseMode(legacy, retail)`.
+3. **Per-frame** (existing tick at `GameWindow.cs:6390`) — pass the
+ same `playerPosition`, `playerYaw`, `playerVelocity`, `isOnGround`,
+ `dt` to *both* cameras' `Update()`. Inactive camera stays warm so
+ toggle swaps are instant.
+4. **View matrix** — renderer pulls `cameraController.Active.View`.
+5. **Translucency** — `RetailChaseCamera.PlayerTranslucency` is read
+ after `Update()` and applied to the player entity (mechanism TBD
+ during impl — see "Open implementation questions" below).
+6. **DebugPanel** — new "Chase camera" CollapsingHeader writes to
+ `CameraDiagnostics` via `DebugVM` mirror properties; changes take
+ effect on the next frame.
+
+## Math (the retail-faithful update loop)
+
+Per-frame inputs:
+
+- `playerPos` (Vector3, world coords; Z up)
+- `playerYaw` (radians; 0 = +X, π/2 = +Y)
+- `playerVelocity` (Vector3, world space; Z component nonzero on
+ slopes)
+- `isOnGround` (bool; currently unused but accepted for parity with
+ the legacy camera signature — may be used for an extra-damping branch
+ in a future iteration)
+- `dt` (seconds since last frame)
+
+**Step 1 — velocity history.** FIFO-push `playerVelocity` into
+`_velocityRing`. Bump `_velocityCount` to min(count+1, 5).
+
+**Step 2 — averaged velocity.**
+
+```
+avgVel = sum(_velocityRing[0.._velocityCount]) / _velocityCount
+```
+
+**Step 3 — heading vector.**
+
+```
+if CameraDiagnostics.AlignToSlope and ‖avgVel‖² > 1e-4:
+ heading = normalize(avgVel) # tilts with terrain
+else:
+ yaw = playerYaw + YawOffset
+ heading = (cos(yaw), sin(yaw), 0) # flat fallback
+```
+
+Matches retail's `target_status & ALIGN_WITH_PLANE` branch (decomp
+:95644-95795) with the contact-plane fallback collapsed into the
+flat-fallback path (we don't have `contact_plane.N` exposed yet; the
+flat fallback is visually indistinguishable in the stationary case).
+
+**Step 4 — orthonormal basis** (heading-frame, slope-tilted):
+
+```
+forward = heading
+if |forward.Z| > 0.99: # heading is near-vertical (rare; airborne edge case)
+ right = normalize(cross(forward, (1,0,0))) # use world +X as a tilt reference
+else:
+ right = normalize(cross(forward, (0,0,1))) # standard
+up = cross(right, forward) # already unit (forward + right orthonormal)
+```
+
+**Step 5 — target pose.**
+
+```
+pivotWorld = playerPos + (0, 0, PivotHeight)
+viewer_offset = (0, -Distance*cos(Pitch), Distance*sin(Pitch)) # (right, forward, up) local
+targetEye = pivotWorld
+ + right * viewer_offset.X
+ + forward * viewer_offset.Y
+ + up * viewer_offset.Z
+targetForward = normalize(pivotWorld - targetEye) # camera looks at pivot
+```
+
+The `viewer_offset` parameterization matches retail's default `(0,
+-2.5·scale, 0.75·scale)` when `Distance ≈ 2.61` and `Pitch ≈ 16.7°`
+(0.291 rad).
+
+**Step 6 — exponential damping** (two independent decay rates):
+
+```
+tAlpha = clamp(CameraDiagnostics.TranslationStiffness * dt * 10, 0, 1)
+rAlpha = clamp(CameraDiagnostics.RotationStiffness * dt * 10, 0, 1)
+
+if not _initialised:
+ _dampedEye = targetEye
+ _dampedForward = targetForward
+ _initialised = true
+else:
+ _dampedEye = lerp(_dampedEye, targetEye, tAlpha)
+ _dampedForward = normalize(lerp(_dampedForward, targetForward, rAlpha))
+```
+
+The normalized lerp of the forward unit vector is the standard
+small-step equivalent of quaternion slerp; at `rAlpha ≤ ~0.1` per frame
+(the working range) the difference from `Quaternion.Slerp` is below
+floating-point noise. This preserves retail's *independent*
+translation/rotation rates without quaternion handedness pitfalls.
+
+**Step 7 — view matrix.**
+
+```
+Position = _dampedEye
+View = Matrix4x4.CreateLookAt(_dampedEye, _dampedEye + _dampedForward, (0,0,1))
+```
+
+The `up` reference passed to `CreateLookAt` is the world up, not the
+heading-frame up. This produces a camera that always has the horizon
+horizontal on screen — matches retail (the camera basis tilts pitch,
+but the screen's "up" stays world-up).
+
+**Step 8 — mouse low-pass filter** (separate entry point;
+`FilterMouseDelta(rawX, rawY, weight) → (outX, outY)`):
+
+```
+nowSec = stopwatch elapsed seconds
+if nowSec - _lastFilterTimeSec < CameraDiagnostics.MouseLowPassWindowSec:
+ avgX = (_lastMouseDeltaX + rawX) * 0.5
+ avgY = (_lastMouseDeltaY + rawY) * 0.5
+else:
+ avgX = rawX
+ avgY = rawY
+
+outX = rawX * (1 - weight) + avgX * weight # weight typically 0.5
+outY = rawY * (1 - weight) + avgY * weight
+
+_lastMouseDeltaX = outX
+_lastMouseDeltaY = outY
+_lastFilterTimeSec = nowSec
+```
+
+Matches retail's `CameraSet::FilterMouseInput` (decomp :96250-96279).
+GameWindow's mouse-move handler calls this before feeding `dy` to
+`AdjustPitch` / `dx` to `YawOffset`.
+
+**Step 9 — auto-fade translucency.**
+
+```
+d = distance(_dampedEye, pivotWorld)
+if d >= 0.45: PlayerTranslucency = 0.0
+elif d > 0.20: PlayerTranslucency = 1 - (0.20 - d) / (0.20 - 0.45)
+else: PlayerTranslucency = 1.0
+```
+
+Matches retail's `CameraSet::UpdateCamera` distance check
+(decomp :97703-97725). Reading this property at 0 produces a fully
+opaque player; at 1 fully invisible. GameWindow applies it to the
+player entity's render path (see implementation question Q1).
+
+## Continuous-key offset integration
+
+Retail integrates `viewer_offset += FlagsToVector(held_keys) * dt`
+each frame for Closer/Farther/Raise/Lower as held keys. Since
+acdream's existing input scheme is mouse-wheel = zoom (`AdjustDistance`)
+and RMB-orbit-mouse-Y = pitch (`AdjustPitch`), we don't have keyboard
+bindings for these.
+
+This spec adds four `InputAction`s, all default-unbound (no retail
+keymap entries for them yet — users wire them in Settings if wanted):
+
+- `CameraZoomIn` — `Distance -= adjSpeed * dt`
+- `CameraZoomOut` — `Distance += adjSpeed * dt`
+- `CameraRaise` — `Pitch += adjSpeed * dt * 0.02` (smaller multiplier; pitch in radians)
+- `CameraLower` — `Pitch -= adjSpeed * dt * 0.02`
+
+The wheel-driven `AdjustDistance(step)` and the RMB-mouse-Y-driven
+`AdjustPitch(delta)` keep working unchanged. The damping in step 6
+makes the discrete step from a wheel scroll feel smooth automatically.
+
+## DebugPanel UI
+
+New CollapsingHeader **"Chase camera"** in `DebugPanel.cs`, sitting
+between "Player Info" and "Performance", defaults open. Renders six
+controls bound to `DebugVM` mirror properties:
+
+```
+[ ] Use retail chase camera [env: ACDREAM_RETAIL_CHASE]
+[x] Align to slope [env: ACDREAM_CAMERA_ALIGN_SLOPE]
+Translation stiffness [====|====] 0.45 (slider 0.05 .. 1.0, step 0.01)
+Rotation stiffness [====|====] 0.45 (slider 0.05 .. 1.0, step 0.01)
+Mouse low-pass window [==|======] 0.25 s (slider 0.0 .. 0.5, step 0.01)
+Adjustment speed [=====|===] 40.0 (slider 10 .. 80, step 1)
+```
+
+`DebugVM` gets six new properties, each forwarding to a
+`CameraDiagnostics` static, matching the `ProbeResolveEnabled` mirror
+pattern.
+
+`IPanelRenderer` may need a `SliderFloat(label, ref value, min, max)`
+method if not already present. The ImGui backend wraps
+`ImGui.SliderFloat`; tests use a stub renderer.
+
+## Files touched
+
+**New:**
+- `src/AcDream.Core/Rendering/CameraDiagnostics.cs` — static tunable owner.
+- `src/AcDream.App/Rendering/RetailChaseCamera.cs` — the camera class.
+- `tests/AcDream.Core.Tests/Rendering/CameraDiagnosticsTests.cs` — env-var parse + setter passthrough.
+- `tests/AcDream.Core.Tests/Rendering/RetailChaseCameraTests.cs` — math + damping + low-pass + fade tests.
+
+**Modified:**
+- `src/AcDream.App/Rendering/CameraController.cs` — carry both cameras, swap on flag.
+- `src/AcDream.App/Rendering/GameWindow.cs` — construct both at chase-entry; update both per frame; route mouse low-pass; apply `PlayerTranslucency`. ~30 LOC of new code spread across existing chase-camera call sites, no new feature body.
+- `src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs` — six new mirror properties.
+- `src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs` — new CollapsingHeader.
+- `src/AcDream.UI.Abstractions/Input/InputAction.cs` (or wherever the enum lives) — four new actions.
+
+(Possibly: `src/AcDream.UI.Abstractions/Panels/IPanelRenderer.cs` if
+`SliderFloat` doesn't exist there yet.)
+
+If a new namespace `AcDream.Core.Rendering` doesn't exist, it's created
+fresh. `AcDream.Core` does not currently reference any GL/window/Silk
+types, so adding `CameraDiagnostics` (pure floats + bools) does not
+violate the layering rule (CLAUDE.md §"Code Structure Rules" rule 2).
+
+## Tests
+
+Five test groups in `RetailChaseCameraTests`:
+
+**1. Heading-source fallbacks** — verify the slope-align toggle and
+the small-velocity fallback:
+- `StationaryAlignToSlope_HeadingMatchesPlayerYaw` — zero velocity →
+ heading vector ≈ `(cos yaw, sin yaw, 0)`.
+- `MovingHorizontal_HeadingMatchesVelocity` — sustained `(1, 0, 0)`
+ velocity over 5 frames → heading ≈ `(1, 0, 0)`.
+- `MovingUphill_HeadingHasPositiveZ` — velocity `(1, 0, 0.5)` over 5
+ frames → `heading.Z > 0`.
+- `SlopeAlignDisabled_IgnoresVelocity` — heading always falls back to
+ flat yaw vector regardless of velocity.
+
+**2. 5-frame averaging** — verify the ring buffer:
+- `VelocityRing_AveragesLastN` — feed `[(1,0,0), (1,0,0), (2,0,0),
+ (2,0,0), (3,0,0)]`, expect avg `(1.8, 0, 0)`.
+- `VelocityRing_FifoEvictsOldest` — feed 6 entries, ring still holds 5
+ with the first evicted.
+- `VelocityRing_PartialFillUsesActualCount` — feed 2 entries, avg
+ divides by 2 not 5.
+
+**3. Damping** — verify alpha formula + first-frame snap + lerp:
+- `DampingAlpha_AtRetailDefault_ProducesSevenAndAHalfPercent` —
+ stiffness=0.45, dt=1/60 → alpha ≈ 0.075.
+- `DampingAlpha_LargeDtClampsToOne` — stiffness=0.45, dt=1 → alpha = 1.
+- `FirstUpdate_SnapsToTarget` — initial Update with no prior state
+ sets `_dampedEye = targetEye` exactly (no damping from a stale (0,0,0)).
+- `SecondUpdate_LerpsTowardTarget` — initial pose A, target pose B,
+ alpha=0.5 → position = A + 0.5·(B-A).
+
+**4. Mouse low-pass** — verify averaging window:
+- `MouseDelta_WithinWindow_BlendedWithPrevious` — feed delta=10 at
+ t=0, delta=20 at t=0.1 (window=0.25) → second output averages with
+ the first.
+- `MouseDelta_BeyondWindow_PassesThrough` — feed delta=10 at t=0,
+ delta=20 at t=0.5 → second output is unmodified.
+- `MouseDelta_WeightZero_OutputsRaw` — weight=0 → output = raw
+ regardless of history.
+- `MouseDelta_WeightOne_OutputsAveraged` — weight=1 within window →
+ output = average.
+
+**5. Auto-fade** — verify the linear ramp constants:
+- `Translucency_DistanceFar_IsZero` — d ≥ 0.45 → t = 0.
+- `Translucency_DistanceMid_RampsLinearly` — d=0.325 (midpoint) →
+ t = 0.5.
+- `Translucency_DistanceNear_IsOne` — d ≤ 0.20 → t = 1.
+- `Translucency_AtThreshold_IsExact` — d = 0.45 → t = 0; d = 0.20 → t = 1.
+
+Plus a small `CameraDiagnosticsTests` covering env-var parsing
+(`UseRetailChaseCamera=1` reads as true, default false; `AlignToSlope=0`
+reads as false, default true).
+
+No visual / integration test; visual feel is the manual acceptance
+test (see below).
+
+## Acceptance criteria
+
+1. `dotnet build` green.
+2. `dotnet test` green, with new tests covering the math above.
+3. With `ACDREAM_RETAIL_CHASE=1` set (or the dev-tools toggle flipped
+ on), running the client + walking in Holtburg produces noticeably
+ smoother camera motion than the legacy camera: visible lag when
+ turning, visible coast-and-settle when stopping, visible
+ tilt-with-terrain on hill crests.
+4. With the toggle OFF, behavior is identical to before this change
+ (legacy `ChaseCamera` untouched).
+5. Toggling the switch at runtime swaps cameras without snapping or
+ crashing; the newly-active camera takes a few frames to ease into
+ place from the warm state of the inactive camera.
+6. Jumping with retail mode on produces the "see yourself rise above
+ the camera" feedback *without* the `_trackedZ` hack — the existing
+ hack stays in `ChaseCamera` (legacy) untouched.
+
+## Open implementation questions
+
+The plan resolves these before coding:
+
+**Q1: Where does `PlayerTranslucency` apply?**
+The retail call is `CPhysicsObj::SetTranslucencyHierarchical(player, t)`.
+Need to find the acdream equivalent — likely a property on `WorldEntity`
+or its mesh-batch metadata. If absent, the plan adds a minimal
+`Translucency` property and threads it through the render path. If
+the change to `WorldEntity` is more than ~20 LOC, the auto-fade feature
+ships in a follow-up commit rather than blocking the main toggle.
+
+**Q2: Where does the player's world velocity come from?**
+`PlayerMovementController` has `BodyVelocity` (Vector3) at line 155.
+The chase-camera update call site at `GameWindow.cs:6390` already
+holds a reference to `_playerController`; threading `playerController.
+BodyVelocity` into the `RetailChaseCamera.Update` call is one extra
+argument. Confirm during impl.
+
+**Q3: `IPanelRenderer.SliderFloat`?**
+Quick check during impl — if not present, add it to
+`IPanelRenderer` + `ImGuiPanelRenderer` (one-line each).
+
+## Out of scope / future work
+
+- **First-person ("InHead") mode toggle.** Retail's `SetInHead`
+ collapses `viewer_offset` to `(0, 0.18, 0)`. We don't have a first-
+ person mode yet; out of scope.
+- **Look-down mode (`ToggleLookDown`)** — retail's "look down at floor"
+ mode for inventory drag/drop. Out of scope.
+- **Map mode (`ToggleMapMode`)** — retail's "top-down map view." Out
+ of scope.
+- **Camera-vs-world collision.** Retail's per-frame update doesn't
+ raycast world geometry (see investigation report 2026-05-18 in chat).
+ The auto-fade handles "camera passes through player"; we don't
+ attempt "camera collides with wall" — same as retail.
+- **Making retail the default.** Default stays off in this spec; flip
+ in a follow-up commit after visual verification.
+- **Deleting legacy `ChaseCamera`.** Stays around for A/B comparison
+ until retail mode is proven and made default; then a single
+ cleanup commit deletes it + the `_trackedZ` hack.
+
+## References
+
+- **Retail decomp:** `docs/research/named-retail/acclient_2013_pseudo_c.txt`
+ lines 95505 (`CameraManager::UpdateCamera`), 97643
+ (`CameraSet::UpdateCamera`), 95957 (`CameraManager` constructor),
+ 97916 (`CameraSet::SetDefaultOffsets`), 96250
+ (`CameraSet::FilterMouseInput`), 97103 (`CameraSet::Rotate`), 97350
+ (`CameraSet::Closer`).
+- **Retail symbols:** `docs/research/named-retail/symbols.json` —
+ search for `CameraManager` / `CameraSet`.
+- **Investigation report:** chat transcript 2026-05-18 (the brainstorm
+ preceding this spec).
+- **Existing legacy:** `src/AcDream.App/Rendering/ChaseCamera.cs`.
+- **Diagnostic owner pattern:**
+ `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`.
diff --git a/docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md b/docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md
new file mode 100644
index 00000000..f4333755
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md
@@ -0,0 +1,248 @@
+# Indoor Cell Rendering Fix — Design
+
+**Status:** Brainstormed 2026-05-19. Pivoted mid-brainstorm — see §1.5 for
+the corrected root-cause analysis. Awaiting user review.
+**Scope:** Diagnose + fix the actual break in the EnvCell rendering chain.
+**Out of scope this phase:** Cell collision symptoms (no wall collision
+exiting, weird open-air collisions). Filed as a follow-up phase pending
+user repro data.
+
+---
+
+## 1. Symptom
+
+Walking into Holtburg Inn: the exterior building stab renders (walls visible
+from inside), but the interior cell's own room mesh — floor, inner walls,
+ceiling — is missing. The user can walk through the empty interior with no
+floor visible underfoot.
+
+## 1.5 What the root cause is NOT (corrected mid-brainstorm)
+
+Initial hypothesis: N.5 retirement (commit
+[`dcae2b6`](../../../#) 2026-05-08) deleted the legacy cell-mesh drain path
+with the assumption "WB handles EnvCell geometry through its own pipeline,"
+and that assumption was wrong.
+
+**Closer inspection during brainstorm proved that assumption is correct.**
+WB's `ObjectMeshManager.PrepareMeshData(id, isSetup)` at
+[`ObjectMeshManager.cs:557`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs:557)
+dispatches on the **dat record type** (not on the `isSetup` parameter).
+When the id resolves to a `DBObjType.EnvCell`, it routes to
+`PrepareEnvCellMeshData(id, envCell, ct)` at line 1186, which produces an
+`ObjectMeshData` with `IsSetup=true`, `SetupParts` = [static objects +
+cellGeometry], `EnvCellGeometry` = the floor/wall/ceiling room mesh.
+
+The dispatcher correctly handles `IsSetup=true` at
+[`WbDrawDispatcher.cs:607-621`](../../../src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs:607) —
+it iterates `SetupParts`, looks up each part's render data, composes
+transforms, and draws each.
+
+`DefaultDatReaderWriter` loads region cell dats during construction
+([`DefaultDatReaderWriter.cs:66-89`](../../../references/WorldBuilder/WorldBuilder.Shared/Services/DefaultDatReaderWriter.cs:66))
+so `ResolveId(envCellId)` will find the cell record.
+
+`LandblockSpawnAdapter.OnLandblockLoaded` iterates `landblock.Entities` and
+calls `_adapter.IncrementRefCount(meshRef.GfxObjId)` for each
+([`LandblockSpawnAdapter.cs:75-80`](../../../src/AcDream.App/Rendering/Wb/LandblockSpawnAdapter.cs:75)).
+Cell entities have `ServerGuid == 0` (atlas-tier), so they pass the filter
+at line 73. Their `MeshRef.GfxObjId == envCellId` reaches `IncrementRefCount`.
+
+**The chain looks structurally intact.** Floors SHOULD render today. They
+don't. Therefore the failure is subtler than "we never invoke the load."
+
+## 2. Real failure point — to be determined by diagnostics
+
+Six untested hypotheses, in rough order of probability:
+
+1. **WB silently fails to build the `ObjectMeshData`.** `PrepareEnvCellMeshData`
+ returns null when the Environment dat can't resolve, or when
+ `PrepareCellStructMeshData` returns null (texture issues, surface
+ resolution failure). WB doesn't log; the failure is invisible.
+
+2. **`SetupParts.cellGeomId` is uploaded but its texture batches are empty.**
+ `UploadGfxObjMeshData` returning null at line 675 is treated as a
+ non-fatal substitution — the render data has no draw batches, dispatcher
+ silently draws nothing.
+
+3. **Cell entity is culled before reaching the dispatcher.** `visibleCellIds`
+ filter at `WbDrawDispatcher.cs:317-319` rejects entities whose
+ `ParentCellId` isn't in the visible set. If the cell entity's
+ `ParentCellId == envCellId` but the visibility BFS doesn't include the
+ player's current cell (because `FindCameraCell` returns null when camera
+ is in third-person above the building, etc.), the cell entity is
+ skipped.
+
+4. **Double-spawn conflict between WB's static-object SetupParts and
+ acdream's per-stab entity hydration.** `PrepareEnvCellMeshData` iterates
+ `envCell.StaticObjects` and adds each as a SetupPart. Meanwhile acdream
+ already hydrates the same static objects as separate `WorldEntity`
+ instances at [`GameWindow.cs:5390-5439`](../../../src/AcDream.App/Rendering/GameWindow.cs:5390).
+ WB might be holding extra ref counts on those GfxObj IDs that block
+ eviction or cause cache thrash. Unlikely to cause "missing floor" but
+ worth ruling out.
+
+5. **Transform composition bug.** `ComposePartWorldMatrix(entityWorld,
+ meshRef.PartTransform, partTransform)` — if our cell entity's
+ `meshRef.PartTransform == cellTransform` and WB's `partTransform`
+ already bakes the cell origin, the floor lands at `2 × cellOrigin`,
+ far below or beside the actual cell. The user would describe this
+ as "missing" because the floor is now outside the visible frustum.
+
+6. **The cell entity's `MeshRefs` only has one entry, but WB expects
+ multiple.** The dispatcher iterates `entity.MeshRefs`, but each MeshRef
+ gets its own `TryGetRenderData(meshRef.GfxObjId)` call. For cell
+ entities we have `MeshRefs = { MeshRef(envCellId, cellTransform) }`.
+ When the lookup returns an `IsSetup=true` render data, the dispatcher
+ does the right thing (line 607-621) — iterates SetupParts. So this
+ should work; ruling out.
+
+## 3. Solution
+
+### Phase 1 — Diagnostics (this phase's work)
+
+Five probes, each individually toggleable via env-var + DebugPanel
+checkbox. The probes live in a new
+`AcDream.Core.Rendering.RenderingDiagnostics` static class (mirroring
+the `AcDream.Core.Physics.PhysicsDiagnostics` pattern shipped in L.2a)
+so they're discoverable from one place and survive across the
+Core / App seam.
+
+Each probe is **rate-limited**: by default, one line per (envCellId,
+frame-modulo-30) — i.e., once per second per cell at 30 Hz — to avoid
+log spam. When `ACDREAM_PROBE_INDOOR_VERBOSE=1` is also set, the
+rate-limit drops and every frame logs.
+
+| Env var (and DebugPanel mirror) | Probe | Code location | Line format |
+|---|---|---|---|
+| `ACDREAM_PROBE_INDOOR_WALK` | Cell-entity dispatcher walk | `WbDrawDispatcher.WalkVisibleEntities` (rate-limited per cellId) | `[indoor-walk] cellEnt=0xID pos=(x,y,z) parentCell=0xID landblockVisible=B aabbVisible=B cellInVis=B drawn=B` |
+| `ACDREAM_PROBE_INDOOR_LOOKUP` | Render-data lookup for cell entities | `WbDrawDispatcher.DrawAccumulated` per cell entity | `[indoor-lookup] cellId=0xID hit=B isSetup=B partCount=N hasEnvCellGeom=B partsHit=N partsMiss=N` |
+| `ACDREAM_PROBE_INDOOR_UPLOAD` | WB upload result for envCellId | `WbMeshAdapter.IncrementRefCount` (on first call per id) + a callback hooked into `_meshManager.Tick()` for completion | `[indoor-upload] cellId=0xID requested=true completed=B partsCount=N cellGeomVerts=N error="..."` |
+| `ACDREAM_PROBE_INDOOR_XFORM` | Composed world transform for cell-geometry SetupPart | `WbDrawDispatcher` inside the `IsSetup` branch at line 607-621, for partGfxObjId matching `(envCellId | 0x1_00000000UL)` | `[indoor-xform] cellId=0xID cellOrigin=(x,y,z) entityWorld=(...) partTransform=(...) composed=(x,y,z y-axis,z-axis) detExpected≈1 detActual=F` |
+| `ACDREAM_PROBE_INDOOR_CULL` | Visibility / cull decision per cell entity | `WbDrawDispatcher.WalkVisibleEntities` (the two filter sites at lines 304-305 and 317-319) | `[indoor-cull] cellEnt=0xID reason="visibleCellIds-miss" or "frustum" or "served" details="..."` |
+
+The five probes can be enabled independently or together. The user's
+common case is `ACDREAM_PROBE_INDOOR_ALL=1` which sets all five at
+once.
+
+#### Implementation outline
+
+1. **New file** `src/AcDream.Core/Rendering/RenderingDiagnostics.cs` —
+ five static `bool` properties, each backed by an env-var read at
+ startup, each runtime-settable from the DebugPanel.
+2. **DebugPanel section** — new "Indoor rendering diagnostics" block
+ in the existing DebugPanel "Diagnostics" group, with one checkbox
+ per probe + a master "all" toggle.
+3. **WbDrawDispatcher edits** — instrument the walk and the IsSetup
+ draw branch. The walk probe needs to know whether the entity passed
+ the cell-visibility filter; the cull probe needs the same data.
+ Cleanest: emit BOTH lines in one place when either probe is on.
+4. **WbMeshAdapter edits** — `IncrementRefCount` logs an `[indoor-upload]
+ requested=true` line when the id is recognized as an EnvCell
+ (high-bit check `(id & 0xFFFF) >= 0x0100`). On Tick(), when a
+ completion drains for an envCellId, log the result line with the
+ actual ObjectMeshData/ObjectRenderData fields.
+5. **No GameWindow changes** beyond passing the diagnostics class
+ into the dispatcher (if not already accessible).
+
+#### Capture procedure
+
+1. Build with the probe instrumentation. `dotnet build` green.
+2. Launch with `ACDREAM_PROBE_INDOOR_ALL=1`. Walk to Holtburg Inn,
+ stand at the doorway, then step inside, then walk around the room.
+3. Stop the client, grep `launch.log` for `[indoor-*]` lines.
+4. The captured log identifies WHICH hypothesis matches:
+ - **H1 (null upload)** → `[indoor-upload] completed=false`
+ - **H2 (empty batches)** → `[indoor-upload] cellGeomVerts=0`
+ - **H3 (cull bug)** → `[indoor-cull] reason="visibleCellIds-miss"`
+ - **H4 (double-spawn)** → `[indoor-lookup] partCount` includes
+ static-object IDs that ALSO appear in `landblock.Entities`
+ - **H5 (transform double-apply)** → `[indoor-xform] composed`
+ world position lands at `2 × cellOrigin` instead of `cellOrigin`
+ - **H6 (MeshRefs structure)** → ruled out; probe data would still
+ surface it as `hit=true isSetup=true partCount=N` followed by
+ all `partsHit=0`
+
+### Phase 2 — Fix the specific break (next phase)
+
+Once the probe identifies the failure point, implement the surgical
+fix. Likely shapes per hypothesis:
+
+| Hypothesis | Fix shape |
+|---|---|
+| H1 — WB returns null | Add WB logging or pre-check the dat resolution path in WbMeshAdapter |
+| H2 — Empty batches | Investigate WB texture pipeline; possibly a missing texture in the cell's surface list |
+| H3 — Cull bug | Fix `ParentCellId` assignment OR loosen the visibility filter for cell entities |
+| H4 — Double-spawn | Stop WB from spawning static-object parts in EnvCell setups (filter them in PrepareEnvCellMeshData, or skip acdream's per-stab hydration when WB handles the cell) |
+| H5 — Transform double-apply | Replace `MeshRef.PartTransform = cellTransform` with `entity.Position+Rotation = cellPosition` |
+| H6 — MeshRefs structure | Already ruled out in §2 |
+
+Phase 2's actual code change is small and well-targeted once Phase 1
+gives us a definite answer.
+
+## 4. Why NOT build a separate cell renderer
+
+The original brainstorm proposed adapting `_pendingCellMeshes` data into
+WB via a new `UploadCellMesh` adapter method. **That solution is wrong** —
+it would duplicate work WB already does, fragment the rendering pipeline,
+and bypass WB's existing GPU memory management. Worse, it would hide
+whatever the actual bug is, not fix it.
+
+## 5. Edge cases
+
+| Scenario | Behavior |
+|---|---|
+| Visible during diagnostic capture | Probe is heavy (per-frame, per-entity). Bounded by short walk; runtime-toggle off when done. |
+| Probe spam in production | Default OFF, mirrored to DebugPanel. Same pattern as L.2a `ACDREAM_PROBE_RESOLVE` / `ACDREAM_PROBE_CELL`. |
+| Concurrent landblock stream | Probe records per frame across all loaded cells — useful for cross-cell comparison ("does cell X load but cell Y not?"). |
+
+## 6. Testing strategy
+
+**Unit tests:** none in Phase 1. The probe is diagnostic, not behavioral.
+
+**Visual verification (user-driven, end-to-end):**
+
+- Add probe, launch client, walk into Holtburg Inn.
+- Read probe output to identify which hypothesis matches.
+- Brief Phase 2 in a new design (or amend this one) once the failure
+ point is known.
+
+**Phase 2 unit tests:** depend on the fix shape. If H5 (transform
+double-apply), tests verify the world matrix composition. If H3 (cull
+bug), tests verify visibility BFS for indoor entities.
+
+## 7. What's NOT in this phase
+
+- Cell collision symptoms — investigated separately.
+- Particle/fire emitter integration — already shipped.
+- Light registration — already shipped.
+- Stab-leak-through-walls — deferred.
+
+## 8. Acceptance criteria
+
+**Phase 1 (this phase):**
+
+- [ ] `AcDream.Core.Rendering.RenderingDiagnostics` static class created
+ with five `bool` properties + master `IndoorAll` toggle, each backed
+ by an env-var read at startup and runtime-settable.
+- [ ] DebugPanel "Diagnostics" group has a new "Indoor rendering"
+ subsection with six checkboxes (five probes + master).
+- [ ] `WbDrawDispatcher` emits `[indoor-walk]`, `[indoor-lookup]`,
+ `[indoor-xform]`, `[indoor-cull]` lines when the respective probe
+ is on. Rate-limited to ~1/sec per cell unless verbose mode active.
+- [ ] `WbMeshAdapter` emits `[indoor-upload]` lines for EnvCell IDs:
+ one `requested` line on first `IncrementRefCount`, one `completed`
+ line when WB's Tick drains the result (success or failure).
+- [ ] `dotnet build` clean. `dotnet test` clean (the diagnostics-only
+ change should not affect any test).
+- [ ] Probe captured at Holtburg Inn confirms which hypothesis matches.
+ Capture procedure documented in §3 above.
+- [ ] Phase 2 design (amended spec or new spec) documents the surgical
+ fix matched to the identified hypothesis.
+
+**Phase 2 (next phase, driven by Phase 1 output):**
+
+- [ ] `dotnet build` clean, `dotnet test` clean.
+- [ ] Visual verification: walking into Holtburg Inn renders interior
+ floor + walls correctly.
+- [ ] Roadmap updated.
+- [ ] Probes left in place for future regressions but defaulted off.
diff --git a/docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md b/docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
new file mode 100644
index 00000000..cb454efd
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md
@@ -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` 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)
+{
+ /// Bit 2 of Flags. See research doc §"PortalSide flag semantics".
+ 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 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(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
+{
+ ///
+ /// 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.
+ ///
+ public static uint FindCellList(
+ PhysicsDataCache cache,
+ Vector3 worldSphereCenter,
+ float sphereRadius,
+ uint currentCellId,
+ out CellSet candidateSet);
+
+ ///
+ /// 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`.
+ ///
+ public static void FindTransitCellsSphere(
+ PhysicsDataCache cache,
+ CellPhysics currentCell,
+ uint currentCellId,
+ Vector3 worldSphereCenter,
+ float sphereRadius,
+ ref CellSet candidateSet);
+
+ ///
+ /// 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.
+ ///
+ public static void CheckBuildingTransit(
+ PhysicsDataCache cache,
+ BuildingPhysics buildingPhysics,
+ Vector3 worldSphereCenter,
+ float sphereRadius,
+ ref CellSet candidateSet);
+
+ ///
+ /// 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.
+ ///
+ public static void AddAllOutsideCells(
+ PhysicsDataCache cache,
+ Vector3 worldSphereCenter,
+ float sphereRadius,
+ uint currentCellId,
+ ref CellSet candidateSet);
+}
+```
+
+`CellSet` is a small helper — either `HashSet` 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(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, +X−Y, −X+Y, −X−Y 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).
diff --git a/docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md b/docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
new file mode 100644
index 00000000..cf823e9e
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-19-indoor-walkable-plane-bsp-port-design.md
@@ -0,0 +1,458 @@
+# Indoor Walkable-Plane BSP Port — Design
+
+**Status:** Brainstormed 2026-05-19. Awaiting user spec review before plan.
+**Scope:** Replace `TryFindIndoorWalkablePlane`'s linear first-match scan with a thin wrapper over the existing retail-faithful BSP walkable-finder (`BSPQuery.FindWalkableInternal`). Restores closest-walkable-poly-along-up-vector semantics for indoor cells with multiple floors at different Z (cellars, 2nd floors, balconies).
+**Predecessor:** Indoor walking Phase 2 — Portal-based cell tracking ([`docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md`](2026-05-19-indoor-portal-cell-tracking-design.md)) shipped 2026-05-19. Phase 2's commit 6 (`eb0f772`) introduced `TryFindIndoorWalkablePlane` as a stop-gap walkable-plane synthesis when the indoor BSP returns OK; it uses a linear first-match XY scan that ignores Z, which collapses to wrong-floor selection on any multi-Z indoor geometry.
+**Retail oracle:** `docs/research/named-retail/acclient_2013_pseudo_c.txt`:
+- `BSPLEAF::find_walkable` at 326793 — the leaf-level walkable test.
+- `BSPNODE::find_walkable` at 326211 — the BSP-traversal internal-node version.
+- `CPolygon::walkable_hits_sphere` at 323006 — `N·up > walkableAllowance` AND XY-overlap test.
+- `CPolygon::adjust_sphere_to_plane` at 322032 — slides sphere along the movement vector to rest on the polygon's plane; `walk_interp` is ratcheted down to the earliest hit.
+
+---
+
+## 1. What we know
+
+**The retail walkable-finder is already ported.** [`BSPQuery.FindWalkableInternal`](../../../src/AcDream.Core/Physics/BSPQuery.cs:647) implements the full retail algorithm — BSP traversal, leaf-level polygon scan, `WalkableHitsSphere` + `AdjustSphereToPlane`. It is used by:
+- `BSPQuery.StepSphereDown` (Path 3) at [BSPQuery.cs:1107](../../../src/AcDream.Core/Physics/BSPQuery.cs:1107) — the step-down branch invoked from `DoStepDown` → `TransitionalInsert(5)`.
+- `BSPQuery.FindCollisions` Path 4 (Collide) at [BSPQuery.cs:1492](../../../src/AcDream.Core/Physics/BSPQuery.cs:1492) — landing on a surface after free-fall.
+
+**The new helper that doesn't use it.** `Transition.TryFindIndoorWalkablePlane` at [TransitionTypes.cs:1192](../../../src/AcDream.Core/Physics/TransitionTypes.cs:1192) was added in Phase 2 commit 6 (`eb0f772`) to synthesize an indoor walkable plane in the case where the indoor BSP returns OK (no wall collision). Its body iterates `cellPhysics.Resolved` in dictionary order, returns the first polygon with `Normal.Z >= 0.6664` whose XY contains the foot. There is no Z-proximity test.
+
+**Visual evidence (user-reported, 2026-05-19, post-Phase-2-merge):**
+- Walking UP stairs in houses works (step_up → DoStepDown routes through Path 3 = `FindWalkableInternal`, which already picks the closest walkable).
+- Walking DOWN into cellars is broken (player can't descend; standing on upper floor with cellar floor beneath → linear scan picks upper floor → ValidateWalkable can't drop player below it).
+- Walking on 2nd floor is broken (linear scan picks 1st-floor poly → player snaps to 1st floor or is reported airborne above 2nd floor).
+- "Invisible obstacles at certain spots" — suspected cascade effect of wrong-Z ContactPlane (resolver flags body as airborne / submerged → next-frame collision misroutes). Not a separate root cause hypothesis.
+
+---
+
+## 2. Goal
+
+Route indoor walkable-plane synthesis through `BSPQuery.FindWalkableInternal` so the synthesized ContactPlane is the polygon the player is actually standing on (closest walkable surface below the foot, along the local up vector), not the first walkable polygon in dictionary order.
+
+**Expected to fix:**
+- Cellar descent (player can step off the upper floor and descend through the stairwell onto the cellar floor).
+- 2nd-floor walking (player stays on the upper floor without snapping back to ground level).
+
+**Possibly fixes (cascade):**
+- "Invisible obstacles at certain spots" — if this is downstream of wrong-Z ContactPlane causing the resolver to misclassify the body's grounded/airborne state. If after the fix these persist, that's a separate phase.
+
+**Possibly fixes (related):**
+- ISSUES.md #88 (indoor static objects vibrate). If the per-frame ContactPlane Z flickers between two overlapping floors, dependent state may re-fire (`EntityScriptActivator` OnCreate/OnRemove, per-part transforms). Not the primary target; user re-verifies #88 after the fix lands.
+
+**Out of scope:**
+- Outdoor terrain walkable selection (`PhysicsEngine.SampleTerrainWalkable`) — unchanged.
+- BSP collision dispatcher (`BSPQuery.FindCollisions` Paths 1–6) — unchanged.
+- Step-up / step-down flow (`DoStepUp` / `DoStepDown`) — unchanged. Stairs going UP already work; we don't risk regressing that path.
+- Cell transit / portal traversal (Phase 2 `CellTransit`) — unchanged.
+- Building-shell outdoor→indoor entry (`CheckBuildingTransit`) — unchanged.
+- Issue #89 (`SphereIntersectsCellBsp` retail-faithful port) — unchanged; separate phase.
+
+---
+
+## 3. Architecture
+
+**One change, two callers.**
+
+1. **New public entry point in `BSPQuery`** — a thin wrapper over the existing private `FindWalkableInternal`.
+
+2. **Refactor of `Transition.TryFindIndoorWalkablePlane`** — replace the linear scan body with a call to the new entry point. Public signature unchanged.
+
+3. **Removal of `Transition.PointInPolygonXY`** — only callsite was the linear-scan body; becomes dead code.
+
+```
+Movement tick → resolver substep
+ │
+ ▼
+Transition.FindEnvCollisions (TransitionTypes.cs:1262) [UNCHANGED]
+ │
+ ├─ Transform foot sphere to cell-local space [UNCHANGED]
+ ├─ BSPQuery.FindCollisions(cellPhysics.BSP, ...) [UNCHANGED]
+ │ └─ Wall collision: returns Slid / Adjusted / Collided
+ │ → early-return; never reaches walkable synthesis
+ ├─ If result != OK → early-return [UNCHANGED]
+ └─ If result == OK:
+ ▼
+ Transition.TryFindIndoorWalkablePlane [REFACTORED]
+ │
+ ├─ Save path.WalkableAllowance
+ ├─ Set path.WalkableAllowance = PhysicsGlobals.FloorZ
+ ├─ Build localMovement = -localUp * PROBE_DISTANCE
+ ├─ Build localSphere from localFootCenter + radius
+ │
+ ├─ ╔══════════════════════════════════════════════╗
+ │ ║ BSPQuery.FindWalkableSphere ║ [NEW WRAPPER]
+ │ ║ wraps the existing FindWalkableInternal ║
+ │ ║ (BSPNode.find_walkable + BSPLeaf.find_walkable
+ │ ║ port — retail at acclient_2013_pseudo_c.txt
+ │ ║ 326211 + 326793) ║
+ │ ╚══════════════════════════════════════════════╝
+ │ │
+ │ ▼
+ │ ResolvedPolygon? hitPoly + Vector3 adjustedCenter
+ │
+ ├─ Restore path.WalkableAllowance (try/finally)
+ ├─ If hitPoly == null → return false (caller falls
+ │ through to outdoor-terrain backstop, unchanged)
+ └─ Transform hitPoly plane + vertices to world space
+ (existing helper logic, kept verbatim)
+ → return true with (worldPlane, worldVertices, hitPolyId)
+ ▼
+ Transition.ValidateWalkable(worldPlane, ...) [UNCHANGED]
+```
+
+The refactor is surgical: one helper body changes, one wrapper is added, one dead helper deleted. No call-site changes. No new types. No new flags. No new env vars.
+
+---
+
+## 4. Implementation surface
+
+### 4.1 `BSPQuery.FindWalkableSphere` (new public entry point) + small extension to `FindWalkableInternal`
+
+`ResolvedPolygon` does not carry its own id — the polyId is the `Dictionary` key. `FindWalkableInternal` iterates `foreach (ushort polyId in node.Polygons)` in the leaf branch (BSPQuery.cs:665), so the key IS available internally — we just need to expose it. Two coupled changes:
+
+**(a) Extend `FindWalkableInternal` signature** with a `ref ushort hitPolyId` param. Update the leaf branch's write to set both `hitPoly` AND `hitPolyId` together. Update all internal recursion sites (BSPQuery.cs:688, :695, :701, :703) to thread the new ref. Update the two existing callers (`StepSphereDown` at :1107 and Path 4 at :1492) to declare a local `ushort _` if they don't care about the id (existing behavior preserved — they only use `hitPoly`).
+
+**(b) Add the public wrapper.** Place in `src/AcDream.Core/Physics/BSPQuery.cs` adjacent to `StepSphereDown` (around line 1085) since they share the same call shape into `FindWalkableInternal`.
+
+```csharp
+///
+/// "Stand here, find my contact plane" entry point over the BSPNode/BSPLeaf
+/// find_walkable BSP traversal. Probes downward by
+/// along and returns the closest walkable polygon the
+/// sphere would rest on, with the sphere's center adjusted to lie on that plane.
+///
+///
+/// Wraps the existing private — which already
+/// implements the retail-faithful walkable-finder
+/// (BSPNODE::find_walkable + BSPLEAF::find_walkable +
+/// CPolygon::walkable_hits_sphere + CPolygon::adjust_sphere_to_plane,
+/// acclient_2013_pseudo_c.txt:326211, :326793, :323006, :322032).
+///
+///
+///
+/// Intended call site: indoor walkable-plane synthesis in
+/// when the indoor cell-BSP
+/// collision returns OK (no wall hit) and the resolver still needs a
+/// ContactPlane to feed ValidateWalkable. Outdoor terrain has its own path
+/// () and does not use this.
+///
+///
+///
+/// The caller is responsible for setting transition.SpherePath.WalkableAllowance
+/// to the desired walkability threshold (typically )
+/// before calling, and restoring it after. Cheapest pattern: try/finally with
+/// save→set→call→restore.
+///
+///
+/// Root of the cell's PhysicsBSP.
+/// Pre-resolved polygon dictionary from PhysicsDataCache.
+/// Current transition (read for WalkableAllowance / walk_interp).
+/// Foot sphere in cell-local space.
+/// Downward probe distance in meters. Typical: 0.5f.
+/// Up vector in cell-local space (typically Vector3.UnitZ).
+/// Output: the walkable polygon found, or null on miss.
+/// Output: polygon id (dictionary key) of the hit. Zero on miss.
+///
+/// Output: sphere center adjusted onto the polygon plane. Equal to input
+/// sphere.Origin on miss.
+///
+/// True if a walkable polygon was found; false otherwise.
+public static bool FindWalkableSphere(
+ PhysicsBSPNode? root,
+ Dictionary resolved,
+ Transition transition,
+ DatReaderWriter.Types.Sphere sphere,
+ float probeDistance,
+ Vector3 up,
+ out ResolvedPolygon? hitPoly,
+ out ushort hitPolyId,
+ out Vector3 adjustedCenter)
+{
+ adjustedCenter = sphere.Origin;
+ hitPoly = null;
+ hitPolyId = 0;
+
+ if (root is null) return false;
+
+ var validPos = new CollisionSphere(sphere.Origin, sphere.Radius);
+ var movement = -up * probeDistance;
+ bool changed = false;
+ ushort polyId = 0;
+
+ FindWalkableInternal(root, resolved, transition.SpherePath, validPos,
+ movement, up, ref hitPoly, ref polyId, ref changed);
+
+ if (changed && hitPoly is not null)
+ {
+ adjustedCenter = validPos.Center;
+ hitPolyId = polyId;
+ return true;
+ }
+
+ hitPoly = null;
+ hitPolyId = 0;
+ return false;
+}
+```
+
+**Notes on the wrapper:**
+- Pure, no side effects on call args other than `out` params.
+- `FindWalkableInternal` mutates `validPos` and `path.WalkInterp` (via `AdjustSphereToPlane`); the wrapper isolates `validPos` to a local. `path.WalkInterp` mutation is intentional and matches retail's `walk_interp` ratcheting — caller's responsibility to save/restore if needed.
+- No new dependencies. All types already in scope.
+
+### 4.2 `Transition.TryFindIndoorWalkablePlane` (refactored body + extended signature)
+
+File: `src/AcDream.Core/Physics/TransitionTypes.cs` (around line 1192). Signature gains a `sphereRadius` parameter (rationale §4.3):
+
+```csharp
+internal bool TryFindIndoorWalkablePlane(
+ CellPhysics cellPhysics,
+ Vector3 localFootCenter,
+ float sphereRadius,
+ out System.Numerics.Plane worldPlane,
+ out Vector3[] worldVertices,
+ out uint hitPolyId)
+```
+
+The helper changes from `internal static` to `internal` (instance method) so it can access `this.SpherePath` for the WalkableAllowance save/restore and pass `this` (Transition) to `BSPQuery.FindWalkableSphere`. The single callsite at TransitionTypes.cs:1358 is already inside a Transition instance method (`FindEnvCollisions`).
+
+Body:
+
+```csharp
+worldPlane = default;
+worldVertices = System.Array.Empty();
+hitPolyId = 0;
+
+if (cellPhysics.BSP?.Root is null) return false;
+
+// Build foot sphere in cell-local space. Caller passes localFootCenter already
+// transformed into cell-local space and the resolver's foot-sphere radius.
+var localSphere = new DatReaderWriter.Types.Sphere
+{
+ Origin = localFootCenter,
+ Radius = sphereRadius,
+};
+
+// Save/restore WalkableAllowance: the BSP walkable test consumes
+// path.WalkableAllowance (CPolygon::walkable_hits_sphere reads this field,
+// acclient_2013_pseudo_c.txt:323010). For "standing here, find my floor" we
+// want the walkability slope threshold FloorZ. The outer resolver may have
+// set it to LandingZ (airborne→ground transition) or another value; we
+// must not leak our change back to the resolver.
+float savedWalkableAllowance = this.SpherePath.WalkableAllowance;
+this.SpherePath.WalkableAllowance = PhysicsGlobals.FloorZ;
+
+ResolvedPolygon? hitPoly = null;
+ushort hitId = 0;
+Vector3 adjustedCenter;
+bool found;
+
+try
+{
+ found = BSPQuery.FindWalkableSphere(
+ cellPhysics.BSP.Root,
+ cellPhysics.Resolved,
+ this,
+ localSphere,
+ INDOOR_WALKABLE_PROBE_DISTANCE, // see §4.3
+ Vector3.UnitZ, // local Z is up for indoor cells (identity transform)
+ out hitPoly,
+ out hitId,
+ out adjustedCenter);
+}
+finally
+{
+ this.SpherePath.WalkableAllowance = savedWalkableAllowance;
+}
+
+if (!found || hitPoly is null) return false;
+
+// Transform hit polygon's plane + vertices to world space. This block is
+// kept verbatim from the existing TryFindIndoorWalkablePlane implementation —
+// the world-transform math is unchanged.
+var worldNormal = Vector3.TransformNormal(hitPoly.Plane.Normal, cellPhysics.WorldTransform);
+worldNormal = Vector3.Normalize(worldNormal);
+var worldV0 = Vector3.Transform(hitPoly.Vertices[0], cellPhysics.WorldTransform);
+float worldD = -Vector3.Dot(worldNormal, worldV0);
+worldPlane = new System.Numerics.Plane(worldNormal, worldD);
+
+worldVertices = new Vector3[hitPoly.Vertices.Length];
+for (int i = 0; i < hitPoly.Vertices.Length; i++)
+ worldVertices[i] = Vector3.Transform(hitPoly.Vertices[i], cellPhysics.WorldTransform);
+
+hitPolyId = hitId;
+return true;
+```
+
+### 4.3 Constants and parameter rationale
+
+| Symbol | Kind | Value / source | Rationale |
+|---|---|---|---|
+| `INDOOR_WALKABLE_PROBE_DISTANCE` | `private const float` in `Transition` | `0.5f` | 50 cm. Larger than the +0.02f cell-origin Z-bump (25× headroom). Larger than any realistic step riser (~20 cm). Smaller than a full cell height (~3 m) so we don't reach through a thin floor into the cell above/below. |
+| `sphereRadius` | method parameter | sourced from `sp.GlobalSphere[0].Radius` at the `FindEnvCollisions` call site (already bound at TransitionTypes.cs:1268) | The foot sphere radius is per-entity (player ≠ creature). Hardcoding would be wrong; threading the parameter is one line at the callsite. |
+
+### 4.4 Callsite update
+
+At TransitionTypes.cs:1358, the existing call:
+
+```csharp
+if (TryFindIndoorWalkablePlane(cellPhysics, localCenter,
+ out var indoorPlane,
+ out var indoorVertices,
+ out uint _))
+```
+
+becomes:
+
+```csharp
+if (TryFindIndoorWalkablePlane(cellPhysics, localCenter, sphereRadius,
+ out var indoorPlane,
+ out var indoorVertices,
+ out uint _))
+```
+
+`sphereRadius` is already in scope from line 1268.
+
+### 4.5 Delete `Transition.PointInPolygonXY`
+
+At TransitionTypes.cs:1238. Only call site was the deleted linear-scan loop. Remove the method entirely.
+
+---
+
+## 5. Diagnostics
+
+**Extend existing `[indoor-bsp]` probe surface, no new probe.**
+
+The existing `[indoor-bsp]` line in `FindEnvCollisions` (at TransitionTypes.cs:1334) already prints per-call BSP-collision state. Add a sibling line `[indoor-walkable]` printed when the OK-result branch calls `TryFindIndoorWalkablePlane`, gated on the same `PhysicsDiagnostics.ProbeIndoorBspEnabled` flag (no new flag).
+
+Print format:
+```
+[indoor-walkable] cell=0x000000C4 wpos=(2.34,-31.05,-2.78) probe=0.50 result=HIT poly=0x0042 wn=(0.000,0.000,1.000) wD=-2.75 dz=+0.03
+```
+or
+```
+[indoor-walkable] cell=0x000000C4 wpos=(2.34,-31.05,-2.78) probe=0.50 result=MISS
+```
+
+Where:
+- `wpos` — world-space foot center.
+- `wn`/`wD` — world-space plane normal + D (on HIT only).
+- `dz` — signed Z gap between foot center and plane (positive = player above plane, negative = below).
+
+Runtime-toggleable via the existing DebugPanel "Indoor BSP probe" checkbox. Cost when probe disabled: a single bool check (early-return).
+
+For cellar descent, expect the trace to flip from "always picks upper floor (dz≈+3.0)" pre-fix to "picks cellar floor below (dz≈+0.03)" post-fix.
+
+---
+
+## 6. Testing
+
+### 6.1 Unit tests (new)
+
+Location: `tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs` (adjacent to existing BSPQuery tests).
+
+**Test 1: `FindWalkableSphere_TwoOverlappingFloors_PicksClosestBelowFoot`**
+
+Synthetic mini-BSP with two horizontal walkable polys at Z=0 and Z=3, both passing XY through (0,0). Foot sphere centered at (0,0,1) radius 0.48, probe 0.5. Assert returned poly is the Z=0 one; assert `adjustedCenter.Z` ≈ 0.48.
+
+**Test 2: `FindWalkableSphere_TwoOverlappingFloors_FootAbove_PicksUpper`**
+
+Same mini-BSP. Foot at (0,0,3.6), probe 0.5. Assert returned poly is the Z=3 one; assert `adjustedCenter.Z` ≈ 3.48.
+
+**Test 3: `FindWalkableSphere_NoWalkableInRange_ReturnsFalse`**
+
+Two polys at Z=0 and Z=3. Foot at (0,0,5), probe 0.5. Sphere is more than `probe + radius` above the Z=3 plane; nothing in range. Assert returns false; assert `adjustedCenter == sphere.Origin`.
+
+**Test 4: `FindWalkableSphere_SteepPolyRejected`**
+
+Mini-BSP with one poly whose normal Z = 0.5 (52° slope, below FloorZ=0.6664). Foot above it. Caller sets `WalkableAllowance = FloorZ`. Assert returns false (poly rejected as too steep).
+
+**Test 5: `TryFindIndoorWalkablePlane_RoutesThroughBSPQuery_PreservesAllowance`**
+
+Integration test with a real `CellPhysics` fixture (two-floor cell). Set `path.WalkableAllowance` to a sentinel value (e.g. 0.42f). Call `TryFindIndoorWalkablePlane`. Assert returned plane corresponds to the closest floor below the foot. Assert `path.WalkableAllowance == 0.42f` after the call (save/restore worked).
+
+### 6.2 Existing test baselines
+
+- `dotnet build` clean.
+- `dotnet test` shows the same 8 pre-existing failures (MotionInterpreter / BSPStepUp baseline). No new failures.
+- Phase 2 indoor-walking conformance unchanged (single-floor cottage cell remains correct — the new closest-below algorithm degenerates to the existing first-match behavior when only one walkable poly exists).
+
+### 6.3 Visual verification (the real acceptance test)
+
+User-driven, the only "milestone" verification the project uses (per CLAUDE.md "milestones are textual events").
+
+**Required scenarios:**
+
+| # | Scenario | Pre-fix behavior | Acceptance |
+|---|---|---|---|
+| 1 | Walk into Holtburg cottage, walk around single-floor interior | Works (Phase 2) | Still works — no regression |
+| 2 | Walk between cottage rooms via doorways | Works (Phase 2) | Still works — no regression |
+| 3 | Walk back outside through cottage door | Works (Phase 2) | Still works — no regression |
+| 4 | Find any building with a cellar entry, walk to and descend the stairs | Stuck / bounces at top of stairs | Smooth descent onto cellar floor |
+| 5 | Find any 2-story building, climb stairs to 2nd floor, walk around upper floor | Snaps back to 1st floor or "invisible obstacles" | Stays on 2nd floor, free movement |
+| 6 | Walk near previously-reported "invisible obstacle" spots | Hits invisible wall | (Hypothesis check) — invisible obstacles gone if cascade theory correct |
+| 7 | (Optional) Observe bookshelves/furnaces #88 vibration | Visible jitter | (Hypothesis check) — jitter reduced if cascade theory correct |
+
+**If scenario 4 or 5 still fails after this lands, that's an indicator the diagnosis is incomplete — file a follow-up phase.** If scenario 6 still fails but 4/5 work, that's a separate bug (probably real BSP-classification issue, not walkable-plane).
+
+---
+
+## 7. Edge cases
+
+Handled by the existing `FindWalkableInternal` (not new logic):
+- Sphere doesn't intersect any BSP node → no recursion, `changed` stays false, miss path runs.
+- BSP root is null → wrapper returns false before recursion.
+- Multiple walkable polys in the same leaf → loop visits all, `AdjustSphereToPlane` ratchets `walk_interp` down to the closest hit (retail-faithful behavior, see acclient_2013_pseudo_c.txt:322055).
+- `WalkableAllowance > 1.0` (illegal) → `WalkableHitsSphere` returns false for every poly, miss path runs (defensive).
+
+Introduced by the wrapper:
+- `WalkableAllowance` save/restore wrapped in try/finally so a thrown exception inside `FindWalkableInternal` doesn't leak modified state to the resolver.
+
+Cell-state edge cases (unchanged from Phase 2):
+- Cell has no walkable polys (only walls + ceiling) → wrapper returns false → `FindEnvCollisions` falls through to outdoor-terrain backstop (existing behavior at TransitionTypes.cs:1372).
+- Cell origin Z-bump (+0.02f) interaction — probe distance 0.5f is 25× the bump, so the bump is noise within the probe range.
+
+---
+
+## 8. Acceptance criteria
+
+1. `dotnet build -c Debug` clean.
+2. `dotnet test` shows the same 8 pre-existing failures (no new failures from this work).
+3. New unit tests 1–5 (§6.1) pass.
+4. Visual verification scenarios 1–5 (§6.3) all pass per user testing.
+5. Visual verification scenarios 6 and 7 reported as PASS/FAIL by user (cascade hypothesis confirmation, not gating).
+6. Roadmap shipped table updated.
+7. ISSUES.md #83 closed (Walking up stairs broken — bug now scoped to "walking DOWN in multi-floor cells").
+8. Phase memory updated if a durable lesson surfaces during implementation.
+
+---
+
+## 9. References
+
+**Retail oracle:**
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:326211` — `BSPNODE::find_walkable`
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:326793` — `BSPLEAF::find_walkable`
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:323006` — `CPolygon::walkable_hits_sphere`
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:322032` — `CPolygon::adjust_sphere_to_plane`
+- `docs/research/named-retail/acclient_2013_pseudo_c.txt:323565` — `BSPTREE::step_sphere_up` (related; uses find_walkable via step_sphere_down)
+
+**acdream code:**
+- `src/AcDream.Core/Physics/BSPQuery.cs:647` — `FindWalkableInternal` (existing retail port)
+- `src/AcDream.Core/Physics/BSPQuery.cs:1085` — `StepSphereDown` (existing caller of FindWalkableInternal)
+- `src/AcDream.Core/Physics/TransitionTypes.cs:1192` — `TryFindIndoorWalkablePlane` (the helper being refactored)
+- `src/AcDream.Core/Physics/TransitionTypes.cs:1262` — `FindEnvCollisions` (the single callsite)
+- `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` — diagnostic flag pattern (existing `ProbeIndoorBspEnabled`)
+
+**Predecessor specs:**
+- [`docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md`](2026-05-19-indoor-walking-phase1-bsp-cluster-design.md) — Phase 1 (cell-BSP wall collision).
+- [`docs/superpowers/specs/2026-05-19-indoor-portal-cell-tracking-design.md`](2026-05-19-indoor-portal-cell-tracking-design.md) — Phase 2 (portal cell tracking).
+
+**Phase 2 ship handoff:**
+- [`docs/research/2026-05-19-indoor-walking-phase2-shipped-handoff.md`](../../research/2026-05-19-indoor-walking-phase2-shipped-handoff.md) — context for the `TryFindIndoorWalkablePlane` introduction in commit `eb0f772`.
+
+**Issue tracking:**
+- `docs/ISSUES.md` #83 (Walking up stairs broken) — primary scope. Title is misleading per user: actual symptom is walking DOWN in multi-floor cells (cellars, descending stairs).
+- `docs/ISSUES.md` #88 (Indoor static objects vibrate) — possibly downstream; user re-verifies after fix lands.
+- `docs/ISSUES.md` #89 (Port `SphereIntersectsCellBsp`) — explicitly out of scope; separate phase.
diff --git a/docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md b/docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md
new file mode 100644
index 00000000..c57a8609
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-19-indoor-walking-phase1-bsp-cluster-design.md
@@ -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?` 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 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 —
+ - One surgical change to TransitionTypes / GameWindow / BSPQuery
+ - Commit message cites probe evidence line
+ - Closes ISSUES.md #84
+
+3. fix(physics): Cluster A #85 —
+ - 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/-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).
diff --git a/docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md b/docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md
new file mode 100644
index 00000000..bbc7c571
--- /dev/null
+++ b/docs/superpowers/specs/2026-05-19-phase2-indoor-cell-rendering-fix-design.md
@@ -0,0 +1,189 @@
+# Indoor Cell Rendering Fix — Phase 2 Design
+
+**Status:** Brainstormed 2026-05-19. Awaiting user review.
+**Scope:** Surface the silent failure in WB's `PrepareEnvCellMeshData` for 26/123 Holtburg cells, then implement the targeted fix.
+**Predecessor:** Phase 1 (`docs/superpowers/specs/2026-05-19-indoor-cell-rendering-fix-design.md`) shipped the five `[indoor-*]` probes that confirmed hypothesis H1.
+**Capture evidence:** `docs/research/2026-05-19-indoor-cell-rendering-probe-capture.md`.
+
+---
+
+## 1. What we know
+
+Phase 1's `ACDREAM_PROBE_INDOOR_ALL=1` capture at Holtburg `0xA9B4` proved:
+
+- 123 EnvCells requested via `WbMeshAdapter.IncrementRefCount` → only **97 complete**.
+- **26 cells** silently fail. They get `[indoor-upload] requested` but never `[indoor-upload] completed`.
+- The dispatcher then tries to draw them, `TryGetRenderData` returns null, draw is silently skipped → user sees **missing floor**.
+- The first interior cell `0xA9B40100` (likely the inn entry or another major building anchor) is among the 26.
+
+The smoking gun is in WB's [`ObjectMeshManager.PrepareMeshData`](../../../references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ObjectMeshManager.cs):
+
+```csharp
+catch (Exception ex) {
+ _logger.LogError(ex, "Error preparing mesh data for 0x{Id:X16}", id);
+ return null;
+}
+```
+
+WB logs the exception via its injected `_logger`. But [`WbMeshAdapter.cs:71`](../../../src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs:71) constructs `ObjectMeshManager` with `NullLogger.Instance` — so the log goes to `/dev/null`. The exception type and message are lost.
+
+## 2. Solution — three components
+
+### Component 1 — Exception-surfacing wrap
+
+Capture the `Task` returned by `_meshManager.PrepareMeshDataAsync(id, isSetup: false)` and attach a continuation that, for EnvCell IDs only, logs the failure cause.
+
+Three logged outcomes:
+
+- **Task faulted** → `[indoor-upload] FAILED cellId=0x... exception=: stack=[]`. Unwrap `AggregateException.InnerException` for cleaner output.
+- **Task succeeded with null result** → `[indoor-upload] NULL_RESULT cellId=0x...`. WB's deliberate null-return path (e.g., `ResolveId` returned empty, type was `Unknown`).
+- **Task succeeded with non-null result** → no extra log. The existing `Tick()` drain already emits `[indoor-upload] completed`.
+
+The continuation:
+- Runs on `TaskScheduler.Default` (`ThreadPool`) so it doesn't block the render thread.
+- Only attached for EnvCell IDs (gated by `RenderingDiagnostics.IsEnvCellId(id)`) when `ProbeIndoorUploadEnabled` is true — zero cost when off.
+- Captures `cellId` (a `ulong` value) only; no instance closure leakage.
+- Truncates stack trace to top 3 frames.
+
+Concrete code shape:
+
+```csharp
+if (_metadataPopulated.Add(id))
+{
+ PopulateMetadata(id);
+ var prepTask = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
+
+ if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
+ {
+ _pendingEnvCellRequests.Add(id);
+ Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8}");
+
+ ulong cellId = id;
+ _ = prepTask.ContinueWith(t =>
+ {
+ if (t.IsFaulted && t.Exception is not null)
+ {
+ var ex = t.Exception.InnerException ?? t.Exception;
+ var stack = (ex.StackTrace ?? "").Split('\n')
+ .Take(3).Select(s => s.Trim()).Where(s => s.Length > 0);
+ Console.WriteLine(
+ $"[indoor-upload] FAILED cellId=0x{cellId:X8} " +
+ $"exception={ex.GetType().Name}: {ex.Message} " +
+ $"stack=[{string.Join(" | ", stack)}]");
+ }
+ else if (t.IsCompletedSuccessfully && t.Result is null)
+ {
+ Console.WriteLine($"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8}");
+ }
+ }, TaskScheduler.Default);
+ }
+}
+```
+
+`using System.Linq;` and `using System.Threading.Tasks;` may need adding (likely already present).
+
+### Component 2 — Capture procedure
+
+Standard launch:
+
+```powershell
+$env:ACDREAM_PROBE_INDOOR_UPLOAD = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath launch.log
+```
+
+Walk into Holtburg Inn, walk into nearby buildings whose cells were on the missing-26 list (`0xA9B40100`, `0xA9B40111`, etc.). Close gracefully.
+
+Analyze:
+
+```powershell
+Get-Content launch.log |
+ Where-Object { $_ -match '\[indoor-upload\] (FAILED|NULL_RESULT)' } |
+ Select-Object -Unique
+```
+
+Expected output: a per-cell list of distinct exception types or null-return signals. Most cells likely share 1–3 root causes.
+
+### Component 3 — Targeted fix (shape unknown until Component 2 captures)
+
+Once Component 2 reveals the exception type + message, the fix is one localized code change. Likely shapes:
+
+| Captured cause | Fix shape |
+|---|---|
+| Texture decode `Exception` (e.g. `KeyNotFoundException` on surface ID) | Guard at `WbMeshAdapter.PopulateMetadata` or pre-validate surfaces; possibly patch WB fork. |
+| `KeyNotFoundException` for missing `Environment` / `CellStruct` | Log + skip cell with a sentinel render-data; report which dat is stale. |
+| `NullReferenceException` in `PrepareCellStructMeshData` | Add null guard at the specific call site. |
+| WB internal logic bug | Fork patch to WB. |
+| `NULL_RESULT` (ResolveId returned empty / type was Unknown) | Investigate dat file integrity; possibly user needs a dat update. |
+
+The fix is one or two code edits, lands as a single commit, and is followed by a re-launch verifying:
+- `[indoor-upload] FAILED` / `NULL_RESULT` lines disappear for the previously-failing cells.
+- `[indoor-upload] completed` appears for those cells.
+- Visual verification: floor renders in Holtburg Inn.
+
+---
+
+## 3. Edge cases
+
+| Scenario | Behavior |
+|---|---|
+| Probe toggled off mid-session | Continuation still emits if attached at request time. Acceptable — capturing the cause once matters more than honoring runtime toggle. |
+| Continuation fires after adapter disposed | Harmless console write on dying process. No memory leak; closure captures only the `ulong` cellId. |
+| Same cell requested twice | `_metadataPopulated.Add(id)` guards; continuation attaches exactly once. Re-streaming after Remove+Add keeps the sticky set. First failure is what we want. |
+| Cancellation | `t.IsCanceled` is neither `IsFaulted` nor `IsCompletedSuccessfully`. Continuation silently skips. Acceptable — cancellation isn't a failure cause. |
+| `Task.Result` on faulted task | Re-throws AggregateException. Our gate `else if (t.IsCompletedSuccessfully && t.Result is null)` ensures we never read Result without a clean success state. |
+| WB's `_logger.LogError` for the same exception | WbMeshAdapter passes `NullLogger` — WB's log goes nowhere. Our continuation is what surfaces it. Discussed below. |
+
+**Why not just inject a real logger into `ObjectMeshManager`?** Could replace `NullLogger.Instance` with a real logger that writes to `Console.WriteLine`. Tradeoff:
+
+- Real logger: simpler, leverages WB's existing `_logger.LogError` call → catches GfxObj + Setup + EnvCell failures.
+- Our continuation: scoped to EnvCell IDs only → less noise.
+
+Going with the continuation approach because:
+1. The probe flag is already in place.
+2. Phase 2 is targeted at EnvCells.
+3. Real-logger would emit thousands of GfxObj/Setup log lines during landblock streaming, drowning the EnvCell signal.
+
+We can revisit if a future debugging session calls for broader visibility.
+
+---
+
+## 4. Testing strategy
+
+### Unit tests
+
+None for Component 1 — the continuation is straight wiring around an async API; the logic is "if faulted, log; if null result, log." Testing requires either mocking `Task` (low value) or running a real WB instance (impractical in unit tests).
+
+### Visual verification (end-to-end)
+
+Component 2's capture procedure is the verification mechanism:
+
+1. Build green.
+2. Launch with probe flag on, walk into Holtburg.
+3. Confirm `[indoor-upload] FAILED` or `NULL_RESULT` lines appear for ~26 cells.
+4. Apply Component 3's fix.
+5. Re-launch, re-walk Holtburg.
+6. **Acceptance:** previously-failing cells now produce `[indoor-upload] completed` lines AND the user can see the floor in Holtburg Inn.
+
+---
+
+## 5. What's NOT in this phase
+
+- Tightening `IsEnvCellId` false-positives (flagged in Phase 1 capture note). Deferred — doesn't block Phase 2 since the upload probe gates on the correct path.
+- Cell collision symptoms (no wall collision when exiting, weird open-air collisions). Separate investigation phase.
+- Stab-leak-through-walls (Phase 1 Task 3). Deferred.
+- Broader WB logger injection for GfxObj/Setup failures. Open if we ever want broader diagnostic visibility.
+
+---
+
+## 6. Acceptance criteria
+
+- [ ] `WbMeshAdapter.IncrementRefCount` captures the prep task and attaches a continuation for EnvCell IDs.
+- [ ] Continuation logs `[indoor-upload] FAILED cellId=0x... exception=: stack=[...]` for faulted tasks.
+- [ ] Continuation logs `[indoor-upload] NULL_RESULT cellId=0x...` for clean-null returns.
+- [ ] `dotnet build` clean. `dotnet test` clean (no new failures; pre-existing 8 physics/input failures unchanged).
+- [ ] Capture launched, FAILED/NULL_RESULT lines appear for the previously-missing cells, distinct causes identified.
+- [ ] Component 3 fix designed and implemented for each distinct cause.
+- [ ] Re-capture confirms `[indoor-upload] completed` appears for cells previously missing.
+- [ ] Visual verification: floor renders in Holtburg Inn.
+- [ ] Roadmap updated with Phase 2 shipped.
+- [ ] Commit messages cite the captured exception types + the fix rationale.
diff --git a/references/WorldBuilder b/references/WorldBuilder
index 167788be..34460c44 160000
--- a/references/WorldBuilder
+++ b/references/WorldBuilder
@@ -1 +1 @@
-Subproject commit 167788be6fce65f5ebe79eef07a0b7d28bd7aa81
+Subproject commit 34460c44d7fb921afa50ee30288a53236f50f451
diff --git a/src/AcDream.App/AcDream.App.csproj b/src/AcDream.App/AcDream.App.csproj
index 84eb67ab..c5378e2c 100644
--- a/src/AcDream.App/AcDream.App.csproj
+++ b/src/AcDream.App/AcDream.App.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 5a126b8b..8d4ab445 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -154,6 +154,16 @@ public sealed class PlayerMovementController
/// Full 3D world-space velocity of the physics body. Exposed for diagnostic logging.
public Vector3 BodyVelocity => _body.Velocity;
+ ///
+ /// 2026-05-16 — current contact plane (normal + distance) for the
+ /// physics body. Exposed so the network outbound layer can stamp
+ /// it into for retail's diff-driven
+ /// AP cadence: SendPositionEvent re-sends if cell OR contact-plane
+ /// changed since last_sent, per
+ /// acclient_2013_pseudo_c.txt:700233 ShouldSendPositionEvent.
+ ///
+ public System.Numerics.Plane ContactPlane => _body.ContactPlane;
+
// Jump charge state.
private bool _jumpCharging;
private float _jumpExtent;
@@ -186,10 +196,33 @@ public sealed class PlayerMovementController
// (2026-05-01 motion-trace findings.md): retail sends ~1 Hz at rest,
// not the 5 Hz our pre-fix code used. Sending at 5 Hz was harmless
// but wasteful and probably looked like jitter to observers.
- private float _heartbeatAccum;
- public const float HeartbeatInterval = 1.0f; // 1 sec — retail / holtburger
+ ///
+ /// 2026-05-16 — retail-faithful AP cadence. Matches retail's
+ /// CommandInterpreter::ShouldSendPositionEvent (acclient_2013_pseudo_c.txt
+ /// at address 0x006b45e0) which gates on either (a) position-or-cell
+ /// change since the last send, or (b) at-rest 1 sec heartbeat elapsed.
+ /// `time_between_position_events` constant at 0x006b3efb = 1.0 sec.
+ ///
+ /// Old model: a 1 Hz idle / 10 Hz active flat accumulator. That
+ /// missed retail's per-frame-while-moving behaviour and forced the
+ /// four B.6 workarounds (arrival margin, re-send on arrival, AP
+ /// flush, retry flag) to compensate for the lag in ACE's server-side
+ /// WithinUseRadius poll. Replaced by diff-driven cadence below.
+ ///
+ public const float HeartbeatInterval = 1.0f; // retail 0x006b3efb
+
+ private System.Numerics.Vector3 _lastSentPos;
+ private uint _lastSentCellId;
+ private System.Numerics.Plane _lastSentContactPlane;
+ private float _lastSentTime;
+ private bool _lastSentInitialized;
+ private float _simTimeSeconds;
public bool HeartbeatDue { get; private set; }
+ /// Sim-time accumulator (advanced by dt at the top of Update).
+ /// Exposed for the network outbound layer to stamp NotePositionSent.
+ public float SimTimeSeconds => _simTimeSeconds;
+
// L.5 retail physics-tick gate (2026-04-30).
//
// Retail's CPhysicsObj::update_object subdivides per-frame dt into
@@ -234,22 +267,51 @@ public sealed class PlayerMovementController
private float _autoWalkMinDistance;
private float _autoWalkDistanceToObject;
private bool _autoWalkMoveTowards;
- // Walk-vs-run decision is made ONCE at BeginServerAutoWalk based on
- // initial distance vs the wire's WalkRunThreshold, then held for the
- // duration of the auto-walk. Earlier per-frame evaluation produced
- // "runs partway then walks the rest" which the user reported as
- // wrong: the character should run all the way to the target then
- // stop, not transition to a walk near the end.
+ // 2026-05-16 (retail-faithful) — walk-vs-run is a ONE-SHOT
+ // decision at chain start. Per user observation 2026-05-16: if
+ // initial distance is at or above the walk-run threshold, the
+ // body runs all the way to the target; otherwise it walks all
+ // the way. No per-frame switching as the player closes distance.
+ //
+ // Formula matches retail's MovementParameters::get_command
+ // (decomp 0x0052aa00, line 308000+):
+ // running = (initialDist - distance_to_object) >= walk_run_threshhold
+ // The "distance left to walk" (current minus use-radius) is
+ // compared against the wire-supplied threshold (15m default,
+ // retail constant at 0x005243b5). The retail function reads
+ // `arg2` as the current distance but in practice is called at
+ // chain setup with the initial distance, and the resulting
+ // decision is cached for the rest of the chain — matching the
+ // user-observed "run all the way / walk all the way" behaviour.
private bool _autoWalkInitiallyRunning;
///
/// True while a server-initiated auto-walk (MoveToObject inbound) is
- /// active on the local player. The next call
- /// synthesizes Forward+Run input and steers toward
- /// the destination until arrival or user-input cancellation.
+ /// active on the local player. Update drives the body's velocity
+ /// and motion state machine DIRECTLY from the wire-supplied path
+ /// data, NOT via synthesized player-input. The
+ /// motion-state-change detection downstream sees no user input
+ /// during auto-walk, so no MoveToState wire packet is built — ACE's
+ /// server-side MoveToChain can run uninterrupted until its callback
+ /// fires.
///
public bool IsServerAutoWalking => _autoWalkActive;
+ // 2026-05-16 (issue #75) — tracks whether the auto-walk overlay is
+ // actually advancing the body this frame. False during the
+ // turn-first phase (rotating in place toward target) and after
+ // arrival. Drives the animation cycle override: walking animation
+ // only plays when the body is actually moving forward.
+ private bool _autoWalkMovingForwardThisFrame;
+
+ // 2026-05-16 (issue #69 fix) — turn direction this frame.
+ // +1 = rotating counter-clockwise (Yaw increasing) → TurnLeft cycle
+ // -1 = rotating clockwise (Yaw decreasing) → TurnRight cycle
+ // 0 = aligned or not turning
+ // Drives the animation cycle override during turn-first phase so
+ // the body plays the actual turn animation instead of statue-pivoting.
+ private int _autoWalkTurnDirectionThisFrame;
+
///
/// Fires once when an auto-walk reaches its destination naturally
/// (i.e. called with
@@ -368,7 +430,7 @@ public sealed class PlayerMovementController
float minDistance,
float distanceToObject,
bool moveTowards,
- float walkRunThreshold)
+ bool canCharge)
{
_autoWalkActive = true;
_autoWalkDestination = destinationWorld;
@@ -376,17 +438,28 @@ public sealed class PlayerMovementController
_autoWalkDistanceToObject = distanceToObject;
_autoWalkMoveTowards = moveTowards;
- // Decide run vs walk ONCE based on the initial horizontal
- // distance from the player to the destination. Run-all-the-way
- // is the retail-faithful behaviour the user observed: pick a
- // distant target → character runs the whole way, decelerates
- // to a stop at use radius. Earlier per-frame evaluation made
- // the body transition to a walk inside threshold and felt
- // wrong (the user reported "runs partway then walks").
- float dx = destinationWorld.X - _body.Position.X;
- float dy = destinationWorld.Y - _body.Position.Y;
- float initialDist = MathF.Sqrt(dx * dx + dy * dy);
- _autoWalkInitiallyRunning = initialDist > walkRunThreshold;
+ // Issue #77 fix (2026-05-18) — retail-faithful walk-vs-run.
+ //
+ // Retail's MovementParameters::get_command (decomp 0x0052aa00)
+ // gates run on the CanCharge flag (bit 0x10 of
+ // MovementParameters). Cleared → fall through to the inner
+ // walk_run_threshold check, which ACE's 15 m wire default +
+ // 0.6 m use-radius makes practically always walk for any
+ // chase under 15.6 m. Set → unconditional HoldKey_Run.
+ //
+ // ACE's Creature.SetWalkRunThreshold sets CanCharge when
+ // (server-side player→target distance) >= WalkRunThreshold /
+ // 2 (= 7.5 m for the 15 m default), and clears it otherwise.
+ // The CanCharge bit IS the wire-side walk-vs-run answer; we
+ // just relay it.
+ //
+ // Previously we hardcoded a 1.0 m threshold against
+ // initialDist - distanceToObject, which forced run at any
+ // chase past ~1.6 m — including the 3-5 m "walk range" the
+ // user expected to walk in (issue #77 reproduction). Honoring
+ // CanCharge restores the retail bucket: walk under ~7.5 m,
+ // run beyond.
+ _autoWalkInitiallyRunning = canCharge;
}
///
@@ -405,6 +478,29 @@ public sealed class PlayerMovementController
AutoWalkArrived?.Invoke();
}
+ ///
+ /// 2026-05-16. Called by the network outbound layer after every
+ /// AutonomousPosition or MoveToState that carries the player's
+ /// position. Resets the diff-driven heartbeat clock so the next
+ /// `HeartbeatDue` evaluation requires either a fresh state change
+ /// (cell, contact-plane, or frame) OR another full HeartbeatInterval.
+ /// Mirrors retail's SendPositionEvent at
+ /// acclient_2013_pseudo_c.txt:700345-700348 which updates
+ /// `last_sent_position`, `last_sent_position_time`, AND
+ /// `last_sent_contact_plane` after every send.
+ ///
+ public void NotePositionSent(System.Numerics.Vector3 worldPos,
+ uint cellId,
+ System.Numerics.Plane contactPlane,
+ float nowSeconds)
+ {
+ _lastSentPos = worldPos;
+ _lastSentCellId = cellId;
+ _lastSentContactPlane = contactPlane;
+ _lastSentTime = nowSeconds;
+ _lastSentInitialized = true;
+ }
+
///
/// B.6 slice 2 (2026-05-14). If a server-initiated auto-walk is
/// active, either cancel it (user pressed a movement key) or
@@ -422,9 +518,33 @@ public sealed class PlayerMovementController
/// distanceToObject; flee arrives at minDistance.
///
///
- private MovementInput ApplyAutoWalkOverlay(float dt, MovementInput input)
+ ///
+ /// 2026-05-16 (issue #75 refactor) — drive the body directly from
+ /// the wire-supplied path data during server-initiated auto-walk,
+ /// without synthesizing player-input. Replaces the earlier
+ /// ApplyAutoWalkOverlay which returned a synthesized Forward+Run
+ /// MovementInput; that synthesis leaked to the wire as an outbound
+ /// MoveToState packet ("user is RunForward") which ACE read as
+ /// user-took-manual-control and cancelled its own MoveToChain. The
+ /// architecture now mirrors retail's MovementManager::PerformMovement
+ /// case 6 (decomp 0x00524440): step the body's velocity + motion
+ /// state directly; the user-input pipeline downstream sees no input
+ /// because the user didn't press anything, so no MoveToState gets
+ /// built.
+ ///
+ ///
+ /// Returns true when this method consumed motion control for
+ /// the frame (auto-walk active, no user override, no arrival).
+ /// Caller () must skip the user-input motion +
+ /// body-velocity sections to avoid them overriding the auto-walk's
+ /// velocity assignment.
+ ///
+ ///
+ private bool DriveServerAutoWalk(float dt, MovementInput input)
{
- if (!_autoWalkActive) return input;
+ _autoWalkMovingForwardThisFrame = false;
+ _autoWalkTurnDirectionThisFrame = 0;
+ if (!_autoWalkActive) return false;
// User-input cancellation. Any direct movement key takes over.
// Mouse-only turning (no movement key) doesn't cancel — the
@@ -435,7 +555,7 @@ public sealed class PlayerMovementController
if (userOverride)
{
EndServerAutoWalk("user-input");
- return input;
+ return false;
}
// Horizontal distance to target — server owns Z, our local body
@@ -463,11 +583,17 @@ public sealed class PlayerMovementController
float arrivalThreshold = _autoWalkMoveTowards
? _autoWalkDistanceToObject
: _autoWalkMinDistance;
- const float TinyMargin = 0.05f;
- float effectiveArrival = MathF.Max(arrivalThreshold - TinyMargin, 0.1f);
+ // 2026-05-16 — retail "stop at the radius" semantics.
+ // Previously had a 0.05 m TinyMargin inside the threshold to
+ // ensure ACE's server-side WithinUseRadius poll saw us inside
+ // the radius before our next AP heartbeat. With the
+ // diff-driven AP cadence (Task B2) ACE sees the final position
+ // the same frame we arrive — no margin needed. Retail's
+ // arrival check is `dist <= radius` exact at
+ // CMotionInterp::apply_interpreted_movement integration.
bool withinArrival =
(_autoWalkMoveTowards
- && dist <= effectiveArrival)
+ && dist <= arrivalThreshold)
|| (!_autoWalkMoveTowards
&& dist >= arrivalThreshold + RemoteMoveToDriver.ArrivalEpsilon);
@@ -502,16 +628,26 @@ public sealed class PlayerMovementController
// MathF.Min(|delta|, maxStep) naturally clamps the final
// fractional step to exactly delta, so we land on the
// target heading without overshoot.
- // 2026-05-16 — retail-faithful turn rate. Auto-walk knows
- // its run/walk decision from _autoWalkInitiallyRunning
- // (set at BeginServerAutoWalk based on initial distance vs
- // WalkRunThreshold). Running rotation is 50% faster per
+ // 2026-05-16 — retail-faithful turn rate. Auto-walk's
+ // run/walk decision (one-shot at chain start) drives the
+ // turn rate: running rotation is 50% faster per
// run_turn_factor at retail 0x007c8914.
float maxStep = RemoteMoveToDriver.TurnRateFor(_autoWalkInitiallyRunning) * dt;
- Yaw += MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
+ float yawStep = MathF.Sign(delta) * MathF.Min(MathF.Abs(delta), maxStep);
+ Yaw += yawStep;
while (Yaw > MathF.PI) Yaw -= 2f * MathF.PI;
while (Yaw < -MathF.PI) Yaw += 2f * MathF.PI;
+ // 2026-05-16 (issue #69) — record rotation direction so the
+ // animation override can pick the TurnLeft/TurnRight cycle.
+ // Sign convention matches user-driven A/D in Update:
+ // yawStep > 0 ⇔ TurnLeft (Yaw increases)
+ // yawStep < 0 ⇔ TurnRight (Yaw decreases)
+ // Small dead-zone avoids flickering between Turn cycles
+ // when the residual delta is effectively zero.
+ if (MathF.Abs(yawStep) > 1e-5f)
+ _autoWalkTurnDirectionThisFrame = yawStep > 0f ? +1 : -1;
+
// Two alignment thresholds:
// walkWhileTurning (30°): outside this, body turns in place.
// Inside, body walks forward while
@@ -536,15 +672,14 @@ public sealed class PlayerMovementController
if (withinArrival && aligned)
{
EndServerAutoWalk("arrived");
- return input;
+ return false;
}
- // Walk vs run decided ONCE at BeginServerAutoWalk based on
- // initial distance — held for the rest of the auto-walk so the
- // character keeps running all the way to the target instead of
- // transitioning to a walk inside the threshold. Matches user-
- // observed retail behaviour ("if its far away it should run
- // all the way to the object and then stop").
+ // Walk vs run uses the one-shot decision from BeginServerAutoWalk
+ // (initial distance minus use-radius vs walkRunThreshold).
+ // Held for the rest of the auto-walk so the body runs all
+ // the way to a far target, or walks all the way to a near
+ // one — matching user-observed retail behaviour.
bool shouldRun = _autoWalkInitiallyRunning;
// Turn-first gate: if not yet within the 30° walking band,
@@ -554,22 +689,80 @@ public sealed class PlayerMovementController
// into it.
bool moveForward = walkAligned && !withinArrival;
- // Synthesize "moving forward" input. The rest of Update reads
- // Yaw + input.Forward + input.Run to drive _motion + body
- // velocity exactly as it does for user-driven W (+ optional Shift).
- // We zero any mouse delta so a stale frame doesn't fight the
- // steering.
- return input with
+ if (!moveForward)
{
- Forward = moveForward,
- Run = moveForward && shouldRun,
- Backward = false,
- StrafeLeft = false,
- StrafeRight = false,
- TurnLeft = false,
- TurnRight = false,
- MouseDeltaX = 0f,
- };
+ // Turn-in-place phase. Two sub-cases land here:
+ // (a) initial turn — body must rotate to face the target
+ // before we drive forward (walkAligned == false at chain
+ // start, body is stationary).
+ // (b) overshoot recovery — body crossed the destination, so
+ // desiredYaw flipped ~180° and walkAligned dropped to
+ // false; body needs to turn around before walking back.
+ // (c) settling — body is within use-radius but not aligned
+ // enough to fire arrival (withinArrival == true,
+ // !aligned); body holds position while finishing rotation
+ // so the arrival predicate fires on the next tick.
+ //
+ // Issue #77 fix: explicitly zero horizontal velocity. Without
+ // this, in case (b) the body keeps the prior frame's running
+ // velocity (RunAnimSpeed × runRate ≈ 11 m/s) and slides past
+ // the destination by several meters before the turn-around
+ // rotation completes — the "runs and slides away, runs back,
+ // picks up" symptom reported in issue #77 / bug B. Cases (a)
+ // and (c) zero a velocity that's already zero, so the change
+ // is a no-op there.
+ //
+ // The motion-interpreter state also has to step out of
+ // WalkForward so get_state_velocity (used downstream) reports
+ // standing-velocity, not the prior frame's run-speed.
+ _motion.DoMotion(MotionCommand.Ready, 1.0f);
+ if (_body.OnWalkable)
+ {
+ float savedWorldVz = _body.Velocity.Z;
+ _body.set_local_velocity(new Vector3(0f, 0f, savedWorldVz));
+ }
+ return true;
+ }
+
+ // Drive motion state machine + body velocity directly. This
+ // mirrors what the user-input section would have done with
+ // synthesized Forward+Run, but without putting anything into
+ // MovementInput — so the outbound-packet pipeline never builds
+ // a MoveToState packet for auto-walk frames.
+ uint forwardCmd;
+ float forwardCmdSpeed;
+ if (shouldRun && _weenie.InqRunRate(out float runRate))
+ {
+ // Wire-compatible: WalkForward command @ runRate triggers
+ // ACE's auto-upgrade to RunForward for observers. Same
+ // shape as the user-input section's running path.
+ forwardCmd = MotionCommand.WalkForward;
+ forwardCmdSpeed = runRate;
+ }
+ else
+ {
+ forwardCmd = MotionCommand.WalkForward;
+ forwardCmdSpeed = 1.0f;
+ }
+
+ _autoWalkMovingForwardThisFrame = true;
+
+ // Update interpreted motion state — drives the animation cycle
+ // via UpdatePlayerAnimation downstream + the MotionInterpreter's
+ // state-velocity getter (used for our velocity assignment below).
+ _motion.DoMotion(forwardCmd, forwardCmdSpeed);
+
+ // Set body velocity directly. Only meaningful when grounded;
+ // mirror the user-input section's `if (_body.OnWalkable)` gate
+ // so we don't override gravity/jump velocity mid-air.
+ if (_body.OnWalkable)
+ {
+ float savedWorldVz = _body.Velocity.Z;
+ var stateVel = _motion.get_state_velocity();
+ _body.set_local_velocity(new Vector3(0f, stateVel.Y, savedWorldVz));
+ }
+
+ return true;
}
// L.2a slice 1 (2026-05-12): centralized CellId mutation so the
@@ -613,14 +806,24 @@ public sealed class PlayerMovementController
public MovementResult Update(float dt, MovementInput input)
{
- // B.6 slice 2 (2026-05-14): server-initiated auto-walk overlay.
- // When _autoWalkActive, steer Yaw toward _autoWalkDestination and
- // synthesize Forward+Run input so the rest of Update runs the
- // body forward as if the user were holding W. User movement-key
- // input cancels the auto-walk (retail UX). Arrival check fires
- // before synthesizing, so a one-frame arrival doesn't waste a
- // tick of velocity past the target.
- input = ApplyAutoWalkOverlay(dt, input);
+ _simTimeSeconds += dt;
+
+ // 2026-05-16 (issue #75 refactor): server-initiated auto-walk
+ // drives the body's velocity + motion state machine DIRECTLY.
+ // When _autoWalkActive, DriveServerAutoWalk steps Yaw, computes
+ // velocity from wire-supplied runRate, calls _motion.DoMotion,
+ // and sets _body.set_local_velocity. The user-input motion +
+ // velocity sections below are SKIPPED so they don't override
+ // the auto-walk's assignments. Critically, no synthesized input
+ // gets put back into `input` — the outbound-packet pipeline at
+ // GameWindow.cs:6410 sees user-input null/Ready throughout the
+ // auto-walk and never builds a MoveToState packet, leaving
+ // ACE's server-side MoveToChain to run uninterrupted until its
+ // TryUseItem/TryPickUp callback fires. Retail equivalent:
+ // MovementManager::PerformMovement case 6 (decomp 0x00524440)
+ // calls CPhysicsObj::MoveToObject server-side; the local body
+ // is moved without ever touching CommandInterpreter input.
+ bool autoWalkConsumedMotion = DriveServerAutoWalk(dt, input);
// Portal-space guard: while teleporting, no input is processed and
// no physics is resolved. Return a zero-movement result so the caller
@@ -668,6 +871,14 @@ public sealed class PlayerMovementController
_body.Orientation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, Yaw - MathF.PI / 2f);
// ── 2. Set velocity via MotionInterpreter state machine ───────────────
+ // 2026-05-16 (issue #75): skip when DriveServerAutoWalk owns
+ // motion control this frame — it has already called
+ // _motion.DoMotion + _body.set_local_velocity from the auto-
+ // walk's path data + runRate. Running this section would
+ // overwrite the auto-walk velocity with the user-input
+ // (Ready/Stand) velocity, freezing the body.
+ if (!autoWalkConsumedMotion)
+ {
// Determine the dominant forward/backward command and speed.
uint forwardCmd;
float forwardCmdSpeed;
@@ -755,6 +966,7 @@ public sealed class PlayerMovementController
_body.set_local_velocity(new Vector3(localX, localY, savedWorldVz));
}
+ } // end of `if (!autoWalkConsumedMotion)` — section 2
// ── 3. Jump (charged) ─────────────────────────────────────────────────
// Hold spacebar to charge (0→1 over JumpChargeRate seconds).
@@ -919,7 +1131,7 @@ public sealed class PlayerMovementController
// L.4-diag (2026-04-30): trace position transitions so we can see
// whether the body is actually moving frame-to-frame on the steep
// roof, or whether it's frozen at the impact point.
- if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1"
+ if (AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled
&& resolveResult.CollisionNormalValid)
{
Console.WriteLine(
@@ -1001,7 +1213,7 @@ public sealed class PlayerMovementController
: (!prevOnWalkable && !nowOnWalkable);
// L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug.
- bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1";
+ bool diagSteep = AcDream.Core.Physics.PhysicsDiagnostics.DumpSteepRoofEnabled;
if (diagSteep && resolveResult.CollisionNormalValid)
{
var n0 = resolveResult.CollisionNormal;
@@ -1192,31 +1404,55 @@ public sealed class PlayerMovementController
}
// ── 8. Heartbeat timer (always while in-world, not just while moving) ─
- // Holtburger fires AutonomousPosition heartbeat at 1 Hz regardless of
- // motion state (gated only by has_autonomous_position_sync_target).
- // Retail's CommandInterpreter::SendPositionEvent gates on
- // transient_state (Contact + OnWalkable + valid Position), not on
- // motion. The pre-fix isMoving gate stopped acdream from heart-beating
- // at rest, which left observers with stale last-known positions during
- // long idle periods. PortalSpace (handled at the top of Update via
- // early return) skips Update entirely, so reaching this line implies
- // we're in a valid in-world pose.
- _heartbeatAccum += dt;
- // B.6+B.7 (2026-05-15): bump heartbeat from 1 Hz to ~10 Hz while
- // the body is actively moving (auto-walk OR user pressing W/A/S/D).
- // ACE's server-side CreateMoveToChain polls WithinUseRadius every
- // ~0.1 s using the latest Player.Location; 1 Hz heartbeats leave
- // up to 1 s of stale position data on the server, which meant
- // ACE's MoveToChain rejected our re-sent Use action as still
- // out-of-range. With 10 Hz updates ACE sees us approaching in
- // ~real-time and the server-side chain converges normally —
- // retires the arrival-margin / re-send / flush-AP workarounds.
- bool activelyMoving = _autoWalkActive
- || input.Forward || input.Backward
- || input.StrafeLeft || input.StrafeRight;
- float effectiveInterval = activelyMoving ? 0.1f : HeartbeatInterval;
- HeartbeatDue = _heartbeatAccum >= effectiveInterval;
- if (HeartbeatDue) _heartbeatAccum = 0f;
+ // 2026-05-16 (closes #74) — retail-faithful AP cadence per
+ // CommandInterpreter::ShouldSendPositionEvent at
+ // acclient_2013_pseudo_c.txt:700233-700285. Two-branch:
+ //
+ // Branch 1 — interval NOT yet elapsed (< 1 sec since last
+ // send): send only if cell changed OR contact-plane changed
+ // (mid-walk events that matter — stair / hill / cell cross).
+ //
+ // Branch 2 — interval HAS elapsed (>= 1 sec): send only if
+ // cell OR position frame changed. Truly idle = no send
+ // (retail's `last_sent.frame == player.frame` check at
+ // acclient_2013_pseudo_c.txt:700248-700265).
+ //
+ // SendPositionEvent (line 700327) gates the actual send on
+ // (state & 1) != 0 && (state & 2) != 0 — Contact AND
+ // OnWalkable both set. We mirror that gate so airborne and
+ // wall-contact-without-walkable suppress AP entirely;
+ // MoveToState carries jump/fall snapshots while airborne.
+ //
+ // Effective rates:
+ // Truly idle (grounded, no movement) : 0 Hz
+ // Smooth movement (no cell/plane changes) : ~1 Hz (interval)
+ // Cell crossings + stair/hill steps : per-event
+ // Airborne : 0 Hz
+ //
+ // Bootstrap: when NotePositionSent has never been called
+ // (_lastSentInitialized=false), every state-changed branch is
+ // forced true so the first AP gets a chance to fire.
+
+ bool intervalElapsed = !_lastSentInitialized
+ || (_simTimeSeconds - _lastSentTime) >= HeartbeatInterval;
+
+ bool cellChanged = !_lastSentInitialized
+ || _lastSentCellId != CellId;
+ bool planeChanged = !_lastSentInitialized
+ || !ApproxPlaneEqual(_lastSentContactPlane, _body.ContactPlane);
+ bool frameChanged = !_lastSentInitialized
+ || !ApproxPositionEqual(_lastSentPos, _body.Position);
+
+ bool sendThisFrame = intervalElapsed
+ ? (cellChanged || frameChanged)
+ : (cellChanged || planeChanged);
+
+ // Grounded-on-walkable gate per acclient_2013_pseudo_c.txt:700327
+ // (`(state & 1) != 0 && (state & 2) != 0`). Both flags must be
+ // set simultaneously, NOT a bitwise-OR mask test.
+ bool groundedOnWalkable = _body.InContact && _body.OnWalkable;
+
+ HeartbeatDue = groundedOnWalkable && sendThisFrame;
// K-fix5 (2026-04-26): local-animation-cycle pacing. Visual rate
// should match the actual movement speed. For Forward+Run this is
@@ -1230,6 +1466,40 @@ public sealed class PlayerMovementController
? (_weenie.InqRunRate(out float vrrAnim) ? vrrAnim : 1f)
: 1f;
+ // 2026-05-16 (issue #75) — server-initiated auto-walk drives
+ // the local animation cycle directly:
+ // - moving forward → WalkForward / RunForward (legs animate)
+ // - turn-first phase → TurnLeft / TurnRight (issue #69 fix)
+ // - aligned but pre-step / arrival → no override, falls to
+ // the user-input section's default (idle)
+ // UpdatePlayerAnimation reads LocalAnimationCommand +
+ // LocalAnimationSpeed; without these overrides the body
+ // translates/rotates without leg/arm animation. The motion
+ // cycle commands here flow into the animation sequencer
+ // ONLY — the wire-layer guard at GameWindow.cs:6419 prevents
+ // them from leaking to a user-MoveToState packet during
+ // auto-walk.
+ if (_autoWalkMovingForwardThisFrame)
+ {
+ if (_autoWalkInitiallyRunning && _weenie.InqRunRate(out float autoWalkRunRate))
+ {
+ localAnimCmd = MotionCommand.RunForward;
+ localAnimSpeed = autoWalkRunRate;
+ }
+ else
+ {
+ localAnimCmd = MotionCommand.WalkForward;
+ localAnimSpeed = 1f;
+ }
+ }
+ else if (_autoWalkTurnDirectionThisFrame != 0)
+ {
+ localAnimCmd = _autoWalkTurnDirectionThisFrame > 0
+ ? MotionCommand.TurnLeft
+ : MotionCommand.TurnRight;
+ localAnimSpeed = 1f;
+ }
+
return new MovementResult(
Position: Position,
RenderPosition: RenderPosition,
@@ -1256,4 +1526,41 @@ public sealed class PlayerMovementController
JumpExtent: outJumpExtent,
JumpVelocity: outJumpVelocity);
}
+
+ ///
+ /// 2026-05-16. Position-equality test for diff-driven AP cadence.
+ /// Retail uses Frame::is_equal at acclient_2013_pseudo_c.txt:700263
+ /// which is essentially exact float comparison after a memcmp of
+ /// the frame struct. For floating-point safety we use a tiny epsilon
+ /// — sub-millimeter — that's well below any movement we'd want to
+ /// suppress sending for.
+ ///
+ private static bool ApproxPositionEqual(
+ System.Numerics.Vector3 a, System.Numerics.Vector3 b)
+ {
+ const float Epsilon = 0.001f; // 1 mm
+ return MathF.Abs(a.X - b.X) < Epsilon
+ && MathF.Abs(a.Y - b.Y) < Epsilon
+ && MathF.Abs(a.Z - b.Z) < Epsilon;
+ }
+
+ ///
+ /// 2026-05-16. Contact-plane-equality test for retail's
+ /// sub-interval AP gate. Retail's SendPositionEvent stores
+ /// last_sent_contact_plane and ShouldSendPositionEvent re-sends
+ /// during the sub-interval window if the plane has changed (e.g.,
+ /// player stepped onto stairs / a hill — same cell but different
+ /// contact normal). Tiny epsilon on normal + distance covers
+ /// floating-point noise from the physics integration.
+ ///
+ private static bool ApproxPlaneEqual(
+ System.Numerics.Plane a, System.Numerics.Plane b)
+ {
+ const float NormalEpsilon = 1e-4f;
+ const float DistanceEpsilon = 0.001f;
+ return MathF.Abs(a.Normal.X - b.Normal.X) < NormalEpsilon
+ && MathF.Abs(a.Normal.Y - b.Normal.Y) < NormalEpsilon
+ && MathF.Abs(a.Normal.Z - b.Normal.Z) < NormalEpsilon
+ && MathF.Abs(a.D - b.D) < DistanceEpsilon;
+ }
}
diff --git a/src/AcDream.App/Net/LiveSessionController.cs b/src/AcDream.App/Net/LiveSessionController.cs
new file mode 100644
index 00000000..eb714f05
--- /dev/null
+++ b/src/AcDream.App/Net/LiveSessionController.cs
@@ -0,0 +1,114 @@
+using System;
+using System.Net;
+using System.Net.Sockets;
+using AcDream.Core.Net;
+
+namespace AcDream.App.Net;
+
+///
+/// Owns the network-side lifecycle of a live —
+/// DNS resolution, endpoint construction, session instantiation, per-frame
+/// Tick, and disposal. The post-construction work (event wiring,
+/// Connect, character validation, EnterWorld, post-login UI
+/// state setup) stays in GameWindow for now because it touches
+/// renderer / chat / player-controller state that hasn't been extracted yet.
+///
+///
+///
+/// Step 2 of the extraction sequence described in
+/// docs/architecture/code-structure.md §4. Future expansions can
+/// fold more of TryStartLiveSession into this controller as the
+/// surrounding state (event handlers, command bus, settings VM) gets
+/// extracted in later steps.
+///
+///
+/// Behavior preservation contract: this class produces
+/// byte-identical console output and event-wireup sequencing to the
+/// pre-refactor inline code path. The DNS-resolution lines, the
+/// "live: connecting to ..." line, and the wiring-vs-Connect ordering
+/// all match the previous flow.
+///
+///
+public sealed class LiveSessionController : IDisposable
+{
+ ///
+ /// Active session, or when offline / before
+ /// succeeded / after .
+ ///
+ public WorldSession? Session { get; private set; }
+
+ ///
+ /// Resolves the endpoint, instantiates the ,
+ /// hands it to for caller-side event
+ /// subscriptions, and returns the live session. The caller is
+ /// responsible for the subsequent Connect /
+ /// EnterWorld dance.
+ ///
+ public WorldSession? CreateAndWire(RuntimeOptions options, Action wireEvents)
+ {
+ if (options is null) throw new ArgumentNullException(nameof(options));
+ if (wireEvents is null) throw new ArgumentNullException(nameof(wireEvents));
+
+ if (!options.LiveMode) return null;
+
+ if (string.IsNullOrEmpty(options.LiveUser) || string.IsNullOrEmpty(options.LivePass))
+ {
+ Console.WriteLine("live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
+ return null;
+ }
+
+ try
+ {
+ var endpoint = ResolveEndpoint(options.LiveHost, options.LivePort);
+ Console.WriteLine($"live: connecting to {endpoint} as {options.LiveUser}");
+ Session = new WorldSession(endpoint);
+ wireEvents(Session);
+ return Session;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"live: session setup failed: {ex.Message}");
+ Session?.Dispose();
+ Session = null;
+ return null;
+ }
+ }
+
+ ///
+ /// Drains the inbound network queue. Proxies to
+ /// ; no-op when
+ /// is .
+ ///
+ public void Tick() => Session?.Tick();
+
+ ///
+ /// Tears down the live session. Safe to call multiple times.
+ ///
+ public void Dispose()
+ {
+ Session?.Dispose();
+ Session = null;
+ }
+
+ ///
+ /// Resolve a host string (literal IP or DNS name) to an
+ /// . Pre-refactor logic preserved exactly:
+ /// try first, fall back to
+ /// , prefer IPv4 (ACE + retail use
+ /// IPv4 UDP exclusively), throw on empty resolution.
+ ///
+ private static IPEndPoint ResolveEndpoint(string host, int port)
+ {
+ IPAddress ip;
+ if (!IPAddress.TryParse(host, out ip!))
+ {
+ var addrs = Dns.GetHostAddresses(host);
+ ip = Array.Find(addrs, a => a.AddressFamily == AddressFamily.InterNetwork)
+ ?? (addrs.Length > 0
+ ? addrs[0]
+ : throw new Exception($"DNS resolved no addresses for '{host}'"));
+ Console.WriteLine($"live: resolved {host} → {ip}");
+ }
+ return new IPEndPoint(ip, port);
+ }
+}
diff --git a/src/AcDream.App/Program.cs b/src/AcDream.App/Program.cs
index e989b6ee..bc43997b 100644
--- a/src/AcDream.App/Program.cs
+++ b/src/AcDream.App/Program.cs
@@ -1,3 +1,4 @@
+using AcDream.App;
using AcDream.App.Plugins;
using AcDream.App.Rendering;
using AcDream.Core.Plugins;
@@ -15,6 +16,11 @@ if (string.IsNullOrWhiteSpace(datDir))
return 2;
}
+// Single read of the startup-time process environment. Every downstream
+// consumer (GameWindow + collaborators) reads the typed bundle, not the
+// raw env vars. See docs/architecture/code-structure.md §2 Rule 4.
+var runtimeOptions = RuntimeOptions.FromEnvironment(datDir);
+
var worldGameState = new AcDream.Core.Plugins.WorldGameState();
var worldEvents = new AcDream.Core.Plugins.WorldEvents();
var host = new AppPluginHost(new SerilogAdapter(Log.Logger), worldGameState, worldEvents);
@@ -50,7 +56,7 @@ try
catch (Exception ex) { Log.Error(ex, "plugin enable failed: {Id}", plugin.Manifest.Id); }
}
- using var window = new GameWindow(datDir, worldGameState, worldEvents);
+ using var window = new GameWindow(runtimeOptions, worldGameState, worldEvents);
window.Run();
}
finally
diff --git a/src/AcDream.App/Rendering/CameraController.cs b/src/AcDream.App/Rendering/CameraController.cs
index c0614509..c855a9a2 100644
--- a/src/AcDream.App/Rendering/CameraController.cs
+++ b/src/AcDream.App/Rendering/CameraController.cs
@@ -1,47 +1,80 @@
// src/AcDream.App/Rendering/CameraController.cs
+using AcDream.Core.Rendering;
+
namespace AcDream.App.Rendering;
public sealed class CameraController
{
- public OrbitCamera Orbit { get; }
- public FlyCamera Fly { get; }
- public ChaseCamera? Chase { get; private set; }
- public ICamera Active { get; private set; }
- public bool IsFlyMode => Active == Fly;
- public bool IsChaseMode => Chase is not null && Active == Chase;
+ public OrbitCamera Orbit { get; }
+ public FlyCamera Fly { get; }
+ public ChaseCamera? Chase { get; private set; }
+ public RetailChaseCamera? RetailChase { get; private set; }
+
+ ///
+ /// The renderer-facing active camera. Both the legacy and retail
+ /// chase cameras are held simultaneously so that flipping
+ /// takes effect
+ /// on the very next access to this property — no re-entry required,
+ /// no notification mechanism, no stale state.
+ ///
+ public ICamera Active
+ {
+ get
+ {
+ if (_mode == Mode.Fly) return Fly;
+ if (_mode == Mode.Chase)
+ {
+ if (CameraDiagnostics.UseRetailChaseCamera && RetailChase is not null)
+ return RetailChase;
+ if (Chase is not null) return Chase;
+ }
+ return Orbit;
+ }
+ }
+
+ public bool IsFlyMode => _mode == Mode.Fly;
+ public bool IsChaseMode => _mode == Mode.Chase;
public event Action? ModeChanged;
+ private enum Mode { Orbit, Fly, Chase }
+ private Mode _mode = Mode.Orbit;
+
public CameraController(OrbitCamera orbit, FlyCamera fly)
{
Orbit = orbit;
- Fly = fly;
- Active = orbit;
+ Fly = fly;
}
public void ToggleFly()
{
- Active = IsFlyMode ? (ICamera)Orbit : Fly;
+ _mode = IsFlyMode ? Mode.Orbit : Mode.Fly;
ModeChanged?.Invoke(IsFlyMode);
}
- public void EnterChaseMode(ChaseCamera chase)
+ ///
+ /// Store both cameras simultaneously; picks
+ /// between them per-read via the flag — no re-entry needed on flip.
+ ///
+ public void EnterChaseMode(ChaseCamera legacy, RetailChaseCamera retail)
{
- Chase = chase;
- Active = chase;
+ Chase = legacy;
+ RetailChase = retail;
+ _mode = Mode.Chase;
ModeChanged?.Invoke(IsChaseMode);
}
public void ExitChaseMode()
{
- Active = Fly;
- Chase = null;
+ Chase = null;
+ RetailChase = null;
+ _mode = Mode.Fly;
ModeChanged?.Invoke(IsFlyMode);
}
public void SetAspect(float aspect)
{
Orbit.Aspect = aspect;
- Fly.Aspect = aspect;
+ Fly.Aspect = aspect;
}
}
diff --git a/src/AcDream.App/Rendering/CellVisibility.cs b/src/AcDream.App/Rendering/CellVisibility.cs
index f3e0c55b..bcca4eca 100644
--- a/src/AcDream.App/Rendering/CellVisibility.cs
+++ b/src/AcDream.App/Rendering/CellVisibility.cs
@@ -330,6 +330,21 @@ public sealed class CellVisibility
local.Z <= cell.LocalBoundsMax.Z + PointInCellEpsilon;
}
+ ///
+ /// Brute-force scan of every loaded cell to test whether
+ /// is inside any of them. Does not touch
+ /// the camera cache (), so this is safe
+ /// to call alongside in the same frame
+ /// for a different position (e.g. player position when the camera is
+ /// in third-person chase mode).
+ ///
+ public bool IsInsideAnyCell(Vector3 worldPoint)
+ {
+ foreach (var cell in _cellLookup.Values)
+ if (PointInCell(worldPoint, cell)) return true;
+ return false;
+ }
+
// ------------------------------------------------------------------
// GetVisibleCells (BFS)
// ------------------------------------------------------------------
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 457ac115..302a1249 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -12,6 +12,7 @@ public sealed class GameWindow : IDisposable
{
private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene);
+ private readonly AcDream.App.RuntimeOptions _options;
private readonly string _datDir;
private readonly WorldGameState _worldGameState;
private readonly WorldEvents _worldEvents;
@@ -219,12 +220,10 @@ public sealed class GameWindow : IDisposable
// Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but
// never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime.
// Keep the experimental path available for DAT archaeology only.
- private readonly bool _enableSkyPesDebug =
- string.Equals(Environment.GetEnvironmentVariable("ACDREAM_ENABLE_SKY_PES"), "1", StringComparison.Ordinal);
+ // Backed by ACDREAM_ENABLE_SKY_PES via RuntimeOptions.EnableSkyPesDebug.
// Diagnostic: hide a specific humanoid part (>=10 parts) at render.
- private static readonly int s_hidePartIndex =
- int.TryParse(Environment.GetEnvironmentVariable("ACDREAM_HIDE_PART"), out var hp) ? hp : -1;
+ // Backed by ACDREAM_HIDE_PART via RuntimeOptions.HidePartIndex.
// Issue #47 — use retail's close-detail GfxObj selection on
// humanoid setups. When enabled, every per-part GfxObj id (after
@@ -233,8 +232,7 @@ public sealed class GameWindow : IDisposable
// for the full retail-decomp citation. Default-on after visual
// confirmation; set ACDREAM_RETAIL_CLOSE_DEGRADES=0 only for
// diagnostic before/after comparisons.
- private static readonly bool s_retailCloseDegrades =
- !string.Equals(Environment.GetEnvironmentVariable("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal);
+ // Backed by ACDREAM_RETAIL_CLOSE_DEGRADES via RuntimeOptions.RetailCloseDegrades.
// Issue #48 diagnostic — dump per-scenery-spawn placement evidence
// (rendered gfx id, sample source physics-vs-bilinear, ground/baseLoc/finalZ,
@@ -242,8 +240,7 @@ public sealed class GameWindow : IDisposable
// the user identify a floating tree by its world coordinates and tell
// whether the cause is BaseLoc.Z addition (H1), bilinear-fallback drift
// (H2), or DIDDegrade selection (H3). Diagnostic-first per CLAUDE.md.
- private static readonly bool s_dumpSceneryZ =
- string.Equals(Environment.GetEnvironmentVariable("ACDREAM_DUMP_SCENERY_Z"), "1", StringComparison.Ordinal);
+ // Backed by ACDREAM_DUMP_SCENERY_Z via RuntimeOptions.DumpSceneryZ.
///
/// Issue #47 humanoid-setup detector. Matches Aluvian Male
@@ -575,10 +572,11 @@ public sealed class GameWindow : IDisposable
// _panelHost does. Self-subscribes to CombatState in its ctor, so
// disposing isn't required (panel host holds the only ref).
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm;
- private static readonly bool DevToolsEnabled =
- Environment.GetEnvironmentVariable("ACDREAM_DEVTOOLS") == "1";
- private static readonly bool DumpMoveTruthEnabled =
- Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOVE_TRUTH") == "1";
+ // DevToolsEnabled + DumpMoveTruthEnabled now read through _options
+ // (RuntimeOptions.DevTools / DumpMoveTruth). Kept the same names for
+ // local readability via expression-bodied properties.
+ private bool DevToolsEnabled => _options.DevTools;
+ private bool DumpMoveTruthEnabled => _options.DumpMoveTruth;
// Phase I.3 — real ICommandBus for live sessions. Constructed when
// the live session spins up (so SendChatCmd handlers can close over
@@ -629,6 +627,7 @@ public sealed class GameWindow : IDisposable
// Phase B.2: player movement mode.
private AcDream.App.Input.PlayerMovementController? _playerController;
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
+ private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
private bool _playerMode;
private uint _playerServerGuid;
private uint? _playerCurrentAnimCommand;
@@ -740,6 +739,11 @@ public sealed class GameWindow : IDisposable
// Phase 4.7: optional live connection to an ACE server. Enabled only when
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
// the offline rendering pipeline.
+ // Step 2 re-attempt (2026-05-16, debug pass): the network-side lifecycle
+ // (DNS, endpoint, WorldSession construction, Tick, Dispose) lives in
+ // LiveSessionController. _liveSession remains as a convenience handle
+ // for the ~60 outbound SendXxx call sites; it tracks Controller.Session.
+ private AcDream.App.Net.LiveSessionController? _liveSessionController;
private AcDream.Core.Net.WorldSession? _liveSession;
private int _liveCenterX;
private int _liveCenterY;
@@ -748,8 +752,8 @@ public sealed class GameWindow : IDisposable
// K-fix1 (2026-04-26): cached at startup so per-frame branches are
// single-flag reads instead of env-var lookups. True iff
// ACDREAM_LIVE=1 was set when the window came up.
- private static readonly bool LiveModeEnabled =
- Environment.GetEnvironmentVariable("ACDREAM_LIVE") == "1";
+ // Backed by RuntimeOptions.LiveMode via the _options field.
+ private bool LiveModeEnabled => _options.LiveMode;
///
/// K-fix1 (2026-04-26): true iff live mode is configured AND we have
@@ -791,13 +795,11 @@ public sealed class GameWindow : IDisposable
// Current selection: written by Q-cycle (combat) and LMB click (interact); cleared on entity despawn.
private uint? _selectedGuid;
- // B.6+B.7 (2026-05-15): pending action that triggered an auto-walk.
- // When the local body arrives at the auto-walk target,
- // OnAutoWalkArrivedReSendAction re-sends the action close-range so
- // ACE completes it via WithinUseRadius even if its server-side
- // MoveToChain already timed out. Cleared before each re-send to
- // prevent infinite loops (the re-sent action's auto-walk would
- // arrive immediately at the same position, infinite re-fire).
+ // B.6/B.7 (2026-05-16): pending close-range action that will be fired
+ // once the local auto-walk overlay reports arrival (body has finished
+ // rotating to face the target). Only set for close-range Use/PickUp;
+ // far-range sends fire the wire packet immediately at SendUse/SendPickUp
+ // time. Cleared before the deferred send fires — single-fire, no retry.
private (uint Guid, bool IsPickup)? _pendingPostArrivalAction;
private readonly record struct LiveEntityInfo(
string? Name,
@@ -816,9 +818,10 @@ public sealed class GameWindow : IDisposable
private int _liveAnimRejectSingleFrame;
private int _liveAnimRejectPartFrames;
- public GameWindow(string datDir, WorldGameState worldGameState, WorldEvents worldEvents)
+ public GameWindow(AcDream.App.RuntimeOptions options, WorldGameState worldGameState, WorldEvents worldEvents)
{
- _datDir = datDir;
+ _options = options ?? throw new System.ArgumentNullException(nameof(options));
+ _datDir = options.DatDir;
_worldGameState = worldGameState;
_worldEvents = worldEvents;
SpellBook = new AcDream.Core.Spells.Spellbook(SpellTable);
@@ -991,7 +994,17 @@ public sealed class GameWindow : IDisposable
// here goes ONLY through ApplyDelta — no separate
// YawOffset write. dy still pitches the camera only.
_mouseLook.ApplyDelta(dx, sens);
- _chaseCamera.AdjustPitch(dy * 0.003f * sens);
+ if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
+ {
+ float nowSec = (float)(Environment.TickCount64 / 1000.0);
+ var (_, filteredDy) = _retailChaseCamera.FilterMouseDelta(
+ rawX: 0f, rawY: dy, weight: 0.5f, nowSec: nowSec);
+ _retailChaseCamera.AdjustPitch(filteredDy * 0.003f * sens);
+ }
+ else
+ {
+ _chaseCamera.AdjustPitch(dy * 0.003f * sens);
+ }
}
else if (_rmbHeld)
{
@@ -1002,8 +1015,19 @@ public sealed class GameWindow : IDisposable
// ANYTHING when in player mode — character yaw is
// dispatcher-only (A/D keys). K.2: MMB mouse-look path
// above takes precedence when active.
- _chaseCamera.YawOffset -= dx * 0.004f * sens;
- _chaseCamera.AdjustPitch(dy * 0.003f * sens);
+ if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
+ {
+ float nowSec = (float)(Environment.TickCount64 / 1000.0);
+ var (filteredDx, filteredDy) = _retailChaseCamera.FilterMouseDelta(
+ rawX: dx, rawY: dy, weight: 0.5f, nowSec: nowSec);
+ _retailChaseCamera.YawOffset -= filteredDx * 0.004f * sens;
+ _retailChaseCamera.AdjustPitch(filteredDy * 0.003f * sens);
+ }
+ else
+ {
+ _chaseCamera.YawOffset -= dx * 0.004f * sens;
+ _chaseCamera.AdjustPitch(dy * 0.003f * sens);
+ }
}
// K-fix1 (2026-04-26): no default-pitch path. With
// neither MMB nor RMB held, mouse moves the cursor
@@ -1106,7 +1130,7 @@ public sealed class GameWindow : IDisposable
// Phase E.2 audio: init OpenAL + hook sink. Suppressible via
// ACDREAM_NO_AUDIO=1 for headless tests / broken audio drivers.
- if (Environment.GetEnvironmentVariable("ACDREAM_NO_AUDIO") != "1")
+ if (!_options.NoAudio)
{
try
{
@@ -1751,15 +1775,13 @@ public sealed class GameWindow : IDisposable
_farRadius = _resolvedQuality.FarRadius;
// Legacy override: ACDREAM_STREAM_RADIUS acts as nearRadius and
- // ensures farRadius >= streamRadius.
+ // ensures farRadius >= streamRadius. Parsed once into
+ // RuntimeOptions.LegacyStreamRadius (null when unset or invalid).
+ if (_options.LegacyStreamRadius is { } sr)
{
- var legacyEnv = Environment.GetEnvironmentVariable("ACDREAM_STREAM_RADIUS");
- if (int.TryParse(legacyEnv, out var sr) && sr >= 0)
- {
- _nearRadius = sr;
- _streamingRadius = sr; // keep debug overlay in sync
- _farRadius = System.Math.Max(sr, _farRadius);
- }
+ _nearRadius = sr;
+ _streamingRadius = sr; // keep debug overlay in sync
+ _farRadius = System.Math.Max(sr, _farRadius);
}
Console.WriteLine(
$"streaming: nearRadius={_nearRadius} (window={2*_nearRadius+1}x{2*_nearRadius+1})" +
@@ -1824,38 +1846,80 @@ public sealed class GameWindow : IDisposable
private void TryStartLiveSession()
{
- if (Environment.GetEnvironmentVariable("ACDREAM_LIVE") != "1") return;
-
- var host = Environment.GetEnvironmentVariable("ACDREAM_TEST_HOST") ?? "127.0.0.1";
- var portStr = Environment.GetEnvironmentVariable("ACDREAM_TEST_PORT") ?? "9000";
- var user = Environment.GetEnvironmentVariable("ACDREAM_TEST_USER");
- var pass = Environment.GetEnvironmentVariable("ACDREAM_TEST_PASS");
- if (string.IsNullOrEmpty(user) || string.IsNullOrEmpty(pass))
+ // Step 2 (2026-05-16): delegate pre-Connect setup to LiveSessionController.
+ // The controller owns DNS resolution + WorldSession instantiation + the
+ // wireEvents callback; this method keeps the Connect → CharacterList →
+ // EnterWorld → post-setup dance because those touch GameWindow state.
+ _liveSessionController = new AcDream.App.Net.LiveSessionController();
+ _liveSession = _liveSessionController.CreateAndWire(_options, WireLiveSessionEvents);
+ if (_liveSession is null)
{
- Console.WriteLine("live: ACDREAM_LIVE set but TEST_USER/TEST_PASS missing; skipping");
+ _combatChatTranslator?.Dispose();
+ _combatChatTranslator = null;
+ _liveSessionController = null;
return;
}
+ var user = _options.LiveUser!;
+ var pass = _options.LivePass!;
+ var host = _options.LiveHost;
+ var port = _options.LivePort;
try
{
- // Resolve DNS names (e.g. play.coldeve.ac) as well as literal
- // IP addresses. `IPAddress.Parse` throws on hostnames; fall
- // back to `Dns.GetHostAddresses` and prefer IPv4 (ACE + retail
- // use IPv4 UDP exclusively).
- System.Net.IPAddress ip;
- if (!System.Net.IPAddress.TryParse(host, out ip!))
+ Chat.OnSystemMessage($"connecting to {host}:{port} as {user}", chatType: 1);
+ _liveSession.Connect(user, pass);
+ Chat.OnSystemMessage("connected — character list received", chatType: 1);
+
+ if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0)
{
- var addrs = System.Net.Dns.GetHostAddresses(host);
- ip = System.Array.Find(addrs,
- a => a.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
- ?? (addrs.Length > 0 ? addrs[0] : throw new System.Exception(
- $"DNS resolved no addresses for '{host}'"));
- Console.WriteLine($"live: resolved {host} → {ip}");
+ Console.WriteLine("live: no characters on account; disconnecting");
+ _liveSessionController.Dispose();
+ _liveSessionController = null;
+ _liveSession = null;
+ return;
}
- var endpoint = new System.Net.IPEndPoint(ip, int.Parse(portStr));
- Console.WriteLine($"live: connecting to {endpoint} as {user}");
- _liveSession = new AcDream.Core.Net.WorldSession(endpoint);
- _liveSession.EntitySpawned += OnLiveEntitySpawned;
+
+ var chosen = _liveSession.Characters.Characters[0];
+ _playerServerGuid = chosen.Id;
+ _vitalsVm?.SetLocalPlayerGuid(chosen.Id);
+ Chat.SetLocalPlayerGuid(chosen.Id);
+ _worldState.MarkPersistent(chosen.Id);
+ Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
+ _liveSession.EnterWorld(user, characterIndex: 0);
+
+ _activeToonKey = chosen.Name;
+ if (_settingsStore is not null && _settingsVm is not null)
+ {
+ var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
+ _settingsVm.LoadCharacterContext(toonBag);
+ Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
+ }
+ _playerModeAutoEntry?.Arm();
+ Console.WriteLine($"live: in world — CreateObject stream active " +
+ $"(so far: {_liveSpawnReceived} received, {_liveSpawnHydrated} hydrated)");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"live: session failed: {ex.Message}");
+ _combatChatTranslator?.Dispose();
+ _combatChatTranslator = null;
+ _liveSessionController?.Dispose();
+ _liveSessionController = null;
+ _liveSession = null;
+ }
+ }
+
+ ///
+ /// Step 2 helper: subscribes the live to all
+ /// the parsers / handlers / translators that GameWindow needs.
+ /// Called once by
+ /// immediately after the
+ /// is constructed and BEFORE any network I/O.
+ ///
+ private void WireLiveSessionEvents(AcDream.Core.Net.WorldSession session)
+ {
+ _liveSession = session;
+ _liveSession.EntitySpawned += OnLiveEntitySpawned;
_liveSession.EntityDeleted += OnLiveEntityDeleted;
_liveSession.MotionUpdated += OnLiveMotionUpdated;
_liveSession.PositionUpdated += OnLivePositionUpdated;
@@ -2016,7 +2080,11 @@ public sealed class GameWindow : IDisposable
// plus a local echo into ChatLog so the player sees their own
// message immediately. Closes over _liveSession + Chat so this
// wiring only exists for the lifetime of the live session.
- var liveSession = _liveSession;
+ // Step 2: capture the non-null `session` parameter rather than
+ // the nullable _liveSession field. Semantically identical (the
+ // field WAS set to `session` at the top of WireLiveSessionEvents)
+ // but the compiler can prove non-null for the lambda body.
+ var liveSession = session;
var chat = Chat;
_commandBus = new AcDream.UI.Abstractions.LiveCommandBus();
var turbineChat = TurbineChat;
@@ -2124,58 +2192,6 @@ public sealed class GameWindow : IDisposable
LocalPlayer.OnVitalUpdate(v.VitalId, v.Ranks, v.Start, v.Xp, v.Current);
_liveSession.VitalCurrentUpdated += v =>
LocalPlayer.OnVitalCurrent(v.VitalId, v.Current);
-
- Chat.OnSystemMessage($"connecting to {host}:{portStr} as {user}", chatType: 1);
- _liveSession.Connect(user, pass);
- Chat.OnSystemMessage("connected — character list received", chatType: 1);
-
- if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0)
- {
- Console.WriteLine("live: no characters on account; disconnecting");
- _liveSession.Dispose();
- _liveSession = null;
- return;
- }
-
- var chosen = _liveSession.Characters.Characters[0];
- _playerServerGuid = chosen.Id; // Phase B.2: store for Tab-key player-mode entry
- _vitalsVm?.SetLocalPlayerGuid(chosen.Id); // Phase D.2a — devtools HP bar tracks this guid
- Chat.SetLocalPlayerGuid(chosen.Id); // Phase J — recognize own /say echo from ACE's HearSpeech broadcast
- _worldState.MarkPersistent(chosen.Id); // player entity survives landblock unloads
- Console.WriteLine($"live: entering world as 0x{chosen.Id:X8} {chosen.Name}");
- _liveSession.EnterWorld(user, characterIndex: 0);
-
- // L.0 Character tab: swap the SettingsVM's character bag
- // from the "default" pre-login bag to the actual chosen
- // toon's bag. Every Save from now on writes under the
- // chosen toon's name. LoadCharacterContext rebinds BOTH
- // persisted + draft so HasUnsavedChanges doesn't flag the
- // swap as a pending edit.
- _activeToonKey = chosen.Name;
- if (_settingsStore is not null && _settingsVm is not null)
- {
- var toonBag = _settingsStore.LoadCharacter(_activeToonKey);
- _settingsVm.LoadCharacterContext(toonBag);
- Console.WriteLine($"settings: loaded character[{_activeToonKey}] preferences");
- }
- // Phase K.2: arm auto-entry. The guard's predicates won't
- // pass yet — the entity stream hasn't started — but the
- // OnUpdate tick re-checks every frame and fires once
- // everything converges (typically 100-300 ms after EnterWorld
- // returns). User can pre-empt via DebugPanel "Toggle
- // Free-Fly Mode" or Tab; both call Cancel() first.
- _playerModeAutoEntry?.Arm();
- Console.WriteLine($"live: in world — CreateObject stream active " +
- $"(so far: {_liveSpawnReceived} received, {_liveSpawnHydrated} hydrated)");
- }
- catch (Exception ex)
- {
- Console.WriteLine($"live: session failed: {ex.Message}");
- _combatChatTranslator?.Dispose();
- _combatChatTranslator = null;
- _liveSession?.Dispose();
- _liveSession = null;
- }
}
///
@@ -2482,7 +2498,7 @@ public sealed class GameWindow : IDisposable
// changes resolve (which match against the resolved mesh's
// surfaces) and BEFORE the GfxObjMesh.Build / texture upload
// path consumes the part list.
- if (s_retailCloseDegrades && IsIssue47HumanoidSetup(setup))
+ if (_options.RetailCloseDegrades && IsIssue47HumanoidSetup(setup))
{
for (int partIdx = 0; partIdx < parts.Count; partIdx++)
{
@@ -3402,16 +3418,17 @@ public sealed class GameWindow : IDisposable
pathData.OriginZ,
_liveCenterX,
_liveCenterY);
+ bool canCharge = update.MotionState.CanCharge;
_playerController.BeginServerAutoWalk(
destWorld,
pathData.MinDistance,
pathData.DistanceToObject,
update.MotionState.MoveTowards,
- pathData.WalkRunThreshold);
+ canCharge);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
- $"[autowalk-begin] dest=({destWorld.X:F2},{destWorld.Y:F2},{destWorld.Z:F2}) minDist={pathData.MinDistance:F2} objDist={pathData.DistanceToObject:F2} walkRunThresh={pathData.WalkRunThreshold:F2} towards={update.MotionState.MoveTowards}"));
+ $"[autowalk-begin] dest=({destWorld.X:F2},{destWorld.Y:F2},{destWorld.Z:F2}) minDist={pathData.MinDistance:F2} objDist={pathData.DistanceToObject:F2} canCharge={canCharge} towards={update.MotionState.MoveTowards}"));
}
}
// Note: do NOT cancel auto-walk on a non-MoveTo motion
@@ -4740,6 +4757,11 @@ public sealed class GameWindow : IDisposable
// 4. Recenter chase camera on the new position.
_chaseCamera?.Update(snappedPos, _playerController.Yaw);
+ _retailChaseCamera?.Update(snappedPos, _playerController.Yaw,
+ playerVelocity: System.Numerics.Vector3.Zero,
+ isOnGround: true,
+ contactPlaneNormal: System.Numerics.Vector3.UnitZ,
+ dt: 1f / 60f);
// 5. Return to InWorld.
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
@@ -5212,7 +5234,7 @@ public sealed class GameWindow : IDisposable
// (physics-vs-bilinear sampler drift), and H3 (DIDDegrade slot 0).
// User identifies a floating tree visually, finds the matching
// line by world coords + gfx id, the data picks the hypothesis.
- if (s_dumpSceneryZ)
+ if (_options.DumpSceneryZ)
{
string source = maybePhysicsZ.HasValue ? "physics" : "bilinear";
foreach (var mr in meshRefs)
@@ -5359,7 +5381,7 @@ public sealed class GameWindow : IDisposable
BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform);
// Cache CellStruct physics BSP for indoor collision.
- _physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform);
+ _physicsDataCache.CacheCellStruct(envCellId, envCell, cellStruct, cellTransform);
}
}
}
@@ -5688,6 +5710,49 @@ public sealed class GameWindow : IDisposable
}
}
+ // Phase 2: cache building portal lists for CellTransit.CheckBuildingTransit.
+ // Iterates LandBlockInfo.Buildings — each BuildingInfo has a Frame (world-
+ // relative origin + orientation) and a Portals list. The landcell id is
+ // derived from the building's frame origin using retail's row-major grid
+ // formula (gridX * 8 + gridY + 1) within the 192m × 192m landblock.
+ if (lbInfo is not null && lbInfo.Buildings.Count > 0)
+ {
+ uint lbPrefix = lb.LandblockId & 0xFFFF0000u;
+ foreach (var building in lbInfo.Buildings)
+ {
+ if (building.Portals.Count == 0) continue;
+
+ var bldPortals = new System.Collections.Generic.List(
+ building.Portals.Count);
+ foreach (var bp in building.Portals)
+ {
+ bldPortals.Add(new AcDream.Core.Physics.BldPortalInfo(
+ otherCellId: lbPrefix | (uint)bp.OtherCellId,
+ otherPortalId: bp.OtherPortalId,
+ flags: (ushort)bp.Flags));
+ }
+
+ // Build a world transform for the building. Frame.Origin is
+ // landblock-relative; add the landblock world origin to get
+ // world space.
+ var bldOriginWorld = building.Frame.Origin + origin;
+ var buildingTransform =
+ System.Numerics.Matrix4x4.CreateFromQuaternion(building.Frame.Orientation)
+ * System.Numerics.Matrix4x4.CreateTranslation(bldOriginWorld);
+
+ // Derive the outdoor landcell id containing this building.
+ // Reuse TerrainSurface.ComputeOutdoorCellId rather than
+ // re-deriving the row-major (gridX * 8 + gridY + 1) formula here.
+ // Frame.Origin is landblock-relative, same coordinate space as
+ // ComputeOutdoorCellId expects (local X/Y within the 192m block).
+ uint landcellLow = terrainSurface.ComputeOutdoorCellId(
+ building.Frame.Origin.X, building.Frame.Origin.Y);
+ uint landcellId = lbPrefix | landcellLow;
+
+ _physicsDataCache.CacheBuilding(landcellId, bldPortals, buildingTransform);
+ }
+ }
+
_physicsEngine.AddLandblock(lb.LandblockId, terrainSurface, cellSurfaces,
portalPlanes, origin.X, origin.Y);
}
@@ -6253,7 +6318,8 @@ public sealed class GameWindow : IDisposable
// CreateObject events find their landblock already loaded in
// GpuWorldState. Non-blocking — returns immediately if no datagrams
// are in the kernel buffer. Fires EntitySpawned events synchronously.
- _liveSession?.Tick();
+ // Step 2: routed through the controller; functionally identical.
+ _liveSessionController?.Tick();
// Phase K.1a — tick the input dispatcher so Hold-type bindings
// re-fire while their chord is held. K.1b adds the subscribers
@@ -6328,6 +6394,21 @@ public sealed class GameWindow : IDisposable
if (_inputDispatcher is null) return;
_playerMouseDeltaX = 0f; // defensive: ensure no leakage even if some path writes it
+ // Retail-style held-key offset integration. Only active when
+ // retail chase is selected; legacy camera ignores these.
+ if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
+ {
+ float adj = AcDream.Core.Rendering.CameraDiagnostics.CameraAdjustmentSpeed * (float)dt;
+ if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomIn))
+ _retailChaseCamera.AdjustDistance(-adj);
+ if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomOut))
+ _retailChaseCamera.AdjustDistance(+adj);
+ if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraRaise))
+ _retailChaseCamera.AdjustPitch(+adj * 0.02f);
+ if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraLower))
+ _retailChaseCamera.AdjustPitch(-adj * 0.02f);
+ }
+
// K-fix1 (2026-04-26): retail-faithful movement semantics.
// * Default speed = RUN. Forward / backward / strafe all run
// by default; holding Shift (MovementWalkMode) drops to
@@ -6384,16 +6465,30 @@ public sealed class GameWindow : IDisposable
_worldState.RelocateEntity(pe, currentLb);
}
- // Update chase camera. K-fix12 (2026-04-26): pass isOnGround
- // so the camera pins its Z to last-grounded while the
- // player is airborne — without this the camera follows
- // player.Z 1:1 during a jump and the player's screen
- // position never changes. With the pin: player visibly
- // rises above the camera, matching retail "you can see
- // yourself jump" feedback.
+ // Update chase camera(s). The CameraController exposes whichever
+ // is currently selected via CameraDiagnostics.UseRetailChaseCamera;
+ // both update every frame so toggling the flag swaps instantly
+ // with the new camera already warm.
+ //
+ // Legacy ChaseCamera: pre-K-fix12 args (isOnGround pins Z during
+ // jumps as a workaround for the visual feel retail gets from
+ // low-stiffness damping).
_chaseCamera.Update(result.RenderPosition, _playerController.Yaw,
isOnGround: result.IsOnGround,
dt: (float)dt);
+ // RetailChaseCamera: heading is the player's facing direction
+ // projected onto the contact plane when grounded, or the
+ // world XY plane when airborne. The contact plane normal
+ // tilts the camera basis with terrain; the airborne
+ // fallback keeps the basis horizontal during jumps so the
+ // player visibly rises in frame without the camera
+ // swinging vertically (was the symptom of using raw
+ // velocity-vector heading).
+ _retailChaseCamera!.Update(result.RenderPosition, _playerController.Yaw,
+ playerVelocity: _playerController.BodyVelocity,
+ isOnGround: result.IsOnGround,
+ contactPlaneNormal: _playerController.ContactPlane.Normal,
+ dt: (float)dt);
// Send outbound movement messages to the live server.
if (_liveSession is not null)
@@ -6409,7 +6504,23 @@ public sealed class GameWindow : IDisposable
var wireRot = YawToAcQuaternion(_playerController.Yaw);
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
- if (result.MotionStateChanged)
+ // 2026-05-16 (issue #75): wire-layer semantic gate —
+ // user-MoveToState packets are ONLY for user-initiated
+ // motion intent. During server-controlled auto-walk
+ // (inbound MoveToObject), motion-state transitions
+ // come from the auto-walk's animation override, not
+ // from user input. Sending a MoveToState in that case
+ // would tell ACE "user took control" and cancel its
+ // own MoveToChain. This is NOT a band-aid like the
+ // earlier grace-period — it's the wire-layer's
+ // expression of retail's architectural split between
+ // user-input motion and server-driven motion: they
+ // share the local motion-state machine but only
+ // user-input flows back to the wire. Without the
+ // refactor (issue #75) this guard masked a synthesis
+ // leak; with the refactor it expresses the proper
+ // semantic.
+ if (result.MotionStateChanged && !_playerController.IsServerAutoWalking)
{
// HoldKey axis values — retail enum (holtburger types.rs HoldKey):
// Invalid = 0, None = 1, Run = 2
@@ -6445,6 +6556,16 @@ public sealed class GameWindow : IDisposable
DumpMovementTruthOutbound(
"MTS", seq, result, wirePos, wireCellId, contactByte);
_liveSession.SendGameAction(body);
+ // B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
+ // HeartbeatDue resets its interval from THIS send — mirrors retail's
+ // SendMovementEvent (acclient_2013_pseudo_c.txt:0x006b4680) writing
+ // last_sent_position_time + last_sent_position + contact_plane
+ // after each MTS send.
+ _playerController.NotePositionSent(
+ worldPos: _playerController.Position,
+ cellId: _playerController.CellId,
+ contactPlane: _playerController.ContactPlane,
+ nowSeconds: _playerController.SimTimeSeconds);
}
if (_playerController.HeartbeatDue)
@@ -6463,6 +6584,16 @@ public sealed class GameWindow : IDisposable
DumpMovementTruthOutbound(
"AP", seq, result, wirePos, wireCellId, contactByte);
_liveSession.SendGameAction(body);
+ // B.6/B.7 (2026-05-16): stamp the diff-driven heartbeat clock so
+ // HeartbeatDue resets its interval from THIS send — mirrors retail's
+ // SendPositionEvent (acclient_2013_pseudo_c.txt:700345-700348)
+ // writing last_sent_position_time + last_sent_position +
+ // last_sent_contact_plane after each AP.
+ _playerController.NotePositionSent(
+ worldPos: _playerController.Position,
+ cellId: _playerController.CellId,
+ contactPlane: _playerController.ContactPlane,
+ nowSeconds: _playerController.SimTimeSeconds);
}
if (result.JumpExtent.HasValue && result.JumpVelocity.HasValue)
@@ -6760,11 +6891,24 @@ public sealed class GameWindow : IDisposable
var visibility = _cellVisibility.ComputeVisibility(camPos);
bool cameraInsideCell = visibility?.CameraCell is not null;
+ // Lighting decisions (sun zeroed, indoor ambient applied) must
+ // track the PLAYER's cell, not the camera's. In third-person
+ // chase mode the camera enters interiors before the player body
+ // does, so a camera-based trigger flips the scene to indoor
+ // lighting prematurely. Retail's CellManager::ChangePosition
+ // @ 0x004559B0 reads CObjCell::seen_outside on the player's
+ // current cell — that's the semantics we want here. When the
+ // player isn't in player mode (orbit / fly debug camera) we
+ // fall back to the camera trigger.
+ bool playerInsideCell = (_playerMode && _playerController is not null)
+ ? _cellVisibility.IsInsideAnyCell(_playerController.Position)
+ : cameraInsideCell;
+
// Phase C.1: tick retail PhysicsScript particle hooks. Named
// retail decomp confirms SkyObject.PesObjectId is copied by
// SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is
// debug-only and disabled for normal retail rendering.
- if (_enableSkyPesDebug)
+ if (_options.EnableSkyPesDebug)
UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell);
_scriptRunner?.Tick((float)deltaSeconds);
_particleSystem?.Tick((float)deltaSeconds);
@@ -6773,7 +6917,7 @@ public sealed class GameWindow : IDisposable
// the scene-lighting UBO once per frame. Every shader that
// consumes binding=1 reads the same data for the rest of the
// frame — terrain, static mesh, instanced mesh, sky.
- UpdateSunFromSky(kf, cameraInsideCell);
+ UpdateSunFromSky(kf, playerInsideCell);
Lighting.Tick(camPos);
var ubo = AcDream.Core.Lighting.SceneLightingUbo.Build(
Lighting, in atmo, camPos, (float)WorldTime.DayFraction);
@@ -7944,7 +8088,7 @@ public sealed class GameWindow : IDisposable
partTransform = partTransform * scaleMat;
var template = ae.PartTemplate[i];
- if (s_hidePartIndex >= 0 && i == s_hidePartIndex && partCount >= 10)
+ if (_options.HidePartIndex >= 0 && i == _options.HidePartIndex && partCount >= 10)
{
continue;
}
@@ -8231,18 +8375,28 @@ public sealed class GameWindow : IDisposable
/// Derive the current sun (directional light, slot 0 of the UBO)
/// from the interpolated ,
/// plus the cell ambient. Indoor cells force the sun intensity to
- /// zero (r13 §13.7) and substitute a fixed dungeon-tone ambient.
+ /// zero and substitute a flat 0.2 white ambient — exact retail
+ /// behavior per CellManager::ChangePosition @ 0x004559B0,
+ /// which calls SmartBox::SetWorldAmbientLight(0.2f, 0xFFFFFFFF)
+ /// when the player's CObjCell::seen_outside flag is 0.
+ /// Indoor brightness then comes from per-cell point lights
+ /// (Setup.Lights on the cell's static objects, registered through
+ /// ).
+ /// The trigger is the PLAYER's cell, not the camera's — third-person
+ /// chase camera enters interiors before the player body does, and
+ /// retail keys lighting off the player position.
///
- private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool cameraInsideCell)
+ private void UpdateSunFromSky(AcDream.Core.World.SkyKeyframe kf, bool playerInsideCell)
{
// Sun direction: points FROM the sun TOWARDS the world. Our
// shader does dot(N, -forward) so a positive N·L means the
// surface faces the sun.
var sunToWorld = -AcDream.Core.World.SkyStateProvider.SunDirectionFromKeyframe(kf);
- if (cameraInsideCell)
+ if (playerInsideCell)
{
- // Dungeon default per r13 §3 — warm-dark ambient, no sun.
+ // Indoor default — retail's flat 0.2 neutral ambient, sun
+ // zeroed. See xref to retail decomp in the doc comment above.
Lighting.Sun = new AcDream.Core.Lighting.LightSource
{
Kind = AcDream.Core.Lighting.LightKind.Directional,
@@ -8252,7 +8406,7 @@ public sealed class GameWindow : IDisposable
Range = 1f,
};
Lighting.CurrentAmbient = new AcDream.Core.Lighting.CellAmbientState(
- AmbientColor: new System.Numerics.Vector3(0.10f, 0.09f, 0.08f),
+ AmbientColor: new System.Numerics.Vector3(0.20f, 0.20f, 0.20f),
SunColor: System.Numerics.Vector3.Zero,
SunDirection: sunToWorld);
}
@@ -8931,6 +9085,7 @@ public sealed class GameWindow : IDisposable
_cameraController?.ExitChaseMode();
_playerController = null;
_chaseCamera = null;
+ _retailChaseCamera = null;
_playerCurrentAnimCommand = null;
}
else
@@ -9007,69 +9162,56 @@ public sealed class GameWindow : IDisposable
{
if (_cameraController is null || _window is null) return;
+ // 2026-05-16 — retail-faithful screen-rect picker. The hit area
+ // is the same screen-space rect the target indicator draws
+ // (computed via the shared AcDream.Core.Selection.ScreenProjection
+ // helper). Per user feedback 2026-05-16: clicking the indicator
+ // brackets — including the rect corners — must select the entity.
+ // The per-type radius/offset heuristics retired here (1.0/1.6/2.0
+ // m radii, 0.2/0.9/1.0/1.5 m vertical offsets, IsTallSceneryGuid)
+ // existed to make a 3D ray-sphere picker approximate the visible
+ // rect; the new picker doesn't need them.
var camera = _cameraController.Active;
- var (origin, direction) = AcDream.Core.Selection.WorldPicker.BuildRay(
- mouseX: _lastMouseX, mouseY: _lastMouseY,
- viewportW: _window.Size.X, viewportH: _window.Size.Y,
- view: camera.View, projection: camera.Projection);
+ var viewport = new System.Numerics.Vector2((float)_window.Size.X, (float)_window.Size.Y);
- if (direction.LengthSquared() < 1e-6f) return; // degenerate ray
+ // Indoor walking Phase 1 #86 (2026-05-19): snapshot the currently-
+ // cached EnvCell physics so the picker can occlude entities behind
+ // walls. Snapshot is per-pick (one click), iteration is bounded
+ // by the streaming radius (~80 cells at radius 4).
+ var loadedCellPhysics = new List();
+ foreach (var cellId in _physicsDataCache.CellStructIds)
+ {
+ var cp = _physicsDataCache.GetCellStruct(cellId);
+ if (cp is not null) loadedCellPhysics.Add(cp);
+ }
var picked = AcDream.Core.Selection.WorldPicker.Pick(
- origin, direction,
- _entitiesByServerGuid.Values,
+ mouseX: _lastMouseX, mouseY: _lastMouseY,
+ view: camera.View, projection: camera.Projection,
+ viewport: viewport,
+ candidates: _entitiesByServerGuid.Values,
skipServerGuid: _playerServerGuid,
- maxDistance: 50f,
- // B.7 (2026-05-15): widen the pick sphere for large flat
- // objects (doors, lifestones, portals, corpses) so their
- // visible surface stays clickable even though the entity
- // origin is a single point. 0.7 m default is fine for
- // humanoids and most items; doors / portals need ~2 m
- // to cover the doorframe.
- //
- // 2026-05-15 sign-class extension: post-mounted scenery
- // (Holtburg town sign etc.) needs the sphere TALLER than
- // wider. We classify "non-creature, non-flat, non-small-item"
- // as tall scenery and grow the sphere to 1.6 m radius lifted
- // to 1.5 m vertical offset — covers a 3 m post from
- // ground to top. Mirrors TargetIndicatorPanel.EntityHeightFor's
- // 3 m default so the click sphere matches the visible box.
- radiusForGuid: g =>
- {
- if (_lastSpawnByGuid.TryGetValue(g, out var s)
- && s.ObjectDescriptionFlags is { } odf)
- {
- // BF_DOOR = 0x1000, BF_LIFESTONE = 0x4000,
- // BF_PORTAL = 0x40000, BF_CORPSE = 0x2000
- // (acclient.h:6431-6463)
- const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
- if ((odf & LargeFlatMask) != 0) return 2.0f;
- }
- if (IsTallSceneryGuid(g)) return 1.6f;
- // 1.0 m sphere centred at chest height (see
- // verticalOffsetForGuid) covers a 1.8 m humanoid from
- // shin to crown without overlapping neighbours.
- return 1.0f;
- },
- verticalOffsetForGuid: g =>
- {
- // Lift the pick sphere to mid-body so chest/head clicks
- // hit instead of missing past the top of a feet-anchored
- // sphere. WorldEntity.Position is at feet level
- // (Z=ground); humanoids reach ~1.8 m, items sit close
- // to the ground (~0.2 m above their feet).
- if (_liveEntityInfoByGuid.TryGetValue(g, out var info)
- && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
- return 0.9f; // humanoid mid-body
- if (_lastSpawnByGuid.TryGetValue(g, out var s)
- && s.ObjectDescriptionFlags is { } odf)
- {
- const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
- if ((odf & LargeFlatMask) != 0) return 1.0f; // mid-door
- }
- if (IsTallSceneryGuid(g)) return 1.5f; // mid-pole height
- return 0.2f; // small ground item — sphere just above feet
- });
+ // Resolver: Setup's SelectionSphere is the ONLY input. If the
+ // entity's Setup didn't bake a SelectionSphere, return null —
+ // the picker skips it, which matches retail behaviour
+ // (Render::GfxObjUnderSelectionRay at 0x0054c740 skips
+ // candidates with no drawing_sphere data). Earlier defensive
+ // 1.5 m × scale synth was removed 2026-05-16 — it made
+ // dat-incomplete entities click as phantom hitboxes the size
+ // of an NPC, diverging from retail and masking real Setup-
+ // loading bugs.
+ sphereForEntity: e =>
+ TryGetEntitySelectionSphere(e.ServerGuid, out var c, out var r)
+ ? ((System.Numerics.Vector3, float)?)(c, r)
+ : null,
+ // Match the indicator's TriangleSize (8 px) so the click area
+ // extends out to the bracket corners — what the user perceives
+ // as "selectable extent."
+ inflatePixels: 8f,
+ cellOccluder: loadedCellPhysics.Count > 0
+ ? (origin, direction) =>
+ AcDream.Core.Selection.CellBspRayOccluder.NearestWallT(origin, direction, loadedCellPhysics)
+ : null);
if (picked is uint guid)
{
@@ -9102,7 +9244,7 @@ public sealed class GameWindow : IDisposable
string radStr = pickUseRadius.HasValue ? pickUseRadius.Value.ToString("F2", System.Globalization.CultureInfo.InvariantCulture) : "null";
string setupStr = pickSetupId.HasValue ? $"0x{pickSetupId.Value:X8}" : "null";
Console.WriteLine(System.FormattableString.Invariant(
- $"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} tallScenery={IsTallSceneryGuid(guid)} color=({col.R},{col.G},{col.B})"));
+ $"[B.7] pick-info guid=0x{guid:X8} itemType=0x{rawItemType:X8} pwd=0x{pwdBits:X8} use={useStr} useRadius={radStr} scale={pickScale:F2} setup={setupStr} color=({col.R},{col.G},{col.B})"));
_debugVm?.AddToast($"Selected: {label}");
if (useImmediately) SendUse(guid);
}
@@ -9120,48 +9262,54 @@ public sealed class GameWindow : IDisposable
return;
}
- // 2026-05-16 — R is conceptually "use." It smart-routes to
- // pickup as a downstream optimization (see the isPickupableItem
- // dispatch below), but the GATE is always IsUseableTarget —
- // what retail's UseObject would do.
+ // 2026-05-16 (Phase B.6 follow-up) — R is the universal "interact"
+ // key. Retail dispatches by TARGET TYPE first; the useability gate
+ // is enforced by each individual action handler (SendUse checks
+ // IsUseableTarget; SendPickUp checks IsPickupableTarget), not as
+ // a top-level block. Previously the IsUseableTarget gate at the
+ // entry point rejected USEABLE_NO=1 items (spell components,
+ // gems) which retail accepts as pickupable — they just aren't
+ // "useable" in the activate-from-world sense.
+ //
+ // Dispatch order:
+ // 1. Creature → SendUse (talk to NPC, attack monster)
+ // 2. Pickupable → SendPickUp (small items, corpses)
+ // 3. Useable → SendUse (doors, portals, lifestones,
+ // potions / scrolls activated from world)
+ // 4. Else → toast "X cannot be used" (signs, banners,
+ // decorative scenery)
+ //
// Retail string at acclient_2013_pseudo_c.txt:1033115
// (data_7e2a70): "The %s cannot be used".
- if (!IsUseableTarget(sel))
+
+ bool isCreature = _liveEntityInfoByGuid.TryGetValue(sel, out var info)
+ && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0;
+
+ if (isCreature)
{
- string label = DescribeLiveEntity(sel);
- _debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
- Console.WriteLine($"[B.4b] R-key ignored — not useable guid=0x{sel:X8}");
+ SendUse(sel);
return;
}
- // B.7 (2026-05-15): the user requested R behave as a universal
- // interact key — pickup for items, use for NPCs / doors /
- // lifestones / portals / corpses. Matches retail's "use"
- // behaviour where the action picked depends on the target's
- // type rather than forcing the player to remember a different
- // hotkey per target type.
- bool isPickupableItem = true;
- if (_liveEntityInfoByGuid.TryGetValue(sel, out var info)
- && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
+ if (IsPickupableTarget(sel))
{
- // NPCs / monsters / players are Use targets, never PickUp.
- isPickupableItem = false;
- }
- if (_lastSpawnByGuid.TryGetValue(sel, out var spawn)
- && spawn.ObjectDescriptionFlags is { } odf)
- {
- // BF_DOOR | BF_LIFESTONE | BF_PORTAL | BF_CORPSE → Use, not PickUp.
- // (acclient.h:6431-6463)
- const uint NonPickupMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
- if ((odf & NonPickupMask) != 0) isPickupableItem = false;
+ SendPickUp(sel);
+ return;
}
- if (isPickupableItem) SendPickUp(sel);
- else SendUse(sel);
+ if (IsUseableTarget(sel))
+ {
+ SendUse(sel);
+ return;
+ }
+
+ string label = DescribeLiveEntity(sel);
+ _debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ Console.WriteLine($"[B.4b] R-key ignored — neither pickupable nor useable guid=0x{sel:X8}");
}
- private void SendUse(uint guid, bool isRetryAfterArrival = false)
+ private void SendUse(uint guid)
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
@@ -9169,52 +9317,46 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast("Not in world");
return;
}
- // 2026-05-15: defense-in-depth useability gate. Double-click flows
- // directly through SendUse without passing UseCurrentSelection's
- // dispatcher gate, so re-check here. Silent ignore matches retail
- // (acclient.h:6478 ITEM_USEABLE — USEABLE_REMOTE bit required).
- // isRetryAfterArrival bypasses the gate because we only retry an
- // action we previously gated through.
- if (!isRetryAfterArrival && !IsUseableTarget(guid))
+
+ // Retail-faithful useability gate (acclient_2013_pseudo_c.txt:256455
+ // ItemUses::IsUseable). Signs / banners with useability=0 silently
+ // ignore Use.
+ if (!IsUseableTarget(guid))
{
- // Retail-style client-side toast for unusable targets
- // (signs, decorative scenery with USEABLE_NO / USEABLE_UNDEF).
- // Retail string at acclient_2013_pseudo_c.txt:1033115
- // (data_7e2a70): "The %s cannot be used" (no trailing period).
- string label = DescribeLiveEntity(guid);
- _debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotBeUsed(label));
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
Console.WriteLine($"[B.4b] SendUse ignored — not useable guid=0x{guid:X8}");
return;
}
- // B.6 (2026-05-15): install a speculative auto-walk on the local
- // player toward the target. For far targets ACE will overwrite
- // this with its own MovementType=6 wire payload (and a better
- // wire-supplied use-radius). For close-range targets ACE skips
- // MoveToChain entirely and just rotates server-side; our
- // overlay provides the matching local rotation. Either way the
- // alignment-gated arrival ensures the body finishes facing
- // the target before stopping.
+
+ // B.6 (2026-05-15): install speculative local auto-walk against
+ // the target so close-range Use rotates the body to face before
+ // the action fires. For FAR targets, ACE's CreateMoveToChain
+ // (Player_Move.cs:37-179) takes over via inbound MovementType=6
+ // and our overlay is overwritten by ACE's wire-supplied radius.
//
- // User feedback (2026-05-15): 'first is rotation, when you are
- // facing, then using.' For close-range we DEFER the wire packet
- // until our local overlay arrives (turn-then-fire). The
- // _pendingPostArrivalAction handler will re-fire SendUse with
- // isRetryAfterArrival=true after the body finishes turning.
- // For far range we still send immediately so ACE can start
- // its MoveToChain.
- if (!isRetryAfterArrival)
+ // 2026-05-16: simplified — close-range deferral now fires the
+ // wire packet ONCE on AutoWalkArrived (turn-first done), not a
+ // retry of an earlier failed send. No re-send path.
+ bool closeRange = IsCloseRangeTarget(guid);
+ InstallSpeculativeTurnToTarget(guid);
+
+ if (closeRange)
{
- InstallSpeculativeTurnToTarget(guid);
+ // Defer the wire packet — OnAutoWalkArrivedSendDeferredAction
+ // will fire it after rotation completes.
_pendingPostArrivalAction = (guid, false);
- if (IsCloseRangeTarget(guid))
- {
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
- Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
- return; // wait for AutoWalkArrived to fire the wire send
- }
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ Console.WriteLine($"[B.4b] use deferred (close-range, turn-first) guid=0x{guid:X8}");
+ return;
}
+ // Far range: send Use ONCE. ACE's CreateMoveToChain
+ // (Player_Use.cs:205) holds a callback (TryUseItem) and fires
+ // it server-side when WithinUseRadius passes during the
+ // MoveToChain poll (Player_Move.cs:150). No client-side retry
+ // needed — the Phase B.6 MoveToState-suppression fix
+ // (GameWindow.cs:6410) keeps ACE's chain alive during the
+ // walk.
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
_liveSession.SendGameAction(body);
@@ -9225,13 +9367,9 @@ public sealed class GameWindow : IDisposable
Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-out] op=use target=0x{guid:X8} name=\"{label}\" seq={seq}"));
}
- // Remember this action so OnAutoWalkArrivedReSendAction can
- // re-fire it close-range. Skip when this IS the re-send.
- if (!isRetryAfterArrival)
- _pendingPostArrivalAction = (guid, false);
}
- private void SendPickUp(uint itemGuid, bool isRetryAfterArrival = false)
+ private void SendPickUp(uint itemGuid)
{
if (_liveSession is null
|| _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
@@ -9245,7 +9383,7 @@ public sealed class GameWindow : IDisposable
// "cannot pick up creatures!" message instead of the generic
// "can't be picked up!".
// Retail string acclient_2013_pseudo_c.txt:401642 (data_7e22b4).
- if (!isRetryAfterArrival && IsLiveCreatureTarget(itemGuid))
+ if (IsLiveCreatureTarget(itemGuid))
{
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CannotPickUpCreatures);
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
@@ -9256,7 +9394,7 @@ public sealed class GameWindow : IDisposable
// Generic non-pickupable gate (signs, banners, decorative scenery).
// Retail string acclient_2013_pseudo_c.txt:401589 (sprintf
// "The %s can't be picked up!").
- if (!isRetryAfterArrival && !IsPickupableTarget(itemGuid))
+ if (!IsPickupableTarget(itemGuid))
{
string label = DescribeLiveEntity(itemGuid);
_debugVm?.AddToast(AcDream.Core.Ui.RetailMessages.CantBePickedUp(label));
@@ -9264,22 +9402,29 @@ public sealed class GameWindow : IDisposable
Console.WriteLine($"[B.5] SendPickUp ignored — not pickupable item=0x{itemGuid:X8}");
return;
}
+
// B.6 (2026-05-15): same speculative turn-to-target + deferral as
// SendUse — close-range pickup rotates locally to face the
// item first, then the wire packet fires when the local
// overlay reports arrival.
- if (!isRetryAfterArrival)
+ //
+ // 2026-05-16: simplified — FIRST send on arrival, not a retry.
+ bool closeRange = IsCloseRangeTarget(itemGuid);
+ InstallSpeculativeTurnToTarget(itemGuid);
+
+ if (closeRange)
{
- InstallSpeculativeTurnToTarget(itemGuid);
_pendingPostArrivalAction = (itemGuid, true);
- if (IsCloseRangeTarget(itemGuid))
- {
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
- Console.WriteLine($"[B.5] pickup deferred (close-range, turn-first) item=0x{itemGuid:X8}");
- return;
- }
+ if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
+ Console.WriteLine($"[B.5] pickup deferred (close-range, turn-first) item=0x{itemGuid:X8}");
+ return;
}
+ // Far range: send PickUp ONCE. Same auto-fire-via-MoveToChain
+ // callback pattern as SendUse — ACE's chain fires
+ // PutItemInContainer/Move server-side when in range. No
+ // client-side retry; Phase B.6 MoveToState suppression keeps
+ // ACE's chain alive.
var seq = _liveSession.NextGameActionSequence();
var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
seq, itemGuid, _playerServerGuid, placement: 0);
@@ -9291,39 +9436,41 @@ public sealed class GameWindow : IDisposable
Console.WriteLine(System.FormattableString.Invariant(
$"[autowalk-out] op=pickup target=0x{itemGuid:X8} name=\"{label}\" seq={seq}"));
}
- // Remember this action so OnAutoWalkArrivedReSendAction can
- // re-fire it close-range. Skip when this IS the re-send.
- if (!isRetryAfterArrival)
- _pendingPostArrivalAction = (itemGuid, true);
}
///
- /// B.6+B.7 (2026-05-15). Fires when
- /// signals natural arrival at an auto-walk target. Force-flushes
- /// the player's current authoritative position to ACE first, then
- /// re-sends the Use/PickUp. Without the position flush, ACE
- /// processes the re-sent Use before the next per-frame
- /// AutonomousPosition heartbeat — so ACE's Player.Location is
- /// still stale (back where the auto-walk started) and ACE replies
- /// with another MoveToObject instead of completing the action.
+ /// 2026-05-16. Fires the deferred close-range Use/PickUp action
+ /// once the local auto-walk overlay reports arrival (i.e. the body
+ /// has finished rotating to face the target). Unlike the old
+ /// OnAutoWalkArrivedReSendAction, this is a FIRST send — not a
+ /// retry of an earlier failed send. Far-range Use/PickUp paths
+ /// fire the wire packet immediately at / time
+ /// and never touch _pendingPostArrivalAction.
///
- private void OnAutoWalkArrivedReSendAction()
+ private void OnAutoWalkArrivedSendDeferredAction()
{
if (_pendingPostArrivalAction is not (uint guid, bool isPickup) pending)
return;
- // Clear FIRST to break any retry loop.
_pendingPostArrivalAction = null;
- // Send a fresh AutonomousPosition NOW so ACE's server-side
- // Player.Location updates to our arrived position before ACE
- // sees the re-sent action.
- SendAutonomousPositionNow();
+ if (_liveSession is null
+ || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld)
+ return;
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
- Console.WriteLine(System.FormattableString.Invariant(
- $"[autowalk-arrived-resend] guid=0x{guid:X8} isPickup={isPickup}"));
- if (isPickup) SendPickUp(guid, isRetryAfterArrival: true);
- else SendUse(guid, isRetryAfterArrival: true);
+ var seq = _liveSession.NextGameActionSequence();
+ if (isPickup)
+ {
+ var body = AcDream.Core.Net.Messages.InteractRequests.BuildPickUp(
+ seq, guid, _playerServerGuid, placement: 0);
+ _liveSession.SendGameAction(body);
+ Console.WriteLine($"[B.5] pickup-deferred item=0x{guid:X8} container=0x{_playerServerGuid:X8} seq={seq}");
+ }
+ else
+ {
+ var body = AcDream.Core.Net.Messages.InteractRequests.BuildUse(seq, guid);
+ _liveSession.SendGameAction(body);
+ Console.WriteLine($"[B.4b] use-deferred guid=0x{guid:X8} seq={seq}");
+ }
}
///
@@ -9404,63 +9551,27 @@ public sealed class GameWindow : IDisposable
if ((odf & LargeFlatMask) != 0) useRadius = 2.0f;
}
+ // Issue #77 fix (2026-05-18) — predict ACE's CanCharge bit
+ // from local distance so the speculative auto-walk uses the
+ // same walk/run as the wire-triggered overwrite that arrives
+ // moments later. ACE's Creature.SetWalkRunThreshold sets
+ // CanCharge when player→target distance >= WalkRunThreshold /
+ // 2 = 7.5 m (the 15 m wire default halved). Match exactly so
+ // the speculative install doesn't flip walk↔run when ACE's
+ // MoveToObject broadcast overwrites it.
+ const float AceCanChargeDistance = 7.5f;
+ var bodyPos = _playerController.Position;
+ float ddx = entity.Position.X - bodyPos.X;
+ float ddy = entity.Position.Y - bodyPos.Y;
+ float distToTarget = MathF.Sqrt(ddx * ddx + ddy * ddy);
+ bool speculativeCanCharge = distToTarget >= AceCanChargeDistance;
+
_playerController.BeginServerAutoWalk(
destinationWorld: entity.Position,
minDistance: 0f,
distanceToObject: useRadius,
moveTowards: true,
- // 15 m matches ACE's MoveToParameters.SetDefaults
- // WalkRunThreshold for non-combat Use/PickUp paths.
- // Using 9999 here forced walk-mode for the brief window
- // between this speculative install and ACE's MovementType=6
- // overwrite — far targets briefly walked before switching
- // to run, which the user observed as "we only walk, not
- // running from the correct threshold". 15.0 lines up with
- // what ACE will send anyway, so the initial decision and
- // the overwrite agree.
- walkRunThreshold: 15.0f);
- }
-
- ///
- /// B.6+B.7 (2026-05-15). Send an out-of-frame AutonomousPosition
- /// packet using the controller's current authoritative state.
- /// Used to flush position to ACE on auto-walk arrival before
- /// re-sending the Use/PickUp action; without it, ACE's
- /// Player.Location is the pre-walk position and the action
- /// resolves out-of-range.
- ///
- private void SendAutonomousPositionNow()
- {
- if (_liveSession is null
- || _liveSession.CurrentState != AcDream.Core.Net.WorldSession.State.InWorld
- || _playerController is null)
- return;
-
- var pos = _playerController.Position;
- int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f);
- int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f);
- float localX = pos.X - (lbX - _liveCenterX) * 192f;
- float localY = pos.Y - (lbY - _liveCenterY) * 192f;
- uint wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (_playerController.CellId & 0xFFFFu);
- var wirePos = new System.Numerics.Vector3(localX, localY, pos.Z);
- var wireRot = YawToAcQuaternion(_playerController.Yaw);
- byte contactByte = _playerController.IsAirborne ? (byte)0 : (byte)1;
-
- var seq = _liveSession.NextGameActionSequence();
- var body = AcDream.Core.Net.Messages.AutonomousPosition.Build(
- gameActionSequence: seq,
- cellId: wireCellId,
- position: wirePos,
- rotation: wireRot,
- instanceSequence: _liveSession.InstanceSequence,
- serverControlSequence: _liveSession.ServerControlSequence,
- teleportSequence: _liveSession.TeleportSequence,
- forcePositionSequence: _liveSession.ForcePositionSequence,
- lastContact: contactByte);
- _liveSession.SendGameAction(body);
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeAutoWalkEnabled)
- Console.WriteLine(System.FormattableString.Invariant(
- $"[autowalk-flush-ap] seq={seq} cell=0x{wireCellId:X8} pos=({wirePos.X:F2},{wirePos.Y:F2},{wirePos.Z:F2})"));
+ canCharge: speculativeCanCharge);
}
private uint? SelectClosestCombatTarget(bool showToast)
@@ -9515,74 +9626,6 @@ public sealed class GameWindow : IDisposable
return (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0;
}
- ///
- /// 2026-05-15. True when the entity is "tall scenery" — has a known
- /// non-zero ItemType that is NOT in the small-carry-item mask AND
- /// has no door/lifestone/portal/corpse PWD bits AND is not a
- /// creature. The Holtburg town sign is the canonical example: a
- /// 3 m post-mounted entity that needs the pick sphere lifted to
- /// mid-pole with a wider radius so the user can click any part of
- /// the visible mesh, not just the pole base.
- ///
- ///
- /// Mirrors 's
- /// classification — both fall into the "everything else: 3 m default"
- /// branch — so the visible indicator box and the click sphere
- /// match.
- ///
- ///
- private bool IsTallSceneryGuid(uint guid)
- {
- // Creatures are never "tall scenery" — picker uses humanoid sphere.
- if (_liveEntityInfoByGuid.TryGetValue(guid, out var info)
- && (info.ItemType & AcDream.Core.Items.ItemType.Creature) != 0)
- return false;
-
- if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn))
- return false;
-
- // Doors / lifestones / portals / corpses → LargeFlatMask branch.
- if (spawn.ObjectDescriptionFlags is { } odf)
- {
- const uint LargeFlatMask = 0x1000u | 0x4000u | 0x40000u | 0x2000u;
- if ((odf & LargeFlatMask) != 0) return false;
- }
-
- uint it = spawn.ItemType ?? 0u;
-
- // 2026-05-15 — useability-based discriminator. Mirrors
- // TargetIndicatorPanel.EntityHeightFor exactly so the click
- // sphere matches the visible box. A small-item ItemType is
- // a REAL pickup item only if it is also useable from the
- // world (USEABLE_REMOTE bit, acclient.h:6478). Otherwise
- // (Misc + USEABLE_UNDEF — the Holtburg sign case) it's tall
- // scenery and gets the lifted+widened sphere.
- const uint USEABLE_REMOTE_BIT = 0x20u;
- bool useableFromWorld = spawn.Useability is uint u
- && (u & USEABLE_REMOTE_BIT) != 0;
-
- const uint SmallItemMask =
- (uint)(AcDream.Core.Items.ItemType.MeleeWeapon
- | AcDream.Core.Items.ItemType.Armor
- | AcDream.Core.Items.ItemType.Clothing
- | AcDream.Core.Items.ItemType.Jewelry
- | AcDream.Core.Items.ItemType.Food
- | AcDream.Core.Items.ItemType.Money
- | AcDream.Core.Items.ItemType.Misc
- | AcDream.Core.Items.ItemType.MissileWeapon
- | AcDream.Core.Items.ItemType.Container
- | AcDream.Core.Items.ItemType.Gem
- | AcDream.Core.Items.ItemType.SpellComponents
- | AcDream.Core.Items.ItemType.Writable
- | AcDream.Core.Items.ItemType.Key
- | AcDream.Core.Items.ItemType.Caster);
- // Real pickup item: small-item-class AND useable. NOT tall scenery.
- if ((it & SmallItemMask) != 0 && useableFromWorld) return false;
-
- // Everything else (signs / banners / untyped scenery /
- // Misc-typed-but-non-useable): tall scenery.
- return true;
- }
///
/// 2026-05-16 — retail-faithful port of
@@ -9785,56 +9828,75 @@ public sealed class GameWindow : IDisposable
/// pickup flow for entities where ACE didn't publish useability.
///
///
+ ///
+ /// 2026-05-16 — pickup gate is ItemType-based, NOT useability-based.
+ ///
+ ///
+ /// The earlier (useability & USEABLE_REMOTE) != 0u check
+ /// was a misread of the audit. USEABLE_REMOTE (0x20) gates the USE
+ /// action ("can the player activate this item from the world");
+ /// PICKUP is a separate action governed by retail's
+ /// PutItemInContainer handler, which accepts any small-item-class
+ /// entity from the world regardless of useability bits. A spell
+ /// component with useability=USEABLE_NO=1 is still pickupable in
+ /// retail — USEABLE_NO blocks using the component (you can't
+ /// "activate" it standalone), not picking it up.
+ ///
+ ///
+ ///
+ /// Now matches retail: small-item ItemType class OR BF_CORPSE bit
+ /// → pickupable. Server validates the request server-side
+ /// (in-range, target-still-exists, container-has-room).
+ ///
+ ///
private bool IsPickupableTarget(uint guid)
{
- if (_lastSpawnByGuid.TryGetValue(guid, out var spawn))
+ if (!_lastSpawnByGuid.TryGetValue(guid, out var spawn))
+ return false;
+
+ // 2026-05-16 — primary discriminator is the BF_STUCK
+ // ObjectDescriptionFlag (acclient.h:6435, bit 0x4). Retail and
+ // ACE mark immovable world objects (signs, banners, doors,
+ // benches) as Stuck server-side. ACE's PutItemInContainer
+ // handler (Player_Inventory.cs:831-836) responds with
+ // WeenieError.Stuck (0x29) when the client attempts a pickup
+ // on an item with the Stuck flag — so the client should gate
+ // out signs etc. before sending the wire packet.
+ //
+ // Discriminates same-ItemType ambiguity that useability can't:
+ // Holtburg sign (Misc + USEABLE_NO + BF_STUCK) → block
+ // Spell component (Misc + USEABLE_NO + ~BF_STUCK) → allow
+ // Door (no SmallItemMask + BF_DOOR + BF_STUCK) → never matches SmallItemMask, separately
+ if (spawn.ObjectDescriptionFlags is { } odf)
{
- if (spawn.Useability is uint useability)
- {
- const uint USEABLE_REMOTE = 0x20u;
- return (useability & USEABLE_REMOTE) != 0u;
- }
-
- // Useability null: corpses are pickupable; signs aren't.
- if (spawn.ObjectDescriptionFlags is { } odf)
- {
- const uint BF_CORPSE = 0x2000u;
- if ((odf & BF_CORPSE) != 0u)
- {
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
- Console.WriteLine(System.FormattableString.Invariant(
- $"[useability-fallback] pickup-corpse guid=0x{guid:X8} (ACE sent no useability bit)"));
- return true;
- }
- }
-
- // Small-item ItemType fallback (covers F on dropped items
- // when ACE doesn't publish useability for the weenie).
- uint it = spawn.ItemType ?? 0u;
- const uint SmallItemMask =
- (uint)(AcDream.Core.Items.ItemType.MeleeWeapon
- | AcDream.Core.Items.ItemType.Armor
- | AcDream.Core.Items.ItemType.Clothing
- | AcDream.Core.Items.ItemType.Jewelry
- | AcDream.Core.Items.ItemType.Food
- | AcDream.Core.Items.ItemType.Money
- | AcDream.Core.Items.ItemType.Misc
- | AcDream.Core.Items.ItemType.MissileWeapon
- | AcDream.Core.Items.ItemType.Container
- | AcDream.Core.Items.ItemType.Gem
- | AcDream.Core.Items.ItemType.SpellComponents
- | AcDream.Core.Items.ItemType.Writable
- | AcDream.Core.Items.ItemType.Key
- | AcDream.Core.Items.ItemType.Caster);
- if ((it & SmallItemMask) != 0u)
- {
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeUseabilityFallbackEnabled)
- Console.WriteLine(System.FormattableString.Invariant(
- $"[useability-fallback] pickup-smallitem guid=0x{guid:X8} itemType=0x{it:X8} (ACE sent no useability bit)"));
- return true;
- }
+ const uint BF_STUCK = 0x0004u;
+ const uint BF_CORPSE = 0x2000u;
+ // Corpses are pickupable (loot) — BF_CORPSE wins over
+ // any BF_STUCK that might be coincidentally set.
+ if ((odf & BF_CORPSE) != 0u) return true;
+ // Anything else with BF_STUCK is immovable scenery.
+ if ((odf & BF_STUCK) != 0u) return false;
}
- return false;
+
+ // Small-item ItemType class: dropped weapons, armor, food,
+ // jewelry, money, misc, gems, spell components, etc.
+ uint it = spawn.ItemType ?? 0u;
+ const uint SmallItemMask =
+ (uint)(AcDream.Core.Items.ItemType.MeleeWeapon
+ | AcDream.Core.Items.ItemType.Armor
+ | AcDream.Core.Items.ItemType.Clothing
+ | AcDream.Core.Items.ItemType.Jewelry
+ | AcDream.Core.Items.ItemType.Food
+ | AcDream.Core.Items.ItemType.Money
+ | AcDream.Core.Items.ItemType.Misc
+ | AcDream.Core.Items.ItemType.MissileWeapon
+ | AcDream.Core.Items.ItemType.Container
+ | AcDream.Core.Items.ItemType.Gem
+ | AcDream.Core.Items.ItemType.SpellComponents
+ | AcDream.Core.Items.ItemType.Writable
+ | AcDream.Core.Items.ItemType.Key
+ | AcDream.Core.Items.ItemType.Caster);
+ return (it & SmallItemMask) != 0u;
}
private string DescribeLiveEntity(uint guid)
@@ -9877,6 +9939,7 @@ public sealed class GameWindow : IDisposable
_cameraController?.ExitChaseMode();
_playerController = null;
_chaseCamera = null;
+ _retailChaseCamera = null;
_playerCurrentAnimCommand = null;
_playerMouseDeltaX = 0f;
}
@@ -9932,7 +9995,14 @@ public sealed class GameWindow : IDisposable
&& _playerMode
&& _chaseCamera is not null)
{
- _cameraController.EnterChaseMode(_chaseCamera);
+ if (_retailChaseCamera is null)
+ {
+ _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
+ {
+ Aspect = _chaseCamera.Aspect,
+ };
+ }
+ _cameraController.EnterChaseMode(_chaseCamera, _retailChaseCamera);
return;
}
_cameraController.ToggleFly();
@@ -9955,15 +10025,10 @@ public sealed class GameWindow : IDisposable
_playerController = new AcDream.App.Input.PlayerMovementController(_physicsEngine);
- // B.6+B.7 (2026-05-15): re-send the Use/PickUp action on local
- // auto-walk arrival. ACE's server-side MoveToChain may have
- // already timed out by the time the local body arrives (we
- // walk locally but don't send tracking position updates to
- // ACE during the walk yet, so ACE's WithinUseRadius check may
- // never have passed). Resending close-range hits ACE's
- // CreateMoveToChain WithinUseRadius shortcut (Player_Move.cs:66)
- // and completes the action immediately.
- _playerController.AutoWalkArrived += OnAutoWalkArrivedReSendAction;
+ // B.6/B.7 (2026-05-16): fire the deferred close-range Use/PickUp
+ // action (first send, not a retry) when the local auto-walk overlay
+ // reports arrival (body finished rotating to face the target).
+ _playerController.AutoWalkArrived += OnAutoWalkArrivedSendDeferredAction;
// K-fix7 (2026-04-26): if PlayerDescription already arrived, the
// server's Run / Jump skill values are cached here — push them
@@ -10038,12 +10103,16 @@ public sealed class GameWindow : IDisposable
{
Aspect = _window!.Size.X / (float)_window.Size.Y,
};
+ _retailChaseCamera = new AcDream.App.Rendering.RetailChaseCamera
+ {
+ Aspect = _window!.Size.X / (float)_window.Size.Y,
+ };
// K.1b: _playerMouseDeltaX is no longer consumed by
// MovementInput, but we still reset it here so any stale
// accumulated value from a previous session doesn't leak
// into a future code path that re-enables mouse-yaw.
_playerMouseDeltaX = 0f;
- _cameraController?.EnterChaseMode(_chaseCamera);
+ _cameraController?.EnterChaseMode(_chaseCamera, _retailChaseCamera);
// K-fix1 (2026-04-26): latch the "we have entered chase at least
// once" flag so the live-mode pre-login render gate stops
// suppressing the scene. From here on, the orbit camera (if the
@@ -10187,10 +10256,13 @@ public sealed class GameWindow : IDisposable
if (_cameraController is null) return;
float dir = (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp) ? 1f : -1f;
- if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
+ if (_playerMode && _cameraController.IsChaseMode)
{
// Chase mode: zoom (closer on ScrollUp).
- _chaseCamera.AdjustDistance(-dir * 0.8f);
+ if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
+ _retailChaseCamera.AdjustDistance(-dir * 0.8f);
+ else if (_chaseCamera is not null)
+ _chaseCamera.AdjustDistance(-dir * 0.8f);
}
else if (_cameraController.IsFlyMode)
{
@@ -10282,7 +10354,8 @@ public sealed class GameWindow : IDisposable
// Phase I.7: unsubscribe combat → chat translator before the
// session it depends on goes away.
_combatChatTranslator?.Dispose();
- _liveSession?.Dispose();
+ _liveSessionController?.Dispose();
+ _liveSession = null;
_audioEngine?.Dispose(); // Phase E.2: stop all voices, close AL context
_wbDrawDispatcher?.Dispose();
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
diff --git a/src/AcDream.App/Rendering/RetailChaseCamera.cs b/src/AcDream.App/Rendering/RetailChaseCamera.cs
new file mode 100644
index 00000000..25fa2a9e
--- /dev/null
+++ b/src/AcDream.App/Rendering/RetailChaseCamera.cs
@@ -0,0 +1,377 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Rendering;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Retail-faithful chase camera. Ports the chase-cam behavior from the
+/// 2013 acclient (CameraManager + CameraSet, decomp at
+/// docs/research/named-retail/acclient_2013_pseudo_c.txt:95505):
+/// exponential damping toward a target pose, 5-frame velocity-averaged
+/// slope-aligned heading frame, mouse-input low-pass filter.
+///
+///
+/// Sits behind
+/// next to the legacy ; both update every
+/// frame so toggling the flag swaps cameras instantly. Visible behavior
+/// vs legacy: lag-then-catch-up on turn/stop, tilt-with-terrain on
+/// hills, jump-feedback without the legacy _trackedZ hack.
+///
+///
+///
+/// Spec: docs/superpowers/specs/2026-05-18-retail-chase-camera-design.md.
+///
+///
+public sealed class RetailChaseCamera : ICamera
+{
+ // ICamera surface.
+ public Vector3 Position { get; private set; }
+ public float Aspect { get; set; } = 16f / 9f;
+ public float FovY { get; set; } = MathF.PI / 3f;
+ public Matrix4x4 View { get; private set; } = Matrix4x4.Identity;
+ public Matrix4x4 Projection =>
+ Matrix4x4.CreatePerspectiveFieldOfView(FovY, Aspect, 1f, 5000f);
+
+ // ── Public tunables (per-instance) ──────────────────────────────
+
+ /// Length of the viewer_offset vector. Retail default ≈ 2.61.
+ public float Distance { get; set; } = 2.61f;
+
+ /// Angle of the camera above the heading-frame XY plane. Retail default ≈ 0.291 rad (16.7°).
+ public float Pitch { get; set; } = 0.291f;
+
+ ///
+ /// Yaw offset added on top of player yaw when slope-align is off
+ /// or velocity is too small to derive a heading. Used by hold-RMB
+ /// orbit to swing the camera around the player without rotating
+ /// the character.
+ ///
+ public float YawOffset { get; set; } = 0f;
+
+ /// Height of look-at anchor above the player's feet (m). Retail default 1.5.
+ public float PivotHeight { get; set; } = 1.5f;
+
+ /// Computed translucency for the player mesh (0 = opaque, 1 = invisible). Read by GameWindow.
+ public float PlayerTranslucency { get; private set; }
+
+ /// Clamp bounds carried over from legacy ChaseCamera.
+ public const float DistanceMin = 2f;
+ public const float DistanceMax = 40f;
+ public const float PitchMin = -0.7f;
+ public const float PitchMax = 1.4f;
+
+ // ── Damped state ────────────────────────────────────────────────
+
+ private readonly Vector3[] _velocityRing = new Vector3[5];
+ private int _velocityCount;
+ private Vector3 _dampedEye;
+ private Vector3 _dampedForward = new(1f, 0f, 0f);
+ private bool _initialised;
+
+ // Mouse-filter state — shared by FilterMouseDelta entrypoint.
+ private float _lastMouseDeltaX;
+ private float _lastMouseDeltaY;
+ private float _lastFilterTimeSec;
+
+ // ── Per-frame entry point ────────────────────────────────────────
+
+ ///
+ /// Advance the camera one frame. Caller passes the player's current
+ /// pose + velocity (in world space) + the frame's dt in
+ /// seconds. After this returns, ,
+ /// , and reflect
+ /// the new state.
+ ///
+ public void Update(
+ Vector3 playerPosition,
+ float playerYaw,
+ Vector3 playerVelocity,
+ bool isOnGround,
+ Vector3 contactPlaneNormal,
+ float dt)
+ {
+ // 1. Push velocity into 5-frame ring, get average.
+ PushVelocity(_velocityRing, ref _velocityCount, playerVelocity);
+ Vector3 avgVel = AverageVelocity(_velocityRing, _velocityCount);
+
+ // 2. Heading vector — player's facing projected onto the contact
+ // plane (grounded) or world XY (airborne). See ComputeHeading
+ // doc + retail decomp :95644-95795 for why this is facing-based
+ // rather than velocity-based.
+ Vector3 heading = ComputeHeading(
+ avgVel,
+ playerYaw + YawOffset,
+ isOnGround,
+ contactPlaneNormal,
+ CameraDiagnostics.AlignToSlope);
+
+ // 3. Orthonormal heading-frame basis.
+ var (forward, _, up) = BuildBasis(heading);
+
+ // 4. Target pose.
+ Vector3 pivotWorld = playerPosition + new Vector3(0f, 0f, PivotHeight);
+ float horizontal = Distance * MathF.Cos(Pitch);
+ float vertical = Distance * MathF.Sin(Pitch);
+ // viewer_offset = -horizontal along forward + vertical along up.
+ Vector3 targetEye = pivotWorld + forward * (-horizontal) + up * vertical;
+ Vector3 targetForward = Vector3.Normalize(pivotWorld - targetEye);
+
+ // 5. Exponential damping (independent translation + rotation rates).
+ if (!_initialised)
+ {
+ _dampedEye = targetEye;
+ _dampedForward = targetForward;
+ _initialised = true;
+ }
+ else
+ {
+ float tAlpha = ComputeDampingAlpha(CameraDiagnostics.TranslationStiffness, dt);
+ float rAlpha = ComputeDampingAlpha(CameraDiagnostics.RotationStiffness, dt);
+ _dampedEye = Vector3.Lerp(_dampedEye, targetEye, tAlpha);
+ _dampedForward = Vector3.Normalize(Vector3.Lerp(_dampedForward, targetForward, rAlpha));
+ }
+
+ // 6. Publish renderer surface.
+ Position = _dampedEye;
+ View = Matrix4x4.CreateLookAt(_dampedEye, _dampedEye + _dampedForward, new Vector3(0f, 0f, 1f));
+
+ // 7. Auto-fade translucency.
+ float d = Vector3.Distance(_dampedEye, pivotWorld);
+ PlayerTranslucency = ComputeTranslucency(d);
+ }
+
+ ///
+ /// Adjust the camera distance (zoom) by a delta, clamped to
+ /// ... Mirrors
+ /// legacy ChaseCamera.AdjustDistance.
+ ///
+ public void AdjustDistance(float delta) =>
+ Distance = Math.Clamp(Distance + delta, DistanceMin, DistanceMax);
+
+ ///
+ /// Adjust the camera pitch by a delta (radians), clamped to
+ /// ... Mirrors legacy
+ /// ChaseCamera.AdjustPitch.
+ ///
+ public void AdjustPitch(float delta) =>
+ Pitch = Math.Clamp(Pitch + delta, PitchMin, PitchMax);
+
+ ///
+ /// Public entry point for the mouse-input low-pass filter. Calls
+ /// on each axis with shared state.
+ ///
+ public (float outX, float outY) FilterMouseDelta(float rawX, float rawY, float weight, float nowSec)
+ {
+ // X first — advances the shared timestamp.
+ float x = FilterMouseAxis(rawX, weight, nowSec,
+ ref _lastMouseDeltaX, ref _lastFilterTimeSec, CameraDiagnostics.MouseLowPassWindowSec);
+ // Y uses a throwaway timestamp so the within-window check still uses the original delta
+ // (X already advanced _lastFilterTimeSec to nowSec; if Y reused it, the within-window
+ // check would be 0 < windowSec which is always true — which is what we want here, since
+ // both axes are sampled simultaneously and should both blend.).
+ float yTimeShadow = _lastFilterTimeSec - 1f; // force within-window path for the Y axis
+ float y = FilterMouseAxis(rawY, weight, nowSec,
+ ref _lastMouseDeltaY, ref yTimeShadow, CameraDiagnostics.MouseLowPassWindowSec);
+ return (x, y);
+ }
+
+ // Math primitives — pure, internal-static for unit-testability.
+
+ ///
+ /// Pick the heading vector that drives the camera basis. Mirrors
+ /// retail's CameraManager::UpdateCamera ALIGN_WITH_PLANE
+ /// path (decomp acclient_2013_pseudo_c.txt:95644-95795):
+ ///
+ /// - Base heading is the player's facing
+ /// direction in world space — (cos yaw, sin yaw, 0)
+ /// — not the velocity vector. Velocity only gates whether
+ /// slope-alignment fires.
+ /// - If is off
+ /// OR the player's horizontal velocity is below epsilon (i.e.
+ /// stationary OR jumping straight up), return that base
+ /// heading unchanged. This is the bit that keeps the camera
+ /// from swinging vertically during a jump.
+ /// - Otherwise project the base heading onto
+ /// the plane perpendicular to a surface normal:
+ /// 's Normal when
+ /// grounded (slope-aligned), world (0, 0, 1) when
+ /// airborne (which is a no-op since the base is already
+ /// horizontal).
+ /// - Normalize. If the projection collapsed
+ /// (heading parallel to normal), fall back to the unprojected
+ /// base.
+ ///
+ ///
+ /// 5-frame averaged player velocity in world space.
+ /// Player facing yaw + any orbit offset, radians.
+ /// Player's transient_state & 1 — does describe a valid contact plane?
+ /// Player's current contact plane normal in world space; ignored when is false.
+ /// User-tunable; when false skips the projection and returns the flat facing direction.
+ internal static Vector3 ComputeHeading(
+ Vector3 avgVelocity,
+ float yaw,
+ bool isOnGround,
+ Vector3 contactPlaneNormal,
+ bool alignToSlope)
+ {
+ // Base heading: player's facing direction in world XY plane.
+ Vector3 baseHeading = new(MathF.Cos(yaw), MathF.Sin(yaw), 0f);
+
+ if (!alignToSlope) return baseHeading;
+
+ // Slope-align gate: player must be moving in XY. Retail tests
+ // |vx| > 0.0002 AND |vy| > 0.0002 (decomp :95704, :95713). The
+ // horizontal-magnitude-squared form is a cleaner equivalent.
+ // Without this, the airborne path would still project against
+ // world up (no-op) which is fine — but the standing-jump case
+ // wants the historical `direction` fallback that retail uses.
+ float hMagSq = avgVelocity.X * avgVelocity.X + avgVelocity.Y * avgVelocity.Y;
+ if (hMagSq < 1e-4f) return baseHeading;
+
+ // Pick the projection plane normal:
+ // grounded → contact_plane.N (slope-aligned camera basis)
+ // airborne → world up (projection becomes a no-op because
+ // baseHeading is already in the XY plane — but
+ // keeping the code path uniform makes the airborne
+ // case impossible to swing vertically).
+ Vector3 normal;
+ if (isOnGround && contactPlaneNormal.LengthSquared() > 0.01f)
+ normal = Vector3.Normalize(contactPlaneNormal);
+ else
+ normal = new Vector3(0f, 0f, 1f);
+
+ // Project baseHeading onto plane perpendicular to normal:
+ // projected = forward - normal * dot(forward, normal)
+ // On flat ground this is a no-op (dot ≈ 0). On a slope the
+ // projected vector gains a Z component matching the slope angle,
+ // which tilts the camera basis with the terrain.
+ float dot = Vector3.Dot(baseHeading, normal);
+ Vector3 projected = baseHeading - normal * dot;
+
+ // Degenerate: facing nearly parallel to normal (rare — would
+ // require player rotated to face into the ground). Fall back to
+ // the unprojected base heading.
+ if (projected.LengthSquared() < 1e-4f) return baseHeading;
+ return Vector3.Normalize(projected);
+ }
+
+ ///
+ /// Build an orthonormal basis with forward = heading. World
+ /// up is (0, 0, 1); if heading is near-parallel to it
+ /// the right axis falls back to world +X so the cross
+ /// product doesn't collapse.
+ ///
+ internal static (Vector3 forward, Vector3 right, Vector3 up) BuildBasis(Vector3 heading)
+ {
+ Vector3 forward = Vector3.Normalize(heading);
+ Vector3 worldUp = new(0f, 0f, 1f);
+
+ Vector3 right;
+ if (MathF.Abs(forward.Z) > 0.99f)
+ {
+ // Near-vertical forward — use world +X as the secondary axis.
+ right = Vector3.Normalize(Vector3.Cross(forward, new Vector3(1f, 0f, 0f)));
+ }
+ else
+ {
+ right = Vector3.Normalize(Vector3.Cross(forward, worldUp));
+ }
+ Vector3 up = Vector3.Cross(right, forward); // already unit (forward + right orthonormal)
+ return (forward, right, up);
+ }
+
+ ///
+ /// FIFO-push a velocity sample into the 5-entry ring. Returns the
+ /// updated ring (mutates the input array; the return is for fluent
+ /// usage in tests). grows from 0 toward 5
+ /// and stays at 5 once the ring is full.
+ ///
+ internal static Vector3[] PushVelocity(Vector3[] ring, ref int count, Vector3 sample)
+ {
+ if (ring.Length != 5)
+ throw new ArgumentException("velocity ring must have 5 entries", nameof(ring));
+
+ // Shift left by 1 (oldest is overwritten), append new sample at the tail.
+ for (int i = 0; i < 4; i++) ring[i] = ring[i + 1];
+ ring[4] = sample;
+ if (count < 5) count++;
+ return ring;
+ }
+
+ ///
+ /// Average the most-recent entries of the
+ /// ring (entries [ring.Length-count .. ring.Length)). Returns
+ /// when count is zero.
+ ///
+ internal static Vector3 AverageVelocity(Vector3[] ring, int count)
+ {
+ if (count == 0) return Vector3.Zero;
+ Vector3 sum = Vector3.Zero;
+ int start = ring.Length - count;
+ for (int i = start; i < ring.Length; i++) sum += ring[i];
+ return sum / count;
+ }
+
+ ///
+ /// Exponential-damping rate per frame.
+ /// alpha = clamp(stiffness * dt * 10, 0, 1). At
+ /// stiffness=0.45, dt=1/60 → ~0.075
+ /// (~150 ms half-life). Matches retail's
+ /// x_1 = stiffness * dt * 10 formulation.
+ ///
+ internal static float ComputeDampingAlpha(float stiffness, float dt)
+ {
+ float a = stiffness * dt * 10f;
+ if (a <= 0f) return 0f;
+ if (a >= 1f) return 1f;
+ return a;
+ }
+
+ ///
+ /// Low-pass filter for a single mouse axis. Mirrors retail's
+ /// CameraSet::FilterMouseInput: if last sample was within
+ /// , blend output with the average of
+ /// (previous, raw); otherwise pass-through. Final output =
+ /// raw * (1 - weight) + blended * weight. Updates
+ /// and
+ /// to the new state.
+ ///
+ internal static float FilterMouseAxis(
+ float raw,
+ float weight,
+ float nowSec,
+ ref float lastDelta,
+ ref float lastTimeSec,
+ float windowSec)
+ {
+ float avg;
+ if (nowSec - lastTimeSec < windowSec)
+ avg = (lastDelta + raw) * 0.5f;
+ else
+ avg = raw;
+
+ float output = raw * (1f - weight) + avg * weight;
+ lastDelta = output;
+ lastTimeSec = nowSec;
+ return output;
+ }
+
+ ///
+ /// Player-mesh translucency as a function of camera-to-pivot
+ /// distance. 0 = fully opaque, 1 = fully transparent.
+ /// Opaque at and beyond 0.45 m; fully transparent at and within
+ /// 0.20 m; linear ramp between. Matches retail's CameraSet::
+ /// UpdateCamera distance check (decomp :97703–97725).
+ ///
+ internal static float ComputeTranslucency(float distance)
+ {
+ const float Far = 0.45f;
+ const float Near = 0.20f;
+
+ if (distance >= Far) return 0f;
+ if (distance <= Near) return 1f;
+ // Linear: t = 1 - (Near - distance) / (Near - Far)
+ return 1f - (Near - distance) / (Near - Far);
+ }
+}
diff --git a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
index 36ebdc92..6dbf0c88 100644
--- a/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
+++ b/src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Numerics;
using System.Runtime.InteropServices;
using AcDream.Core.Meshing;
+using AcDream.Core.Rendering;
using AcDream.Core.Terrain;
using AcDream.Core.World;
using Chorizite.OpenGLSDLBackend.Lib;
@@ -140,6 +141,31 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
private bool _disposed;
+ ///
+ /// Per-cell-entity last-log frame number for rate-limiting the
+ /// [indoor-walk] / [indoor-lookup] / [indoor-xform] / [indoor-cull]
+ /// probes. Defaults to 30 frames at 30Hz = 1 sec.
+ ///
+ private readonly Dictionary _lastIndoorProbeFrame = new();
+ private int _indoorProbeFrameCounter;
+ private const int IndoorProbeRateLimitFrames = 30;
+
+ ///
+ /// Returns true at most once per
+ /// frames per cellId. Caller must already have checked that an indoor
+ /// probe flag is enabled.
+ ///
+ private bool ShouldEmitIndoorProbe(ulong cellId)
+ {
+ if (!_lastIndoorProbeFrame.TryGetValue(cellId, out int last)
+ || _indoorProbeFrameCounter - last >= IndoorProbeRateLimitFrames)
+ {
+ _lastIndoorProbeFrame[cellId] = _indoorProbeFrameCounter;
+ return true;
+ }
+ return false;
+ }
+
// Diagnostic counters logged once per ~5s under ACDREAM_WB_DIAG=1.
private int _entitiesSeen;
private int _entitiesDrawn;
@@ -271,6 +297,16 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
/// list. reuses a per-dispatcher scratch field across frames to
/// avoid the 480+ KB / frame GC pressure that the test-friendly overload incurs.
/// Returns walk count via 's EntitiesWalked field.
+ ///
+ ///
+ /// When is non-null the method emits
+ /// [indoor-cull] lines for cell entities rejected by the
+ /// visibleCellIds or frustum filters, and [indoor-walk] lines for
+ /// cell entities that pass all filters. Rate-limited by
+ /// . Pass (the default)
+ /// to disable all probe emission — used by the test-friendly
+ /// overload.
+ ///
///
internal static void WalkEntitiesInto(
IEnumerable landblockEntries,
@@ -279,7 +315,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
HashSet? visibleCellIds,
HashSet? animatedEntityIds,
List<(WorldEntity Entity, int MeshRefIndex, uint LandblockId)> scratch,
- ref WalkResult result)
+ ref WalkResult result,
+ IndoorProbeState? indoorProbeState = null)
{
scratch.Clear();
result.EntitiesWalked = 0;
@@ -314,19 +351,65 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
{
if (entity.MeshRefs.Count == 0) continue;
- if (entity.ParentCellId.HasValue && visibleCellIds is not null
- && !visibleCellIds.Contains(entity.ParentCellId.Value))
+ // Detect cell entity for indoor probes — first MeshRef.GfxObjId
+ // is an EnvCell id (low 16 bits ≥ 0x0100). Cheap to compute;
+ // result reused for all probe checks below.
+ ulong cellProbeId = (ulong)entity.MeshRefs[0].GfxObjId;
+ bool isCellEntity = indoorProbeState is not null
+ && RenderingDiagnostics.IsEnvCellId(cellProbeId);
+
+ bool cellInVis = !(entity.ParentCellId.HasValue
+ && visibleCellIds is not null
+ && !visibleCellIds.Contains(entity.ParentCellId.Value));
+ if (!cellInVis)
+ {
+ if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
+ && indoorProbeState!.ShouldEmit(cellProbeId))
+ {
+ Console.WriteLine(
+ $"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
+ $"reason=visibleCellIds-miss " +
+ $"parentCell=0x{entity.ParentCellId!.Value:X8}");
+ }
continue;
+ }
// Per-entity AABB frustum cull (perf #3). Animated entities bypass —
// they're tracked at landblock level + need per-frame work regardless.
// A.5 T18 Change #2: read cached AABB, refresh lazily on AabbDirty.
bool isAnimated = animatedEntityIds?.Contains(entity.Id) == true;
+ bool aabbVisible = true;
if (frustum is not null && !isAnimated && entry.LandblockId != neverCullLandblockId)
{
if (entity.AabbDirty) entity.RefreshAabb();
- if (!FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax))
- continue;
+ aabbVisible = FrustumCuller.IsAabbVisible(frustum.Value, entity.AabbMin, entity.AabbMax);
+ }
+
+ if (!aabbVisible)
+ {
+ if (isCellEntity && RenderingDiagnostics.ProbeIndoorCullEnabled
+ && indoorProbeState!.ShouldEmit(cellProbeId))
+ {
+ Console.WriteLine(
+ $"[indoor-cull] cellEnt=0x{entity.Id:X8} " +
+ $"reason=frustum " +
+ $"aabbMin=({entity.AabbMin.X:F1},{entity.AabbMin.Y:F1},{entity.AabbMin.Z:F1}) " +
+ $"aabbMax=({entity.AabbMax.X:F1},{entity.AabbMax.Y:F1},{entity.AabbMax.Z:F1})");
+ }
+ continue;
+ }
+
+ // Passed all filters — emit walk probe.
+ if (isCellEntity && RenderingDiagnostics.ProbeIndoorWalkEnabled
+ && indoorProbeState!.ShouldEmit(cellProbeId))
+ {
+ Console.WriteLine(
+ $"[indoor-walk] cellEnt=0x{entity.Id:X8} " +
+ $"pos=({entity.Position.X:F1},{entity.Position.Y:F1},{entity.Position.Z:F1}) " +
+ $"parentCell=0x{(entity.ParentCellId ?? 0u):X8} " +
+ $"meshRef0=0x{cellProbeId:X8} " +
+ $"meshRefCount={entity.MeshRefs.Count} " +
+ $"landblockVisible=true aabbVisible=true cellInVis=true");
}
result.EntitiesWalked++;
@@ -347,6 +430,7 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
HashSet? animatedEntityIds = null)
{
_shader.Use();
+ _indoorProbeFrameCounter++;
var vp = camera.View * camera.Projection;
_shader.SetMatrix4("uViewProjection", vp);
@@ -391,6 +475,24 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// A.5 T26 follow-up (Bug B): use the no-alloc WalkEntitiesInto overload
// that populates _walkScratch (a per-dispatcher field reused across frames)
// instead of allocating a fresh List<(WorldEntity, int)> per frame.
+ //
+ // Pass an IndoorProbeState when any indoor probe is active so the static
+ // WalkEntitiesInto can emit rate-limited [indoor-cull] / [indoor-walk]
+ // lines without needing access to instance fields. Null = probes off.
+ IndoorProbeState? probeState = null;
+ if (RenderingDiagnostics.ProbeIndoorCullEnabled || RenderingDiagnostics.ProbeIndoorWalkEnabled)
+ {
+ // _currentFrame is snapped at construction time. Construct
+ // once per Draw() call only — a second construction within
+ // the same frame would stamp the dictionary with the
+ // (already-advanced) counter value, suppressing the second
+ // pass's emissions for IndoorProbeRateLimitFrames frames.
+ // Today Draw() is called exactly once per frame; if a
+ // future refactor adds a shadow / reflection / second pass,
+ // this assumption needs revisiting.
+ probeState = new IndoorProbeState(_lastIndoorProbeFrame, _indoorProbeFrameCounter);
+ }
+
var walkResult = default(WalkResult);
WalkEntitiesInto(
ToEntries(landblockEntries),
@@ -399,7 +501,8 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
visibleCellIds,
animatedEntityIds,
_walkScratch,
- ref walkResult);
+ ref walkResult,
+ probeState);
// Tier 1 cache (#53) flush-tracking locals. _walkScratch holds one tuple
// per (entity, MeshRefIndex) and is in entity-order, so all MeshRefs of
@@ -582,6 +685,42 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
ulong gfxObjId = meshRef.GfxObjId;
var renderData = _meshAdapter.TryGetRenderData(gfxObjId);
+
+ // [indoor-lookup] probe — emit once per cell entity per sec.
+ // Fires BEFORE the null-renderData early-continue so a miss still
+ // emits hit=false, distinguishing H2 (empty batches) from H6
+ // (dispatcher fails to traverse Setup).
+ ulong lookupCellId = (ulong)gfxObjId;
+ if (RenderingDiagnostics.IsEnvCellId(lookupCellId)
+ && RenderingDiagnostics.ProbeIndoorLookupEnabled
+ // Rate-limit in a separate namespace from [indoor-walk]/[indoor-cull]
+ // (which key on the same gfxObjId). Without this, IndoorAll=1 would
+ // silence the lookup probe whenever the walk probe fired first.
+ && ShouldEmitIndoorProbe(lookupCellId | 0x8000_0000_0000_0000UL))
+ {
+ bool hit = renderData is not null;
+ bool isSetup = hit && renderData!.IsSetup;
+ int partCount = isSetup ? renderData!.SetupParts.Count : 0;
+
+ int partsHit = 0, partsMiss = 0;
+ if (isSetup)
+ {
+ foreach (var (partId, _) in renderData!.SetupParts)
+ {
+ if (_meshAdapter.TryGetRenderData(partId) is not null) partsHit++;
+ else partsMiss++;
+ }
+ }
+
+ bool hasEnvCellGeom = isSetup
+ && renderData!.SetupParts.Exists(t => (t.GfxObjId & 0x1_0000_0000UL) != 0);
+
+ Console.WriteLine(
+ $"[indoor-lookup] cellId=0x{lookupCellId:X8} " +
+ $"hit={hit} isSetup={isSetup} partCount={partCount} " +
+ $"hasEnvCellGeom={hasEnvCellGeom} partsHit={partsHit} partsMiss={partsMiss}");
+ }
+
if (renderData is null)
{
// Tier 1 cache (#53): mesh data is still async-decoding via
@@ -614,6 +753,23 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
var model = ComposePartWorldMatrix(
entityWorld, meshRef.PartTransform, partTransform);
+ // [indoor-xform] probe — only for the cell's synthetic
+ // geometry part (bit 32 set, per WB's PrepareEnvCellMeshData
+ // cellGeomId convention). One line per part per sec.
+ // Disambiguates hypothesis H5 (transform double-apply —
+ // composedT lands at 2 × cellOrigin).
+ if ((partGfxObjId & 0x1_0000_0000UL) != 0
+ && RenderingDiagnostics.ProbeIndoorXformEnabled
+ && ShouldEmitIndoorProbe(partGfxObjId))
+ {
+ Console.WriteLine(
+ $"[indoor-xform] cellGeomId=0x{partGfxObjId:X16} " +
+ $"entityWorldT=({entityWorld.Translation.X:F2},{entityWorld.Translation.Y:F2},{entityWorld.Translation.Z:F2}) " +
+ $"meshRefT=({meshRef.PartTransform.Translation.X:F2},{meshRef.PartTransform.Translation.Y:F2},{meshRef.PartTransform.Translation.Z:F2}) " +
+ $"partT=({partTransform.Translation.X:F2},{partTransform.Translation.Y:F2},{partTransform.Translation.Z:F2}) " +
+ $"composedT=({model.Translation.X:F2},{model.Translation.Y:F2},{model.Translation.Z:F2})");
+ }
+
var restPose = partTransform * meshRef.PartTransform;
ClassifyBatches(partData, partGfxObjId, model, entity, meshRef, palHash, metaTable, restPose, collector);
drewAny = true;
@@ -1289,6 +1445,41 @@ public sealed unsafe class WbDrawDispatcher : IDisposable
// ────────────────────────────────────────────────────────────────────────
+ ///
+ /// Thin wrapper around an instance's rate-limit dictionary + frame
+ /// counter, passed into the static
+ /// overload so it can emit rate-limited probe lines without access
+ /// to instance fields. Null = probes disabled (test-friendly overload).
+ ///
+ internal sealed class IndoorProbeState
+ {
+ private readonly Dictionary _lastFrame;
+ private readonly int _currentFrame;
+ private const int RateLimit = IndoorProbeRateLimitFrames;
+
+ internal IndoorProbeState(Dictionary lastFrame, int currentFrame)
+ {
+ _lastFrame = lastFrame;
+ _currentFrame = currentFrame;
+ }
+
+ ///
+ /// Returns true at most once per
+ /// frames per . Side-effect: stamps the frame
+ /// number into the dictionary on success.
+ ///
+ internal bool ShouldEmit(ulong cellId)
+ {
+ if (!_lastFrame.TryGetValue(cellId, out int last)
+ || _currentFrame - last >= RateLimit)
+ {
+ _lastFrame[cellId] = _currentFrame;
+ return true;
+ }
+ return false;
+ }
+ }
+
private sealed class InstanceGroup
{
public uint Ibo;
diff --git a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
index b57e0431..27297478 100644
--- a/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
+++ b/src/AcDream.App/Rendering/Wb/WbMeshAdapter.cs
@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
using AcDream.Core.Meshing;
+using AcDream.Core.Rendering;
using Chorizite.OpenGLSDLBackend;
using Chorizite.OpenGLSDLBackend.Lib;
using DatReaderWriter;
@@ -34,6 +37,15 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
private readonly AcSurfaceMetadataTable _metadataTable = new();
private readonly HashSet _metadataPopulated = new();
+ ///
+ /// EnvCell ids we've requested via PrepareMeshDataAsync but not yet
+ /// seen completion for in Tick(). Used by the [indoor-upload] probe
+ /// to log requested + completed pairs. Cleared per completion;
+ /// missing completions after a few seconds indicate WB silently
+ /// returned null (hypothesis H1 in the design spec).
+ ///
+ private readonly HashSet _pendingEnvCellRequests = new();
+
///
/// True when this instance was created via ;
/// all public methods no-op when uninitialized.
@@ -65,10 +77,52 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_dats = dats;
_graphicsDevice = new OpenGLGraphicsDevice(gl, logger, new DebugRenderSettings());
_wbDats = new DefaultDatReaderWriter(datDir);
+ // Phase 2 diagnostic — replace NullLogger with a Console-backed
+ // logger so WB's internal catch block at ObjectMeshManager.cs:589
+ // (and similar) surfaces its swallowed exceptions instead of
+ // dropping them. ConsoleErrorLogger filters to LogLevel.Error+
+ // so successful operations stay quiet.
_meshManager = new ObjectMeshManager(
_graphicsDevice,
_wbDats,
- NullLogger.Instance);
+ new ConsoleErrorLogger());
+ }
+
+ ///
+ /// Minimal Console-backed logger that fires only on
+ /// and above. Format:
+ /// [wb-error] <message>
+ /// [wb-error] <ExceptionType>: <ExceptionMessage>
+ /// [wb-error] at <frame> (up to 5 frames)
+ /// Used to surface WB's silently-caught exceptions in
+ /// ObjectMeshManager.PrepareMeshData.
+ ///
+ private sealed class ConsoleErrorLogger : ILogger
+ {
+ public IDisposable BeginScope(TState state) where TState : notnull => NullScope.Instance;
+ public bool IsEnabled(LogLevel logLevel) => logLevel >= LogLevel.Error;
+ public void Log(
+ LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter)
+ {
+ if (!IsEnabled(logLevel)) return;
+ var message = formatter(state, exception);
+ Console.WriteLine($"[wb-error] {message}");
+ if (exception is not null)
+ {
+ Console.WriteLine($"[wb-error] {exception.GetType().Name}: {exception.Message}");
+ var stack = (exception.StackTrace ?? "")
+ .Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
+ .Take(5);
+ foreach (var s in stack) Console.WriteLine($"[wb-error] {s.Trim()}");
+ }
+ }
+
+ private sealed class NullScope : IDisposable
+ {
+ public static readonly NullScope Instance = new();
+ public void Dispose() { }
+ }
}
private WbMeshAdapter()
@@ -133,7 +187,80 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
// isSetup: false — acdream's MeshRefs already carry expanded
// per-part GfxObj ids (0x01XXXXXX). WB's Setup-expansion path is
// unused.
- _ = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
+ var prepTask = _meshManager.PrepareMeshDataAsync(id, isSetup: false);
+
+ // [indoor-upload] requested probe — only for EnvCell ids.
+ if (RenderingDiagnostics.IsEnvCellId(id) && RenderingDiagnostics.ProbeIndoorUploadEnabled)
+ {
+ bool hadRenderDataAtRequest = _meshManager.HasRenderData(id);
+ _pendingEnvCellRequests.Add(id);
+ Console.WriteLine($"[indoor-upload] requested cellId=0x{id:X8} hadRenderData={hadRenderDataAtRequest}");
+
+ // Phase 2 — surface what WB's catch block silently swallows.
+ // ObjectMeshManager.PrepareMeshData has a try/catch at line 589
+ // that calls _logger.LogError(ex, ...) — but we construct
+ // ObjectMeshManager with NullLogger.Instance so the log is
+ // dropped. This continuation captures the same data scoped to
+ // EnvCell ids only. Runs on ThreadPool; non-blocking. Zero cost
+ // when the probe is off.
+ ulong cellId = id;
+ _ = prepTask.ContinueWith(t =>
+ {
+ if (t.IsFaulted && t.Exception is not null)
+ {
+ var ex = t.Exception.InnerException ?? t.Exception;
+ var stack = (ex.StackTrace ?? "").Split('\n')
+ .Take(3).Select(s => s.Trim()).Where(s => s.Length > 0);
+ Console.WriteLine(
+ $"[indoor-upload] FAILED cellId=0x{cellId:X8} " +
+ $"exception={ex.GetType().Name}: {ex.Message} " +
+ $"stack=[{string.Join(" | ", stack)}]");
+ }
+ else if (t.IsCompletedSuccessfully && t.Result is null)
+ {
+ // Phase 2 cause-narrowing: WB's PrepareMeshData can return
+ // null for several reasons (ResolveId empty / TryGet
+ // failed / type Unknown). Cross-check against acdream's own
+ // DatCollection — if WE find the cell but WB doesn't, the
+ // divergence is between dat readers, not a missing record.
+ bool ourCellFound = false;
+ try
+ {
+ ourCellFound = _dats?.Cell.TryGet(
+ (uint)cellId, out _) ?? false;
+ }
+ catch { /* swallow — this is best-effort diagnostic */ }
+
+ int wbResolveCount = -1;
+ string wbSelectedType = "none";
+ bool wbDbTryGetEnvCell = false;
+ bool wbDbIsPortal = false;
+ try
+ {
+ var wbResolutions = _wbDats?.ResolveId((uint)cellId).ToList();
+ wbResolveCount = wbResolutions?.Count ?? -1;
+ if (wbResolutions is not null && wbResolutions.Count > 0)
+ {
+ var selected = wbResolutions
+ .OrderByDescending(r => r.Database == _wbDats!.Portal)
+ .First();
+ wbSelectedType = selected.Type.ToString();
+ wbDbIsPortal = selected.Database == _wbDats!.Portal;
+ try { wbDbTryGetEnvCell = selected.Database.TryGet((uint)cellId, out _); } catch {}
+ }
+ }
+ catch { /* swallow — best-effort */ }
+
+ Console.WriteLine(
+ $"[indoor-upload] NULL_RESULT cellId=0x{cellId:X8} " +
+ $"ourCellDb.TryGet={ourCellFound} " +
+ $"wbResolveId.Count={wbResolveCount} " +
+ $"wbSelectedType={wbSelectedType} " +
+ $"wbDbIsPortal={wbDbIsPortal} " +
+ $"wbDbTryGet={wbDbTryGetEnvCell}");
+ }
+ }, TaskScheduler.Default);
+ }
}
}
@@ -172,7 +299,26 @@ public sealed class WbMeshAdapter : IDisposable, IWbMeshAdapter
_graphicsDevice!.ProcessGLQueue();
while (_meshManager!.StagedMeshData.TryDequeue(out var meshData))
{
- _meshManager.UploadMeshData(meshData);
+ // [indoor-upload] completed probe — check BEFORE upload so we
+ // see what WB actually produced (vertex counts, parts) before
+ // any post-upload mutation.
+ bool isPendingEnvCell = RenderingDiagnostics.ProbeIndoorUploadEnabled
+ && _pendingEnvCellRequests.Remove(meshData.ObjectId);
+
+ var renderData = _meshManager.UploadMeshData(meshData);
+
+ if (isPendingEnvCell)
+ {
+ int parts = meshData.SetupParts?.Count ?? 0;
+ bool hasGeom = meshData.EnvCellGeometry is not null;
+ int cellGeomVerts = meshData.EnvCellGeometry?.Vertices?.Length ?? 0;
+ bool uploadOk = renderData is not null;
+ Console.WriteLine(
+ $"[indoor-upload] completed cellId=0x{meshData.ObjectId:X8} " +
+ $"isSetup={meshData.IsSetup} parts={parts} " +
+ $"hasEnvCellGeom={hasGeom} cellGeomVerts={cellGeomVerts} " +
+ $"uploadOk={uploadOk}");
+ }
}
}
diff --git a/src/AcDream.App/RuntimeOptions.cs b/src/AcDream.App/RuntimeOptions.cs
new file mode 100644
index 00000000..74506403
--- /dev/null
+++ b/src/AcDream.App/RuntimeOptions.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Globalization;
+
+namespace AcDream.App;
+
+///
+/// Typed bundle of startup-time configuration read from the process
+/// environment. Built once in Program.cs and passed to
+/// GameWindow so the rest of the app reads its config through
+/// strongly-typed fields instead of scattered
+/// Environment.GetEnvironmentVariable calls.
+///
+///
+///
+/// Scope: startup-time only — values that don't change
+/// once the window is up. Runtime diagnostic toggles
+/// (e.g. ACDREAM_DUMP_MOTION, ACDREAM_PROBE_*) belong in
+/// diagnostic owner classes (see AcDream.Core.Physics.PhysicsDiagnostics
+/// for the template), not here.
+///
+///
+/// See docs/architecture/code-structure.md §2 Rule 4 for the
+/// rule that drove this extraction, and §4 Step 1 for the broader
+/// extraction sequence this is the first cut of.
+///
+///
+public sealed record RuntimeOptions(
+ string DatDir,
+ bool LiveMode,
+ string LiveHost,
+ int LivePort,
+ string? LiveUser,
+ string? LivePass,
+ bool DevTools,
+ bool DumpMoveTruth,
+ bool NoAudio,
+ bool EnableSkyPesDebug,
+ int HidePartIndex,
+ bool RetailCloseDegrades,
+ bool DumpSceneryZ,
+ int? LegacyStreamRadius)
+{
+ ///
+ /// Build options from the process environment. Used by
+ /// Program.cs at startup.
+ ///
+ public static RuntimeOptions FromEnvironment(string datDir)
+ => Parse(datDir, Environment.GetEnvironmentVariable);
+
+ ///
+ /// Build options from a custom environment getter. Used by tests to
+ /// inject controlled env values without touching the process
+ /// environment.
+ ///
+ /// Resolved dat-file directory.
+ /// Function returning the value for an env-var
+ /// name, or null when unset.
+ public static RuntimeOptions Parse(string datDir, Func env)
+ {
+ if (datDir is null) throw new ArgumentNullException(nameof(datDir));
+ if (env is null) throw new ArgumentNullException(nameof(env));
+
+ return new RuntimeOptions(
+ DatDir: datDir,
+ LiveMode: IsExactlyOne(env("ACDREAM_LIVE")),
+ LiveHost: env("ACDREAM_TEST_HOST") ?? "127.0.0.1",
+ LivePort: TryParseInt(env("ACDREAM_TEST_PORT")) ?? 9000,
+ LiveUser: NullIfEmpty(env("ACDREAM_TEST_USER")),
+ LivePass: NullIfEmpty(env("ACDREAM_TEST_PASS")),
+ DevTools: IsExactlyOne(env("ACDREAM_DEVTOOLS")),
+ DumpMoveTruth: IsExactlyOne(env("ACDREAM_DUMP_MOVE_TRUTH")),
+ NoAudio: IsExactlyOne(env("ACDREAM_NO_AUDIO")),
+ EnableSkyPesDebug: IsExactlyOne(env("ACDREAM_ENABLE_SKY_PES")),
+ HidePartIndex: TryParseInt(env("ACDREAM_HIDE_PART")) ?? -1,
+ // Default-on: any value other than the literal string "0" enables
+ // retail close-detail degrades. Set ACDREAM_RETAIL_CLOSE_DEGRADES=0
+ // only for before/after diagnostic comparisons.
+ RetailCloseDegrades: !string.Equals(env("ACDREAM_RETAIL_CLOSE_DEGRADES"), "0", StringComparison.Ordinal),
+ DumpSceneryZ: IsExactlyOne(env("ACDREAM_DUMP_SCENERY_Z")),
+ // Legacy override for ACDREAM_STREAM_RADIUS. Caller applies it on
+ // top of the quality preset's radii. Null when unset or invalid.
+ LegacyStreamRadius: TryParseNonNegativeInt(env("ACDREAM_STREAM_RADIUS")));
+ }
+
+ /// True iff live-mode credentials are present and valid for connecting.
+ public bool HasLiveCredentials =>
+ LiveMode && !string.IsNullOrEmpty(LiveUser) && !string.IsNullOrEmpty(LivePass);
+
+ private static bool IsExactlyOne(string? s)
+ => string.Equals(s, "1", StringComparison.Ordinal);
+
+ private static string? NullIfEmpty(string? s)
+ => string.IsNullOrEmpty(s) ? null : s;
+
+ private static int? TryParseInt(string? s)
+ => int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var v) ? v : null;
+
+ private static int? TryParseNonNegativeInt(string? s)
+ => TryParseInt(s) is { } v && v >= 0 ? v : null;
+}
diff --git a/src/AcDream.App/UI/TargetIndicatorPanel.cs b/src/AcDream.App/UI/TargetIndicatorPanel.cs
index c65d249a..364bbc18 100644
--- a/src/AcDream.App/UI/TargetIndicatorPanel.cs
+++ b/src/AcDream.App/UI/TargetIndicatorPanel.cs
@@ -84,89 +84,18 @@ public sealed class TargetIndicatorPanel
public float EntityHeight { get; set; } = 1.8f;
///
- /// Resolve the world-space height to use for a given entity's
- /// indicator box. The base height per type is multiplied by the
- /// entity's so an upscaled sign or NPC
- /// gets a proportionally bigger box.
- ///
- /// Per-type base height:
- ///
- ///