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
115
docs/research/2026-07-06-adjust-to-plane-pseudocode.md
Normal file
115
docs/research/2026-07-06-adjust-to-plane-pseudocode.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# `BSPTREE::adjust_to_plane` + `CPolygon::adjust_sphere_to_poly` — pseudocode (#180 sawtooth fix)
|
||||||
|
|
||||||
|
Source: `docs/research/named-retail/acclient_2013_pseudo_c.txt`. Read 2026-07-06
|
||||||
|
while root-causing the #180 residual (the camera-sweep sawtooth: PathClipped
|
||||||
|
stops landing a whole transition step short of the wall contact).
|
||||||
|
|
||||||
|
| Function | VA | pseudo-C line |
|
||||||
|
|---|---|---|
|
||||||
|
| `BSPTREE::adjust_to_plane` | `0x00539bf0` | 323440 |
|
||||||
|
| `BSPTREE::collide_with_pt` (caller) | `0x0053a090` | 323615 |
|
||||||
|
| `CPolygon::adjust_sphere_to_poly` | `0x00538170` | 321999 |
|
||||||
|
|
||||||
|
## ⚠️ ACE'S PORT OF BOTH FUNCTIONS IS WRONG — do not re-import it
|
||||||
|
|
||||||
|
ACE `BSPTree.cs:43-85 adjust_to_plane` + `Polygon.cs:99-114
|
||||||
|
adjust_sphere_to_poly` decode the retail x87 control flow incorrectly, and the
|
||||||
|
result is a function that **always returns false** (trace it: `touchTime==1`
|
||||||
|
re-places the sphere at t=1 — the unchanged check position — and re-tests; any
|
||||||
|
`touchTime<1` iterates 15 times doing nothing; the `<0.02` convergence exit
|
||||||
|
returns false — inverted). ACE never noticed because `PERFECT_CLIP` (0x40) is
|
||||||
|
a viewer/camera flag the SERVER never meaningfully exercises. acdream's
|
||||||
|
pre-fix `BSPQuery.AdjustToPlane`/`AdjustSphereToPoly` copied ACE's shape and
|
||||||
|
inherited the dead machinery — the memory lesson
|
||||||
|
`feedback_bn_decomp_field_names` class 3 (ACE mush-decode wrong in branches
|
||||||
|
ACE never runs; the retail binary outranks it).
|
||||||
|
|
||||||
|
**Consequence (the #180 sawtooth / the ORIGINAL #176 strobe):** with
|
||||||
|
`AdjustToPlane` always failing, `collide_with_pt`'s PerfectClip branch always
|
||||||
|
fell to the bare `Collided` return → the transition reverted the colliding
|
||||||
|
step to the PREVIOUS step boundary. A PathClipped camera sweep therefore
|
||||||
|
stopped a whole sub-step (≤ 1 sphere radius, ~0.25–0.3 m) short of the actual
|
||||||
|
contact — quantized to step boundaries. The pre-fix full-boom camera flipped
|
||||||
|
between 1-step and 2-step backoffs on mm input drift (the measured
|
||||||
|
`pulledIn` 0.27 ↔ 0.53 = one step vs two steps of the ~1.6 m/6-step sweep);
|
||||||
|
the stateful-sought camera turned it into a re-extend/clip-0.27 m sawtooth at
|
||||||
|
~19 Hz. Same root: the stop never resolved to the contact point.
|
||||||
|
|
||||||
|
## `CPolygon::adjust_sphere_to_poly` — 0x00538170
|
||||||
|
|
||||||
|
Returns the parametric time along `movement` at which the SPHERE SURFACE
|
||||||
|
touches the polygon's plane, approaching from the side the start position is
|
||||||
|
on. (The retail tail is x87 compare-bit mush; the reconstruction below is the
|
||||||
|
only shape consistent with the visible operand loads + the caller's use.)
|
||||||
|
|
||||||
|
```
|
||||||
|
adjust_sphere_to_poly(this, CSphere* sphere, Vector3 curPos, Vector3 movement) -> float:
|
||||||
|
dpPos = dot(plane.N, curPos) + plane.d // plane distance of the START (0x0053818d)
|
||||||
|
if |dpPos| < sphere.radius: return 1.0 // start already within one radius of the
|
||||||
|
// plane — no forward touch time (0x005381a4)
|
||||||
|
dpMove = dot(plane.N, movement) // (0x005381ca)
|
||||||
|
if |dpMove| <= 2e-4: return 0.0 // moving parallel to the plane (0x005381d2)
|
||||||
|
r = (dpPos < 0) ? -radius : +radius // touch on the start's side (0x005381ea)
|
||||||
|
return (r - dpPos) / dpMove // UNCLAMPED — retail has no [0,1] clamp
|
||||||
|
```
|
||||||
|
|
||||||
|
Divergences fixed in acdream's port: the early-out was a precise-poly overlap
|
||||||
|
test at the CHECK position (wrong test, wrong endpoint); the touch side was
|
||||||
|
hard-coded `-radius`; the result was clamped to [0,1]. ACE's version keys the
|
||||||
|
± sign on `movement.LengthSquared() <= r²` — a misdecode of the `dpPos < 0`
|
||||||
|
compare.
|
||||||
|
|
||||||
|
## `BSPTREE::adjust_to_plane` — 0x00539bf0
|
||||||
|
|
||||||
|
Finds the closest non-penetrating position along `[curPos → checkPos]` and
|
||||||
|
commits it into the sphere. Two known-time bounds: `clearTime` (var_50/var_4c,
|
||||||
|
init 0.0 — the start is clear) and `hitTime` (var_48/var_44, init **1.0** —
|
||||||
|
the check position hit). ONE iteration counter shared by both phases.
|
||||||
|
|
||||||
|
```
|
||||||
|
adjust_to_plane(this, CSphere* sphere /*in: at checkPos; out: adjusted*/,
|
||||||
|
Vector3 curPos, CPolygon*& hitPoly, Vector3* contactPt) -> success:
|
||||||
|
movement = sphere.center - curPos // (0x00539c16..0x00539c3c)
|
||||||
|
clearTime = 0.0 ; hitTime = 1.0 ; i = 0
|
||||||
|
|
||||||
|
// Phase 1 (0x00539c4d): walk plane-touch times.
|
||||||
|
loop:
|
||||||
|
t = hitPoly->adjust_sphere_to_poly(sphere, curPos, movement)
|
||||||
|
if t == 1.0: break // start within radius → pure [0,1] search
|
||||||
|
sphere.center = curPos + movement * t // (0x00539ca8-0x00539cb9)
|
||||||
|
if !root->sphere_intersects_poly(sphere, movement, &hitPoly, contactPt):
|
||||||
|
clearTime = t // touch position clear (0x00539d02)
|
||||||
|
break
|
||||||
|
// still blocked — by a possibly DIFFERENT poly (the tree test wrote hitPoly)
|
||||||
|
i += 1 ; hitTime = t // (0x00539cdd-0x00539ce5)
|
||||||
|
if i >= 15: return FAIL // the only failure exit (0x00539ce9)
|
||||||
|
|
||||||
|
// Phase 2 (0x00539ddb): binary-search [clearTime, hitTime]; i continues.
|
||||||
|
do:
|
||||||
|
avg = (clearTime + hitTime) * 0.5
|
||||||
|
sphere.center = curPos + movement * avg
|
||||||
|
if !sphere_intersects_poly(...): clearTime = avg
|
||||||
|
else: hitTime = avg
|
||||||
|
if hitTime - clearTime < 0.02: break // CONVERGED (0x00539dca) — NOT a failure
|
||||||
|
i += 1
|
||||||
|
while i < 15
|
||||||
|
|
||||||
|
sphere.center = curPos + movement * clearTime // commit the last CLEAR time (0x00539de1)
|
||||||
|
return SUCCESS // Phase 2 always succeeds (even unconverged)
|
||||||
|
```
|
||||||
|
|
||||||
|
Caller (`collide_with_pt` 0x0053a090, PERFECT_CLIP branch): on success —
|
||||||
|
`set_collision_normal(globalized hitPoly.plane)`, `add_offset_to_check_pos(
|
||||||
|
globalize(adjusted − original) · scale)`, return **Adjusted(3)**; on the rare
|
||||||
|
Phase-1 failure — return **Collided(2)** with NO normal write. acdream's
|
||||||
|
`CollideWithPt` already had this caller shape correct; only the two functions
|
||||||
|
above were broken.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Headless replay `Issue180CorridorSweepHysteresisReplayTests` (real Facility
|
||||||
|
Hub cell 0x8A020164 BSP, the exact captured ray): pre-fix, targets embedded up
|
||||||
|
to 0.25 m into the wall behind the camera passed unclipped and the first
|
||||||
|
detection (s=1.625) stopped a full step back (eyeBack 1.354, tracking the
|
||||||
|
target); post-fix the stop must sit at the surface-contact point (eyeBack ≈
|
||||||
|
constant ≈ 1.61) for every target past first touch.
|
||||||
|
|
@ -573,15 +573,25 @@ public static class BSPQuery
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// <para>
|
||||||
/// Returns 1.0 if the sphere currently intersects the polygon (needs further
|
/// Returns 1.0 when the start center is already within one radius of the
|
||||||
/// back-off), or the parametric time [0,1] of first contact along the movement
|
/// plane (no forward touch time computable — 0x005381a4), 0.0 when the
|
||||||
/// vector. Used by adjust_to_plane binary-search loop.
|
/// 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>
|
||||||
///
|
///
|
||||||
/// <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>
|
/// </summary>
|
||||||
private static float AdjustSphereToPoly(
|
private static float AdjustSphereToPoly(
|
||||||
ResolvedPolygon poly,
|
ResolvedPolygon poly,
|
||||||
|
|
@ -589,19 +599,14 @@ public static class BSPQuery
|
||||||
Vector3 curPos,
|
Vector3 curPos,
|
||||||
Vector3 movement)
|
Vector3 movement)
|
||||||
{
|
{
|
||||||
Vector3 cp = Vector3.Zero;
|
float dpPos = Vector3.Dot(curPos, poly.Plane.Normal) + poly.Plane.D;
|
||||||
if (PolygonHitsSpherePrecise(
|
if (MathF.Abs(dpPos) < checkPos.Radius) return 1f;
|
||||||
poly.Plane, poly.Vertices,
|
|
||||||
checkPos.Center, checkPos.Radius,
|
|
||||||
ref cp))
|
|
||||||
return 1f;
|
|
||||||
|
|
||||||
float dpPos = Vector3.Dot(curPos, poly.Plane.Normal) + poly.Plane.D;
|
|
||||||
float dpMove = Vector3.Dot(movement, poly.Plane.Normal);
|
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;
|
float r = dpPos < 0f ? -checkPos.Radius : checkPos.Radius;
|
||||||
return Math.Clamp(t, 0f, 1f);
|
return (r - dpPos) / dpMove;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|
@ -1104,15 +1109,32 @@ public static class BSPQuery
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// <para>
|
||||||
/// Runs up to 15 forward iterations until touching, then up to 15 binary-search
|
/// Two known-time bounds: <c>clearTime</c> (retail var_50, init 0.0 — the
|
||||||
/// iterations to narrow the touch point. Modifies checkPos.Center in place.
|
/// start is clear) and <c>hitTime</c> (retail var_48, init 1.0 — the check
|
||||||
/// Returns false if convergence fails.
|
/// 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>
|
||||||
///
|
///
|
||||||
/// <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>
|
/// </summary>
|
||||||
private static bool AdjustToPlane(
|
private static bool AdjustToPlane(
|
||||||
PhysicsBSPNode root,
|
PhysicsBSPNode root,
|
||||||
|
|
@ -1124,53 +1146,62 @@ public static class BSPQuery
|
||||||
{
|
{
|
||||||
var movement = checkPos.Center - curPos;
|
var movement = checkPos.Center - curPos;
|
||||||
|
|
||||||
double lowerTime = 0.0;
|
double clearTime = 0.0; // retail var_50 — known-clear
|
||||||
double upperTime = 1.0;
|
double hitTime = 1.0; // retail var_48 — known-hit
|
||||||
|
int i = 0;
|
||||||
|
|
||||||
const int MaxIter = 15;
|
const int MaxIter = 15;
|
||||||
|
|
||||||
// Phase 1: step forward until non-intersecting.
|
// Phase 1 (0x00539c4d): walk plane-touch times.
|
||||||
for (int i = 0; i < MaxIter; i++)
|
while (true)
|
||||||
{
|
{
|
||||||
float touchTime = AdjustSphereToPoly(hitPoly, checkPos, curPos, movement);
|
float touchTime = AdjustSphereToPoly(hitPoly, checkPos, curPos, movement);
|
||||||
|
|
||||||
if (touchTime == 1f)
|
// Start already within one radius of the plane — no projectable touch
|
||||||
{
|
// time; fall through to the pure [0,1] binary search (0x00539c61).
|
||||||
checkPos.Center = curPos + movement * (float)touchTime;
|
if (touchTime == 1f) break;
|
||||||
|
|
||||||
ResolvedPolygon? hp2 = null;
|
checkPos.Center = curPos + movement * touchTime;
|
||||||
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;
|
|
||||||
|
|
||||||
ResolvedPolygon? hp2 = null;
|
ResolvedPolygon? hp2 = null;
|
||||||
Vector3 cp2 = Vector3.Zero;
|
Vector3 cp2 = Vector3.Zero;
|
||||||
|
|
||||||
if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement,
|
if (!SphereIntersectsPolyInternal(root, resolved, checkPos, movement,
|
||||||
ref hp2, ref cp2))
|
ref hp2, ref cp2))
|
||||||
upperTime = (lowerTime + upperTime) * 0.5;
|
{
|
||||||
else
|
clearTime = touchTime; // touch position clear → refine toward the hit
|
||||||
lowerTime = (lowerTime + upperTime) * 0.5;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (upperTime - lowerTime < 0.02)
|
// Still blocked — possibly by a different polygon; the next plane
|
||||||
return false;
|
// 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.Core.Physics;
|
||||||
|
using AcDream.Core.Tests.Conformance;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Core.Tests.Physics;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// #180 residual — the camera-sweep SAWTOOTH in the Facility Hub corridor
|
||||||
|
/// (0x8A020164), root-caused to a dead <c>BSPQuery.AdjustToPlane</c>:
|
||||||
|
/// acdream (via ACE's misdecoded port) had the PerfectClip exact-contact
|
||||||
|
/// machinery structurally inverted so it ALWAYS failed, and every PathClipped
|
||||||
|
/// camera stop reverted to the previous transition-step boundary instead of
|
||||||
|
/// the contact point. Live signature (launch-180-verify.log): the stateful
|
||||||
|
/// sought re-extends ~3 mm/frame, the sweep passes silently until the step
|
||||||
|
/// containing the contact, then clips a whole step (~0.27 m) back — a ~19 Hz
|
||||||
|
/// sawtooth; the pre-stateful camera flipped 1-step vs 2-step backoffs
|
||||||
|
/// (pulledIn 0.27 ↔ 0.53) — the ORIGINAL #176 stripe strobe.
|
||||||
|
///
|
||||||
|
/// This replay walks sweep targets along the exact captured ray
|
||||||
|
/// (pivot → the [flap-sweep] in= of idx 28882) across the wall behind the
|
||||||
|
/// spawn camera, against the REAL cell BSP. It pins the retail-faithful
|
||||||
|
/// stop behavior (BSPTREE::adjust_to_plane 0x00539bf0, pseudocode
|
||||||
|
/// docs/research/2026-07-06-adjust-to-plane-pseudocode.md):
|
||||||
|
///
|
||||||
|
/// 1. targets short of first touch pass unclipped (the wall plane is
|
||||||
|
/// genuinely ~1.61 m out along this ray: dist(center,plane) > r);
|
||||||
|
/// 2. every target past first touch CONTACTS (no silent pass-through
|
||||||
|
/// band — pre-fix, targets embedded up to ~0.25 m passed);
|
||||||
|
/// 3. the clipped eye is the CONSTANT surface-contact point (pre-fix it
|
||||||
|
/// tracked the target one step back: eyeBack = s − ~0.27).
|
||||||
|
/// </summary>
|
||||||
|
public class Issue180CorridorSweepHysteresisReplayTests
|
||||||
|
{
|
||||||
|
private readonly ITestOutputHelper _out;
|
||||||
|
public Issue180CorridorSweepHysteresisReplayTests(ITestOutputHelper output) => _out = output;
|
||||||
|
|
||||||
|
private const float ViewerSphereRadius = 0.3f; // retail viewer_sphere (acclient :93314)
|
||||||
|
|
||||||
|
private const uint FacilityHubLandblock = 0x8A020000u;
|
||||||
|
private const uint CorridorCell = 0x8A020164u;
|
||||||
|
|
||||||
|
// [flap-cam] player=(70.58,-40.16,-5.90) (parked spawn) + PivotHeight 1.5.
|
||||||
|
private static readonly Vector3 Pivot = new(70.58f, -40.16f, -4.40f);
|
||||||
|
|
||||||
|
// [flap-sweep] idx 28882: the captured in= that clipped (the sawtooth's deep edge).
|
||||||
|
private static readonly Vector3 THit = new(70.366051f, -38.628315f, -3.935829f);
|
||||||
|
|
||||||
|
private static (PhysicsEngine, PhysicsDataCache) BuildCorridorEngine(DatCollection dats)
|
||||||
|
{
|
||||||
|
var cache = new PhysicsDataCache();
|
||||||
|
var engine = new PhysicsEngine { DataCache = cache };
|
||||||
|
for (uint low = 0x0100u; low <= 0x01FFu; low++)
|
||||||
|
{
|
||||||
|
uint id = FacilityHubLandblock | low;
|
||||||
|
try { ConformanceDats.LoadEnvCell(dats, cache, id); }
|
||||||
|
catch (InvalidOperationException) { /* cell id not present in this dungeon */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
var heights = new byte[81];
|
||||||
|
var heightTable = new float[256];
|
||||||
|
for (int i = 0; i < 256; i++) heightTable[i] = -1000f;
|
||||||
|
engine.AddLandblock(FacilityHubLandblock, new TerrainSurface(heights, heightTable),
|
||||||
|
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(), 0f, 0f);
|
||||||
|
return (engine, cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Mirror of PhysicsCameraCollisionProbe.SweepEye's transition call.</summary>
|
||||||
|
private static ResolveResult SweepViewer(PhysicsEngine engine, Vector3 pivot, Vector3 desiredEye, uint cellId)
|
||||||
|
{
|
||||||
|
uint startCell = cellId;
|
||||||
|
if ((cellId & 0xFFFFu) >= 0x0100u)
|
||||||
|
{
|
||||||
|
var (pivotCell, found) = engine.AdjustPosition(cellId, pivot);
|
||||||
|
if (found) startCell = pivotCell;
|
||||||
|
}
|
||||||
|
|
||||||
|
Vector3 begin = pivot - new Vector3(0f, 0f, ViewerSphereRadius);
|
||||||
|
Vector3 end = desiredEye - new Vector3(0f, 0f, ViewerSphereRadius);
|
||||||
|
|
||||||
|
return engine.ResolveWithTransition(
|
||||||
|
currentPos: begin,
|
||||||
|
targetPos: end,
|
||||||
|
cellId: startCell,
|
||||||
|
sphereRadius: ViewerSphereRadius,
|
||||||
|
sphereHeight: 0f,
|
||||||
|
stepUpHeight: 0f,
|
||||||
|
stepDownHeight: 0f,
|
||||||
|
isOnGround: false,
|
||||||
|
body: null,
|
||||||
|
moverFlags: ObjectInfoState.IsViewer | ObjectInfoState.PathClipped
|
||||||
|
| ObjectInfoState.FreeRotate | ObjectInfoState.PerfectClip,
|
||||||
|
movingEntityId: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ClippedStop_IsTheContactPoint_NotAStepBoundary()
|
||||||
|
{
|
||||||
|
var datDir = ConformanceDats.ResolveDatDir();
|
||||||
|
if (datDir is null) { _out.WriteLine("SKIP: dats unavailable"); return; }
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
var (engine, _) = BuildCorridorEngine(dats);
|
||||||
|
|
||||||
|
Vector3 dir = Vector3.Normalize(THit - Pivot);
|
||||||
|
|
||||||
|
var clippedEyeBacks = new List<float>();
|
||||||
|
for (float s = 1.20f; s <= 1.75f; s += 0.025f)
|
||||||
|
{
|
||||||
|
Vector3 target = Pivot + dir * s;
|
||||||
|
var r = SweepViewer(engine, Pivot, target, CorridorCell);
|
||||||
|
Vector3 eye = r.Position + new Vector3(0f, 0f, ViewerSphereRadius);
|
||||||
|
float eyeBack = Vector3.Distance(Pivot, eye);
|
||||||
|
_out.WriteLine(FormattableString.Invariant(
|
||||||
|
$"s={s:F3} eyeBack={eyeBack:F3} collNorm={r.CollisionNormalValid}"));
|
||||||
|
|
||||||
|
if (s <= 1.55f)
|
||||||
|
{
|
||||||
|
// (1) Short of first touch (~1.61 m): the sweep must reach the target.
|
||||||
|
Assert.False(r.CollisionNormalValid,
|
||||||
|
$"s={s:F3}: no wall within reach, sweep must not clip");
|
||||||
|
Assert.True(MathF.Abs(eyeBack - s) < 0.01f,
|
||||||
|
$"s={s:F3}: unclipped sweep must reach the target, got eyeBack={eyeBack:F3}");
|
||||||
|
}
|
||||||
|
else if (s >= 1.65f)
|
||||||
|
{
|
||||||
|
// (2) Past first touch: every target must contact — the pre-fix
|
||||||
|
// defect passed targets embedded up to ~0.25 m unclipped.
|
||||||
|
Assert.True(r.CollisionNormalValid,
|
||||||
|
$"s={s:F3}: target past the wall must clip");
|
||||||
|
clippedEyeBacks.Add(eyeBack);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// (3) The clipped stop is the constant surface-contact point. Pre-fix the
|
||||||
|
// stop tracked the target one step back (eyeBack = s − ~0.27, spread
|
||||||
|
// ≈ 0.10 m over this range); retail's adjust_to_plane commits the
|
||||||
|
// last-clear time within a 0.02 window of the contact.
|
||||||
|
Assert.True(clippedEyeBacks.Count >= 4, "expected several clipped samples");
|
||||||
|
float min = float.MaxValue, max = float.MinValue;
|
||||||
|
foreach (var e in clippedEyeBacks) { min = MathF.Min(min, e); max = MathF.Max(max, e); }
|
||||||
|
Assert.True(max - min < 0.03f,
|
||||||
|
$"clipped stops must be one contact point, got spread {max - min:F3} m ({min:F3}..{max:F3})");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue