From 66dc23e087fd6ad70fe15687ab1a8f0c64b60a2d Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 12 May 2026 19:14:34 +0200 Subject: [PATCH] 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; + } + } +}