feat(phys): A6.P4 door — cdb-driven NegPolyHit dispatch (incomplete; needs BSP near-miss recording)

cdb attached to retail at a Holtburg cottage door while user walked the
inside-out off-center scenario. Three trace iterations identified that
retail's collision-recording happens via SPHEREPATH::set_neg_poly_hit
(fires hundreds of times during inside-out walk), NOT via the more
obvious-named COLLISIONINFO setters (which fire 0 times). Apparatus
scripts at tools/cdb/door-inside-out-v[1-3].cdb + symbol-probe.cdb.

Our codebase has NegPolyHitDispatch defined but never called. The
downstream TransitionalInsert NegPolyHit handler was a stub. Two-part
fix landed:

1. BSPQuery.FindCollisions Path 5 (Contact branch) restructured —
   distinguishes full hit (hit0 == true → StepSphereUp) from near-miss
   (hit0 == false but hitPoly0 != null → NegPolyHitDispatch). Mirrors
   retail BSPTREE::find_collisions at
   acclient_2013_pseudo_c.txt:0053a630-0053a6fb.

2. Transition.TransitionalInsert NegPolyHit handler — dispatches to
   step_up + step_up_slide (NegStepUp=true) or records collision
   normal + returns Collided (NegStepUp=false). Mirrors retail
   CTransition::transitional_insert at
   acclient_2013_pseudo_c.txt:0050b7af-0050b7e6.

Tests: all 11 fix-relevant + regression tests pass including issue #98.

VISUAL VERIFICATION (user-driven inside-out off-center): still squeezes
through. Diagnostic [neg-poly-dispatch] probe shows ZERO hits in
production. The Path 5 restructuring doesn't surface NegPolyHit
because our SphereIntersectsPolyInternal only sets hitPoly on FULL
hits — retail's sphere_intersects_poly sets var_5c (closest polygon)
even on near-misses via BSP-traversal side effect.

Remaining fix (next session): add near-miss polygon recording to
SphereIntersectsPolyInternal. Once it sets hitPoly on near-miss BSP
traversal, the Path 5 NegPolyHit dispatch (this commit) will fire
and the TransitionalInsert handler (this commit) will block.

Full handoff with cdb trace table + next-step plan:
docs/research/2026-05-25-door-bug-cdb-retail-trace-findings.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-25 10:36:22 +02:00
parent a657ca946c
commit fd1548af61
7 changed files with 382 additions and 29 deletions

View file

@ -1800,63 +1800,87 @@ public static class BSPQuery
bool hit0 = SphereIntersectsPolyInternal(root, resolved, sphere0, movement,
ref hitPoly0, ref contact0);
if (hit0 || hitPoly0 is not null)
// A6.P4 door inside-out fix (2026-05-25). Retail distinguishes
// FULL HIT (eax_10 != 0 → step_sphere_up early-return) from
// NEAR-MISS (eax_10 == 0 but var_5c != 0 → set_neg_poly_hit).
// Previously this branch conflated both — going to StepSphereUp
// on near-misses too. That meant when sphere 0 had a near-miss
// poly recorded but didn't actually penetrate, we still recursed
// into step_sphere_up, which (because the sphere wasn't actually
// colliding) returned OK and let the sphere walk on. Inside-out
// cottage doors at off-center: sphere 0 has a near-miss with the
// cottage exterior wall east of the doorway, but the full
// sphere_intersects_poly returns false. Pre-fix → walked through.
// Post-fix → set_neg_poly_hit, outer transitional_insert loop
// dispatches to slide_sphere, blocks the sphere properly.
//
// Retail oracle: BSPTREE::find_collisions Contact branch at
// acclient_2013_pseudo_c.txt:0053a630-0053a6fb.
if (hit0)
{
// L.2d slice 1.5 (2026-05-13): record the hit poly EARLY,
// before the StepSphereUp branch can recurse into
// ResolveWithTransition → FindObjCollisions and clobber the
// side-channel via the inner call's per-resolve clear. Path 5
// is the dominant grounded-player path; without this the
// probe's [resolve-bldg] line for every grounded BSP hit was
// mis-labeled as "n/a (cylinder)".
// Full hit — step_sphere_up.
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
var worldNormal = L2W(hitPoly0!.Plane.Normal);
// L.2.3b (2026-04-29): recursion guard. Retail
// (acclient_2013_pseudo_c.txt:272954) gates step_sphere_up on
// `if (sp.step_up == 0 && sp.step_down == 0)`. Without this,
// the inner TransitionalInsert spawned by DoStepDown re-enters
// FindObjCollisions, hits the same wall, and recursively
// re-invokes step-up — churning the contact plane until
// numAttempts decays. Mid-recursion we fall back to wall-slide.
// `if (sp.step_up == 0 && sp.step_down == 0)`.
if (engine is not null && !path.StepUp && !path.StepDown)
return StepSphereUp(transition, worldNormal, engine);
// No engine OR step-up/step-down already in progress — fall
// back to wall-slide so the inner sphere doesn't recurse.
// back to wall-slide.
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
}
// Sphere 0 didn't fully hit. Test sphere 1 (head sphere).
ResolvedPolygon? hitPoly1 = null;
bool hit1 = false;
if (sphere1 is not null)
{
ResolvedPolygon? hitPoly1 = null;
Vector3 contact1 = Vector3.Zero;
Vector3 contact1 = Vector3.Zero;
hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
ref hitPoly1, ref contact1);
bool hit1 = SphereIntersectsPolyInternal(root, resolved, sphere1, movement,
ref hitPoly1, ref contact1);
if (hit1 || hitPoly1 is not null)
if (hit1)
{
// L.2d slice 1.5 (2026-05-13): same early-record as foot
// sphere — head-sphere wall hits also recurse via
// StepSphereUp on the grounded path.
// Sphere 1 full hit while sphere 0 had only near-miss
// (hitPoly0) — retail calls slide_sphere here. Record
// collision + slide.
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
var worldNormal = L2W(hitPoly1!.Plane.Normal);
// L.2.3b: same recursion guard as the foot-sphere branch.
if (engine is not null && !path.StepUp && !path.StepDown)
return StepSphereUp(transition, worldNormal, engine);
collisions.SetCollisionNormal(worldNormal);
collisions.SetSlidingNormal(worldNormal);
return TransitionState.Slid;
}
}
// Neither sphere fully hit. Record neg-poly hit if either had a
// near-miss polygon. Retail's set_neg_poly_hit with stepUp=false
// for sphere 0's near-miss, stepUp=true for sphere 1's near-miss.
// Outer transitional_insert loop then dispatches via slide_sphere
// (stepUp=false) or step_up + step_up_slide (stepUp=true).
if (hitPoly0 is not null)
{
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly0;
NegPolyHitDispatch(path, hitPoly0, stepUp: false, localToWorld);
return TransitionState.OK;
}
if (hitPoly1 is not null)
{
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
PhysicsDiagnostics.LastBspHitPoly = hitPoly1;
NegPolyHitDispatch(path, hitPoly1, stepUp: true, localToWorld);
return TransitionState.OK;
}
return TransitionState.OK;
}

View file

@ -959,11 +959,72 @@ public sealed class Transition
}
// Handle neg-poly hit (backward-facing polygon contact).
//
// A6.P4 door inside-out fix (2026-05-25). Pre-fix this was a
// stub that just cleared the flag — letting the sphere walk
// through closed Holtburg cottage doors at off-center
// positions. cdb trace against retail (door-inside-out-v3.cdb)
// confirmed retail fires SPHEREPATH::set_neg_poly_hit hundreds
// of times during the walk, and the caller dispatches to
// slide_sphere (no neg_step_up) or step_up + step_up_slide
// fallback (neg_step_up). Both paths RECORD the collision
// and stop / slide the sphere.
//
// Retail oracle: CTransition::transitional_insert at
// docs/research/named-retail/acclient_2013_pseudo_c.txt:0050b7af-0050b7e6:
// if (sphere_path.neg_step_up == 0)
// edi = CSphere::slide_sphere(global_sphere, sphere_path,
// collision_info,
// neg_collision_normal, ...);
// else if (CTransition::step_up(this, neg_collision_normal) == 0)
// edi = SPHEREPATH::step_up_slide(sphere_path, this,
// collision_info);
if (sp.NegPolyHit && !sp.StepDown && !sp.StepUp)
{
sp.NegPolyHit = false;
// ACE: dispatch to StepUp or SlideSphere based on NegStepUp flag.
// Simplified: accept current position.
if (PhysicsDiagnostics.ProbeBuildingEnabled || PhysicsDiagnostics.ProbeIndoorBspEnabled)
{
Console.WriteLine(System.FormattableString.Invariant(
$"[neg-poly-dispatch] stepUp={sp.NegStepUp} n=({sp.NegCollisionNormal.X:F3},{sp.NegCollisionNormal.Y:F3},{sp.NegCollisionNormal.Z:F3})"));
}
if (sp.NegStepUp)
{
// Retail CTransition::step_up; on failure SPHEREPATH::step_up_slide.
if (DoStepUp(sp.NegCollisionNormal, engine))
{
// Step-up succeeded — sphere repositioned by climb.
// Fall through to subsequent step-down logic.
}
else
{
var stepUpSlideRes = sp.StepUpSlide(this);
if (stepUpSlideRes == TransitionState.Slid)
{
ci.ContactPlaneValid = false;
ci.ContactPlaneIsWater = false;
continue;
}
if (stepUpSlideRes != TransitionState.OK)
return stepUpSlideRes;
}
}
else
{
// Retail CSphere::slide_sphere — the full retail version
// adjusts sphere position via add_offset_to_check_pos +
// returns Adjusted (on success) or Collided (degenerate).
// Our simpler response: record the collision normal +
// return Collided. The outer engine sees Collided and
// does NOT advance the sphere position — block achieved.
//
// A6.P4 door inside-out fix (2026-05-25): user-visible
// blocking is the goal (retail behavior); the full
// slide-position adjustment can be a later iteration.
ci.SetCollisionNormal(sp.NegCollisionNormal);
return TransitionState.Collided;
}
}
// Handle step-down when in contact but no ground plane found.