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:
parent
2deb539953
commit
3253d841ac
2 changed files with 226 additions and 1 deletions
|
|
@ -177,6 +177,21 @@ public static class BSPQuery
|
|||
/// hits after a sphere has already penetrated past a polygon.
|
||||
/// </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) >= 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>
|
||||
/// </summary>
|
||||
private static bool PosHitsSphere(
|
||||
|
|
@ -191,11 +206,15 @@ public static class BSPQuery
|
|||
sphere.Center, sphere.Radius,
|
||||
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;
|
||||
float moveDot = Vector3.Dot(movement, poly.Plane.Normal);
|
||||
if (moveDot >= 0f) return false;
|
||||
|
||||
if (hit) hitPoly = poly;
|
||||
return hit;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -510,4 +510,210 @@ public class BSPQueryTests
|
|||
Assert.Equal(1.0f, transition.CollisionInfo.ContactPlane.Normal.Z, precision: 3);
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue