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;
+ }
+ }
+}