diff --git a/CLAUDE.md b/CLAUDE.md
index f883018..f88e77f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -526,8 +526,31 @@ acdream's plan lives in two files committed to the repo:
acceptance criteria. Do not drift from the spec without explicit user
approval.
-**Currently between phases.** Phase C.1.5b just shipped; next phase is
-the user's call from the candidate list below.
+**Currently in Phase L.2 (Movement & Collision Conformance).** L.2a slices
+1+2+3 shipped 2026-05-12 (this evening); the natural next step is the
+L.2d slice 1 brainstorm / design spec. Cold-start prompt for the next
+session: [`docs/research/2026-05-12-l2d-next-session-prompt.md`](docs/research/2026-05-12-l2d-next-session-prompt.md).
+Full handoff: [`docs/research/2026-05-12-l2a-shipped-l2d-handoff.md`](docs/research/2026-05-12-l2a-shipped-l2d-handoff.md).
+
+**Phase L.2a (Truth & Diagnostics) slices 1-3 shipped 2026-05-12.**
+Three commits land the L.2 "make every bad movement outcome explainable"
+diagnostic foundation. Slice 1 (`ebef820`) adds runtime-toggleable
+`ACDREAM_PROBE_RESOLVE` (one `[resolve]` line per
+`PhysicsEngine.ResolveWithTransition` call) + `ACDREAM_PROBE_CELL` (one
+`[cell-transit]` line per `PlayerMovementController.CellId` change),
+both backed by a new `AcDream.Core.Physics.PhysicsDiagnostics` static
+class and mirrored as DebugPanel checkboxes. Slice 2 (`e0c08bc`) extends
+the `[resolve]` line with `obj=0x...` attribution. Slice 3 (`a068292`)
+populates the previously-stub `CollisionInfo.CollideObjectGuids` /
+`LastCollidedObjectGuid` (declared in `TransitionTypes.cs` but never
+written anywhere) at the per-object iteration in `FindObjCollisions`,
+so the slice-2 promise is now actually delivered. Visual-verified at
+the Holtburg Town doorway: probes captured 140 wall hits attributed to
+`obj=0xA9B47900` (landblock-baked static = the building itself,
+**NOT** a door entity), confirming L.2d sub-direction as **port
+`CBuildingObj` collision + per-cell walkability** rather than door-
+state-toggle. Plus a definitive L.2e finding: player `CellId` tracked
+as bare low byte (`0x00000029`) with no landblock prefix.
**Phase C.1.5b (per-part PES transforms + dat-hydrated entity DefaultScript)
shipped 2026-05-12.** Closes issue #56. `SetupPartTransforms.Compute(setup)`
@@ -585,6 +608,13 @@ together comprise the streaming + rendering perf foundation for the
project.
**Next phase candidates (in rough preference order):**
+- **L.2d slice 1 brainstorm + spec** (`docs/research/2026-05-12-l2d-next-session-prompt.md`).
+ Direct continuation of tonight's L.2a evidence: port `CBuildingObj` collision
+ + per-cell walkability so doorway gaps are walkable. Unblocks "walk into a
+ building" + sets up G.3 dungeon streaming. **Note:** triage the 8 pre-existing
+ test failures first (none introduced by L.2a slices — verified by stash + rerun
+ — but most touch movement/physics code L.2d will evolve). See the handoff doc's
+ "Open concerns" section.
- **Triage the chronic open-issue list** in `docs/ISSUES.md` — #2 (lightning),
#4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid
coat), #50 (stray tree), #41 (remote-motion blips) have been open since
@@ -744,6 +774,18 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
diagnostics (`[UM_RAW]`, `[SCFAST]`, `[SCFULL]`, `[SETCYCLE]`,
`[FWD_WIRE]`, `[OMEGA_DIAG]`, `[SEQSTATE]`, `[PARTSDIAG]`,
`[VEL_DIAG]`, `[UPCYCLE]`). Heavy.
+- `ACDREAM_PROBE_RESOLVE=1` — L.2a slice 1+2+3 (2026-05-12). One
+ `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call:
+ input + target + output position/cell, ok-vs-partial, grounded-in,
+ contact-plane status, wall normal if hit, **responsible entity
+ guid** (post-slice-3 attribution plumbing), env flag, walkable
+ polygon valid. Heavy (~30 Hz × every entity). Runtime-toggleable
+ via the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`.
+- `ACDREAM_PROBE_CELL=1` — L.2a slice 1 (2026-05-12). One
+ `[cell-transit]` line per `PlayerMovementController.CellId`
+ change: old → new cell, world position, reason tag
+ (`resolver` / `teleport`). Low volume — only fires on actual cell
+ crossings. Runtime-toggleable via the same DebugPanel section.
- *(retired 2026-05-05 by L.3 M2/M3)* `ACDREAM_INTERP_MANAGER` was an
env-var gate on an experimental per-tick remote motion path. L.3 M2
(commit 40d88b9) replaced both gates (`OnLivePositionUpdated` +
diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md
index 8bfc2c1..dfe8057 100644
--- a/docs/plans/2026-04-29-movement-collision-conformance.md
+++ b/docs/plans/2026-04-29-movement-collision-conformance.md
@@ -92,6 +92,35 @@ Goal: make every bad movement outcome explainable.
- Build real-DAT fixture capture for known walls, building ledges, rooftops,
slopes, landblock seams, and dungeon entrances.
+Current shipped slices:
+
+- 2026-04-30: cdb + TTD retail-observer toolchain (`tools/pdb-extract/`,
+ `tools/ttd-record.ps1`, `tools/ttd-query.ps1`) with PDB pairing checker
+ and ring-buffer trace replay. The "retail observer harness" line item.
+- 2026-04 (pre-L.2 rename): `ACDREAM_DUMP_MOVE_TRUTH` paired
+ outbound/server-echo dumper in `GameWindow` covers outbound packet
+ fields + server echo + correction delta with cell-id mismatch.
+- Pre-L.2: scenario-specific dumps `ACDREAM_DUMP_MOTION`,
+ `ACDREAM_DUMP_STEEP_ROOF`, `ACDREAM_DUMP_STEPUP`,
+ `ACDREAM_DUMP_EDGE_SLIDE` for the codepaths hit during prior bug chases.
+- 2026-05-12 (slice 1): general-purpose probes via new
+ `AcDream.Core.Physics.PhysicsDiagnostics` static class.
+ `ACDREAM_PROBE_RESOLVE` emits one `[resolve]` line per
+ `PhysicsEngine.ResolveWithTransition` call (input/output pos+cell,
+ ok-vs-partial, grounded-in, contact-plane status, wall normal if hit,
+ walkable polygon valid, moving entity id).
+ `ACDREAM_PROBE_CELL` emits one `[cell-transit]` line per
+ `PlayerMovementController.CellId` change with old→new + position +
+ reason tag (`resolver`/`teleport`). Both flippable live via the
+ DebugPanel "Diagnostics" section — checkbox toggles take effect on
+ the next resolve, no relaunch required.
+
+Remaining L.2a work: contact-plane probe (general, not just steep-roof),
+ShadowObjectRegistry hit log ("you collided with entity X"), water probe,
+real-DAT fixture-capture pipeline, and folding the older sticky-at-startup
+`ACDREAM_DUMP_*` flags into `PhysicsDiagnostics` for unified runtime
+toggling.
+
### L.2b - Movement Wire / Contact Authority
Goal: stop sending movement packets that claim more certainty than the local
@@ -140,6 +169,23 @@ fallback.
- Audit `Setup.Radius` and cylinder fallback behavior against retail before
relying on them for conformance.
+Current sub-direction (2026-05-12, evidence-driven by L.2a slice 2 + 3):
+The "I can't walk through doorways" symptom at Holtburg is **NOT a door-
+state-toggle issue**. The `[resolve]` probe captured 140 hit=yes lines
+at the doorway with `obj=0xA9B47900` (126 hits) — a landblock-baked
+static in the `0xLLLLxxxx` range, i.e. the **building itself**, not a
+door entity (no `0xCC0Cxxxx`-range hits). The building's baked collision
+mesh is treated as one solid block; the doorway gap that's visible in
+the rendered mesh isn't represented in the collision data we consume.
+
+L.2d slice 1's scope is therefore the `CBuildingObj` + per-cell
+walkability port (interpretation 2 of the handoff). The named retail
+anchors `CCellStruct::point_in_cell`, `CCellStruct::sphere_intersects_cell`,
+`CCellStruct::box_intersects_cell`, `CBuildingObj::find_building_collisions`
+are the entry points. Spec to be written at
+`docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md`.
+Handoff: [docs/research/2026-05-12-l2a-shipped-l2d-handoff.md](../research/2026-05-12-l2a-shipped-l2d-handoff.md).
+
### L.2e - Cell Ownership: Outdoor Seams, CELLARRAY, cell_bsp
Goal: the resolver knows which cell owns the movement and which adjacent cells
diff --git a/docs/research/2026-05-12-l2a-shipped-l2d-handoff.md b/docs/research/2026-05-12-l2a-shipped-l2d-handoff.md
new file mode 100644
index 0000000..9ecfcaa
--- /dev/null
+++ b/docs/research/2026-05-12-l2a-shipped-l2d-handoff.md
@@ -0,0 +1,176 @@
+# L.2a shipped — L.2d direction confirmed — Cold-Start Handoff
+
+**Created:** 2026-05-12 evening, immediately after the L.2a-slice-1/2/3 work landed and visual-verified.
+**Audience:** the next agent picking up Phase L.2 (Movement & Collision Conformance).
+**Purpose:** give you everything you need to start L.2d brainstorming cold, without spelunking through this session's transcript.
+
+---
+
+## TL;DR
+
+Phase L.2a (Truth & Diagnostics) shipped three slices tonight. They surfaced **three concrete L.2 findings** with reproducible evidence — converting "we should look at this someday" theories into "here is the entity id, here is the wall normal, here is the cell id." With those findings in hand, the next concrete physics work in the L.2 roadmap is **L.2d slice 1 — port `CBuildingObj` collision so doorway gaps are walkable.** Brainstorm + spec, then port.
+
+**Three slices shipped to `claude/intelligent-poitras-b2c4f9`:**
+
+| Commit | What | Why |
+|---|---|---|
+| [`ebef820`](.) | L.2a slice 1: `[resolve]` + `[cell-transit]` probes + DebugPanel mirror | Foundation for every later L.2 change to be evidence-driven |
+| [`e0c08bc`](.) | L.2a slice 2: surface hit object guid in `[resolve]` line | Tell us WHICH entity is the wall, not just the wall normal |
+| [`a068292`](.) | L.2a slice 3: populate the previously-stub `CollisionInfo.CollideObjectGuids` / `LastCollidedObjectGuid` | Slice 2 found these fields were declared but never written — fixed the structural gap |
+
+---
+
+## Three findings from the L.2a probes
+
+All produced by walking around Holtburg + pushing W into a Town doorway with `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1`.
+
+### Finding 1 — L.2e cell-id format gap (DEFINITIVE)
+
+The player's tracked `CellId` is being recorded as a **bare low byte** (`0x00000029`), with no landblock prefix. AC cell ids are normally `0xLLLLCCCC` — landblock id (4 hex digits) + cell-within-landblock (4 hex digits, `0x0001-0x00FF` outdoor or `0x0100+` indoor).
+
+Evidence from a tonight log:
+
+```
+[cell-transit] 0x00000001 -> 0x00000029 pos=(132.585,21.015,94.000) reason=resolver
+```
+
+NPCs in the same area show MIXED forms in their resolve lines:
+- `cell=0xA9B3000E` ← full landblock-prefixed (correct)
+- `cell=0x00000032` ← bare low byte (matches the bug shape)
+
+Likely source: `ResolveOutdoorCellId(...)` at [src/AcDream.Core/Physics/PhysicsEngine.cs:687](src/AcDream.Core/Physics/PhysicsEngine.cs:687) — that's the function that ResolveWithTransition routes the output cell id through before returning. Worth grepping for its body.
+
+This is the L.2e blocker per the plan-of-record:
+> *"Update low outdoor cell id across 24m cell boundaries and landblock seams. Port the retail adjacent-cell search: `find_cell_list`, `check_other_cells`, and `adjust_check_pos`."*
+
+### Finding 2 — L.2c wall-slide is working
+
+The transition layer at this spot does the retail-faithful thing:
+
+```
+[resolve] ent=0x000F4240 in=(132.067,17.567,94.000) cell=0x00000029
+ tgt=(132.239,17.172,94.000)
+ out=(131.938,17.567,94.000) cell=0x00000029
+ ok=True groundedIn=True cp=valid hit=yes n=(0.00,1.00,0.00)
+ obj=0xA9B47900 walkable=True
+```
+
+- Wall normal `(0, 1, 0)` — vertical wall facing +Y, captured correctly.
+- `out` shows the position clamped along the wall: X slid back from 132.067 → 131.938, Y preserved.
+- `ok=True` — resolver completed normally (no `ok=False` anywhere in the trace, 0/140).
+
+**No L.2c work needed at this site.** Edge-slide / wall-slide port from earlier (per the plan-of-record's L.2c "Current shipped slice" note) is doing its job here.
+
+### Finding 3 — L.2d sub-direction = CBuildingObj port (NOT door-toggle)
+
+All 140 hit=yes lines in the doorway-push test came back with the **same dominant `obj=` attribution**:
+
+| obj | hits | range | what it is |
+|---|---|---|---|
+| **`0xA9B47900`** | **126** | `0xLLLLxxxx` (landblock-baked static) | The Holtburg building itself — its baked collision mesh |
+| `0x000F4245` | 14 | `0x000Fxxxx` (local-spawn entity) | An NPC standing near the doorway |
+
+`0xA9B4` matches the Holtburg landblock prefix we logged at startup (`loading world view centered on 0xA9B4FFFF`). The `0x7900` low bytes is its landblock-local entity id. **It's the building's baked collision shape — not a door entity, not a creature.**
+
+**Implication:** the "doorway is blocked" symptom is NOT a door-collision-not-toggled bug (which would have shown a door-range entity id, typically `0xCC0Cxxxx`). It's a **building-mesh fidelity issue**: the building's baked collision data we're loading represents the building as a solid block with no walkable opening where the visual doorway is.
+
+Two non-mutually-exclusive interpretations:
+1. **Collision-mesh extraction is wrong** — we load building geometry but don't respect the BSP nodes that encode doorway openings.
+2. **`CBuildingObj` + per-cell walkability is not ported** — retail uses a per-cell `CObjCell` structure that maps "this interior cell is reachable" / "this exterior cell connects to those interior cells." Without that, we treat the building as one opaque collision volume.
+
+The plan-of-record's L.2d goal:
+> *"Preserve enough building identity to model `CBuildingObj` collision and `bldg_check` behavior."*
+
+points at interpretation 2 as the canonical fix.
+
+---
+
+## What this session deliberately did NOT do
+
+- **Other L.2a slices** (contact-plane probe, ShadowObject hit log, water probe, real-DAT fixture-capture pipeline). Slice 1 + 2 + 3 cover the most-load-bearing case (resolver outcomes + cell transits + entity attribution). The remaining diagnostics serve future L.2 work and can ship opportunistically.
+- **L.2d implementation or brainstorm.** Deliberately parked for a fresh session with this evidence as cold-start context.
+- **L.2e implementation.** The cell-id format finding is filed but not investigated.
+- **Pre-existing test failures.** 8 tests fail at the branch base (none from these slices — verified by stash + rerun on every test cycle). Not from this slice. See "Open concerns" below.
+
+---
+
+## Branch state at handoff
+
+- Branch: `claude/intelligent-poitras-b2c4f9`
+- Three slice commits ahead of `eab347d` (the C.1.5b merge into main), plus a docs commit that adds this handoff + the next-session prompt + plan-of-record / CLAUDE.md updates.
+- Tonight's last code commit was `a068292` (L.2a slice 3); docs commit follows.
+- Worktree clean post-docs-commit; merge to main is the user's planned next operation.
+
+## What's now in the diagnostic surface
+
+Live env vars (both can be flipped at runtime via the DebugPanel "Diagnostics" section if `ACDREAM_DEVTOOLS=1`):
+
+- **`ACDREAM_PROBE_RESOLVE=1`** — one `[resolve]` line per `PhysicsEngine.ResolveWithTransition` call:
+ ```
+ [resolve] ent=0xEEEEEEEE in=(x,y,z) cell=0xCCCCCCCC tgt=(x,y,z) out=(x,y,z) cell=0xCCCCCCCC ok=Y/N groundedIn=Y/N cp=valid|lastKnown|none hit=yes n=(nx,ny,nz) obj=0xOOOOOOOO env nObj=N walkable=Y/N
+ ```
+ Heavy: fires for every entity's resolve per physics tick.
+- **`ACDREAM_PROBE_CELL=1`** — one `[cell-transit]` line per `PlayerMovementController.CellId` change:
+ ```
+ [cell-transit] 0xOLD -> 0xNEW pos=(x,y,z) reason=resolver|teleport
+ ```
+ Low volume — only fires on actual cell crossings.
+
+Both backed by `AcDream.Core.Physics.PhysicsDiagnostics` static class (initial from env var, set/get from anywhere at runtime).
+
+## Files changed in this session
+
+```
+src/AcDream.Core/Physics/PhysicsDiagnostics.cs (new)
+src/AcDream.Core/Physics/PhysicsEngine.cs (modified — probe emission)
+src/AcDream.Core/Physics/TransitionTypes.cs (modified — entity attribution plumbing)
+src/AcDream.App/Input/PlayerMovementController.cs (modified — UpdateCellId chokepoint)
+src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs (modified — Probe* forwarder props)
+src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs (modified — two new checkboxes)
+docs/plans/2026-04-29-movement-collision-conformance.md (modified — shipped-slice note + L.2d sub-direction)
+```
+
+## Open concerns flagged but NOT addressed in this session
+
+- **8 pre-existing test failures** on the branch base, verified by stash+rerun: `MotionInterpreterTests.GetMaxSpeed_*` (3), `PositionManagerTests.ComputeOffset_BothActive_Combined`, `PlayerMovementControllerTests.Update_ForwardInput_MovesInFacingDirection`, `DispatcherToMovementIntegrationTests.Dispatcher_W_held_produces_forward_motion`, `BSPStepUpTests.{D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames,C3_Path6_AirborneMoverHitsSteepSlope_SetsCollide}`. Most touch movement/physics code we're about to evolve in L.2b/L.2c/L.2d — **triage before further L.2 work** is recommended.
+- **Player entity id quirk.** Local player physics entity id observed as `0x000F4240` in the resolve probe, not the server guid `0x5000000A`. This is presumably the dat/local-spawn entity id — fine for diagnostic, worth keeping in mind for any future "is this the player?" check.
+
+## Cold-start checklist for L.2d brainstorm
+
+1. Read this handoff.
+2. Read [docs/plans/2026-04-29-movement-collision-conformance.md](docs/plans/2026-04-29-movement-collision-conformance.md) — focus on L.2d section.
+3. Read the L.2d named-retail anchors:
+ - `CCellStruct::point_in_cell`, `CCellStruct::sphere_intersects_cell`, `CCellStruct::box_intersects_cell`
+ - `CBuildingObj::find_building_collisions`
+ - `CObjCell::find_cell_list` (already shared with L.2e)
+ Grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by `class::method`.
+4. Read [src/AcDream.Core/Physics/TransitionTypes.cs:1386](src/AcDream.Core/Physics/TransitionTypes.cs:1386) — current `FindObjCollisions` loop, where building objects currently route through generic BSP/Cylinder paths.
+5. Read [src/AcDream.Core/Physics/PhysicsDataCache.cs](src/AcDream.Core/Physics/PhysicsDataCache.cs) — how we currently load BSP / GfxObj data; figure out if building-specific data (interior cells, `CBuildingObj`) is loaded but not consumed.
+6. Cross-reference WorldBuilder (`references/WorldBuilder/`) for any building-cell handling already present.
+7. Brainstorm the slice (`superpowers:brainstorming` if useful) — scope, named-retail anchors, conformance tests, real-DAT fixtures.
+8. Write a spec at `docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md`.
+9. Implement in slices with conformance citations in each commit.
+
+## Reproducing the doorway evidence
+
+In case you want to re-capture the trace:
+
+```powershell
+# In the project worktree
+$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_CELL = "1"
+$env:ACDREAM_PROBE_RESOLVE = "1"
+dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch.log"
+```
+
+Walk acdream up to a Holtburg building doorway. Hold W into it for ~2 seconds. Close. Grep `launch.log` for:
+- `cell-transit` — cell tracking
+- `\[resolve\].*hit=yes` — wall hits with object attribution
+
+Wall entity should appear as `obj=0xA9B47XXX` for the same Holtburg building, OR a different `0xA9Bxxxxx` for other buildings in the area.
diff --git a/docs/research/2026-05-12-l2d-next-session-prompt.md b/docs/research/2026-05-12-l2d-next-session-prompt.md
new file mode 100644
index 0000000..bcb7395
--- /dev/null
+++ b/docs/research/2026-05-12-l2d-next-session-prompt.md
@@ -0,0 +1,51 @@
+# Copy-paste prompt — next session for L.2d brainstorm
+
+**This file is meant to be pasted verbatim into a new Claude Code session.** It assumes the next session starts on a freshly-merged `main` with the L.2a-slice-1/2/3 work already landed.
+
+---
+
+## Prompt to paste
+
+> You are picking up Phase L.2d (Movement & Collision Conformance — Shape Fidelity: Sphere / CylSphere / Building Objects) for the acdream project.
+>
+> The previous session shipped L.2a-slice-1/2/3 (resolver + cell-transit probes + entity attribution plumbing) and used the probes to settle the L.2d sub-direction call: **the wall blocking us at building doorways is a landblock-baked static (`0xA9B47900` for the Holtburg test building), NOT a door entity.** The fix is to port `CBuildingObj` + per-cell walkability so the building's baked collision mesh has walkable openings where doorways are. Door-state-toggle is NOT the issue.
+>
+> Before writing any code:
+>
+> 1. **Read the handoff:** `docs/research/2026-05-12-l2a-shipped-l2d-handoff.md` — full context, evidence, file pointers.
+> 2. **Read the plan-of-record:** `docs/plans/2026-04-29-movement-collision-conformance.md` — focus on L.2d, and notice that L.2c already shipped most of its work + L.2a is now ~75% covered.
+> 3. **Read the named-retail anchors** (grep `docs/research/named-retail/acclient_2013_pseudo_c.txt` by `class::method`):
+> - `CCellStruct::point_in_cell`
+> - `CCellStruct::sphere_intersects_cell`
+> - `CCellStruct::box_intersects_cell`
+> - `CBuildingObj::find_building_collisions`
+> - `CObjCell::find_cell_list`
+> 4. **Read current code:**
+> - `src/AcDream.Core/Physics/TransitionTypes.cs:1386` — `FindObjCollisions` (where building objects currently flow through generic BSP path).
+> - `src/AcDream.Core/Physics/PhysicsDataCache.cs` — what building-specific data we already load vs ignore.
+> 5. **Cross-reference WorldBuilder** at `references/WorldBuilder/` for any building-cell handling we can crib.
+>
+> Your deliverable for this session:
+>
+> 1. A brainstorm using `superpowers:brainstorming` if scope is unclear, then
+> 2. A design spec at `docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md` covering:
+> - Named-retail anchors with line numbers from the PDB pseudo-C
+> - Component breakdown (CObjCell port, CBuildingObj port, integration with FindObjCollisions)
+> - Conformance test plan (synthetic + real-DAT fixtures at known Holtburg buildings)
+> - Slice plan (3-5 commits, each conformance-cited)
+> - Acceptance criteria
+> 3. After spec approval, implement slice 1.
+>
+> **Before implementation,** verify the L.2a probes still work — relaunch with `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1 ACDREAM_DEVTOOLS=1`, walk up to the Holtburg test doorway, confirm `[resolve]` lines still show `obj=0xA9B4xxxx` for the wall hits. (Reproduction recipe in the handoff doc's last section.)
+>
+> Side note: **8 pre-existing test failures** exist on main (verified by stash+rerun in the prior session, none from L.2a slice work). Most touch movement/physics code we're about to evolve. **Triage them before sinking deep L.2d effort** — a recent baseline regression in this area could waste hours of L.2d work.
+
+---
+
+## Reading order if you only have 10 minutes
+
+1. `docs/research/2026-05-12-l2a-shipped-l2d-handoff.md` — TL;DR + Three findings sections (5 min).
+2. `docs/plans/2026-04-29-movement-collision-conformance.md` §L.2d (2 min).
+3. `src/AcDream.Core/Physics/TransitionTypes.cs:1386-1543` — current `FindObjCollisions` body (3 min).
+
+From there, decide whether to brainstorm or jump straight to the spec.
diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 1bc88b8..cb9b34b 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -288,12 +288,29 @@ public sealed class PlayerMovementController
_motion.apply_current_movement(cancelMoveTo: false, allowJump: false);
}
+ // L.2a slice 1 (2026-05-12): centralized CellId mutation so the
+ // [cell-transit] probe fires from a single chokepoint. Both the
+ // server-snap path (SetPosition) and the per-frame resolver path
+ // route through here. When PhysicsDiagnostics.ProbeCellEnabled is
+ // off this collapses to a single bool-compare + assignment — zero
+ // logging cost.
+ private void UpdateCellId(uint newCellId, string reason)
+ {
+ if (newCellId != CellId && PhysicsDiagnostics.ProbeCellEnabled)
+ {
+ var pos = _body.Position;
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[cell-transit] 0x{CellId:X8} -> 0x{newCellId:X8} pos=({pos.X:F3},{pos.Y:F3},{pos.Z:F3}) reason={reason}"));
+ }
+ CellId = newCellId;
+ }
+
public void SetPosition(Vector3 pos, uint cellId)
{
_body.Position = pos;
_prevPhysicsPos = pos;
_currPhysicsPos = pos;
- CellId = cellId;
+ UpdateCellId(cellId, "teleport");
// Treat as grounded after a server-side position snap.
_body.TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable;
@@ -760,7 +777,7 @@ public sealed class PlayerMovementController
_wasAirborneLastFrame = !_body.OnWalkable;
- CellId = resolveResult.CellId;
+ UpdateCellId(resolveResult.CellId, "resolver");
// ── 6. Determine outbound motion commands ─────────────────────────────
uint? outForwardCmd = null;
diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
new file mode 100644
index 0000000..2440bb1
--- /dev/null
+++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
@@ -0,0 +1,40 @@
+using System;
+
+namespace AcDream.Core.Physics;
+
+///
+/// L.2a slice 1 (2026-05-12) — runtime-toggleable physics probe flags.
+/// Initialized from env vars at process start; flippable at runtime via
+/// the DebugPanel mirror (or by direct assignment). Log call sites read
+/// these statics so a checkbox toggle takes effect on the next resolve
+/// without relaunching.
+///
+///
+/// Slice 1 ships +
+/// . Future slices may fold the older
+/// ACDREAM_DUMP_* env vars into this class for unified runtime
+/// toggling. Until then, those older flags remain sticky-at-startup
+/// per their original implementation.
+///
+///
+public static class PhysicsDiagnostics
+{
+ ///
+ /// When true, emits
+ /// one structured [resolve] line per call: input + target +
+ /// output position/cell, grounded state, contact-plane status,
+ /// collision-normal validity, walkable polygon status, moving entity
+ /// id. Initial state from ACDREAM_PROBE_RESOLVE=1.
+ ///
+ public static bool ProbeResolveEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1";
+
+ ///
+ /// When true, every change to PlayerMovementController.CellId
+ /// emits one [cell-transit] line: old → new cell, current
+ /// world position, reason tag (resolver / teleport).
+ /// Initial state from ACDREAM_PROBE_CELL=1.
+ ///
+ public static bool ProbeCellEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL") == "1";
+}
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index fe308ae..4bfcb3e 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -677,6 +677,48 @@ public sealed class PhysicsEngine
$"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}");
}
+ // L.2a slice 1 (2026-05-12): general-purpose resolver probe.
+ // One line per call when PhysicsDiagnostics.ProbeResolveEnabled
+ // is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the
+ // DebugPanel checkbox flipped at runtime). Captures every
+ // dimension L.2 cares about: input/output position, input/output
+ // cell, ok-vs-partial, grounded-in vs contact-out, contact-plane
+ // status, wall normal if hit, walkable polygon valid. Zero cost
+ // when off (one static-bool read).
+ if (PhysicsDiagnostics.ProbeResolveEnabled)
+ {
+ var probePost = sp.CheckPos;
+ string probeCp = ci.ContactPlaneValid
+ ? "valid"
+ : (ci.LastKnownContactPlaneValid ? "lastKnown" : "none");
+ string probeHit;
+ if (collisionNormalValid)
+ {
+ // L.2a slice 2 (2026-05-12): include the hit object's guid +
+ // environment flag so we can tell whether the wall is a building
+ // (CBuildingObj), a door (CC0Cxxxx range), an NPC, or terrain.
+ // Without this we know the wall normal but not the responsible
+ // entity — half the L.2d sub-direction call.
+ string objPart = ci.LastCollidedObjectGuid.HasValue
+ ? System.FormattableString.Invariant(
+ $" obj=0x{ci.LastCollidedObjectGuid.Value:X8}")
+ : "";
+ string envPart = ci.CollidedWithEnvironment ? " env" : "";
+ int objCount = ci.CollideObjectGuids.Count;
+ string objCountPart = objCount > 1
+ ? System.FormattableString.Invariant($" nObj={objCount}")
+ : "";
+ probeHit = System.FormattableString.Invariant(
+ $"yes n=({collisionNormal.X:F2},{collisionNormal.Y:F2},{collisionNormal.Z:F2}){objPart}{envPart}{objCountPart}");
+ }
+ else
+ {
+ probeHit = "no";
+ }
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}"));
+ }
+
if (ok)
{
bool onGround = ci.ContactPlaneValid
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index f2c4f6c..1a3a12f 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -1389,6 +1389,7 @@ public sealed class Transition
var sp = SpherePath;
var oi = ObjectInfo;
+ var ci = CollisionInfo;
// #42 diagnostic (2026-05-05): identify which static object causes
// the airborne first-frame ~1m push. Capture sphere check pos at
@@ -1460,6 +1461,14 @@ public sealed class Transition
if (CollisionExemption.ShouldSkip(obj.State, obj.Flags, ObjectInfo.State))
continue;
+ // L.2a slice 3 (2026-05-12): snapshot collision-normal state so
+ // we can tell whether THIS object's BSP/CylSphere test produced a
+ // new collision (BSPQuery sets the normal but may still return OK
+ // for slide cases). Together with the `result != OK` check below
+ // this populates ci.CollideObjectGuids + LastCollidedObjectGuid so
+ // the [resolve] probe surfaces the responsible entity id.
+ bool collisionWasValidPre = ci.CollisionNormalValid;
+
TransitionState result;
if (obj.CollisionType == ShadowCollisionType.BSP)
@@ -1523,6 +1532,22 @@ public sealed class Transition
result = CylinderCollision(obj, sp);
}
+ // L.2a slice 3: attribute the collision (if any) to this entity.
+ // Two cases:
+ // - result != OK: the object stopped the transition (hard-block).
+ // - result == OK but the normal flipped from invalid→valid during
+ // this call: BSPQuery captured a slide normal without halting.
+ // Either way this object is responsible for the hit, so add its
+ // entity id. CollideObjectGuids carries the full chain; the last
+ // assignment to LastCollidedObjectGuid wins which matches retail's
+ // "most recent" semantics for the probe.
+ if (result != TransitionState.OK
+ || (!collisionWasValidPre && ci.CollisionNormalValid))
+ {
+ ci.CollideObjectGuids.Add(obj.EntityId);
+ ci.LastCollidedObjectGuid = obj.EntityId;
+ }
+
if (result != TransitionState.OK)
{
if (airborneDiag)
diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
index dc55080..e990686 100644
--- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs
@@ -199,15 +199,21 @@ public sealed class DebugPanel : IPanel
{
if (!r.CollapsingHeader("Diagnostics", defaultOpen: true)) return;
- bool dumpMotion = _vm.DumpMotion;
- bool dumpVitals = _vm.DumpVitals;
- bool dumpOpcodes = _vm.DumpOpcodes;
- bool dumpSky = _vm.DumpSky;
+ bool dumpMotion = _vm.DumpMotion;
+ bool dumpVitals = _vm.DumpVitals;
+ bool dumpOpcodes = _vm.DumpOpcodes;
+ bool dumpSky = _vm.DumpSky;
+ bool probeResolve = _vm.ProbeResolve;
+ bool probeCell = _vm.ProbeCell;
- if (r.Checkbox("Dump motion (ACDREAM_DUMP_MOTION)", ref dumpMotion)) _vm.DumpMotion = dumpMotion;
- if (r.Checkbox("Dump vitals (ACDREAM_DUMP_VITALS)", ref dumpVitals)) _vm.DumpVitals = dumpVitals;
- if (r.Checkbox("Dump opcodes (ACDREAM_DUMP_OPCODES)", ref dumpOpcodes)) _vm.DumpOpcodes = dumpOpcodes;
- if (r.Checkbox("Dump sky (ACDREAM_DUMP_SKY)", ref dumpSky)) _vm.DumpSky = dumpSky;
+ if (r.Checkbox("Dump motion (ACDREAM_DUMP_MOTION)", ref dumpMotion)) _vm.DumpMotion = dumpMotion;
+ if (r.Checkbox("Dump vitals (ACDREAM_DUMP_VITALS)", ref dumpVitals)) _vm.DumpVitals = dumpVitals;
+ if (r.Checkbox("Dump opcodes (ACDREAM_DUMP_OPCODES)", ref dumpOpcodes)) _vm.DumpOpcodes = dumpOpcodes;
+ if (r.Checkbox("Dump sky (ACDREAM_DUMP_SKY)", ref dumpSky)) _vm.DumpSky = dumpSky;
+ // L.2a slice 1 (2026-05-12): unlike the four above, these
+ // forward to PhysicsDiagnostics so a toggle takes effect live.
+ if (r.Checkbox("Probe resolve (ACDREAM_PROBE_RESOLVE)", ref probeResolve)) _vm.ProbeResolve = probeResolve;
+ if (r.Checkbox("Probe cell-transit (ACDREAM_PROBE_CELL)",ref probeCell)) _vm.ProbeCell = probeCell;
r.Spacing();
diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
index 9e914af..693cc2d 100644
--- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
+++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs
@@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.Core.Combat;
+using AcDream.Core.Physics;
namespace AcDream.UI.Abstractions.Panels.Debug;
@@ -234,6 +235,32 @@ public sealed class DebugVM
/// Mirror of ACDREAM_DUMP_SKY.
public bool DumpSky { get; set; }
+ // L.2a slice 1 (2026-05-12): unlike DumpMotion/Vitals/Opcodes/Sky
+ // above (which are display-only mirrors of sticky-at-startup env
+ // vars), these forward directly to the PhysicsDiagnostics statics,
+ // so checkbox toggles take effect on the next physics resolve.
+ ///
+ /// Runtime mirror of PhysicsDiagnostics.ProbeResolveEnabled
+ /// (env var ACDREAM_PROBE_RESOLVE). Toggling here flips the
+ /// resolver probe live — no relaunch required.
+ ///
+ public bool ProbeResolve
+ {
+ get => PhysicsDiagnostics.ProbeResolveEnabled;
+ set => PhysicsDiagnostics.ProbeResolveEnabled = value;
+ }
+
+ ///
+ /// Runtime mirror of PhysicsDiagnostics.ProbeCellEnabled
+ /// (env var ACDREAM_PROBE_CELL). Toggling here flips the
+ /// cell-transit probe live.
+ ///
+ public bool ProbeCell
+ {
+ get => PhysicsDiagnostics.ProbeCellEnabled;
+ set => PhysicsDiagnostics.ProbeCellEnabled = value;
+ }
+
// ── Action hooks invoked by panel buttons ──────────────────────────
///