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:
commit
d1d02c34c2
10 changed files with 921 additions and 24 deletions
|
|
@ -169,22 +169,40 @@ fallback.
|
||||||
- Audit `Setup.Radius` and cylinder fallback behavior against retail before
|
- Audit `Setup.Radius` and cylinder fallback behavior against retail before
|
||||||
relying on them for conformance.
|
relying on them for conformance.
|
||||||
|
|
||||||
Current sub-direction (2026-05-12, evidence-driven by L.2a slice 2 + 3):
|
Current sub-direction (revised 2026-05-13 evening after slice 1 + 1.5
|
||||||
The "I can't walk through doorways" symptom at Holtburg is **NOT a door-
|
shipped and Holtburg-doorway capture analyzed — third reframe):
|
||||||
state-toggle issue**. The `[resolve]` probe captured 140 hit=yes lines
|
L.2d as scoped ("shape fidelity: Sphere / CylSphere / Building Objects")
|
||||||
at the doorway with `obj=0xA9B47900` (126 hits) — a landblock-baked
|
is **essentially closed at the Holtburg site that motivated this phase**.
|
||||||
static in the `0xLLLLxxxx` range, i.e. the **building itself**, not a
|
Building BSP collision works correctly — the slice-1.5 probe captured
|
||||||
door entity (no `0xCC0Cxxxx`-range hits). The building's baked collision
|
real triangles in plausible world positions for `gfxObj=0x01000A2B` with
|
||||||
mesh is treated as one solid block; the doorway gap that's visible in
|
`bspR=13.99m`. The 121 wall hits the L.2a probe attributed to
|
||||||
the rendered mesh isn't represented in the collision data we consume.
|
`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
|
The actual blocker is a server-spawned **Door** entity — Setup
|
||||||
walkability port (interpretation 2 of the handoff). The named retail
|
`0x020019FF` named `"Door"` — that ACE places at each Holtburg-town
|
||||||
anchors `CCellStruct::point_in_cell`, `CCellStruct::sphere_intersects_cell`,
|
building threshold (five doors total observed across `0xA9B40029`,
|
||||||
`CCellStruct::box_intersects_cell`, `CBuildingObj::find_building_collisions`
|
`0xA9B40154`, `0xA9B40155`). It registers as a Cylinder shadow entry
|
||||||
are the entry points. Spec to be written at
|
via the server-spawn path; its Cylinder collision blocks the player
|
||||||
`docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md`.
|
walking into the doorway. That's **door-state handling**, a different
|
||||||
Handoff: [docs/research/2026-05-12-l2a-shipped-l2d-handoff.md](../research/2026-05-12-l2a-shipped-l2d-handoff.md).
|
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
|
### L.2e - Cell Ownership: Outdoor Seams, CELLARRAY, cell_bsp
|
||||||
|
|
||||||
|
|
|
||||||
251
docs/research/2026-05-13-l2d-slice1-shipped-handoff.md
Normal file
251
docs/research/2026-05-13-l2d-slice1-shipped-handoff.md
Normal 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.
|
||||||
|
|
@ -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 323744–323751). 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 ~1544–1549 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, ~50–100 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 (323744–323751) |
|
||||||
|
| `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/)
|
||||||
|
|
@ -2973,6 +2973,10 @@ public sealed class GameWindow : IDisposable
|
||||||
AcDream.Core.Physics.ShadowCollisionType.Cylinder,
|
AcDream.Core.Physics.ShadowCollisionType.Cylinder,
|
||||||
cylHeight: height, scale: 1.0f,
|
cylHeight: height, scale: 1.0f,
|
||||||
state: state, flags: flags);
|
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)
|
private bool RemoveLiveEntityByServerGuid(uint serverGuid, bool logDelete)
|
||||||
|
|
@ -5533,6 +5537,12 @@ public sealed class GameWindow : IDisposable
|
||||||
origin.X, origin.Y, lb.LandblockId,
|
origin.X, origin.Y, lb.LandblockId,
|
||||||
AcDream.Core.Physics.ShadowCollisionType.BSP, 0f,
|
AcDream.Core.Physics.ShadowCollisionType.BSP, 0f,
|
||||||
partScale);
|
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++;
|
entityBsp++;
|
||||||
partIndex++;
|
partIndex++;
|
||||||
|
|
@ -5584,6 +5594,10 @@ public sealed class GameWindow : IDisposable
|
||||||
entity.Rotation, cylRadius,
|
entity.Rotation, cylRadius,
|
||||||
origin.X, origin.Y, lb.LandblockId,
|
origin.X, origin.Y, lb.LandblockId,
|
||||||
AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight);
|
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++;
|
entityCyl++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5614,6 +5628,10 @@ public sealed class GameWindow : IDisposable
|
||||||
entity.Rotation, sphRadius,
|
entity.Rotation, sphRadius,
|
||||||
origin.X, origin.Y, lb.LandblockId,
|
origin.X, origin.Y, lb.LandblockId,
|
||||||
AcDream.Core.Physics.ShadowCollisionType.Cylinder, sphHeight);
|
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++;
|
entityCyl++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5632,6 +5650,10 @@ public sealed class GameWindow : IDisposable
|
||||||
entity.Position, entity.Rotation, fr,
|
entity.Position, entity.Rotation, fr,
|
||||||
origin.X, origin.Y, lb.LandblockId,
|
origin.X, origin.Y, lb.LandblockId,
|
||||||
AcDream.Core.Physics.ShadowCollisionType.Cylinder, fh);
|
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++;
|
entityCyl++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -5813,6 +5835,10 @@ public sealed class GameWindow : IDisposable
|
||||||
baseCenter, entity.Rotation, cylRadius,
|
baseCenter, entity.Rotation, cylRadius,
|
||||||
origin.X, origin.Y, lb.LandblockId,
|
origin.X, origin.Y, lb.LandblockId,
|
||||||
AcDream.Core.Physics.ShadowCollisionType.Cylinder, cylHeight);
|
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++;
|
entityCyl++;
|
||||||
if (_isScenery) scRegistered++;
|
if (_isScenery) scRegistered++;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1214,15 +1214,29 @@ public static class BSPQuery
|
||||||
if (!obj.State.HasFlag(ObjectInfoState.PerfectClip))
|
if (!obj.State.HasFlag(ObjectInfoState.PerfectClip))
|
||||||
{
|
{
|
||||||
collisions.SetCollisionNormal(collisionNormal);
|
collisions.SetCollisionNormal(collisionNormal);
|
||||||
|
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||||
|
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||||
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly;
|
||||||
return TransitionState.Collided;
|
return TransitionState.Collided;
|
||||||
}
|
}
|
||||||
|
|
||||||
var validPos = new CollisionSphere(checkPos);
|
var validPos = new CollisionSphere(checkPos);
|
||||||
|
|
||||||
if (!AdjustToPlane(root, resolved, validPos, curPos, hitPoly, contactPoint))
|
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;
|
return TransitionState.Collided;
|
||||||
|
}
|
||||||
|
|
||||||
collisions.SetCollisionNormal(collisionNormal);
|
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;
|
var adjusted = validPos.Center - checkPos.Center;
|
||||||
// ACE: path.LocalSpacePos.LocalToGlobalVec(adjusted) * scale
|
// ACE: path.LocalSpacePos.LocalToGlobalVec(adjusted) * scale
|
||||||
|
|
@ -1530,6 +1544,16 @@ public static class BSPQuery
|
||||||
|
|
||||||
if (hit0 || hitPoly0 is not null)
|
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);
|
var worldNormal = L2W(hitPoly0!.Plane.Normal);
|
||||||
// L.2.3b (2026-04-29): recursion guard. Retail
|
// L.2.3b (2026-04-29): recursion guard. Retail
|
||||||
// (acclient_2013_pseudo_c.txt:272954) gates step_sphere_up on
|
// (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)
|
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);
|
var worldNormal = L2W(hitPoly1!.Plane.Normal);
|
||||||
// L.2.3b: same recursion guard as the foot-sphere branch.
|
// L.2.3b: same recursion guard as the foot-sphere branch.
|
||||||
if (engine is not null && !path.StepUp && !path.StepDown)
|
if (engine is not null && !path.StepUp && !path.StepDown)
|
||||||
|
|
@ -1638,6 +1668,9 @@ public static class BSPQuery
|
||||||
|
|
||||||
collisions.SetCollisionNormal(worldNormal0);
|
collisions.SetCollisionNormal(worldNormal0);
|
||||||
collisions.SetSlidingNormal(worldNormal0);
|
collisions.SetSlidingNormal(worldNormal0);
|
||||||
|
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||||
|
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||||
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
||||||
return TransitionState.Slid;
|
return TransitionState.Slid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1645,6 +1678,9 @@ public static class BSPQuery
|
||||||
// Per retail (acclient_2013_pseudo_c.txt:323783-323821).
|
// Per retail (acclient_2013_pseudo_c.txt:323783-323821).
|
||||||
path.SetCollide(worldNormal0);
|
path.SetCollide(worldNormal0);
|
||||||
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
||||||
|
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||||
|
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||||
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
|
||||||
return TransitionState.Adjusted;
|
return TransitionState.Adjusted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1672,12 +1708,18 @@ public static class BSPQuery
|
||||||
|
|
||||||
collisions.SetCollisionNormal(worldNormal1);
|
collisions.SetCollisionNormal(worldNormal1);
|
||||||
collisions.SetSlidingNormal(worldNormal1);
|
collisions.SetSlidingNormal(worldNormal1);
|
||||||
|
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||||
|
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||||
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||||
return TransitionState.Slid;
|
return TransitionState.Slid;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Head sphere hit shallow surface: SetCollide.
|
// Head sphere hit shallow surface: SetCollide.
|
||||||
path.SetCollide(worldNormal1);
|
path.SetCollide(worldNormal1);
|
||||||
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
path.WalkableAllowance = PhysicsGlobals.LandingZ;
|
||||||
|
// L.2d slice 1 (2026-05-13): diagnostic side-channel.
|
||||||
|
if (PhysicsDiagnostics.ProbeBuildingEnabled)
|
||||||
|
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
|
||||||
return TransitionState.Adjusted;
|
return TransitionState.Adjusted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,11 +10,11 @@ namespace AcDream.Core.Physics;
|
||||||
/// without relaunching.
|
/// without relaunching.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Slice 1 ships <see cref="ProbeResolveEnabled"/> +
|
/// L.2d slice 1 (2026-05-13) adds <see cref="ProbeBuildingEnabled"/> +
|
||||||
/// <see cref="ProbeCellEnabled"/>. Future slices may fold the older
|
/// the <see cref="LastBspHitPoly"/> diagnostic side-channel. Future
|
||||||
/// <c>ACDREAM_DUMP_*</c> env vars into this class for unified runtime
|
/// slices may fold the older <c>ACDREAM_DUMP_*</c> env vars into this
|
||||||
/// toggling. Until then, those older flags remain sticky-at-startup
|
/// class for unified runtime toggling. Until then, those older flags
|
||||||
/// per their original implementation.
|
/// remain sticky-at-startup per their original implementation.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class PhysicsDiagnostics
|
public static class PhysicsDiagnostics
|
||||||
|
|
@ -37,4 +37,61 @@ public static class PhysicsDiagnostics
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static bool ProbeCellEnabled { get; set; } =
|
public static bool ProbeCellEnabled { get; set; } =
|
||||||
Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL") == "1";
|
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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1469,6 +1469,15 @@ public sealed class Transition
|
||||||
// the [resolve] probe surfaces the responsible entity id.
|
// the [resolve] probe surfaces the responsible entity id.
|
||||||
bool collisionWasValidPre = ci.CollisionNormalValid;
|
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;
|
TransitionState result;
|
||||||
|
|
||||||
if (obj.CollisionType == ShadowCollisionType.BSP)
|
if (obj.CollisionType == ShadowCollisionType.BSP)
|
||||||
|
|
@ -1541,13 +1550,78 @@ public sealed class Transition
|
||||||
// entity id. CollideObjectGuids carries the full chain; the last
|
// entity id. CollideObjectGuids carries the full chain; the last
|
||||||
// assignment to LastCollidedObjectGuid wins which matches retail's
|
// assignment to LastCollidedObjectGuid wins which matches retail's
|
||||||
// "most recent" semantics for the probe.
|
// "most recent" semantics for the probe.
|
||||||
if (result != TransitionState.OK
|
bool attributed = result != TransitionState.OK
|
||||||
|| (!collisionWasValidPre && ci.CollisionNormalValid))
|
|| (!collisionWasValidPre && ci.CollisionNormalValid);
|
||||||
|
if (attributed)
|
||||||
{
|
{
|
||||||
ci.CollideObjectGuids.Add(obj.EntityId);
|
ci.CollideObjectGuids.Add(obj.EntityId);
|
||||||
ci.LastCollidedObjectGuid = 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 (result != TransitionState.OK)
|
||||||
{
|
{
|
||||||
if (airborneDiag)
|
if (airborneDiag)
|
||||||
|
|
|
||||||
|
|
@ -203,8 +203,9 @@ public sealed class DebugPanel : IPanel
|
||||||
bool dumpVitals = _vm.DumpVitals;
|
bool dumpVitals = _vm.DumpVitals;
|
||||||
bool dumpOpcodes = _vm.DumpOpcodes;
|
bool dumpOpcodes = _vm.DumpOpcodes;
|
||||||
bool dumpSky = _vm.DumpSky;
|
bool dumpSky = _vm.DumpSky;
|
||||||
bool probeResolve = _vm.ProbeResolve;
|
bool probeResolve = _vm.ProbeResolve;
|
||||||
bool probeCell = _vm.ProbeCell;
|
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 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 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.
|
// 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 resolve (ACDREAM_PROBE_RESOLVE)", ref probeResolve)) _vm.ProbeResolve = probeResolve;
|
||||||
if (r.Checkbox("Probe cell-transit (ACDREAM_PROBE_CELL)",ref probeCell)) _vm.ProbeCell = probeCell;
|
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();
|
r.Spacing();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -261,6 +261,20 @@ public sealed class DebugVM
|
||||||
set => PhysicsDiagnostics.ProbeCellEnabled = value;
|
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 ──────────────────────────
|
// ── Action hooks invoked by panel buttons ──────────────────────────
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
98
tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs
Normal file
98
tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue