Merge branch 'claude/intelligent-poitras-b2c4f9' — Phase L.2a slices 1+2+3 (resolver + cell-transit probes + entity attribution)

Ships Phase L.2a (Truth & Diagnostics) for Movement & Collision Conformance,
plus the L.2d sub-direction call backed by reproducible evidence.

Three code commits:
- ebef820 — L.2a slice 1: [resolve] + [cell-transit] probes with DebugPanel mirror.
- e0c08bc — L.2a slice 2: extend [resolve] line with hit object guid + env flag.
- a068292 — L.2a slice 3: populate previously-stub CollisionInfo entity
  attribution at the per-object call site in Transition.FindObjCollisions.

Plus a docs commit (eb401e8) shipping the cold-start handoff, next-session
prompt, L.2 plan-of-record shipped-slice notes, and CLAUDE.md status updates.

Three findings backed by reproducible Holtburg doorway evidence:
1. L.2e cell-id format gap: player CellId tracked as bare low byte
   (0x00000029), no landblock prefix.
2. L.2c wall-slide working: clean clamping with correct normal, no ok=False.
3. L.2d sub-direction confirmed: 126/140 wall hits attributed to
   obj=0xA9B47900 (landblock-baked building static); no door-range
   entity ids appear. Fix is CBuildingObj + per-cell walkability port,
   NOT door-state-toggle.

Build green; 1032/1040 unit tests pass. The 8 failing tests are
pre-existing on the branch base (verified by stash + rerun); none from
L.2a slice work.

Next session: L.2d slice 1 brainstorm + design spec
(docs/research/2026-05-12-l2d-next-session-prompt.md).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-12 18:16:50 +02:00
commit acad14e534
10 changed files with 484 additions and 12 deletions

View file

@ -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` +

View file

@ -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

View file

@ -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.

View file

@ -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.

View file

@ -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;

View file

@ -0,0 +1,40 @@
using System;
namespace AcDream.Core.Physics;
/// <summary>
/// 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.
///
/// <para>
/// Slice 1 ships <see cref="ProbeResolveEnabled"/> +
/// <see cref="ProbeCellEnabled"/>. Future slices may fold the older
/// <c>ACDREAM_DUMP_*</c> env vars into this class for unified runtime
/// toggling. Until then, those older flags remain sticky-at-startup
/// per their original implementation.
/// </para>
/// </summary>
public static class PhysicsDiagnostics
{
/// <summary>
/// When true, <see cref="PhysicsEngine.ResolveWithTransition"/> emits
/// one structured <c>[resolve]</c> 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 <c>ACDREAM_PROBE_RESOLVE=1</c>.
/// </summary>
public static bool ProbeResolveEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1";
/// <summary>
/// When true, every change to <c>PlayerMovementController.CellId</c>
/// emits one <c>[cell-transit]</c> line: old → new cell, current
/// world position, reason tag (<c>resolver</c> / <c>teleport</c>).
/// Initial state from <c>ACDREAM_PROBE_CELL=1</c>.
/// </summary>
public static bool ProbeCellEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL") == "1";
}

View file

@ -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

View file

@ -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)

View file

@ -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();

View file

@ -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
/// <summary>Mirror of <c>ACDREAM_DUMP_SKY</c>.</summary>
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.
/// <summary>
/// Runtime mirror of <c>PhysicsDiagnostics.ProbeResolveEnabled</c>
/// (env var <c>ACDREAM_PROBE_RESOLVE</c>). Toggling here flips the
/// resolver probe live — no relaunch required.
/// </summary>
public bool ProbeResolve
{
get => PhysicsDiagnostics.ProbeResolveEnabled;
set => PhysicsDiagnostics.ProbeResolveEnabled = value;
}
/// <summary>
/// Runtime mirror of <c>PhysicsDiagnostics.ProbeCellEnabled</c>
/// (env var <c>ACDREAM_PROBE_CELL</c>). Toggling here flips the
/// cell-transit probe live.
/// </summary>
public bool ProbeCell
{
get => PhysicsDiagnostics.ProbeCellEnabled;
set => PhysicsDiagnostics.ProbeCellEnabled = value;
}
// ── Action hooks invoked by panel buttons ──────────────────────────
/// <summary>