fix(phys): A6.P4 door — pos_hits_sphere records near-miss polygon

Retail's CPolygon::pos_hits_sphere at
acclient_2013_pseudo_c.txt:322974-322993 records the polygon pointer
(*arg5 = this at line 00539509) on STATIC overlap BEFORE the front-
face cull (dot(N, movement) >= 0 return 0 at line 0053952f). So when
a sphere statically overlaps a wall but is moving parallel/away from
the wall normal, retail returns 0 (no full hit) but the polygon
pointer IS set so Path 5's set_neg_poly_hit dispatch at
acclient_2013_pseudo_c.txt:0053a6ea fires and the outer
transitional_insert loop slides the sphere along the wall.

Pre-fix our PosHitsSphere set hitPoly only when both the static-
overlap AND the front-face cull passed. Near-miss polygons were
dropped → Path 5's `if (hitPoly0 is not null)` branch never fired →
NegPolyHit stayed false → outer loop never slid → inside-out cottage
doors let spheres squeeze through walls they were touching.

The handoff (docs/research/2026-05-25-door-bug-cdb-retail-trace-findings.md)
hypothesized swept-sphere intersection + closest-considered-polygon
tracking. Reading the actual retail decomp of pos_hits_sphere AND
polygon_hits_sphere_slow_but_sure (acclient_2013_pseudo_c.txt:322504-
322635) showed both functions are STATIC tests; the motion vector is
used only for the front-face cull. The fix is a one-line reordering.

Adds 3 unit tests in BSPQueryTests covering:
  - Sphere overlaps wall + moves parallel → NegPolyHit fires (RED→GREEN)
  - Sphere overlaps wall + moves away    → NegPolyHit fires (RED→GREEN)
  - Sphere overlaps wall + moves into    → Slid (regression guard, already
                                            passed)

Verification:
  * 3 new Path 5 tests pass.
  * Full Core suite: 14 failures with-fix vs 17 failures baseline-no-fix.
    The with-fix failure set is a STRICT SUBSET of baseline — zero
    regressions. The 14 remaining failures are pre-existing static-leak
    flakiness between test classes (documented in CLAUDE.md) and 2 stale-
    capture LiveCompare_* document-the-bug tests.
  * All handoff "must-stay-green" tests pass:
    - Directional_OutsideIn_SouthApproach_BlocksAtSlabSouthFace
    - Directional_InsideOut_NorthApproach_BlocksAtSlabNorthFace
    - CornerSlide_AlcoveEastToCottageNorth_ShouldBlock
    - Geometric_DoorSlabAtSphereHeight_OverlapsInZ
    - CellarUpTrajectoryReplayTests.LiveCompare_FirstCap_FixClosesCottageFloorCap
      (issue #98 CRITICAL — no regression).

Per CLAUDE.md: needs visual verification at Holtburg cottage door
inside-out off-center (~50 cm) scenario before the A6.P4 phase is
marked complete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-25 11:05:04 +02:00
parent 2deb539953
commit 3253d841ac
2 changed files with 226 additions and 1 deletions

View file

@ -177,6 +177,21 @@ public static class BSPQuery
/// hits after a sphere has already penetrated past a polygon. /// hits after a sphere has already penetrated past a polygon.
/// </para> /// </para>
/// ///
/// <para>
/// Near-miss recording (A6.P4 door bug fix, 2026-05-25): the
/// <paramref name="hitPoly"/> out-pointer is written whenever the sphere
/// statically overlaps the polygon — BEFORE the movement check. So when
/// the sphere is touching a wall but moving parallel/away, the function
/// returns false (cull rejected) but the polygon IS recorded for the
/// outer dispatch's <c>NegPolyHit</c> path. This mirrors retail's
/// <c>CPolygon::pos_hits_sphere</c> at
/// <c>acclient_2013_pseudo_c.txt:322974-322993</c> (<c>*arg5 = this</c>
/// at <c>00539509</c> fires on static-overlap, BEFORE the
/// <c>dot(N, movement) &gt;= 0 → return 0</c> cull at <c>0053952f</c>).
/// Without this ordering Path 5's near-miss dispatch is dead code (the
/// inside-out cottage door walkthrough bug).
/// </para>
///
/// <para>ACE: Polygon.cs pos_hits_sphere.</para> /// <para>ACE: Polygon.cs pos_hits_sphere.</para>
/// </summary> /// </summary>
private static bool PosHitsSphere( private static bool PosHitsSphere(
@ -191,11 +206,15 @@ public static class BSPQuery
sphere.Center, sphere.Radius, sphere.Center, sphere.Radius,
ref contactPoint); ref contactPoint);
// Retail: *arg5 = this is set BEFORE the movement cull when the
// static overlap succeeded. Path 5's near-miss dispatch reads this
// to fire NegPolyHit when the sphere is touching but moving away.
if (hit) hitPoly = poly;
// ACE: dist = Dot(movement, Plane.Normal); if dist >= 0 return false; // ACE: dist = Dot(movement, Plane.Normal); if dist >= 0 return false;
float moveDot = Vector3.Dot(movement, poly.Plane.Normal); float moveDot = Vector3.Dot(movement, poly.Plane.Normal);
if (moveDot >= 0f) return false; if (moveDot >= 0f) return false;
if (hit) hitPoly = poly;
return hit; return hit;
} }

View file

@ -510,4 +510,210 @@ public class BSPQueryTests
Assert.Equal(1.0f, transition.CollisionInfo.ContactPlane.Normal.Z, precision: 3); Assert.Equal(1.0f, transition.CollisionInfo.ContactPlane.Normal.Z, precision: 3);
Assert.Equal(-94.0f, transition.CollisionInfo.ContactPlane.D, precision: 2); Assert.Equal(-94.0f, transition.CollisionInfo.ContactPlane.D, precision: 2);
} }
// =========================================================================
// A6.P4 door bug — Path 5 near-miss polygon recording (2026-05-25)
//
// Retail's CPolygon::pos_hits_sphere at acclient_2013_pseudo_c.txt:322974-
// 322993 records the polygon pointer (*arg5 = this) on STATIC overlap
// BEFORE the front-face cull. So when a sphere statically overlaps a
// polygon but its movement is parallel/away from the polygon normal,
// the polygon is still recorded — the BSPLEAF's pos_hits_sphere returns
// 0 (no full hit) but arg4 (the polygon out-pointer) IS set.
//
// Path 5 Contact branch in retail (acclient_2013_pseudo_c.txt:0053a630-
// 0053a6fb) reads that near-miss polygon (var_5c) and, when no full hit
// happens but a polygon WAS recorded, calls SPHEREPATH::set_neg_poly_hit
// (line 0053a6ea). The outer transitional_insert loop then dispatches
// via slide_sphere so the sphere slides along the wall.
//
// Pre-fix our PosHitsSphere only recorded the polygon when both the
// static-overlap AND the front-face cull passed. This dropped the
// near-miss case → Path 5 never fired NegPolyHitDispatch → outer loop
// never slid → inside-out cottage doors let spheres squeeze through
// walls they were touching.
// =========================================================================
/// <summary>
/// Build a vertical wall polygon in the XZ-plane at Y=0 facing +Y.
/// 4 m × 4 m, centered at cell-local origin. Vertices are CCW when viewed
/// from +Y so the implicit winding-normal matches the explicit
/// <c>Plane(Vector3.UnitY, 0f)</c> (which the precise overlap test uses
/// to compute edge cross products).
/// </summary>
private static (PhysicsBSPNode root, Dictionary<ushort, ResolvedPolygon> resolved)
BuildSingleWallBsp()
{
var verts = new[]
{
new Vector3(-2f, 0f, -2f),
new Vector3(-2f, 0f, 2f),
new Vector3( 2f, 0f, 2f),
new Vector3( 2f, 0f, -2f),
};
var root = new PhysicsBSPNode
{
Type = BSPNodeType.Leaf,
BoundingSphere = new Sphere
{
Origin = new Vector3(0f, 0f, 0f),
Radius = 4f,
},
};
root.Polygons.Add(0);
var resolved = new Dictionary<ushort, ResolvedPolygon>
{
[0] = new ResolvedPolygon
{
Vertices = verts,
// +Y-facing plane at Y=0 → normal=(0,+1,0), d=0
Plane = new Plane(Vector3.UnitY, 0f),
NumPoints = 4,
SidesType = CullMode.None,
Id = 0,
},
};
return (root, resolved);
}
[Fact]
public void FindCollisions_Path5_SphereOverlapsWallButMovesParallel_SetsNegPolyHit()
{
// Sphere center at +Y 0.3 m with radius 0.48 — abs(dist)=0.3 < radius
// (0.48 - 0.0002 = 0.4798), so polygon_hits_sphere_slow_but_sure
// accepts the contact (static overlap = true).
//
// Movement is pure +X (parallel to wall): moveDot =
// dot((1,0,0), (0,+1,0)) = 0
// Front-face cull at acclient_2013_pseudo_c.txt:00539524-00539538
// returns 0 because dpMove >= 0. But the polygon pointer IS recorded
// (line 00539509 `*arg5 = this` fires when static-overlap result != 0).
//
// Expected: Path 5 (Contact branch) sees hitPoly0 != null but hit0 ==
// false → NegPolyHitDispatch fires → path.NegPolyHit = true → state
// returns OK.
var (root, resolved) = BuildSingleWallBsp();
var transition = new Transition();
transition.SpherePath.WalkInterp = 1.0f;
// Path 5: Contact branch.
transition.ObjectInfo.State = ObjectInfoState.Contact;
var localSphere = new Sphere
{
Origin = new Vector3(0f, 0.3f, 0f),
Radius = 0.48f,
};
// localCurrCenter: previous sphere center. movement = current - curr.
// Move +X by 0.05 m (small tick step parallel to the wall).
var localCurrCenter = localSphere.Origin - new Vector3(0.05f, 0f, 0f);
var state = BSPQuery.FindCollisions(
root,
resolved,
transition,
localSphere,
localSphere1: null,
localCurrCenter: localCurrCenter,
localSpaceZ: Vector3.UnitZ,
scale: 1.0f,
localToWorld: Quaternion.Identity,
engine: null,
worldOrigin: Vector3.Zero);
Assert.Equal(TransitionState.OK, state);
Assert.True(transition.SpherePath.NegPolyHit,
"Sphere statically overlaps wall but moves parallel → near-miss " +
"polygon should be recorded, NegPolyHit should fire. Retail " +
"oracle: CPolygon::pos_hits_sphere at " +
"acclient_2013_pseudo_c.txt:322974-322993 (*arg5 = this BEFORE " +
"the front-face cull).");
}
[Fact]
public void FindCollisions_Path5_SphereOverlapsWallAndMovesAway_SetsNegPolyHit()
{
// Same overlap geometry, but motion is AWAY from the wall (+Y).
// moveDot = dot((0,+1,0), (0,+1,0)) = +1 > 0 → cull rejects.
// Static overlap is still true, so retail records the polygon.
var (root, resolved) = BuildSingleWallBsp();
var transition = new Transition();
transition.SpherePath.WalkInterp = 1.0f;
transition.ObjectInfo.State = ObjectInfoState.Contact;
var localSphere = new Sphere
{
Origin = new Vector3(0f, 0.3f, 0f),
Radius = 0.48f,
};
var localCurrCenter = localSphere.Origin - new Vector3(0f, 0.05f, 0f);
var state = BSPQuery.FindCollisions(
root,
resolved,
transition,
localSphere,
localSphere1: null,
localCurrCenter: localCurrCenter,
localSpaceZ: Vector3.UnitZ,
scale: 1.0f,
localToWorld: Quaternion.Identity,
engine: null,
worldOrigin: Vector3.Zero);
Assert.Equal(TransitionState.OK, state);
Assert.True(transition.SpherePath.NegPolyHit,
"Sphere overlaps wall and moves away → near-miss polygon should " +
"still be recorded (front-face cull rejects motion but retail " +
"records the polygon BEFORE that check).");
}
[Fact]
public void FindCollisions_Path5_SphereOverlapsWallAndMovesInto_DispatchesStepSphereUp()
{
// Regression guard for the FULL-HIT case in the same Path 5 branch.
// Sphere overlaps wall AND moves INTO it: moveDot < 0, cull does NOT
// reject, pos_hits_sphere returns 1, Path 5 takes the `if (hit0)`
// branch. With engine=null we fall through to the slide fallback
// (SetCollisionNormal + SetSlidingNormal + return Slid).
var (root, resolved) = BuildSingleWallBsp();
var transition = new Transition();
transition.SpherePath.WalkInterp = 1.0f;
transition.ObjectInfo.State = ObjectInfoState.Contact;
var localSphere = new Sphere
{
Origin = new Vector3(0f, 0.3f, 0f),
Radius = 0.48f,
};
// Movement -Y (into the wall from the +Y side).
var localCurrCenter = localSphere.Origin - new Vector3(0f, -0.05f, 0f);
var state = BSPQuery.FindCollisions(
root,
resolved,
transition,
localSphere,
localSphere1: null,
localCurrCenter: localCurrCenter,
localSpaceZ: Vector3.UnitZ,
scale: 1.0f,
localToWorld: Quaternion.Identity,
engine: null,
worldOrigin: Vector3.Zero);
Assert.Equal(TransitionState.Slid, state);
Assert.True(transition.CollisionInfo.CollisionNormalValid,
"Full hit should set the collision normal (slide fallback).");
Assert.False(transition.SpherePath.NegPolyHit,
"Full hit should NOT also fire NegPolyHit — that's the near-miss " +
"path only. Retail at acclient_2013_pseudo_c.txt:0053a647 returns " +
"early on eax_10 != 0 (full hit) before the near-miss dispatch.");
}
} }