Reframes L.2d direction based on ACE BuildingObj.cs:39-52 + named-retail acclient_2013_pseudo_c.txt:701260: retail's find_building_collisions is one BSP test on PartArray.Parts[0]. No per-cell walkability. Per-cell work (find_cell_list, point_in_cell, sphere/box_intersects_cell) is L.2e territory. Slice 1 is now a read-only BSP-hit diagnostic that captures full collision evidence per L.2a [resolve] hit=yes line. Distinguishes 3 hypotheses (wrong BSP loaded / over-registered parts / BSPQuery flaw) before any behavior change. Slice 2 is the actual fix, scoped from slice 1's evidence. Authors: brainstorm session 2026-05-13 (cold-start from L.2a slice 1+2+3 evidence). Predecessor handoff at docs/research/2026-05-12-l2a-shipped-l2d-handoff.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
24 KiB
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. 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.
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 and named-retail acclient_2013_pseudo_c.txt:701260 define find_building_collisions as 6 lines:
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'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 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 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 | 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, DebugVM.cs | Third Diagnostics row: Probe BSP hits (slow). Visible when ACDREAM_DEVTOOLS=1. |
| 3 | [resolve-bldg] emission |
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 | 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 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 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; 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. |
| 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/:
-
PhysicsDiagnosticsTests.BuildingProbe_GatesByEnvVar— verify the static flag gates output. SetPhysicsDiagnostics.ProbeBuilding = false, run a synthetic hit, assert no[resolve-bldg]output. Set to true, repeat, assert output present. -
FindObjCollisionsTests.Probe_FormatsHitFields— register a synthetic BSPShadowEntrywith a 4-vertex known polygon (vertices and plane explicitly chosen), sweep a sphere into it, assert the emitted line contains the expectedpartIdx,bspR(within±0.01),hitPoly.numVerts=4, andv0_world(within±0.01). -
FindObjCollisionsTests.Probe_CylinderHit_DumpsNa— register a synthetic cylinderShadowEntry, sweep a sphere into it, assert the emitted line contains the literal substringhitPoly: 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
dotnet buildgreen; the 3 new tests green. (8 pre-existing failures unchanged — these are not in scope for slice 1; see Operational notes.)- 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 registeredShadowEntryfor the player's neighborhood landblocks. - One
[resolve-bldg]line per[resolve] ... hit=yesline.
- One
- The trace permits a ≤5-line "hypothesis X / Y / Z" memo with concrete evidence pointing at slice 2's form.
- 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
PhysicsDataCacheTeststhat loads a real Holtburg building GfxObj from the DAT, verifiesResolvedpolygon plane normals + counts match retail-extracted ground-truth (via Binary Ninja PDB dump ofphysics_polygonsin a known building DID). Then fix the cache's BSP-selection logic. Conformance-cited. - If Y: Add
EntityProvenanceenum (LandblockBuilding | Stab | Scenery | EnvCellStab | ServerSpawn) — minimal version, populated at construction inLandblockLoader+GameWindow.BuildInteriorEntitiesForStreaming. In the MeshRefs loop, gate "register every MeshRef with non-null BSP root" → "registerMeshRefs[0]only whenProvenance == LandblockBuilding". CiteBuildingObj.cs:45+acclient_2013_pseudo_c.txt:701268. - If Z: Side-by-side audit. Pull
BSPQuery.FindCollisionsopen againstBSPTREE::find_collisions(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.
Cross-reference C# port: 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)):
git submodule update --init --recursive references/WorldBuilder— populates the tracked submodule in this worktree.- 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_CombinedPlayerMovementControllerTests.Update_ForwardInput_MovesInFacingDirectionDispatcherToMovementIntegrationTests.Dispatcher_W_held_produces_forward_motionBSPStepUpTests.{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
$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 review revealed retail's
find_building_collisionsis one BSP test onParts[0]with no per-cell walkability involvement. - 2026-05-13: Picked diagnostic-first slice 1 (option A in brainstorm) over a faithful
BuildingObjport. 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; ifProvenancebecomes 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
- L.2a handoff: docs/research/2026-05-12-l2a-shipped-l2d-handoff.md
- Named-retail pseudo-C: docs/research/named-retail/acclient_2013_pseudo_c.txt
- Named-retail symbol map: docs/research/named-retail/symbols.json
- ACE BuildingObj: references/ACE/Source/ACE.Server/Physics/Common/BuildingObj.cs
- ACE SortCell: references/ACE/Source/ACE.Server/Physics/Common/SortCell.cs
- ACE Landblock: references/ACE/Source/ACE.Server/Physics/Common/Landblock.cs
- Current physics surface: src/AcDream.Core/Physics/