diff --git a/docs/research/2026-07-06-adjust-to-plane-pseudocode.md b/docs/research/2026-07-06-adjust-to-plane-pseudocode.md new file mode 100644 index 00000000..e1ac613d --- /dev/null +++ b/docs/research/2026-07-06-adjust-to-plane-pseudocode.md @@ -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. diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 3538e497..2f609ee5 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -573,15 +573,25 @@ public static class BSPQuery // ------------------------------------------------------------------------- /// - /// Polygon.adjust_sphere_to_poly — compute parametric contact time. + /// CPolygon::adjust_sphere_to_poly (0x00538170, pc:321999) — the parametric + /// time along at which the sphere SURFACE + /// touches the polygon's plane, approaching from the side the start + /// position is on. /// /// - /// 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 + /// (±radius − dpPos) / dpMove with the sign chosen by the start's + /// plane side (0x005381ea). UNCLAMPED, matching retail. /// /// - /// ACE: Polygon.cs adjust_sphere_to_poly. + /// + /// ⚠️ 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). + /// /// 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 // ------------------------------------------------------------------------- /// - /// 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 [curPos → checkPos] and commit it + /// into . /// /// - /// 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: clearTime (retail var_50, init 0.0 — the + /// start is clear) and hitTime (retail var_48, init 1.0 — the check + /// position hit). Phase 1 walks plane-touch times from + /// , re-testing the whole tree at each — + /// the tree test can surface a DIFFERENT blocking polygon, which feeds the + /// next iteration (retail passes &hitPoly 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). /// /// - /// ACE: BSPTree.cs adjust_to_plane. + /// + /// ⚠️ 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. + /// /// 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; } diff --git a/tests/AcDream.Core.Tests/Physics/Issue180CorridorSweepHysteresisReplayTests.cs b/tests/AcDream.Core.Tests/Physics/Issue180CorridorSweepHysteresisReplayTests.cs new file mode 100644 index 00000000..8a8ddb46 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue180CorridorSweepHysteresisReplayTests.cs @@ -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; + +/// +/// #180 residual — the camera-sweep SAWTOOTH in the Facility Hub corridor +/// (0x8A020164), root-caused to a dead BSPQuery.AdjustToPlane: +/// 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). +/// +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(), Array.Empty(), 0f, 0f); + return (engine, cache); + } + + /// Mirror of PhysicsCameraCollisionProbe.SweepEye's transition call. + 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(); + 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})"); + } +}