From 92cd7238ff23dc7fc555ec1bf073824af9874535 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 12 May 2026 19:01:44 +0200 Subject: [PATCH 1/4] docs(phys L.2d): design spec for slice 1 BSP-hit diagnostic + L.2d reframe Reframes L.2d direction based on ACE BuildingObj.cs:39-52 + named-retail acclient_2013_pseudo_c.txt:701260: retail's find_building_collisions is one BSP test on PartArray.Parts[0]. No per-cell walkability. Per-cell work (find_cell_list, point_in_cell, sphere/box_intersects_cell) is L.2e territory. Slice 1 is now a read-only BSP-hit diagnostic that captures full collision evidence per L.2a [resolve] hit=yes line. Distinguishes 3 hypotheses (wrong BSP loaded / over-registered parts / BSPQuery flaw) before any behavior change. Slice 2 is the actual fix, scoped from slice 1's evidence. Authors: brainstorm session 2026-05-13 (cold-start from L.2a slice 1+2+3 evidence). Predecessor handoff at docs/research/2026-05-12-l2a-shipped-l2d-handoff.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...05-13-l2d-cbuildingobj-collision-design.md | 311 ++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md diff --git a/docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md b/docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md new file mode 100644 index 0000000..28c8ef4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md @@ -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(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/ C:\Users\erikn\source\repos\acdream\references\`. + +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/) From 66dc23e087fd6ad70fe15687ab1a8f0c64b60a2d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 12 May 2026 19:14:34 +0200 Subject: [PATCH 2/4] feat(phys L.2d slice 1): BSP-hit diagnostic probe + plan-of-record correction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds ACDREAM_PROBE_BUILDING — a read-only per-shadow-entry probe that captures full BSP collision evidence whenever TransitionTypes.FindObjCollisions attributes a hit (via the existing L.2a slice 3 chain). One multi-line [resolve-bldg] entry per attributed hit: partIdx, hasPhys, bspR vs vAabbR, world-space entOrigin_lb, and the actual hit polygon's vertices in both object-local and world space. Paired with a one-time [entity-source] line at every ShadowObjects.Register call site in GameWindow so entityId from a probe line is greppable to its WorldEntity source within a single log file. Plumbing: BSPQuery writes the resolved hit polygon to a new PhysicsDiagnostics.LastBspHitPoly side-channel at the 5 SetCollisionNormal sites in Paths 5/6 + CollideWithPt. TransitionTypes clears that field before each shadow-entry dispatch and reads it back at the L.2a slice 3 attribution site to emit the probe line. Spec component 4 originally described an out ResolvedPolygon? parameter on BSPQuery.FindCollisions; the static side-channel achieves the same observable behavior without plumbing through BSPQuery's recursive private methods. Deviation noted in PhysicsDiagnostics.LastBspHitPoly's XML doc. Reframes the plan-of-record's L.2d sub-direction paragraph: the 2026-05-12 handoff proposed porting CBuildingObj + per-cell walkability, but ACE BuildingObj.cs:39-52 + named-retail acclient_2013_pseudo_c.txt:701260 show find_building_collisions is one BSP test on Parts[0]. Per-cell walkability belongs to L.2e, not L.2d. L.2d slice 1 is the diagnostic; slice 2 is the actual fix scoped from slice 1's evidence (one of three hypotheses: wrong BSP loaded / over-registered parts / BSPQuery flaw). Tests: 2 synthetic unit tests in PhysicsDiagnosticsTests.cs pin the static API contract that the BSPQuery → side-channel → TransitionTypes emission chain depends on. The multi-line line format itself is verified by acceptance criterion 2 (live Holtburg-doorway capture) — covering it here would require a heavy PhysicsEngine + Transition fixture for a diagnostic-only emission. Verified: dotnet build green; the 2 new tests pass; the 8 pre-existing test failures listed in the L.2a handoff (MotionInterpreter GetMaxSpeed_*, PositionManager.ComputeOffset_BothActive_Combined, PlayerMovementController.Update_ForwardInput_*, Dispatcher.W_held_*, BSPStepUpTests.{D4,C3}) remain failing — none introduced by this slice. Spec: docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md Conformance anchors: - acclient_2013_pseudo_c.txt:701260 (CBuildingObj::find_building_collisions) - acclient_2013_pseudo_c.txt:323725 (BSPTREE::find_collisions) - ACE references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs:39-52 Co-Authored-By: Claude Opus 4.7 (1M context) --- ...26-04-29-movement-collision-conformance.md | 34 ++++--- src/AcDream.App/Rendering/GameWindow.cs | 26 +++++ src/AcDream.Core/Physics/BSPQuery.cs | 32 ++++++ .../Physics/PhysicsDiagnostics.cs | 67 ++++++++++++- src/AcDream.Core/Physics/TransitionTypes.cs | 67 ++++++++++++- .../Panels/Debug/DebugPanel.cs | 10 +- .../Panels/Debug/DebugVM.cs | 14 +++ .../Physics/PhysicsDiagnosticsTests.cs | 98 +++++++++++++++++++ 8 files changed, 326 insertions(+), 22 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md index dfe8057..d77901d 100644 --- a/docs/plans/2026-04-29-movement-collision-conformance.md +++ b/docs/plans/2026-04-29-movement-collision-conformance.md @@ -169,21 +169,29 @@ 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): +Current sub-direction (revised 2026-05-13 in slice 1 design spec): 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. +state-toggle issue** — the `[resolve]` probe captured 140 hit=yes lines +at the doorway with `obj=0xA9B47900` (126 hits), one specific BSP shadow +entry. The 2026-05-12 handoff initially proposed porting `CBuildingObj` + +**per-cell walkability** as the fix, but reading +[ACE 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) +shows that's **not how retail solves doorways**. `find_building_collisions` +is just one BSP test on `PartArray.Parts[0]`. The doorway gap lives +inside that part's physics BSP itself. Per-cell walkability +(`CCellStruct::point_in_cell`, `sphere_intersects_cell`, +`box_intersects_cell`, `CObjCell::find_cell_list`) is how the resolver +selects **which cells** to iterate, not how it decides whether a wall +has a hole — that work belongs to **L.2e**, not L.2d. -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`. +L.2d slice 1 is therefore a **read-only BSP-hit diagnostic** that +captures full collision evidence per `[resolve]` `hit=yes` line. +Distinguishes three hypotheses (wrong BSP loaded / over-registered +parts / BSPQuery flaw) from a single Holtburg-doorway capture; slice +2 is the right-sized fix scoped from slice 1's evidence. Design spec: +[docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md](../superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md). Handoff: [docs/research/2026-05-12-l2a-shipped-l2d-handoff.md](../research/2026-05-12-l2a-shipped-l2d-handoff.md). ### L.2e - Cell Ownership: Outdoor Seams, CELLARRAY, cell_bsp diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index e079bbd..3cc4b15 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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++; } diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 0cb17e4..6c7178b 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -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 @@ -1545,6 +1559,9 @@ public static class BSPQuery // back to wall-slide so the inner sphere doesn't recurse. collisions.SetCollisionNormal(worldNormal); collisions.SetSlidingNormal(worldNormal); + // L.2d slice 1 (2026-05-13): diagnostic side-channel. + if (PhysicsDiagnostics.ProbeBuildingEnabled) + PhysicsDiagnostics.LastBspHitPoly = hitPoly0; return TransitionState.Slid; } @@ -1565,6 +1582,9 @@ public static class BSPQuery collisions.SetCollisionNormal(worldNormal); collisions.SetSlidingNormal(worldNormal); + // L.2d slice 1 (2026-05-13): diagnostic side-channel. + if (PhysicsDiagnostics.ProbeBuildingEnabled) + PhysicsDiagnostics.LastBspHitPoly = hitPoly1; return TransitionState.Slid; } } @@ -1638,6 +1658,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 +1668,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 +1698,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; } } diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs index 2440bb1..f30a741 100644 --- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs +++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs @@ -10,11 +10,11 @@ namespace AcDream.Core.Physics; /// without relaunching. /// /// -/// Slice 1 ships + -/// . Future slices may fold the older -/// ACDREAM_DUMP_* env vars into this class for unified runtime -/// toggling. Until then, those older flags remain sticky-at-startup -/// per their original implementation. +/// L.2d slice 1 (2026-05-13) adds + +/// the diagnostic side-channel. Future +/// slices may fold the older ACDREAM_DUMP_* env vars into this +/// class for unified runtime toggling. Until then, those older flags +/// remain sticky-at-startup per their original implementation. /// /// public static class PhysicsDiagnostics @@ -37,4 +37,61 @@ public static class PhysicsDiagnostics /// public static bool ProbeCellEnabled { get; set; } = Environment.GetEnvironmentVariable("ACDREAM_PROBE_CELL") == "1"; + + /// + /// L.2d slice 1 (2026-05-13). When true, every BSP-shadow-entry hit + /// attributed by TransitionTypes.FindObjCollisions emits a + /// multi-line [resolve-bldg] 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. + /// + /// + /// Also gates a one-time [entity-source] log line at every + /// ShadowObjects.Register(...) call site in GameWindow + /// — makes entityId=0xA9B479 in a probe line greppable to its + /// source registration within the same log file. + /// + /// + /// + /// Initial state from ACDREAM_PROBE_BUILDING=1. Mirrorable + /// via DebugVM.ProbeBuilding when ACDREAM_DEVTOOLS=1. + /// + /// + /// + /// Spec: docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md. + /// + /// + public static bool ProbeBuildingEnabled { get; set; } = + Environment.GetEnvironmentVariable("ACDREAM_PROBE_BUILDING") == "1"; + + /// + /// L.2d slice 1 (2026-05-13). Diagnostic side-channel: the + /// that + /// recorded for the most recent collision-normal write. + /// clears this to + /// before each shadow-entry test and reads it + /// back after, so emitting the [resolve-bldg] probe line can + /// reference the actual hit poly without plumbing an out-param + /// through BSPQuery's recursive private methods. + /// + /// + /// Written by only when + /// is true, so this stays + /// zero-cost in normal play. Cylinder collisions leave this + /// — the probe line emits + /// hitPoly: n/a (cylinder) in that case. + /// + /// + /// + /// Not threadsafe — physics runs on a single thread. If that + /// changes, this needs [ThreadStatic] 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. + /// + /// + public static ResolvedPolygon? LastBspHitPoly { get; set; } } diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 1a3a12f..c881d2c 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1469,6 +1469,13 @@ public sealed class Transition // the [resolve] probe surfaces the responsible entity id. bool collisionWasValidPre = ci.CollisionNormalValid; + // L.2d slice 1 (2026-05-13): clear the BSP-hit side-channel so the + // [resolve-bldg] emission below reads only this iteration's poly. + // Cylinder collisions leave it null on purpose (probe emits + // "hitPoly: n/a (cylinder)"). + if (PhysicsDiagnostics.ProbeBuildingEnabled) + PhysicsDiagnostics.LastBspHitPoly = null; + TransitionState result; if (obj.CollisionType == ShadowCollisionType.BSP) @@ -1541,13 +1548,69 @@ 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; + if (poly is null) + { + sb.Append("\n hitPoly: n/a (cylinder)"); + } + 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) diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs index e990686..cdf7980 100644 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugPanel.cs @@ -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(); diff --git a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs index 693cc2d..e08750d 100644 --- a/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs +++ b/src/AcDream.UI.Abstractions/Panels/Debug/DebugVM.cs @@ -261,6 +261,20 @@ public sealed class DebugVM set => PhysicsDiagnostics.ProbeCellEnabled = value; } + /// + /// L.2d slice 1 (2026-05-13). Runtime mirror of + /// PhysicsDiagnostics.ProbeBuildingEnabled (env var + /// ACDREAM_PROBE_BUILDING). Toggling here flips the per-hit + /// [resolve-bldg] diagnostic + the registration-time + /// [entity-source] log lines. Heavy when enabled — emits one + /// multi-line entry per BSP hit per physics tick. + /// + public bool ProbeBuilding + { + get => PhysicsDiagnostics.ProbeBuildingEnabled; + set => PhysicsDiagnostics.ProbeBuildingEnabled = value; + } + // ── Action hooks invoked by panel buttons ────────────────────────── /// diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs new file mode 100644 index 0000000..5103de1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/PhysicsDiagnosticsTests.cs @@ -0,0 +1,98 @@ +using AcDream.Core.Physics; +using DatReaderWriter.Enums; +using System.Numerics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// L.2d slice 1 (2026-05-13) — unit coverage for the new +/// flag and +/// diagnostic +/// side-channel. +/// +/// +/// The full multi-line [resolve-bldg] format itself is verified +/// by the slice's acceptance criterion #2 (live Holtburg-doorway +/// capture) — covering it here would require a heavy +/// PhysicsEngine + ShadowObjectRegistry + Transition +/// 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. +/// +/// +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; + } + } +} From 8bacef0598450b543d9db4885545aa8f77502352 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 12 May 2026 19:25:32 +0200 Subject: [PATCH 3/4] fix(phys L.2d slice 1.5): probe captures hit poly under StepSphereUp recursion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First Holtburg-doorway capture showed all 191 [resolve-bldg] entries labeled "n/a (cylinder)" — including hits attributed to the building 0xA9B47900 which [entity-source] confirmed was registered as type=BSP. The label was a probe bug, not a real cylinder route. Root cause: BSPQuery's grounded-path (Path 5) returns early via `StepSphereUp(transition, worldNormal, engine)` when no step is already in progress. The slice-1 side-channel write at line 1546 came AFTER that early return, so it never fired for the dominant grounded-player case. Compounding: StepSphereUp recurses into ResolveWithTransition → FindObjCollisions, whose per-entity `LastBspHitPoly = null` clear wiped any earlier write before the outer attribution emitter read it. Fix: 1. BSPQuery Path 5: move LastBspHitPoly write to the top of `if (hit0 || hitPoly0 != null)` blocks (both foot- and head-sphere), BEFORE the StepSphereUp early return. Recursion-safe — the inner resolve's BSP writes will overwrite with the inner entity's poly, but for the dominant case (same wall hit on both outer and inner) that's still the correct attribution. 2. TransitionTypes.FindObjCollisions: drop the per-entity clear of LastBspHitPoly. With BSPQuery now writing at hit-detection time instead of response-computation time, the side-channel value is reliable without per-iteration zeroing. 3. TransitionTypes [resolve-bldg] emission: key the "n/a (cylinder)" label on `obj.CollisionType` directly, not on LastBspHitPoly being null. A BSP entity with a null poly now logs "n/a (BSP path — side-channel not written, missing BSPQuery wire site)" so any future BSPQuery path that's missing the wire is visible in the trace rather than being silently mis-labeled. Verified: build green, the 2 slice-1 tests still pass, 8 pre-existing failures unchanged. Spec: docs/superpowers/specs/2026-05-13-l2d-cbuildingobj-collision-design.md First capture (showing the label bug): launch-l2d-slice1.log lines 12086-12120 (representative [resolve-bldg] entries for obj=0xA9B47900). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/BSPQuery.cs | 22 +++++++++++++----- src/AcDream.Core/Physics/TransitionTypes.cs | 25 +++++++++++++++------ 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 6c7178b..289ff0e 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1544,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 @@ -1559,9 +1569,6 @@ public static class BSPQuery // back to wall-slide so the inner sphere doesn't recurse. collisions.SetCollisionNormal(worldNormal); collisions.SetSlidingNormal(worldNormal); - // L.2d slice 1 (2026-05-13): diagnostic side-channel. - if (PhysicsDiagnostics.ProbeBuildingEnabled) - PhysicsDiagnostics.LastBspHitPoly = hitPoly0; return TransitionState.Slid; } @@ -1575,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) @@ -1582,9 +1595,6 @@ public static class BSPQuery collisions.SetCollisionNormal(worldNormal); collisions.SetSlidingNormal(worldNormal); - // L.2d slice 1 (2026-05-13): diagnostic side-channel. - if (PhysicsDiagnostics.ProbeBuildingEnabled) - PhysicsDiagnostics.LastBspHitPoly = hitPoly1; return TransitionState.Slid; } } diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index c881d2c..d5077b8 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -1469,12 +1469,14 @@ public sealed class Transition // the [resolve] probe surfaces the responsible entity id. bool collisionWasValidPre = ci.CollisionNormalValid; - // L.2d slice 1 (2026-05-13): clear the BSP-hit side-channel so the - // [resolve-bldg] emission below reads only this iteration's poly. - // Cylinder collisions leave it null on purpose (probe emits - // "hitPoly: n/a (cylinder)"). - if (PhysicsDiagnostics.ProbeBuildingEnabled) - PhysicsDiagnostics.LastBspHitPoly = null; + // 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; @@ -1588,10 +1590,19 @@ public sealed class Transition $" entOrigin_lb=({entOriginLb.X:F1},{entOriginLb.Y:F1},{entOriginLb.Z:F1})")); var poly = PhysicsDiagnostics.LastBspHitPoly; - if (poly is null) + // 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( From 34b7f1faa1fb3f81276f639fca745b2cb95d0276 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 12 May 2026 19:46:45 +0200 Subject: [PATCH 4/4] docs(phys L.2d): slice 1 + 1.5 shipped handoff + 3rd plan-of-record reframe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L.2d as scoped is essentially closed at the Holtburg site. The slice-1.5 trace settled the question: the "I can't walk through doorways" symptom is a closed Door entity (Setup 0x020019FF named "Door") at the building threshold, not a building-BSP-collision issue. Building BSP is healthy. The two prior framings turned out wrong: - L.2a handoff (2026-05-12): "per-cell walkability missing" — based on hit attribution pointing at the building, missed the Door cylinder also colliding per tick. - L.2d slice 1 spec (2026-05-13 morning): "BSP shape fidelity, three hypotheses X/Y/Z" — ruled out by the trace once the probe labeling bug was fixed in slice 1.5. Handoff doc captures full evidence, side findings (building double- registration latent bug, missing PhysicsState in entity-source log), and a candidates list for the next-session ordering discussion. Plan-of-record L.2d sub-direction paragraph updated to match: "watch- and-wait" mode, no more slices until a new shape-fidelity bug is observed at a different site. Door-state handling becomes its own sub-phase, scope deferred to project-ordering discussion. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...26-04-29-movement-collision-conformance.md | 54 ++-- .../2026-05-13-l2d-slice1-shipped-handoff.md | 251 ++++++++++++++++++ 2 files changed, 283 insertions(+), 22 deletions(-) create mode 100644 docs/research/2026-05-13-l2d-slice1-shipped-handoff.md diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md index d77901d..7db0325 100644 --- a/docs/plans/2026-04-29-movement-collision-conformance.md +++ b/docs/plans/2026-04-29-movement-collision-conformance.md @@ -169,30 +169,40 @@ fallback. - Audit `Setup.Radius` and cylinder fallback behavior against retail before relying on them for conformance. -Current sub-direction (revised 2026-05-13 in slice 1 design spec): -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), one specific BSP shadow -entry. The 2026-05-12 handoff initially proposed porting `CBuildingObj` + -**per-cell walkability** as the fix, but reading -[ACE 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) -shows that's **not how retail solves doorways**. `find_building_collisions` -is just one BSP test on `PartArray.Parts[0]`. The doorway gap lives -inside that part's physics BSP itself. Per-cell walkability -(`CCellStruct::point_in_cell`, `sphere_intersects_cell`, -`box_intersects_cell`, `CObjCell::find_cell_list`) is how the resolver -selects **which cells** to iterate, not how it decides whether a wall -has a hole — that work belongs to **L.2e**, not L.2d. +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 is therefore a **read-only BSP-hit diagnostic** that -captures full collision evidence per `[resolve]` `hit=yes` line. -Distinguishes three hypotheses (wrong BSP loaded / over-registered -parts / BSPQuery flaw) from a single Holtburg-doorway capture; slice -2 is the right-sized fix scoped from slice 1's evidence. Design spec: +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). -Handoff: [docs/research/2026-05-12-l2a-shipped-l2d-handoff.md](../research/2026-05-12-l2a-shipped-l2d-handoff.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 diff --git a/docs/research/2026-05-13-l2d-slice1-shipped-handoff.md b/docs/research/2026-05-13-l2d-slice1-shipped-handoff.md new file mode 100644 index 0000000..d6e2363 --- /dev/null +++ b/docs/research/2026-05-13-l2d-slice1-shipped-handoff.md @@ -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.