fix #180 (root): retail adjust_to_plane - PathClipped stops now resolve to the contact point, not a step boundary
The autonomous visual loop on the stateful-sought camera exposed the true root of the #176 stripes: a ~19Hz SAWTOOTH. The sought re-extends ~3mm/frame and the sweep silently passes while the 0.3m viewer sphere presses up to ~0.25m past the wall plane, then clips a whole transition step (~0.27m) back. Headless replay against the real Facility Hub corridor BSP (0x8A020164, the captured ray) reproduced it exactly: pre-fix, embedded targets passed unclipped and the first detection stopped at the PREVIOUS STEP BOUNDARY, tracking the target (eyeBack = s - 0.27). Root cause: BSPQuery.AdjustToPlane - copied from ACE's BSPTree.cs port - was structurally inverted and ALWAYS returned false (the touchTime==1 branch re-placed the sphere at the unchanged check position; touchTime<1 iterated doing nothing; the <0.02 convergence exit returned false). With the PerfectClip exact-contact machinery dead, CollideWithPt always fell to the bare Collided path and the transition reverted the colliding step whole. ACE never noticed: PERFECT_CLIP (0x40) is a client camera flag the server never exercises (feedback_bn_decomp_field_names class 3 - the retail binary outranks ACE in branches ACE never runs). The pre-stateful camera flipped 1-step vs 2-step backoffs on mm drift - the measured pulledIn 0.27 <-> 0.53 of the original #176 strobe was step quantization all along. Rewritten per the retail binary (pseudocode doc docs/research/2026-07-06-adjust-to-plane-pseudocode.md): - BSPTREE::adjust_to_plane (0x00539bf0): clearTime/hitTime bounds (0.0/1.0), Phase 1 walks plane-touch times re-testing the whole tree (the tree test feeds a DIFFERENT blocking poly back into the next iteration), Phase 2 binary-searches with the SHARED iteration counter, window < 0.02 = CONVERGED, final commit = last known-clear time. Only failure = Phase-1 exhaustion. - CPolygon::adjust_sphere_to_poly (0x00538170): early-out = plane-band test at the START position (was: precise-poly test at the check position); touch side = sign(dpPos)*radius (was: hard-coded -radius; ACE misdecoded it as movement.LengthSquared() <= r^2); result unclamped per retail. Replay pin Issue180CorridorSweepHysteresisReplayTests: short-of-touch targets pass, past-touch targets always clip, and the clipped stop is the CONSTANT surface-contact point (eyeBack 1.609 across the band; spread < 0.03m) instead of tracking the target. Suites green (Core 2600+2skip / App 729+2skip / UI 425 / Net 385). Pending: visual-loop re-verify + the user gate (#180 + #176 re-gate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
48aaab811c
commit
f10fe4e96c
3 changed files with 348 additions and 52 deletions
|
|
@ -573,15 +573,25 @@ public static class BSPQuery
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Polygon.adjust_sphere_to_poly — compute parametric contact time.
|
||||
/// CPolygon::adjust_sphere_to_poly (0x00538170, pc:321999) — the parametric
|
||||
/// time along <paramref name="movement"/> at which the sphere SURFACE
|
||||
/// touches the polygon's plane, approaching from the side the start
|
||||
/// position is on.
|
||||
///
|
||||
/// <para>
|
||||
/// Returns 1.0 if the sphere currently intersects the polygon (needs further
|
||||
/// back-off), or the parametric time [0,1] of first contact along the movement
|
||||
/// vector. Used by adjust_to_plane binary-search loop.
|
||||
/// Returns 1.0 when the start center is already within one radius of the
|
||||
/// plane (no forward touch time computable — 0x005381a4), 0.0 when the
|
||||
/// movement is parallel to the plane (|dot| ≤ 2e-4 — 0x005381d2), else
|
||||
/// <c>(±radius − dpPos) / dpMove</c> with the sign chosen by the start's
|
||||
/// plane side (0x005381ea). UNCLAMPED, matching retail.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>ACE: Polygon.cs adjust_sphere_to_poly.</para>
|
||||
/// <para>
|
||||
/// ⚠️ Do NOT re-import ACE's Polygon.cs version — its early-out and its
|
||||
/// ±radius selection (`movement.LengthSquared() <= r²`) are misdecodes
|
||||
/// of the retail x87 flow; see
|
||||
/// docs/research/2026-07-06-adjust-to-plane-pseudocode.md (#180).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static float AdjustSphereToPoly(
|
||||
ResolvedPolygon poly,
|
||||
|
|
@ -589,19 +599,14 @@ public static class BSPQuery
|
|||
Vector3 curPos,
|
||||
Vector3 movement)
|
||||
{
|
||||
Vector3 cp = Vector3.Zero;
|
||||
if (PolygonHitsSpherePrecise(
|
||||
poly.Plane, poly.Vertices,
|
||||
checkPos.Center, checkPos.Radius,
|
||||
ref cp))
|
||||
return 1f;
|
||||
float dpPos = Vector3.Dot(curPos, poly.Plane.Normal) + poly.Plane.D;
|
||||
if (MathF.Abs(dpPos) < checkPos.Radius) return 1f;
|
||||
|
||||
float dpPos = Vector3.Dot(curPos, poly.Plane.Normal) + poly.Plane.D;
|
||||
float dpMove = Vector3.Dot(movement, poly.Plane.Normal);
|
||||
if (MathF.Abs(dpMove) < PhysicsGlobals.EPSILON) return 0f;
|
||||
if (MathF.Abs(dpMove) <= PhysicsGlobals.EPSILON) return 0f;
|
||||
|
||||
float t = (-checkPos.Radius - dpPos) / dpMove;
|
||||
return Math.Clamp(t, 0f, 1f);
|
||||
float r = dpPos < 0f ? -checkPos.Radius : checkPos.Radius;
|
||||
return (r - dpPos) / dpMove;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
|
|
@ -1104,15 +1109,32 @@ public static class BSPQuery
|
|||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// BSPTree.adjust_to_plane — binary-search for non-penetrating sphere position.
|
||||
/// BSPTREE::adjust_to_plane (0x00539bf0, pc:323440) — find the closest
|
||||
/// non-penetrating position along <c>[curPos → checkPos]</c> and commit it
|
||||
/// into <paramref name="checkPos"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// Runs up to 15 forward iterations until touching, then up to 15 binary-search
|
||||
/// iterations to narrow the touch point. Modifies checkPos.Center in place.
|
||||
/// Returns false if convergence fails.
|
||||
/// Two known-time bounds: <c>clearTime</c> (retail var_50, init 0.0 — the
|
||||
/// start is clear) and <c>hitTime</c> (retail var_48, init 1.0 — the check
|
||||
/// position hit). Phase 1 walks plane-touch times from
|
||||
/// <see cref="AdjustSphereToPoly"/>, re-testing the whole tree at each —
|
||||
/// the tree test can surface a DIFFERENT blocking polygon, which feeds the
|
||||
/// next iteration (retail passes <c>&hitPoly</c> through). Phase 2
|
||||
/// binary-searches the bounds with the SAME iteration counter; window
|
||||
/// < 0.02 is CONVERGED, and the final center commits at the last
|
||||
/// known-clear time (0x00539de1). The only failure exit is Phase-1
|
||||
/// exhaustion (15 iterations without a clear touch — 0x00539ce9).
|
||||
/// </para>
|
||||
///
|
||||
/// <para>ACE: BSPTree.cs adjust_to_plane.</para>
|
||||
/// <para>
|
||||
/// ⚠️ Do NOT re-import ACE's BSPTree.cs version — its Phase-1 branch is
|
||||
/// inverted and its convergence exit returns false, making the function
|
||||
/// always-fail (dead on the server; PERFECT_CLIP is a client camera flag).
|
||||
/// That dead shape is what quantized PathClipped camera stops to whole
|
||||
/// transition steps: the #180 sawtooth AND the original #176 strobe
|
||||
/// (pulledIn 0.27 ↔ 0.53 = 1-step vs 2-step backoff). Pseudocode + decode:
|
||||
/// docs/research/2026-07-06-adjust-to-plane-pseudocode.md.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
private static bool AdjustToPlane(
|
||||
PhysicsBSPNode root,
|
||||
|
|
@ -1124,53 +1146,62 @@ public static class BSPQuery
|
|||
{
|
||||
var movement = checkPos.Center - curPos;
|
||||
|
||||
double lowerTime = 0.0;
|
||||
double upperTime = 1.0;
|
||||
double clearTime = 0.0; // retail var_50 — known-clear
|
||||
double hitTime = 1.0; // retail var_48 — known-hit
|
||||
int i = 0;
|
||||
|
||||
const int MaxIter = 15;
|
||||
|
||||
// Phase 1: step forward until non-intersecting.
|
||||
for (int i = 0; i < MaxIter; i++)
|
||||
// Phase 1 (0x00539c4d): walk plane-touch times.
|
||||
while (true)
|
||||
{
|
||||
float touchTime = AdjustSphereToPoly(hitPoly, checkPos, curPos, movement);
|
||||
|
||||
if (touchTime == 1f)
|
||||
{
|
||||
checkPos.Center = curPos + movement * (float)touchTime;
|
||||
// Start already within one radius of the plane — no projectable touch
|
||||
// time; fall through to the pure [0,1] binary search (0x00539c61).
|
||||
if (touchTime == 1f) break;
|
||||
|
||||
ResolvedPolygon? hp2 = null;
|
||||
Vector3 cp2 = Vector3.Zero;
|
||||
if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement,
|
||||
ref hp2, ref cp2))
|
||||
{
|
||||
lowerTime = touchTime;
|
||||
break;
|
||||
}
|
||||
upperTime = touchTime;
|
||||
}
|
||||
|
||||
if (i == MaxIter - 1) return false;
|
||||
}
|
||||
|
||||
// Phase 2: binary-search.
|
||||
for (int j = 0; j < MaxIter; j++)
|
||||
{
|
||||
double average = (lowerTime + upperTime) * 0.5;
|
||||
checkPos.Center = curPos + movement * (float)average;
|
||||
checkPos.Center = curPos + movement * touchTime;
|
||||
|
||||
ResolvedPolygon? hp2 = null;
|
||||
Vector3 cp2 = Vector3.Zero;
|
||||
|
||||
if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement,
|
||||
ref hp2, ref cp2))
|
||||
upperTime = (lowerTime + upperTime) * 0.5;
|
||||
else
|
||||
lowerTime = (lowerTime + upperTime) * 0.5;
|
||||
{
|
||||
clearTime = touchTime; // touch position clear → refine toward the hit
|
||||
break;
|
||||
}
|
||||
|
||||
if (upperTime - lowerTime < 0.02)
|
||||
return false;
|
||||
// Still blocked — possibly by a different polygon; the next plane
|
||||
// adjustment targets it (retail's &hitPoly write-back, 0x00539cd3).
|
||||
if (hp2 is not null) hitPoly = hp2;
|
||||
|
||||
i++;
|
||||
hitTime = touchTime;
|
||||
if (i >= MaxIter) return false; // the only failure exit (0x00539ce9)
|
||||
}
|
||||
|
||||
// Phase 2 (0x00539ddb): binary search; the counter CONTINUES from Phase 1.
|
||||
while (i < MaxIter)
|
||||
{
|
||||
double avg = (clearTime + hitTime) * 0.5;
|
||||
checkPos.Center = curPos + movement * (float)avg;
|
||||
|
||||
ResolvedPolygon? hp2 = null;
|
||||
Vector3 cp2 = Vector3.Zero;
|
||||
if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement,
|
||||
ref hp2, ref cp2))
|
||||
clearTime = avg;
|
||||
else
|
||||
hitTime = avg;
|
||||
|
||||
if (hitTime - clearTime < 0.02) break; // converged (0x00539dca)
|
||||
i++;
|
||||
}
|
||||
|
||||
// Commit the last known-clear position (0x00539de1). Phase 2 always
|
||||
// succeeds, converged or not — retail has no failure path here.
|
||||
checkPos.Center = curPos + movement * (float)clearTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue