docs(phys L.2d): design spec for slice 1 BSP-hit diagnostic + L.2d reframe

Reframes L.2d direction based on ACE BuildingObj.cs:39-52 + named-retail
acclient_2013_pseudo_c.txt:701260: retail's find_building_collisions is
one BSP test on PartArray.Parts[0]. No per-cell walkability. Per-cell
work (find_cell_list, point_in_cell, sphere/box_intersects_cell) is
L.2e territory.

Slice 1 is now a read-only BSP-hit diagnostic that captures full
collision evidence per L.2a [resolve] hit=yes line. Distinguishes 3
hypotheses (wrong BSP loaded / over-registered parts / BSPQuery flaw)
before any behavior change. Slice 2 is the actual fix, scoped from
slice 1's evidence.

Authors: brainstorm session 2026-05-13 (cold-start from L.2a slice
1+2+3 evidence). Predecessor handoff at
docs/research/2026-05-12-l2a-shipped-l2d-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-12 19:01:44 +02:00
parent 0c7208a3fe
commit 92cd7238ff

View file

@ -0,0 +1,311 @@
# L.2d — Movement & Collision Conformance: Building Shape Fidelity (design spec)
**Status:** Draft, 2026-05-13. Slice 1 ready to implement after build-env resolution.
**Roadmap owner:** Phase L.2d in [docs/plans/2026-04-29-movement-collision-conformance.md](../../plans/2026-04-29-movement-collision-conformance.md).
**Authors:** brainstorm session 2026-05-13 (cold-start from L.2a slice 1+2+3 evidence).
**Predecessor handoff:** [docs/research/2026-05-12-l2a-shipped-l2d-handoff.md](../../research/2026-05-12-l2a-shipped-l2d-handoff.md).
---
## TL;DR
L.2d slice 1 is a **read-only BSP-hit diagnostic** that captures full collision evidence whenever the L.2a `[resolve]` probe fires `hit=yes`. The trace distinguishes three hypotheses (wrong BSP loaded / over-registered parts / BSPQuery flaw) before any behavior change. Slice 2 is the actual fix, scoped from slice 1's evidence.
This spec replaces the plan-of-record's earlier "port `CBuildingObj` + per-cell walkability" framing — that framing was wrong (see *Reframe* below).
---
## Reframe — what L.2d actually is
The handoff and the plan-of-record's prior "Current sub-direction" paragraph both pointed at `CBuildingObj` + **per-cell walkability** as the missing piece for doorway traversal. Reading the named-retail decomp + ACE port shows that's not how retail solves doorways.
[BuildingObj.cs:39-52](../../../references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs) and named-retail [`acclient_2013_pseudo_c.txt:701260`](../../research/named-retail/acclient_2013_pseudo_c.txt) define `find_building_collisions` as 6 lines:
```csharp
public TransitionState find_building_collisions(Transition transition) {
if (PartArray == null) return TransitionState.OK;
transition.SpherePath.BuildingCheck = true;
var result = PartArray.Parts[0].FindObjCollisions(transition);
transition.SpherePath.BuildingCheck = false;
if (result != OK && !transition.ObjectInfo.State.HasFlag(Contact))
transition.CollisionInfo.CollidedWithEnvironment = true;
return result;
}
```
Retail does **one BSP test on `Parts[0]`**. Period. The `BuildingCheck` flag (`bldg_check` on the SPHEREPATH) only gates `sphere_intersects_solid` in [`BSPTREE::find_collisions`](../../research/named-retail/acclient_2013_pseudo_c.txt)'s **placement-insert / obstruction-ethereal** branch (lines 323323 and 323744323751). Normal walking transitions never read it.
Implications:
- The doorway gap is encoded **inside the physics BSP of `Parts[0]`** itself. If retail's collision works at a building doorway, that physics BSP has leaves marking the doorway interior as non-solid.
- `find_cell_list` / `point_in_cell` / `sphere_intersects_cell` / `box_intersects_cell` (the "per-cell walkability" anchors the handoff listed) are how the resolver selects **which cells** to iterate over per tick, not how it decides **whether the wall has a hole**. That work belongs to **L.2e** (cell ownership / find_cell_list / `CELLARRAY` / outdoor seam updates), not L.2d.
- L.2d's actual goal is **shape fidelity**: when our resolver collides against a building, the resulting behavior should match what retail's `Parts[0]` BSP test would produce.
The L.2a slice 1+2+3 evidence still stands: 126/140 doorway-push hits attribute to `obj=0xA9B47900` (one specific BSP shadow entry). The question is **why that BSP reports a hit where retail's wouldn't.**
---
## Three hypotheses
| Code | Hypothesis | Form a slice-2 fix would take |
|---|---|---|
| **X** | We're loading the **wrong BSP** for that part. Either `GfxObjFlags.HasPhysics` is false and we fell back to visual-mesh AABB; or `PhysicsDataCache.CacheGfxObj` cached the visual BSP root instead of `physics_bsp`. | Fix `PhysicsDataCache` BSP-selection. |
| **Y** | We're **over-registering** building parts. ACE/retail tests *only* `Parts[0]` per `find_building_collisions`. Our [`GameWindow.cs:5495-5539`](../../../src/AcDream.App/Rendering/GameWindow.cs) MeshRefs loop registers *every* part with a non-null BSP root as a separate `ShadowEntry`. A non-zero `partIdx` part may overlap the doorway when `Parts[0]` doesn't. | Skip non-`Parts[0]` registration for building entities (small, retail-faithful); or port a thin `BuildingObj` aggregator. |
| **Z** | BSPQuery has a **traversal flaw** that doesn't see the doorway gap retail does. e.g. swept-sphere classification of `BSPNode` leaves differs from retail's `BSPTREE::find_collisions`. | Audit BSPQuery against [`acclient_2013_pseudo_c.txt:323725`](../../research/named-retail/acclient_2013_pseudo_c.txt) line-by-line. |
Slice 1 collects the evidence to identify which one is true. Slice 2 is the right-sized fix.
---
## Slice 1 — BSP-Hit Diagnostic (this slice)
### Components
| # | Component | File | Change |
|---|---|---|---|
| 1 | `PhysicsDiagnostics.ProbeBuilding` | [src/AcDream.Core/Physics/PhysicsDiagnostics.cs](../../../src/AcDream.Core/Physics/PhysicsDiagnostics.cs) | New `static bool ProbeBuilding` flag, env var `ACDREAM_PROBE_BUILDING`. Same shape as existing `ProbeResolve` / `ProbeCell`. |
| 2 | `DebugPanel` checkbox | [src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs](../../../src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs), [DebugVM.cs](../../../src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs) | Third Diagnostics row: *Probe BSP hits (slow)*. Visible when `ACDREAM_DEVTOOLS=1`. |
| 3 | `[resolve-bldg]` emission | [src/AcDream.Core/Physics/TransitionTypes.cs](../../../src/AcDream.Core/Physics/TransitionTypes.cs) — at the existing L.2a slice 3 attribution site (current line ~15441549 of `FindObjCollisions`) | When `PhysicsDiagnostics.ProbeBuilding` is on and a hit is attributed to a shadow entity, emit one multi-line `[resolve-bldg]` log entry. All fields (`obj`, `partCached`, `physics`, `obj.Position`, `obj.Rotation`) are already in scope. |
| 4 | `BSPQuery.FindCollisions` hit-poly out-param | [src/AcDream.Core/Physics/BSPQuery.cs](../../../src/AcDream.Core/Physics/BSPQuery.cs) | Add optional `out ResolvedPolygon? hitPoly` parameter to the public `FindCollisions` entry point. Default `null` at non-probe call sites. Mutated at the ~5 internal sites where a poly hit is recorded (Path 5/6 of the dispatcher). Cylinder path leaves it `null`. |
| 5 | `[entity-source]` registration log | [src/AcDream.App/Rendering/GameWindow.cs](../../../src/AcDream.App/Rendering/GameWindow.cs) at the 6 `_physicsEngine.ShadowObjects.Register(...)` call sites (lines 2969, 5530, 5581, 5611, 5630, 5810) | When `PhysicsDiagnostics.ProbeBuilding` is on at registration time, emit one line per ShadowEntry registered. Makes `entityId=0xA9B479` greppable to its source within the same log file. |
| 6 | Plan-of-record correction | [docs/plans/2026-04-29-movement-collision-conformance.md](../../plans/2026-04-29-movement-collision-conformance.md) L.2d section | Replace the "Current sub-direction (2026-05-12, evidence-driven by L.2a slice 2 + 3)" paragraph with the ACE-grounded framing (this spec's *Reframe* section, distilled). |
**Total surface: ~150 LOC code, ~80 LOC tests, ~20 LOC doc correction.**
### Data flow
```
walking-into-doorway
▶ PhysicsEngine.ResolveWithTransition
▶ TransitionTypes.FindObjCollisions
▶ for each shadow obj in GetNearbyObjects(...):
▶ BSPQuery.FindCollisions(..., out hitPoly) ← (component 4)
OR CylinderCollision(...) [hitPoly remains null]
▶ on (result != OK || normal flipped):
▶ ci.CollideObjectGuids.Add(obj.EntityId) [existing L.2a sl3]
▶ ci.LastCollidedObjectGuid = obj.EntityId [existing L.2a sl3]
▶ if PhysicsDiagnostics.ProbeBuilding: ← (component 3)
▶ emit [resolve-bldg] entry with level-C fields
```
Registration side (one-time per landblock load):
```
LandblockLoader.BuildEntitiesFromInfo (existing)
▶ GameWindow.RegisterEntityShadows (existing)
▶ for each MeshRef / CylSphere / Sphere:
▶ ShadowObjects.Register(...) [existing]
▶ if PhysicsDiagnostics.ProbeBuilding: ← (component 5)
▶ emit [entity-source] line
```
### Probe output format
Per registration (one-time):
```
[entity-source] id=0xA9B47900 entityId=0xA9B479 partIdx=0 src=0x02000567 lb=0xA9B40000 hasPhys=true
```
Per `[resolve]` `hit=yes` line (per tick while probe is on):
```
[resolve-bldg] obj=0xA9B47900 entityId=0xA9B479 partIdx=0
src=0x02000567 hasPhys=true bspR=8.50 vAabbR=8.45
entOrigin_lb=(132.0,21.0,17.5)
hitPoly: numVerts=4 plane=(0.000,1.000,0.000,-94.123)
v0_local=(-1.2,0.0,0.5) v0_world=(131.5,94.1,18.0)
v1_local=( 1.2,0.0,0.5) v1_world=(133.5,94.1,18.0)
v2_local=( 1.2,0.0,3.0) v2_world=(133.5,94.1,20.5)
v3_local=(-1.2,0.0,3.0) v3_world=(131.5,94.1,20.5)
```
Cylinder shadow entries (Setup-CylSphere/Sphere hits, not building BSP) dump:
```
[resolve-bldg] obj=0x... entityId=0x... partIdx=... src=0x... hasPhys=... bspR=... vAabbR=...
entOrigin_lb=(...)
hitPoly: n/a (cylinder)
```
### Field semantics
| Field | Source | Used to distinguish |
|---|---|---|
| `obj` | `ci.LastCollidedObjectGuid` (the `partId` from the broadphase) | identity |
| `entityId` | `obj / 256` | identity, greppable to `[entity-source]` |
| `partIdx` | `obj & 0xFF` — valid as long as `partIndex < 256` per the `partId = entity.Id * 256 + partIndex` formula at [GameWindow.cs:5529](../../../src/AcDream.App/Rendering/GameWindow.cs:5529); buildings have ≤ a handful of parts in practice, so the assumption holds | **Y**: non-zero `partIdx` hits while `partIdx=0` is innocent ⇒ over-registration |
| `src` | the `WorldEntity.SourceGfxObjOrSetupId` resolved via the partId mapping | which DAT object backs this entity |
| `hasPhys` | `gfxObj.Flags.HasFlag(GfxObjFlags.HasPhysics)` from raw DAT (looked up via `DatCollection.Get<GfxObj>(meshRef.GfxObjId)`) | **X**: false ⇒ visual-AABB fallback in play |
| `bspR` | `partCached.BSP.Root.BoundingSphere.Radius` from `PhysicsDataCache.GetGfxObj(...)` | **X**: vs `vAabbR` to spot visual-vs-physics mismatch |
| `vAabbR` | `partCached.BoundingSphere?.Radius` from `PhysicsDataCache.GetVisualBounds(...)` | as above |
| `entOrigin_lb` | `obj.Position - landblockOrigin`, in landblock-local meters | spatial — does the hit make sense for the building's known position? |
| `hitPoly.*` | new `out ResolvedPolygon?` from `BSPQuery.FindCollisions` (component 4); transformed back to world space via `obj.Position + Vector3.Transform(localVert * obj.Scale, obj.Rotation)` | **Z**: lets us inspect the actual poly being hit; if it's geometrically inside the doorway gap, BSPQuery is mistraversing |
### Hypothesis-distinguishing matrix
| Trace pattern | Hypothesis | Likely slice 2 |
|---|---|---|
| `hasPhys=false` OR `bspR ≈ 0` for most hits | **X** (wrong BSP loaded) | Fix `PhysicsDataCache.CacheGfxObj` BSP-selection or the visual-AABB fallback in `GameWindow` MeshRefs loop. |
| Hits with `partIdx ≠ 0` while no `partIdx = 0` hits exist for the same `entityId` | **Y** (over-registration) | Register only `Parts[0]` for building entities — equivalent to `BuildingObj.find_building_collisions`'s "Parts[0] only" rule. ~40 LOC localized to the MeshRefs loop. |
| `hasPhys=true`, hits all on `partIdx=0`, but `hitPoly` lies inside the visible doorway opening | **Z** (BSPQuery flaw) | Audit `BSPQuery.FindCollisions` against named-retail [`BSPTREE::find_collisions` at 323725](../../research/named-retail/acclient_2013_pseudo_c.txt). |
| Mixed / inconclusive | Slice 1.5 | Expand the probe to dump the entire BSP traversal path for one frame. |
### Tests (synthetic only)
Three tests under `tests/AcDream.Core.Tests/Physics/`:
1. **`PhysicsDiagnosticsTests.BuildingProbe_GatesByEnvVar`** — verify the static flag gates output. Set `PhysicsDiagnostics.ProbeBuilding = false`, run a synthetic hit, assert no `[resolve-bldg]` output. Set to true, repeat, assert output present.
2. **`FindObjCollisionsTests.Probe_FormatsHitFields`** — register a synthetic BSP `ShadowEntry` with a 4-vertex known polygon (vertices and plane explicitly chosen), sweep a sphere into it, assert the emitted line contains the expected `partIdx`, `bspR` (within `±0.01`), `hitPoly.numVerts=4`, and `v0_world` (within `±0.01`).
3. **`FindObjCollisionsTests.Probe_CylinderHit_DumpsNa`** — register a synthetic cylinder `ShadowEntry`, sweep a sphere into it, assert the emitted line contains the literal substring `hitPoly: n/a (cylinder)`.
Output capture: tests redirect `Console.Out` to a `StringWriter`, run the action, read back, assert.
**No real-DAT fixtures in slice 1.** The Holtburg-doorway live capture is the slice's evidence.
### Acceptance criteria
1. `dotnet build` green; the 3 new tests green. (8 pre-existing failures unchanged — these are *not* in scope for slice 1; see *Operational notes*.)
2. Launch with `ACDREAM_PROBE_BUILDING=1 ACDREAM_PROBE_RESOLVE=1 ACDREAM_DEVTOOLS=1`, walk acdream up to a Holtburg town doorway, hold W for ~2 seconds, close. The captured log contains:
- One `[entity-source]` line per registered `ShadowEntry` for the player's neighborhood landblocks.
- One `[resolve-bldg]` line per `[resolve] ... hit=yes` line.
3. The trace permits a ≤5-line "hypothesis X / Y / Z" memo with concrete evidence pointing at slice 2's form.
4. Plan-of-record L.2d section's "Current sub-direction" paragraph rewritten to match this spec's *Reframe* section.
---
## Slice 2 — The actual fix (sketch, scoped post-slice-1)
Slice 2's exact form depends on slice 1's evidence. Outline only:
- **If X**: Add a fixture test to `PhysicsDataCacheTests` that loads a real Holtburg building GfxObj from the DAT, verifies `Resolved` polygon plane normals + counts match retail-extracted ground-truth (via Binary Ninja PDB dump of `physics_polygons` in a known building DID). Then fix the cache's BSP-selection logic. Conformance-cited.
- **If Y**: Add `EntityProvenance` enum (`LandblockBuilding | Stab | Scenery | EnvCellStab | ServerSpawn`) — minimal version, populated at construction in `LandblockLoader` + `GameWindow.BuildInteriorEntitiesForStreaming`. In the MeshRefs loop, gate "register every MeshRef with non-null BSP root" → "register `MeshRefs[0]` only when `Provenance == LandblockBuilding`". Cite [`BuildingObj.cs:45`](../../../references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs) + `acclient_2013_pseudo_c.txt:701268`.
- **If Z**: Side-by-side audit. Pull `BSPQuery.FindCollisions` open against [`BSPTREE::find_collisions`](../../research/named-retail/acclient_2013_pseudo_c.txt) (lines 323725...). Annotate each branch. Fix whichever branch doesn't match.
In all three cases slice 2 is expected to be ~one commit, ~50100 LOC plus a real-DAT fixture test.
---
## Slice 3+ — Optional (post-slice-2 conformance + L.2f)
After slice 2 lands and visual-verified at Holtburg:
- Real-DAT fixture tests for additional known buildings (Yaraq inn, Arwic chapel, dungeon entrance portal frames) — proves the fix isn't Holtburg-specific.
- Folded into L.2f (real-DAT + retail-observer conformance) per the plan-of-record.
- Promote to "L.2d shipped" once at least three building geometries pass conformance both synthetic and live.
---
## Named retail anchors
Primary source: [`docs/research/named-retail/acclient_2013_pseudo_c.txt`](../../research/named-retail/acclient_2013_pseudo_c.txt).
Cross-reference C# port: [`references/ACE/Source/ACE.Server/Physics/`](../../../references/ACE/Source/ACE.Server/Physics/).
| Symbol | PDB Address | Pseudo-C line | Role |
|---|---|---|---|
| `CBuildingObj::find_building_collisions` | `0x006b5300` | 701260 | 6-line entry: sets `bldg_check`, calls `CPhysicsPart::find_obj_collisions` on `Parts[0]` only |
| `CBuildingObj::find_building_transit_cells` | `0x006b5230`, `0x006b52a0` | 701214, 701237 | iterates `Portals`, dispatches to `CEnvCell::check_building_transit` — L.2e territory |
| `CSortCell::find_collisions` | `0x005340a0` | 318337 | LandCell-with-building override; delegates to `CBuildingObj::find_building_collisions` |
| `CPhysicsPart::find_obj_collisions` | `0x0050d8d0` | 275045 | calls `CGfxObj::find_obj_collisions` on its single GfxObj |
| `CGfxObj::find_obj_collisions` | `0x00534700` | 318793 | bounding-sphere broadphase, then calls `BSPTREE::find_collisions` on `this->physics_bsp` |
| `BSPTREE::find_collisions` | `0x0053a440` | 323725 | 6-path dispatcher; `bldg_check` only read in the placement-insert / obstruction-ethereal branch (323744323751) |
| `bldg_check` (SPHEREPATH field) | offset `0x0` in flagblock at `0x00841e7c` | 1155234 | flag, set/cleared by `CBuildingObj::find_building_collisions` |
| `CObjCell::find_cell_list` | `0x0052b4e0` | 308742 | builds `CELLARRAY` of cells overlapping the sphere; **L.2e**, not L.2d |
| `CCellStruct::point_in_cell` | `0x005338f0` | 317657 | tailcalls `BSPTREE::point_inside_cell_bsp`; **L.2e** |
| `CCellStruct::sphere_intersects_cell` | `0x00533900` | 317666 | tailcalls `BSPTREE::sphere_intersects_cell_bsp`; **L.2e** |
| `CCellStruct::box_intersects_cell` | `0x00533910` | 317675 | tailcalls `BSPTREE::box_intersects_cell_bsp`; **L.2e** |
The bottom four anchors are listed because the original handoff named them as L.2d anchors; per the *Reframe* they are not. They remain L.2e anchors.
---
## Operational notes
### Worktree build-env precondition
This worktree at `.claude/worktrees/sharp-chatelet-023dda` is missing `references/` (gitignored except WorldBuilder, which is a submodule that wasn't initialized when the worktree was created). Build fails with unresolved `Chorizite` / `WorldBuilder` / `TerrainEntry` types.
Resolution before slice 1 implementation (decided 2026-05-13: option (i)):
1. `git submodule update --init --recursive references/WorldBuilder` — populates the tracked submodule in this worktree.
2. Directory junctions for the 6 gitignored peer reference dirs from the main checkout:
- `references/ACE`, `references/ACViewer`, `references/Chorizite.ACProtocol`, `references/AC2D`, `references/DatReaderWriter`, `references/holtburger`.
- Windows: `cmd /c mklink /J references/<X> C:\Users\erikn\source\repos\acdream\references\<X>`.
After resolution: `dotnet build` succeeds, and the 8 pre-existing test failures become observable for triage (separate concern; not in slice 1).
### Pre-existing test failures (not in scope)
8 tests fail at the branch base (verified by stash + rerun in the L.2a session). They are *not* introduced by L.2a or slice 1. Most touch movement/physics code:
- `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}`
Acceptance criterion 1 says "8 pre-existing failures unchanged" — slice 1's tests must not introduce new failures, but must not be blocked by these pre-existing ones either. The BSPStepUp two are in the same module slice 1 touches; verify they remain failing in the same way post-slice-1.
Triage is a sibling task — recommend a `triage-failing-tests` slice between L.2d slice 1 and slice 2, since slice 2 may evolve `BSPQuery` (under hypothesis Z) or movement registration (under hypothesis Y), and trying to fix a moving target is wasted effort.
### Live-test reproduction recipe
```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_CELL = "1"
$env:ACDREAM_PROBE_RESOLVE = "1"
$env:ACDREAM_PROBE_BUILDING = "1"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 |
Tee-Object -FilePath "launch-l2d-slice1.log"
```
Walk acdream to a Holtburg town doorway. Hold W for ~2 seconds. Close. Grep `launch-l2d-slice1.log` for:
- `\[entity-source\]` — registered ShadowEntry inventory
- `\[resolve-bldg\]` — per-hit BSP diagnostic
The L.2a probes (`[resolve]`, `[cell-transit]`) should still fire interleaved.
### Verification: L.2a probes still work
Before slice 1 implementation, relaunch with `ACDREAM_PROBE_RESOLVE=1 ACDREAM_PROBE_CELL=1 ACDREAM_DEVTOOLS=1` (NOT `ACDREAM_PROBE_BUILDING` — it doesn't exist yet on the branch base) and confirm `[resolve]` / `[cell-transit]` lines still emit. Validates the branch-base L.2a foundation is intact and acceptance criterion 2 of slice 1 is testable.
---
## Slice plan
| Slice | Commit | Touches | Conformance citation |
|---|---|---|---|
| **1** | `feat(phys L.2d slice 1): BSP-hit diagnostic probe + plan-of-record correction` | `PhysicsDiagnostics.cs`, `TransitionTypes.cs`, `BSPQuery.cs`, `GameWindow.cs`, `DebugPanel.cs`, `DebugVM.cs`, `2026-04-29-movement-collision-conformance.md`, 3 new tests under `tests/AcDream.Core.Tests/Physics/` | `acclient_2013_pseudo_c.txt:701260` (`CBuildingObj::find_building_collisions`), `ACE BuildingObj.cs:39-52`, `acclient_2013_pseudo_c.txt:323725` (`BSPTREE::find_collisions`) |
| **2** | TBD post-slice-1 evidence | depends on X/Y/Z | as appropriate per hypothesis |
| **3+** | TBD (folded into L.2f conformance) | real-DAT fixtures at additional buildings | retail PDB dump of `physics_polygons` for each fixture |
Slice 1 is **one commit**, ~150 LOC code + ~80 LOC tests + ~20 LOC doc correction.
---
## Decision log
- **2026-05-13 (this spec):** Reframed L.2d from "port CBuildingObj + per-cell walkability" to "diagnostic + minimal fix" after [ACE BuildingObj.cs:39-52](../../../references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs) review revealed retail's `find_building_collisions` is one BSP test on `Parts[0]` with no per-cell walkability involvement.
- **2026-05-13:** Picked diagnostic-first slice 1 (option A in brainstorm) over a faithful `BuildingObj` port. Rationale: the plan-of-record's premise was wrong, so committing to a multi-day port before knowing the actual cause risks redoing the design.
- **2026-05-13:** Probe field set = level C (full poly dump). Rationale: distinguishes all three hypotheses in one capture without expansion later.
- **2026-05-13:** Classification source = option A (skip `classified=`, rely on grep-by-entityId). Rationale: YAGNI; if `Provenance` becomes load-bearing for slice 2 (hypothesis Y), introduce it then.
- **2026-05-13:** Doc-update aggressiveness = option A (inline-correct the L.2d section in plan-of-record only). Rationale: doc drift is forbidden by CLAUDE.md.
- **2026-05-13:** Worktree env resolution = option (i) (submodule init + junctions). Rationale: preserves worktree convention.
---
## References
- L.2 plan-of-record: [docs/plans/2026-04-29-movement-collision-conformance.md](../../plans/2026-04-29-movement-collision-conformance.md)
- L.2a handoff: [docs/research/2026-05-12-l2a-shipped-l2d-handoff.md](../../research/2026-05-12-l2a-shipped-l2d-handoff.md)
- Named-retail pseudo-C: [docs/research/named-retail/acclient_2013_pseudo_c.txt](../../research/named-retail/acclient_2013_pseudo_c.txt)
- Named-retail symbol map: [docs/research/named-retail/symbols.json](../../research/named-retail/symbols.json)
- ACE BuildingObj: [references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs](../../../references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs)
- ACE SortCell: [references/ACE/Source/ACE.Server/Physics/Common/SortCell.cs](../../../references/ACE/Source/ACE.Server/Physics/Common/SortCell.cs)
- ACE Landblock: [references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs](../../../references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs)
- Current physics surface: [src/AcDream.Core/Physics/](../../../src/AcDream.Core/Physics/)