feat(phys): A6.P3 slice 5 — [place-fail] probe + sharpened #98 diagnosis

Add ACDREAM_PROBE_PLACEMENT_FAIL gate + LogPlacementFail emitter +
side-channel polygon attribution in PhysicsDiagnostics. Wire into
BSPQuery.FindCollisions Path 1 (Placement/Ethereal) on Collided
returns; wire into Transition.DoStepDown after the placement_insert
TransitionalInsert(1) call; wire into Transition.FindObjCollisions
to emit per-static-object [place-fail-obj] lines.

Run scen4 cellar-up with the probe → 168 [place-fail] events. 80 of
81 BSPQuery Path 1 placement rejections cite polygon 0x0020 in
cellar cell 0xA9B40147's BSP: n=(0,0,-1) d=-0.2, world Z=93.82 —
the cellar ceiling (underside of cottage main floor thickness layer).
0 [place-fail-obj] lines, confirming the failure source is the cell
BSP not a static object.

The probe-driven evidence INVALIDATES the 2026-05-22 morning
handoff's "Path 5 vs Path 6 in BSPQuery.FindCollisions" diagnosis.
Retail's BP4 trace shows every find_collisions hit has collide=0 —
retail enters the same Contact branch we do, no outer-dispatcher
divergence. Retail's BP5 fires 17+ times on the cellar ramp polygon,
not "30 hits all on flat planes" as morning claimed.

The actual divergence is downstream in cell-promotion: retail's
check_cell transitions to cottage cell 0xA9B40146 during the ascent
(BP7 sets ContactPlane to the cottage main floor poly, which lives
in cottage cell's BSP not cellar's). Ours stays at cellar 0xA9B40147,
where the ceiling poly 0x0020 correctly rejects the lifted sphere.

No fix attempted this session per CLAUDE.md discipline check
(3+ failed fixes = handoff). Full slice 5 evidence + concrete
next-session pickup steps at docs/research/2026-05-22-a6-p3-slice5-handoff.md.
ISSUES.md #98 updated with the corrected diagnosis.

Test baseline: 1148 + 8 pre-existing fail. Maintained.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-22 20:02:15 +02:00
parent c479ea68a3
commit cf3deff7c2
7 changed files with 26489 additions and 9 deletions

View file

@ -843,13 +843,31 @@ public static class BSPQuery
if (node.Type == BSPNodeType.Leaf)
{
if (node.Polygons.Count == 0) return false;
if (centerCheck && node.Solid != 0) return true;
if (centerCheck && node.Solid != 0)
{
// A6.P3 slice 5 (2026-05-22): record the solid-leaf cause for
// the [place-fail] probe. Side-channel pattern matches
// LastBspHitPoly — gated on the probe flag so the production
// path pays only one boolean check.
if (PhysicsDiagnostics.ProbePlacementFailEnabled)
PhysicsDiagnostics.LastPlacementFailSolidLeaf = true;
return true;
}
if (!NodeIntersects(node, sphere)) return false;
foreach (ushort polyId in node.Polygons)
{
if (!resolved.TryGetValue(polyId, out var poly)) continue;
if (HitsSphere(poly, sphere)) return true;
if (HitsSphere(poly, sphere))
{
if (PhysicsDiagnostics.ProbePlacementFailEnabled)
{
PhysicsDiagnostics.LastPlacementFailPolyId = poly.Id;
PhysicsDiagnostics.LastPlacementFailPolyNormal = poly.Plane.Normal;
PhysicsDiagnostics.LastPlacementFailPolyD = poly.Plane.D;
}
return true;
}
}
return false;
}
@ -1647,12 +1665,40 @@ public static class BSPQuery
{
const bool clearCell = true;
// A6.P3 slice 5 (2026-05-22) — reset the placement-fail side-channel
// before each SphereIntersectsSolidInternal call so a leftover
// value from a prior call (or from sphere0 if sphere1 is the actual
// failure) doesn't leak into the [place-fail] log.
if (PhysicsDiagnostics.ProbePlacementFailEnabled)
{
PhysicsDiagnostics.LastPlacementFailPolyId = 0;
PhysicsDiagnostics.LastPlacementFailSolidLeaf = false;
}
if (SphereIntersectsSolidInternal(root, resolved, sphere0, clearCell))
{
if (PhysicsDiagnostics.ProbePlacementFailEnabled)
PhysicsDiagnostics.LogPlacementFail(
"Path1.sphere0", sphere0.Center, sphere0.Radius, 0,
path.CheckCellId, worldOrigin, obj.Ethereal);
return TransitionState.Collided;
}
if (PhysicsDiagnostics.ProbePlacementFailEnabled)
{
PhysicsDiagnostics.LastPlacementFailPolyId = 0;
PhysicsDiagnostics.LastPlacementFailSolidLeaf = false;
}
if (sphere1 is not null &&
SphereIntersectsSolidInternal(root, resolved, sphere1, clearCell))
{
if (PhysicsDiagnostics.ProbePlacementFailEnabled)
PhysicsDiagnostics.LogPlacementFail(
"Path1.sphere1", sphere1.Center, sphere1.Radius, 1,
path.CheckCellId, worldOrigin, obj.Ethereal);
return TransitionState.Collided;
}
return TransitionState.OK;
}

View file

@ -349,6 +349,96 @@ public static class PhysicsDiagnostics
Console.WriteLine(sb.ToString());
}
/// <summary>
/// A6.P3 slice 5 placement-insert investigation (2026-05-22). One
/// <c>[place-fail]</c> line per Path 1 (Placement/Ethereal) call in
/// <c>BSPQuery.FindCollisions</c> that returns Collided, plus one per
/// <c>Transition.DoStepDown</c> placement_insert that rejects.
///
/// <para>
/// Investigation target: issue #98 cellar-up stuck. The 2026-05-22
/// handoff diagnosed BSPQuery path-selection (Path 5 vs Path 6) as
/// the divergence, but cross-referencing the retail cdb capture
/// (every BP4 hit shows <c>collide=0</c>) showed retail enters the
/// same Contact branch we do. The actual divergence is downstream:
/// our DoStepUp's step-down probe lifts the sphere onto the cellar
/// ramp, then placement_insert rejects, step_up returns failure,
/// step_up_slide fires, contact-recovery loops forever. This probe
/// identifies which polygon (or solid leaf) causes the placement
/// reject so we know what geometry is blocking the lifted position.
/// </para>
///
/// <para>
/// Initial state from <c>ACDREAM_PROBE_PLACEMENT_FAIL=1</c>.
/// Low volume — only fires on actual rejection (one line per
/// Collided return from Path 1, plus one per DoStepDown placement
/// failure). Safe to leave on during a full scen4 cellar-up capture.
/// </para>
/// </summary>
public static bool ProbePlacementFailEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_PLACEMENT_FAIL") == "1";
/// <summary>
/// Side-channel populated by <c>BSPQuery.SphereIntersectsSolidInternal</c>
/// at the leaf where it returns true. Either
/// <see cref="LastPlacementFailPolyId"/> identifies the polygon that
/// intersected the sphere, or <see cref="LastPlacementFailSolidLeaf"/>
/// is true to indicate the sphere center landed inside a BSP leaf
/// marked solid (no specific polygon). The caller (Path 1) reads
/// these immediately after the true return to emit the
/// <c>[place-fail]</c> line, then clears them before the next test.
///
/// <para>
/// Writes are gated on <see cref="ProbePlacementFailEnabled"/> so the
/// production path pays only one boolean check per leaf hit when the
/// probe is off.
/// </para>
/// </summary>
public static ushort LastPlacementFailPolyId { get; set; }
/// <inheritdoc cref="LastPlacementFailPolyId"/>
public static Vector3 LastPlacementFailPolyNormal { get; set; }
/// <inheritdoc cref="LastPlacementFailPolyId"/>
public static float LastPlacementFailPolyD { get; set; }
/// <inheritdoc cref="LastPlacementFailPolyId"/>
public static bool LastPlacementFailSolidLeaf { get; set; }
/// <summary>
/// Emit one <c>[place-fail]</c> line for a placement_insert rejection.
/// <paramref name="source"/> tags the call site (e.g.
/// <c>"Path1.sphere0"</c> for the foot sphere in Path 1,
/// <c>"Path1.sphere1"</c> for the head sphere,
/// <c>"DoStepDown"</c> for the wrapper). The polygon (or solid leaf)
/// fields come from the side-channel populated during the recursive
/// BSP descent.
///
/// <para>Caller MUST guard with <c>if (!ProbePlacementFailEnabled) return;</c>.</para>
/// </summary>
public static void LogPlacementFail(
string source,
Vector3 sphereCenter,
float radius,
int sphereIdx,
uint cellId,
Vector3 worldOrigin,
bool ethereal)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
string polyDesc = LastPlacementFailSolidLeaf
? "solid_leaf=1"
: LastPlacementFailPolyId != 0
? string.Format(ci, "polyId=0x{0:X4} n=({1:F4},{2:F4},{3:F4}) d={4:F4}",
LastPlacementFailPolyId,
LastPlacementFailPolyNormal.X, LastPlacementFailPolyNormal.Y, LastPlacementFailPolyNormal.Z,
LastPlacementFailPolyD)
: "no_poly_info";
Console.WriteLine(string.Format(ci,
"[place-fail] source={0} cell=0x{1:X8} sphere=({2:F4},{3:F4},{4:F4}) r={5:F4} " +
"sphereIdx={6} worldOrigin=({7:F4},{8:F4},{9:F4}) ethereal={10} {11}",
source, cellId, sphereCenter.X, sphereCenter.Y, sphereCenter.Z, radius,
sphereIdx, worldOrigin.X, worldOrigin.Y, worldOrigin.Z, ethereal, polyDesc));
}
/// <summary>
/// A6.P1 emission helper for the <c>AdjustSphereToPlane</c> site.
/// One line per call: input sphere center, plane geometry, push-back

View file

@ -2036,6 +2036,27 @@ public sealed class Transition
ci.LastCollidedObjectGuid = obj.EntityId;
}
// A6.P3 slice 5 (2026-05-22) — when a placement_insert call against
// this static object's BSP returned Collided, emit a single
// [place-fail-obj] line naming the object that owns the rejecting
// polygon. Pairs with BSPQuery's [place-fail source=Path1.*] line
// (which reports the polygon) so we can answer "which static object
// owns the offending polygon" — issue #98 cellar-up investigation.
if (sp.InsertType == InsertType.Placement
&& result == TransitionState.Collided
&& PhysicsDiagnostics.ProbePlacementFailEnabled)
{
var ciFmt = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ciFmt,
"[place-fail-obj] entityId=0x{0:X8} gfxObjId=0x{1:X8} " +
"collisionType={2} position=({3:F4},{4:F4},{5:F4}) " +
"scale={6:F4} radius={7:F4}",
obj.EntityId, obj.GfxObjId,
obj.CollisionType,
obj.Position.X, obj.Position.Y, obj.Position.Z,
obj.Scale, obj.Radius));
}
// 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),
@ -2518,13 +2539,41 @@ public sealed class Transition
//
// This fix is the cellar-up target (issue #98). May also help
// other "step-up onto sloped surface" scenarios.
var savedInsert = sp.InsertType;
sp.InsertType = InsertType.Placement;
sp.WalkInterp = 1.0f;
var savedInsert = sp.InsertType;
float winterpBeforePlacement = sp.WalkInterp;
sp.InsertType = InsertType.Placement;
sp.WalkInterp = 1.0f;
var placeState = TransitionalInsert(1, engine);
sp.InsertType = savedInsert;
// A6.P3 slice 5 (2026-05-22): log the placement_insert rejection
// with the surrounding step-down context. The matching
// [place-fail] from Path 1 (in BSPQuery) names the offending
// polygon; this entry tells us which DoStepDown call (and the
// sphere position + cell + thresholds active at the time of
// the call) produced that rejection.
if (placeState != TransitionState.OK
&& PhysicsDiagnostics.ProbePlacementFailEnabled)
{
var ci = System.Globalization.CultureInfo.InvariantCulture;
Console.WriteLine(string.Format(ci,
"[place-fail] source=DoStepDown returned={0} " +
"sphere=({1:F4},{2:F4},{3:F4}) cell=0x{4:X8} " +
"stepDownHeight={5:F4} walkableZ={6:F4} " +
"winterpBefore={7:F4} " +
"contactPlane.Nz={8:F4} contactPlaneValid={9} spStepUp={10}",
placeState,
sp.CheckPos.X, sp.CheckPos.Y, sp.CheckPos.Z,
sp.CheckCellId,
stepDownHeight, walkableZ,
winterpBeforePlacement,
CollisionInfo.ContactPlane.Normal.Z,
CollisionInfo.ContactPlaneValid,
sp.StepUp));
}
return placeState == TransitionState.OK;
}