Merge branch 'claude/sharp-chatelet-023dda' — Phase L.2d slice 1 + 1.5 (BSP-hit diagnostic probe; doorway blocker identified as Door entity, not building BSP)

This commit is contained in:
Erik 2026-05-12 19:47:24 +02:00
commit d1d02c34c2
10 changed files with 921 additions and 24 deletions

View file

@ -169,22 +169,40 @@ 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.
Current sub-direction (revised 2026-05-13 evening after slice 1 + 1.5
shipped and Holtburg-doorway capture analyzed — third reframe):
L.2d as scoped ("shape fidelity: Sphere / CylSphere / Building Objects")
is **essentially closed at the Holtburg site that motivated this phase**.
Building BSP collision works correctly — the slice-1.5 probe captured
real triangles in plausible world positions for `gfxObj=0x01000A2B` with
`bspR=13.99m`. The 121 wall hits the L.2a probe attributed to
`obj=0xA9B47900` were **side effects of the player already being pushed
back by a separate Door cylinder entity** at the same doorway threshold.
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).
The actual blocker is a server-spawned **Door** entity — Setup
`0x020019FF` named `"Door"` — that ACE places at each Holtburg-town
building threshold (five doors total observed across `0xA9B40029`,
`0xA9B40154`, `0xA9B40155`). It registers as a Cylinder shadow entry
via the server-spawn path; its Cylinder collision blocks the player
walking into the doorway. That's **door-state handling**, a different
class of problem from L.2d's shape-fidelity scope — it touches network
(`CreateObject` PhysicsState bits), interaction (Use action on door
entity), animation (door open/close), and collision-state-toggle.
Recommend: **leave L.2d in "watch-and-wait" mode** with slice 1's probe
infrastructure in place. No more L.2d slices until a NEW shape-fidelity
bug is observed at a different site (dungeon walls, stairs, roofs) with
the probe-armed client. The door-state work becomes its own sub-phase
(probably nested under B.4 interaction or filed as a new L.2 sub-phase
like L.2g) scoped separately.
Full slice 1 + 1.5 handoff:
[docs/research/2026-05-13-l2d-slice1-shipped-handoff.md](../research/2026-05-13-l2d-slice1-shipped-handoff.md).
Design spec (now mostly historical, framing was wrong but probe
infrastructure shipped from it):
[docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md](../superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md).
Predecessor L.2a 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

View file

@ -0,0 +1,251 @@
# L.2d slice 1 + 1.5 shipped — handoff
**Date:** 2026-05-13 evening, immediately after slice 1.5 + Holtburg verification.
**Branch:** `claude/sharp-chatelet-023dda` (ready to merge to main).
**Predecessor:** [2026-05-12-l2a-shipped-l2d-handoff.md](2026-05-12-l2a-shipped-l2d-handoff.md).
---
## TL;DR
The "I can't walk through Holtburg doorways" symptom is **a closed Door
entity blocking the threshold**, not a building-collision-mesh bug.
Building BSP collision is healthy. The L.2a handoff's framing
("per-cell walkability missing") was wrong, the L.2d-slice-1 spec's
reframe ("BSP shape fidelity, three hypotheses X/Y/Z") was also
wrong, and the actual answer fell out of one capture once the probe
labeling was fixed (slice 1.5). **L.2d as scoped is essentially
closed.** The remaining work is door-state handling — a different
sub-phase entirely.
---
## What shipped on this branch
| Commit | What |
|---|---|
| [`92cd723`](.) | `docs(phys L.2d): design spec for slice 1 BSP-hit diagnostic + L.2d reframe` |
| [`66dc23e`](.) | `feat(phys L.2d slice 1): BSP-hit diagnostic probe + plan-of-record correction` |
| [`8bacef0`](.) | `fix(phys L.2d slice 1.5): probe captures hit poly under StepSphereUp recursion` |
What slice 1 + 1.5 give the next agent:
- **`ACDREAM_PROBE_BUILDING=1`** env var + DebugPanel checkbox: one
multi-line `[resolve-bldg]` entry per attributed BSP shadow-entry hit
(partIdx, hasPhys, bspR vs vAabbR, world-space entOrigin_lb, actual
hit polygon vertices in both local and world coords). Reliable
under `StepSphereUp` recursion after the slice 1.5 fix.
- **`[entity-source]`** one-time log line per `ShadowObjects.Register`
call, gated on the same flag. Makes `entityId=0xA9B479` in a
probe line greppable to its WorldEntity source.
- **`PhysicsDiagnostics.LastBspHitPoly`** — diagnostic side-channel
for any future "what poly did BSPQuery hit" question.
- **The two synthetic tests** in
[PhysicsDiagnosticsTests.cs](../../tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs)
pin the side-channel API contract.
---
## What the trace actually showed
After slice 1.5, walking acdream into a Holtburg town doorway
captured 242 real BSP hit polys + 122 cylinder n/a. **Definitive
finding:**
```
live: spawn guid=0x7A9B4015 name="Door" setup=0x020019FF
pos=(132.6,17.1,94.1)@0xA9B40029 itemType=0x00000080
[entity-source] id=0x000F4244 entityId=0x000F4244 src=0x020019FF
gfxObj=0x020019FF lb=0xA9B40029 type=Cylinder note=server-spawn-root
```
The blocker is a **Door entity** — Setup `0x020019FF` named `"Door"`
server-spawned by ACE at the threshold of each Holtburg town building.
**Five Doors** appear across Holtburg (landblock cells `0xA9B40029`,
`0xA9B40154`, `0xA9B40155`); same Setup DID reused. ItemType
`0x00000080` = Misc category in AC's ItemType flags.
Each Door's Cylinder collision blocks the player. The building BSP
*also* fires (the L.2a evidence the original handoff pointed at), but
the BSP hits were the player **already pushed back by the Door
cylinder** then grazing the doorframe — they look like wall collision
but are a side effect of the Door cylinder push. Slice 1.5's per-tick
multi-entity probe revealed this by showing `nObj=3` on every hit
resolve: one Door + two sphere checks against the building BSP.
The L.2a slice 2 handoff's expectation that doors would be in the
`0xCC0Cxxxx` range was wrong; **doors are in `0x000Fxxxx`** (server-
spawn-root range) because they're hydrated through the live
`CreateObject` stream like NPCs, not the static landblock pipeline.
---
## What this means for L.2d
L.2d as originally scoped ("Shape Fidelity: Sphere / CylSphere /
Building Objects") is essentially **closed at this site**:
- Building BSP is loaded, parsed, queried correctly. `bspR=13.99m` for
GfxObj `0x01000A2B`, real triangles in real positions.
- `Setup.CylSpheres` for Door (`0x020019FF`) is also loaded correctly
— the cylinder is firing the cylinder collision path with sensible
world-space radius.
- No actual shape-fidelity bug observed at this test site.
The remaining work is **door state handling**, which is a different
class of problem entirely — it touches network (CreateObject
PhysicsState bits), interaction (Use action on door entity), animation
(door open/close animation state), and collision-state-toggle
(ETHEREAL during open animation). That doesn't fit under L.2d's
shape-fidelity umbrella.
**Recommend reframing L.2d as "watch-and-wait":** keep the probes for
future shape-fidelity work at other sites (dungeon walls, stairs,
roofs), but don't plan more slices until a NEW shape-fidelity bug is
observed with the probe-armed client.
---
## Side findings (latent bugs to file, not block this slice)
### 1. Building double-registration
The trace shows the same WorldEntity registered TWICE in
ShadowObjectRegistry:
```
[entity-source] id=0xA9B47900 entityId=0xC0A9B479 ... type=BSP note=partIdx=0 hasPhys=true
[entity-source] id=0xC0A9B479 entityId=0xC0A9B479 ... type=Cylinder note=mesh-aabb-fallback
```
[GameWindow.cs:5625](../../src/AcDream.App/Rendering/GameWindow.cs:5625)
gates the mesh-AABB-fallback on `entityBsp == 0`, but the BSP
registration at [line 5530](../../src/AcDream.App/Rendering/GameWindow.cs:5530)
DOES increment `entityBsp`. So the fallback shouldn't fire when BSP
parts exist. Either `entityBsp` isn't being checked in the right
scope, or there's a second mesh-AABB-fallback site that doesn't gate
on `entityBsp`. Worth a short investigation + one-line fix.
Filing as ISSUE candidate. Doesn't break anything observable yet
(cylinder is too far from player to fire at this Holtburg site), but
will cause confusion in any future "why does entity X have two
ShadowEntries" trace.
### 2. PhysicsState / EntityCollisionFlags not in entity-source log
The slice 1 `[entity-source]` log captures `id, entityId, src,
gfxObj, lb, type, note, hasPhys` but **not** `state` (PhysicsState
bits) or `flags` (EntityCollisionFlags). For any future
ethereal-handling / IGNORE_COLLISIONS work — including the door
state handling above — these would be required.
Tiny slice 1.6 if the next agent needs them: add `state=0x{...:X8}
flags={...}` to the format string. ~5 LOC, gated on the same
ProbeBuilding flag.
---
## What the next session probably should NOT do
- **Re-investigate Holtburg doorways with the same setup.** The
evidence is conclusive; we're not going to find new information by
re-running the probe at the same site.
- **Port `CBuildingObj` or per-cell walkability infrastructure.**
That was based on the original (wrong) hypothesis. ACE's
`find_building_collisions` is six lines and doesn't use per-cell
walkability; our equivalent is already in place implicitly.
- **Start L.2d slice 2 as scoped in the design spec.** Hypotheses X /
Y / Z don't apply — the trace ruled them all out. Update or close
the spec.
---
## What the next session COULD do (in rough preference order)
These are NOT prescribed; they're candidates for the project-level
ordering discussion the user wants to have.
1. **Door state handling sub-phase.** New phase (call it L.2g or
nest under B.4). Touches: Use action → server door toggle,
PhysicsState ETHEREAL bit honor, door open/close animation,
collision-shape suppression during open animation. Probably
2-3 commits.
2. **Fix the building double-registration latent bug** (side
finding #1). One-liner, no real impact today but cleaner trace
later.
3. **Capture slice 1.6** (state + flags in entity-source log) if
any future ethereal-related work is on the immediate horizon.
Otherwise defer.
4. **Move to a different L.2 sub-phase entirely** — L.2e
(cell ownership / `find_cell_list` / outdoor seam updates) or
L.2f (real-DAT + retail-observer conformance). Both are scoped
in [the L.2 plan-of-record](../plans/2026-04-29-movement-collision-conformance.md).
5. **Triage the 8 pre-existing test failures** that have shadowed
the last few sessions. Some are in physics modules that L.2d
slice 2 (if it ever happens) would touch — fixing them first
gives a cleaner baseline.
6. **Pick from CLAUDE.md's "Next phase candidates"** list — non-L.2
work like Phase C visual fidelity, N.6 slice 2, or perf tiers
2/3. The session-level "I don't know what to do" feeling is
often easier to resolve by **shipping something in a different
area** for a session.
---
## Reproducibility
Same recipe as L.2a + L.2d slice 1:
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = "1"
$env:ACDREAM_TEST_HOST = "127.0.0.1"
$env:ACDREAM_TEST_PORT = "9000"
$env:ACDREAM_TEST_USER = "testaccount"
$env:ACDREAM_TEST_PASS = "testpassword"
$env:ACDREAM_DEVTOOLS = "1"
$env:ACDREAM_PROBE_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.log"
```
Walk acdream toward any Holtburg building threshold. Hit `Ctrl+F2` to
toggle collision wireframes — you'll see the Door cylinder right at
the threshold. The `name="Door"` line appears in the log at startup
during the `CreateObject` stream replay.
---
## Open questions / unresolved
- **What `PhysicsState` bits is ACE sending for the Door entity?**
Not captured in current logs. Slice 1.6 would answer this.
- **Are these doors *supposed* to be open by default in retail?**
If yes, ACE config issue. If no, retail clients see the same
blocker and players had to open them manually.
- **What does ACE's door-state state machine look like?** Probably
documented in `references/ACE/Source/ACE.Server/Entity/Door.cs`
or similar.
These are doors-and-ACE-side questions; defer to the door-state
sub-phase when (if) it gets scoped.
---
## Worktree state at handoff
- All three slice 1 / 1.5 commits ready to merge to main.
- WorldBuilder submodule initialized + 6 directory junctions in place
for the gitignored peer reference dirs (created during slice 1
prep). Worktree builds clean.
- Three test artifacts (`launch-l2d-slice1.log`, `launch-l2d-slice1b.log`,
`launch-l2d-slice1c.log`) are in working tree but **not committed**
they're large and ephemeral. Delete or preserve at the merge
author's discretion.

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

View file

@ -2973,6 +2973,10 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Physics.ShadowCollisionType.Cylinder,
cylHeight: height, scale: 1.0f,
state: state, flags: flags);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{spawn.Position.Value.LandblockId:X8} type=Cylinder note=server-spawn-root"));
}
private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete)
@ -5533,6 +5537,12 @@ public sealed class GameWindow : IDisposable
origin.X, origin.Y, lb.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.BSP, 0f,
partScale);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
// partCached?.BSP?.Root non-null was checked above (else `continue`),
// so hasPhys=true on this path.
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{partId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{meshRef.GfxObjId:X8} lb=0x{lb.LandblockId:X8} type=BSP note=partIdx={partIndex} hasPhys=true"));
entityBsp++;
partIndex++;
@ -5584,6 +5594,10 @@ public sealed class GameWindow : IDisposable
entity.Rotation, cylRadius,
origin.X, origin.Y, lb.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-cylsphere#{ci}"));
entityCyl++;
}
@ -5614,6 +5628,10 @@ public sealed class GameWindow : IDisposable
entity.Rotation, sphRadius,
origin.X, origin.Y, lb.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder, sphHeight);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-sphere#{si}"));
entityCyl++;
}
}
@ -5632,6 +5650,10 @@ public sealed class GameWindow : IDisposable
entity.Position, entity.Rotation, fr,
origin.X, origin.Y, lb.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder, fh);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{shapeId:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=setup-radius-fallback"));
entityCyl++;
}
}
@ -5813,6 +5835,10 @@ public sealed class GameWindow : IDisposable
baseCenter, entity.Rotation, cylRadius,
origin.X, origin.Y, lb.LandblockId,
AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight);
// L.2d slice 1 (2026-05-13): [entity-source] greppable from [resolve-bldg].
if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeBuildingEnabled)
Console.WriteLine(System.FormattableString.Invariant(
$"[entity-source] id=0x{entity.Id:X8} entityId=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} gfxObj=0x{entity.SourceGfxObjOrSetupId:X8} lb=0x{lb.LandblockId:X8} type=Cylinder note=mesh-aabb-fallback"));
entityCyl++;
if (_isScenery) scRegistered++;
}

View file

@ -1214,15 +1214,29 @@ public static class BSPQuery
if (!obj.State.HasFlag(ObjectInfoState.PerfectClip))
{
collisions.SetCollisionNormal(collisionNormal);
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
if (PhysicsDiagnostics.ProbeBuildingEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
return TransitionState.Collided;
}
var validPos = new CollisionSphere(checkPos);
if (!AdjustToPlane(root, resolved, validPos, curPos, hitPoly, contactPoint))
{
// L.2d slice 1 (2026-05-13): record the would-have-hit poly before
// the early-out — collisions.SetCollisionNormal isn't called on
// this path, but the caller's CollisionInfo.CollisionNormalValid
// check will catch the parent slide site's normal write instead.
if (PhysicsDiagnostics.ProbeBuildingEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
return TransitionState.Collided;
}
collisions.SetCollisionNormal(collisionNormal);
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
if (PhysicsDiagnostics.ProbeBuildingEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
var adjusted = validPos.Center - checkPos.Center;
// ACE: path.LocalSpacePos.LocalToGlobalVec(adjusted) * scale
@ -1530,6 +1544,16 @@ public static class BSPQuery
if (hit0 || hitPoly0 is not null)
{
// L.2d slice 1.5 (2026-05-13): record the hit poly EARLY,
// before the StepSphereUp branch can recurse into
// ResolveWithTransition → FindObjCollisions and clobber the
// side-channel via the inner call's per-resolve clear. Path 5
// is the dominant grounded-player path; without this the
// probe's [resolve-bldg] line for every grounded BSP hit was
// mis-labeled as "n/a (cylinder)".
if (PhysicsDiagnostics.ProbeBuildingEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
var worldNormal = L2W(hitPoly0!.Plane.Normal);
// L.2.3b (2026-04-29): recursion guard. Retail
// (acclient_2013_pseudo_c.txt:272954) gates step_sphere_up on
@ -1558,6 +1582,12 @@ public static class BSPQuery
if (hit1 || hitPoly1 is not null)
{
// L.2d slice 1.5 (2026-05-13): same early-record as foot
// sphere — head-sphere wall hits also recurse via
// StepSphereUp on the grounded path.
if (PhysicsDiagnostics.ProbeBuildingEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
var worldNormal = L2W(hitPoly1!.Plane.Normal);
// L.2.3b: same recursion guard as the foot-sphere branch.
if (engine is not null && !path.StepUp && !path.StepDown)
@ -1638,6 +1668,9 @@ public static class BSPQuery
collisions.SetCollisionNormal(worldNormal0);
collisions.SetSlidingNormal(worldNormal0);
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
if (PhysicsDiagnostics.ProbeBuildingEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
return TransitionState.Slid;
}
@ -1645,6 +1678,9 @@ public static class BSPQuery
// Per retail (acclient_2013_pseudo_c.txt:323783-323821).
path.SetCollide(worldNormal0);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
if (PhysicsDiagnostics.ProbeBuildingEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
return TransitionState.Adjusted;
}
@ -1672,12 +1708,18 @@ public static class BSPQuery
collisions.SetCollisionNormal(worldNormal1);
collisions.SetSlidingNormal(worldNormal1);
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
if (PhysicsDiagnostics.ProbeBuildingEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
return TransitionState.Slid;
}
// Head sphere hit shallow surface: SetCollide.
path.SetCollide(worldNormal1);
path.WalkableAllowance = PhysicsGlobals.LandingZ;
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
if (PhysicsDiagnostics.ProbeBuildingEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
return TransitionState.Adjusted;
}
}

View file

@ -10,11 +10,11 @@ namespace AcDream.Core.Physics;
/// 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.
/// L.2d slice 1 (2026-05-13) adds <see cref="ProbeBuildingEnabled"/> +
/// the <see cref="LastBspHitPoly"/> diagnostic side-channel. 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
@ -37,4 +37,61 @@ public static class PhysicsDiagnostics
/// </summary>
public static bool ProbeCellEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL") == "1";
/// <summary>
/// L.2d slice 1 (2026-05-13). When true, every BSP-shadow-entry hit
/// attributed by <c>TransitionTypes.FindObjCollisions</c> emits a
/// multi-line <c>[resolve-bldg]</c> entry: which part (partIdx vs 0),
/// physics-BSP root radius vs visual AABB radius, world-space entity
/// origin, and the specific hit polygon's vertices in both
/// object-local and world space. Designed to distinguish the three
/// L.2d hypotheses (wrong BSP loaded / over-registered parts /
/// BSPQuery flaw) from a single Holtburg-doorway capture.
///
/// <para>
/// Also gates a one-time <c>[entity-source]</c> log line at every
/// <c>ShadowObjects.Register(...)</c> call site in <c>GameWindow</c>
/// — makes <c>entityId=0xA9B479</c> in a probe line greppable to its
/// source registration within the same log file.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_BUILDING=1</c>. Mirrorable
/// via <c>DebugVM.ProbeBuilding</c> when <c>ACDREAM_DEVTOOLS=1</c>.
/// </para>
///
/// <para>
/// Spec: <c>docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md</c>.
/// </para>
/// </summary>
public static bool ProbeBuildingEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_BUILDING") == "1";
/// <summary>
/// L.2d slice 1 (2026-05-13). Diagnostic side-channel: the
/// <see cref="ResolvedPolygon"/> that <see cref="BSPQuery"/>
/// recorded for the most recent collision-normal write.
/// <see cref="TransitionTypes.FindObjCollisions"/> clears this to
/// <see langword="null"/> before each shadow-entry test and reads it
/// back after, so emitting the <c>[resolve-bldg]</c> probe line can
/// reference the actual hit poly without plumbing an out-param
/// through BSPQuery's recursive private methods.
///
/// <para>
/// Written by <see cref="BSPQuery"/> only when
/// <see cref="ProbeBuildingEnabled"/> is true, so this stays
/// zero-cost in normal play. Cylinder collisions leave this
/// <see langword="null"/> — the probe line emits
/// <c>hitPoly: n/a (cylinder)</c> in that case.
/// </para>
///
/// <para>
/// Not threadsafe — physics runs on a single thread. If that
/// changes, this needs <c>[ThreadStatic]</c> or rethink. Deviation
/// from spec component 4 (which described an out-param); the
/// side-channel keeps BSPQuery's signature stable and the diagnostic
/// path off the production code surface.
/// </para>
/// </summary>
public static ResolvedPolygon? LastBspHitPoly { get; set; }
}

View file

@ -1469,6 +1469,15 @@ public sealed class Transition
// the [resolve] probe surfaces the responsible entity id.
bool collisionWasValidPre = ci.CollisionNormalValid;
// L.2d slice 1.5 (2026-05-13): no per-iteration LastBspHitPoly
// clear. BSPQuery writes the side-channel early (inside
// `if (hit0 || hitPoly0 != null)` BEFORE any StepSphereUp call),
// so by the time we read it back for the [resolve-bldg] emission
// it reflects THIS entity's hit (or stays null if BSP didn't
// hit). For cylinder dispatch we key the "n/a (cylinder)" label
// off `obj.CollisionType` directly at the emission site, so a
// stale BSP value from a prior iteration can't leak through.
TransitionState result;
if (obj.CollisionType == ShadowCollisionType.BSP)
@ -1541,13 +1550,78 @@ public sealed class Transition
// 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))
bool attributed = result != TransitionState.OK
|| (!collisionWasValidPre && ci.CollisionNormalValid);
if (attributed)
{
ci.CollideObjectGuids.Add(obj.EntityId);
ci.LastCollidedObjectGuid = obj.EntityId;
}
// L.2d slice 1 (2026-05-13): emit one multi-line [resolve-bldg]
// entry per attributed hit when the per-shadow-entry probe is on.
// Captures partIdx (distinguishes hypothesis Y: over-registration),
// bspR vs vAabbR (hypothesis X: wrong BSP loaded), and the actual
// hit polygon's vertices in object-local and world space
// (hypothesis Z: BSPQuery flaw). One Holtburg-doorway capture
// resolves which hypothesis is true; slice 2 is the right-sized
// fix. Spec:
// docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md.
// Conformance anchor: ACE BuildingObj.cs:39-52 + named-retail
// acclient_2013_pseudo_c.txt:701260 (find_building_collisions is
// one BSP test on Parts[0]; doorway gap lives inside that BSP).
if (attributed && PhysicsDiagnostics.ProbeBuildingEnabled)
{
uint partIdx = obj.EntityId & 0xFFu;
uint entityIdProbe = obj.EntityId >> 8;
var cachedPhys = engine.DataCache.GetGfxObj(obj.GfxObjId);
var visBounds = engine.DataCache.GetVisualBounds(obj.GfxObjId);
float bspR = cachedPhys?.BoundingSphere?.Radius ?? 0f;
float vAabbR = visBounds?.Radius ?? 0f;
bool hasPhys = cachedPhys is not null;
var entOriginLb = obj.Position - new Vector3(worldOffsetX, worldOffsetY, 0f);
var sb = new System.Text.StringBuilder(256);
sb.Append(System.FormattableString.Invariant(
$"[resolve-bldg] obj=0x{obj.EntityId:X8} entityId=0x{entityIdProbe:X8} partIdx={partIdx}\n"));
sb.Append(System.FormattableString.Invariant(
$" gfxObj=0x{obj.GfxObjId:X8} hasPhys={hasPhys} bspR={bspR:F2} vAabbR={vAabbR:F2}\n"));
sb.Append(System.FormattableString.Invariant(
$" entOrigin_lb=({entOriginLb.X:F1},{entOriginLb.Y:F1},{entOriginLb.Z:F1})"));
var poly = PhysicsDiagnostics.LastBspHitPoly;
// L.2d slice 1.5 (2026-05-13): key the n/a label on the
// entity's CollisionType, not on LastBspHitPoly nullness —
// a BSP hit with null side-channel indicates a BSPQuery code
// path that didn't write (a bug; we should fix it, not
// pretend the entity was a cylinder).
if (obj.CollisionType == ShadowCollisionType.Cylinder)
{
sb.Append("\n hitPoly: n/a (cylinder)");
}
else if (poly is null)
{
sb.Append("\n hitPoly: n/a (BSP path — side-channel not written, missing BSPQuery wire site)");
}
else
{
sb.Append(System.FormattableString.Invariant(
$"\n hitPoly: numVerts={poly.NumPoints} plane=({poly.Plane.Normal.X:F3},{poly.Plane.Normal.Y:F3},{poly.Plane.Normal.Z:F3},{poly.Plane.D:F3})"));
int vMax = Math.Min(poly.Vertices.Length, 4);
for (int vi = 0; vi < vMax; vi++)
{
var vLocal = poly.Vertices[vi];
var vWorld = obj.Position + Vector3.Transform(vLocal * obj.Scale, obj.Rotation);
sb.Append(System.FormattableString.Invariant(
$"\n v{vi}_local=({vLocal.X,5:F2},{vLocal.Y,5:F2},{vLocal.Z,5:F2}) v{vi}_world=({vWorld.X,6:F2},{vWorld.Y,6:F2},{vWorld.Z,6:F2})"));
}
if (poly.Vertices.Length > 4)
sb.Append(System.FormattableString.Invariant(
$"\n ... ({poly.Vertices.Length - 4} more verts elided)"));
}
Console.WriteLine(sb.ToString());
}
if (result != TransitionState.OK)
{
if (airborneDiag)

View file

@ -203,8 +203,9 @@ public sealed class DebugPanel : IPanel
bool dumpVitals = _vm.DumpVitals;
bool dumpOpcodes = _vm.DumpOpcodes;
bool dumpSky = _vm.DumpSky;
bool probeResolve = _vm.ProbeResolve;
bool probeCell = _vm.ProbeCell;
bool probeResolve = _vm.ProbeResolve;
bool probeCell = _vm.ProbeCell;
bool probeBuilding = _vm.ProbeBuilding;
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;
@ -214,6 +215,11 @@ public sealed class DebugPanel : IPanel
// 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;
// L.2d slice 1 (2026-05-13): heavy per-hit BSP diagnostic for
// doorway / building shape-fidelity work. Emits multi-line
// [resolve-bldg] entries; expect log volume to spike at walls.
if (r.Checkbox("Probe BSP hits (ACDREAM_PROBE_BUILDING, slow)",
ref probeBuilding)) _vm.ProbeBuilding = probeBuilding;
r.Spacing();

View file

@ -261,6 +261,20 @@ public sealed class DebugVM
set => PhysicsDiagnostics.ProbeCellEnabled = value;
}
/// <summary>
/// L.2d slice 1 (2026-05-13). Runtime mirror of
/// <c>PhysicsDiagnostics.ProbeBuildingEnabled</c> (env var
/// <c>ACDREAM_PROBE_BUILDING</c>). Toggling here flips the per-hit
/// <c>[resolve-bldg]</c> diagnostic + the registration-time
/// <c>[entity-source]</c> log lines. Heavy when enabled — emits one
/// multi-line entry per BSP hit per physics tick.
/// </summary>
public bool ProbeBuilding
{
get => PhysicsDiagnostics.ProbeBuildingEnabled;
set => PhysicsDiagnostics.ProbeBuildingEnabled = value;
}
// ── Action hooks invoked by panel buttons ──────────────────────────
/// <summary>

View file

@ -0,0 +1,98 @@
using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using System.Numerics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// L.2d slice 1 (2026-05-13) — unit coverage for the new
/// <see cref="PhysicsDiagnostics.ProbeBuildingEnabled"/> flag and
/// <see cref="PhysicsDiagnostics.LastBspHitPoly"/> diagnostic
/// side-channel.
///
/// <para>
/// The full multi-line <c>[resolve-bldg]</c> format itself is verified
/// by the slice's acceptance criterion #2 (live Holtburg-doorway
/// capture) — covering it here would require a heavy
/// <c>PhysicsEngine</c> + <c>ShadowObjectRegistry</c> + <c>Transition</c>
/// fixture for what's a diagnostic-only emission. These tests pin the
/// static API contract that the emission code depends on; if either of
/// these tests breaks the emission will start producing stale data or
/// failing to emit at all.
/// </para>
/// </summary>
public class PhysicsDiagnosticsTests
{
// -----------------------------------------------------------------------
// ProbeBuildingEnabled — flag gates the emission path.
// -----------------------------------------------------------------------
[Fact]
public void ProbeBuilding_StaticApi_Roundtrip()
{
bool initial = PhysicsDiagnostics.ProbeBuildingEnabled;
try
{
PhysicsDiagnostics.ProbeBuildingEnabled = true;
Assert.True(PhysicsDiagnostics.ProbeBuildingEnabled);
PhysicsDiagnostics.ProbeBuildingEnabled = false;
Assert.False(PhysicsDiagnostics.ProbeBuildingEnabled);
}
finally
{
// Restore so a process-wide static doesn't leak between tests
// (env-var init was the only thing that set this before).
PhysicsDiagnostics.ProbeBuildingEnabled = initial;
}
}
// -----------------------------------------------------------------------
// LastBspHitPoly — side-channel set by BSPQuery, read by FindObjCollisions.
//
// TransitionTypes.FindObjCollisions clears this to null before each
// shadow-entry dispatch; BSPQuery writes to it on hit when the probe is
// on; the emission site reads it. A failure here means the side-channel
// can't carry data through the call chain.
// -----------------------------------------------------------------------
[Fact]
public void LastBspHitPoly_StaticApi_Roundtrip()
{
ResolvedPolygon? initial = PhysicsDiagnostics.LastBspHitPoly;
try
{
PhysicsDiagnostics.LastBspHitPoly = null;
Assert.Null(PhysicsDiagnostics.LastBspHitPoly);
var synthetic = new ResolvedPolygon
{
Vertices = new[]
{
new Vector3(-1f, 0f, 0f),
new Vector3( 1f, 0f, 0f),
new Vector3( 1f, 0f, 2f),
new Vector3(-1f, 0f, 2f),
},
Plane = new System.Numerics.Plane(0f, 1f, 0f, -94.123f),
NumPoints = 4,
SidesType = CullMode.None,
};
PhysicsDiagnostics.LastBspHitPoly = synthetic;
var read = PhysicsDiagnostics.LastBspHitPoly;
Assert.NotNull(read);
Assert.Equal(4, read!.NumPoints);
Assert.Equal(synthetic.Plane.D, read.Plane.D);
Assert.Same(synthetic, read);
PhysicsDiagnostics.LastBspHitPoly = null;
Assert.Null(PhysicsDiagnostics.LastBspHitPoly);
}
finally
{
PhysicsDiagnostics.LastBspHitPoly = initial;
}
}
}