From bf18a54369854be60304ec98aa097966a7d5c6c7 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 10:21:51 +0200 Subject: [PATCH] fix #116 (partial, Ghidra-confirmed): slide_sphere degenerate guard uses F_EPSILON, not EpsilonSq MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The user brought up Ghidra; its decompiler (patchmem.gpr, full PDB) resolved the Binary-Ninja `test ah,5` x87 branch-sign ambiguity that blocked the desk read. CSphere::slide_sphere (0x00537440) decompiles cleanly to: fVar3 = |cross(collisionNormal, contactPlane.N)|²; if (::F_EPSILON <= fVar3) { // crease exists ... offset = cross * dot(cross,gDelta)/fVar3; if (|offset|² < ::F_EPSILON) return COLLIDED_TS; // degenerate guard ... add_offset_to_check_pos -> SLID_TS } Retail compares the SQUARED magnitudes against F_EPSILON (0.000199999995 ~= 0.0002 = PhysicsGlobals.EPSILON). Our port compared against EpsilonSq (0.0002^2 = 4e-8) - a ~5000x too-tight threshold (the BN pseudo-C rendered the comparison as `test ah,5` after an x87 FCMP, which is sign-ambiguous; agent reads disagreed). Fixed both comparisons at TransitionTypes.cs:3098,3105 to EPSILON. Effect: crease-exists now needs >=0.81 deg between the wall and contact normals (was 0.011 deg - which routed near-parallel pairs through the numerically unstable projection); the degenerate guard now hard-stops slides under ~1.41 cm like retail (was 0.2 mm). Branch POLARITY was already correct - no change there. No regression: full physics suite (612) + full Core (1443) green. Not a register deviation (no row existed; this is an undocumented porting error corrected to match retail). This does NOT close #116 - it fixes a tangential constant, not either reported shape. Ghidra also settled the two shapes' diagnosis (recorded in ISSUES.md #116 + physics digest): - Shape-1: our cn=UnitZ default IS retail-faithful (validate_transition 0x0050aa70 has the identical `if (collision_normal_valid==0) set_collision_normal(UnitZ)`). The real divergence is upstream - tick-22760 our collision_normal_valid was false where retail's was true (it recorded the door-face normal). Needs the instrumented tick-22760 replay. - Shape-2 (D4 stays skipped, note sharpened): slide_sphere slides in-frame (SLID_TS) so Z=1.92 is faithful and the D4 Z=2.0 hard-stop pin is the suspect half; the threshold fix didn't move D4 (real slide, not degenerate). Needs a cdb trace of an airborne wall hit. Co-Authored-By: Claude Fable 5 --- docs/ISSUES.md | 36 ++++++++++++++++++- src/AcDream.Core/Physics/TransitionTypes.cs | 16 +++++++-- .../Physics/BSPStepUpTests.cs | 19 +++++----- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index aea90676..6d5f5552 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -3930,13 +3930,47 @@ retail's viewer-distance smoothing (update_viewer region) before touching. ## #116 — Slide-response divergence family: near-perpendicular lateral slide lost + first-airborne-frame in-frame slide vs hard stop -**Status:** OPEN +**Status:** OPEN (narrowed) — one Ghidra-confirmed faithfulness fix +SHIPPED 2026-06-12; both reported shapes still need a runtime trace. **Severity:** LOW-MEDIUM (over-blocking, never under-blocking — no walk-throughs; feel-level divergence at walls/doors) **Filed:** 2026-06-11 (BR-7 / A6.P4 ship session) **Component:** physics (slide response — `SlideSphere` degenerate-offset guard + first-contact-frame behavior) +**GHIDRA SESSION 2026-06-12 (the BN branch-sign ambiguity RESOLVED via a +second decompiler — Ghidra MCP, patchmem.gpr, full PDB):** +- **SHIPPED (faithfulness fix):** `CSphere::slide_sphere` (Ghidra + `0x00537440`) compares its SQUARED magnitudes against `::F_EPSILON` + (= 0.000199999995 ≈ 0.0002 = `PhysicsGlobals.EPSILON`): `if (::F_EPSILON + <= |cross|²)` (crease) and `if (|offset|² < ::F_EPSILON) return + COLLIDED_TS` (degenerate guard). Our port compared against `EpsilonSq` + (0.0002² = 4e-8) — a ~5000× too-tight threshold (the BN `test ah,5` + obscured it). Fixed at `TransitionTypes.cs:3098,3105`; full physics + suite (612) + full Core (1443) green, no regression. Crease now needs + ≥0.81° between normals (was 0.011°); the guard stops slides under + ~1.41 cm like retail (was 0.2 mm). NOT a register deviation (no row + existed — it was an undocumented porting error; the fix matches retail). + ⚠️ This does NOT fix either reported shape below. +- **Shape-1 RE-DIAGNOSED — our `cn=UnitZ` default is RETAIL-FAITHFUL.** + Ghidra `validate_transition` (`0x0050aa70`) does exactly our + `TransitionTypes.cs:3701-3702`: `if (collision_normal_valid == 0) + set_collision_normal(UnitZ)`. So the harness `cn=(0,0,1)` is the + faithful FALLBACK; the real divergence is UPSTREAM — at tick-22760 our + `collision_normal_valid` was FALSE (→ UnitZ) where retail's was TRUE + (it had recorded the door-face normal `(0,+1,0)`). The bug is in the + COLLISION-RECORDING path (find_collisions / collide_with_environment), + not slide/validate. Next: replay tick-22760 + (`DoorBugTrajectoryReplayTests`) instrumented to see where our + collision-normal recording drops the wall normal. +- **Shape-2 NARROWED — D4 stays skipped.** Ghidra confirms slide_sphere + applies the slide IN-FRAME (`add_offset_to_check_pos` → SLID_TS), so our + Z=1.92 is faithful TO slide_sphere and the D4 Z=2.0 hard-stop pin is the + SUSPECT half. But the threshold fix did NOT change D4 (its offset is a + real slide, not degenerate), so whether retail's first airborne frame + REACHES slide_sphere (→1.92) or hard-stops upstream still needs a cdb + trace of an airborne wall hit before flipping the assertion. + **Two pinned shapes, both pre-dating BR-7 (the per-cell shadow port left them byte-identical):** diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index 34dc4139..4e6d4b6e 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -3095,14 +3095,26 @@ public sealed class Transition Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal); float dirLenSq = direction.LengthSquared(); - if (dirLenSq >= PhysicsGlobals.EpsilonSq) + // #116 (2026-06-12, Ghidra-confirmed): retail CSphere::slide_sphere + // (0x00537440) compares these SQUARED magnitudes against F_EPSILON + // (0.000199999995 ≈ 0.0002 = PhysicsGlobals.EPSILON), NOT against the + // squared epsilon. Ghidra decomp: `if (::F_EPSILON <= fVar3)` where + // fVar3 = |cross|², and `if (|offset|² < ::F_EPSILON) return + // COLLIDED_TS`. Our port used EpsilonSq (0.0002² = 4e-8) — a ~5000× + // too-tight threshold (the BN pseudo-C `test ah,5` branch obscured the + // constant; the Ghidra second-decompiler pass settled it). Effect: + // crease-exists now needs ≥0.81° between the normals (was 0.011°, + // routing near-parallel pairs through the unstable projection); the + // degenerate guard now stops slides under ~1.41 cm like retail (was + // 0.2 mm). Register: AP-? (divergence retired). See ISSUES.md #116. + if (dirLenSq >= PhysicsGlobals.EPSILON) { // Crease exists: project displacement onto it. float diff = Vector3.Dot(direction, gDelta); float invDirLenSq = 1f / dirLenSq; Vector3 offset = direction * diff * invDirLenSq; - if (offset.LengthSquared() < PhysicsGlobals.EpsilonSq) + if (offset.LengthSquared() < PhysicsGlobals.EPSILON) return TransitionState.Collided; // Subtract current displacement to get the correction vector. diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs index 111316b8..560db1d6 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs @@ -546,14 +546,17 @@ public class BSPStepUpTests /// every frame replays the same hard stop and the character hangs in falling /// animation until another correction breaks the loop. /// - [Fact(Skip = "Issue #116 — slide-response divergence family (P1-era " + - "slide_sphere work made the first airborne wall frame slide in-frame " + - "to Z=1.92 instead of the L.2c-pinned hard stop at Z=2.0; the cached " + - "sliding-normal mechanism retail seeds via get_object_info " + - "(pc:279992, transient bit 4 → init_sliding_normal) only governs the " + - "NEXT frame, so which first-frame response is retail-faithful needs " + - "its own oracle read. NOT a cell-set problem — BR-7/A6.P4 left this " + - "byte-identical. See docs/ISSUES.md #116.")] + [Fact(Skip = "Issue #116 shape-2 — the engine slides IN-FRAME to Z=1.92 " + + "on the first airborne wall frame; this pin expects an L.2c hard stop " + + "at Z=2.0. Ghidra (2026-06-12) confirms retail CSphere::slide_sphere " + + "(0x00537440) applies the slide IN-FRAME (add_offset_to_check_pos → " + + "SLID_TS), so our 1.92 is faithful TO slide_sphere and the Z=2.0 " + + "expectation is the SUSPECT half — but whether retail's first " + + "airborne frame REACHES slide_sphere (→1.92) or hard-stops upstream " + + "(collide_with_environment dispatch / no last-known plane) needs a " + + "cdb trace of an airborne wall hit before flipping the assertion. The " + + "#116 threshold fix (EpsilonSq→F_EPSILON) did NOT change this — the D4 " + + "offset is a real slide, not degenerate. See docs/ISSUES.md #116.")] public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames() { var (root, resolved) = BSPStepUpFixtures.TallWall();