diff --git a/tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs b/tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs new file mode 100644 index 00000000..da53acf6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs @@ -0,0 +1,366 @@ +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; +using Xunit.Abstractions; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Issue #265 capture-driven bisection: "Steep-slope response set" (uphill-jump +/// bounce, lost roof slide, edge wedge). This harness replays a REAL trajectory +/// mined from a live capture (ACDREAM_CAPTURE_RESOLVE, +/// artifacts/matrix-session2-resolve.jsonl, records 3415-3434) through a +/// synthetic single-polygon built from the EXACT +/// polygon the live capture landed on +/// (bodyAfter.walkableVertices: (240,0,88),(264,0,80),(264,24,68), +/// normal (2,3,6)/7 = (0.2857,0.4286,0.8571) — a moderate, WALKABLE-BY-THRESHOLD +/// roof slope, normal.Z=0.857 > PhysicsGlobals.FloorZ (0.6642)). +/// +/// +/// Live symptom this reproduces (mining evidence): the player falls +/// (v ≈ (11.15, 14.13, -23.14) m/s at landing) onto this roof slope. Live capture +/// record 3433 shows collisionNormalValid=true, the correct real polygon +/// normal, and walkablePolygonValid=true — a legitimate walkable landing. +/// Record 3434 (the very next tick) shows the body FROZEN: velocity forced to +/// exactly (0,0,0), transientState=7 (Contact|OnWalkable|Sliding), and the +/// position stays byte-identical for the remaining 12,292 captured ticks (to +/// the end of the file) — i.e. the player never moves again. A second, +/// independent instance of the same shape appears at records 3153-3199+ (a +/// shallower ~18° roof edge, frozen for 46+ captured ticks). Full mining +/// evidence: docs/research/2026-07-30-265-capture-bisect.md. +/// +/// +/// +/// Candidate mechanism (S1): commit db2889af ("#116 shape-1") +/// changed BSPQuery.cs's Path-6 hasSphere1 (head-sphere-only hit +/// while airborne, foot sphere clear) branch from a steepness-gated +/// SetCollide→Adjusted (shallow, Z≥FloorZ) / slide-tangent-then-Slid (steep, +/// Z<FloorZ) dual path — IDENTICAL in shape to the still-unchanged sphere0 +/// (foot) branch a few lines above it — to an UNCONDITIONAL +/// SetCollisionNormal + return Collided, regardless of steepness. A +/// `Collided` return short-circuits TransitionalInsert immediately +/// (if (transitState == TransitionState.Collided) return +/// TransitionState.Collided;) — it never reaches the retry loop's Phase 3 +/// (if (sp.Collide) ...DoCheckWalkable...Placement retry...), which is +/// the ONLY place a shallow/walkable head-sphere hit can smoothly commit to a +/// real ContactPlane + OnWalkable via the SetCollide+Adjusted +/// path. This test's real captured polygon has normal.Z=0.857 — well +/// ABOVE FloorZ (0.6642) — so it is the SHALLOW case, not the steep one; +/// S1 removed the steepness branch entirely, so this shallow graze now takes +/// the SAME hard-stop path a steep hit would. +/// +/// +/// +/// Method: integrates the EXACT +/// captured ballistic state (position + velocity) from record 3415 forward +/// with real gravity (dt=1/30s, matching retail's tick rate), calling +/// every tick exactly like +/// does at the +/// Core boundary (this harness intentionally stops at that boundary — it does +/// NOT call PhysicsObjUpdate.HandleAllCollisions or model the R6 +/// animation-root-motion grounded-movement zeroing, both of which live outside +/// Core and are confirmed NOT part of the S1/S2 candidate set — see the research +/// doc). Once the mover reports IsOnGround, the harness keeps REQUESTING +/// forward motion each tick (simulating held input) so a genuine "does the +/// engine allow continued advance across this surface" signal is observable, +/// rather than trivially replaying the live capture's own (already-frozen, +/// no-input) subsequent targets. +/// +/// +/// +/// A/B protocol (see the research doc for the executed results): this +/// same test is run unmodified against (i) HEAD, (ii) BSPQuery.cs with +/// the S1 sphere1 branch reverted to mirror the still-current sphere0 shape +/// (local, uncommitted diagnostic edit), (iii) production unaffected by S2 +/// (calc_friction's AP-7 threshold) since S2 has zero call sites outside its +/// own unit test — confirmed by grep -rn "\.calc_friction(" src/ — so no +/// S2 toggle is needed for THIS harness, and (iv) both. The per-tick dump +/// ( list, printed via ) +/// is the diff target. +/// +/// +public class Issue265SteepSlopeCaptureBisectTests +{ + private readonly ITestOutputHelper _out; + public Issue265SteepSlopeCaptureBisectTests(ITestOutputHelper output) => _out = output; + + // ── Real captured polygon (bodyAfter.walkableVertices, record 3433) ────── + // artifacts/matrix-session2-resolve.jsonl, tick 3433, cell 0xAAB40011. + // normal = cross(v1-v0, v2-v0) normalized = (2,3,6)/7 exactly. + private static readonly Vector3 RoofV0 = new(240f, 0f, 88f); + private static readonly Vector3 RoofV1 = new(264f, 0f, 80f); + private static readonly Vector3 RoofV2 = new(264f, 24f, 68f); + + // Outdoor cell suffix MUST be < 0x0100 (retail's indoor/outdoor LandCell + // convention — CellTransit.BuildShadowCellSet branches on it) and its + // block index must be (0,0) so that the flood's landblock-local grid math + // (CellTransit.AddAllOutsideCells, an 8x8 24-m-cell grid over the 192-m + // landblock) treats this harness's coordinates as directly landblock-local + // — CellGraph.TryGetTerrainOrigin has no registered terrain for this + // synthetic landblock so it Zero-falls-back, meaning raw "world" position + // IS landblock-local position (documented anchor-frame convention, same + // one Ts4SteepRoofWedgeCaptureTests/DoorBugTrajectoryReplayTests rely on). + // The harness's very first run registered the shadow object at the REAL + // captured world coordinates (X≈256) under this convention — 256 is + // outside the valid [0,192) landblock-local range, so the flood produced + // an empty cell set and the object was silently never registered at all + // (zero collisions the whole replay). Fix: re-anchor the entire synthetic + // scene (triangle + approach trajectory) at the roof centroid so every + // coordinate here is small and landblock-local (see the research doc's + // harness-commissioning note). + // Suffix 0x0001 is the canonical (gridX=0, gridY=0) outdoor LandCell — the + // grid cell whose local origin is (0,0) — matching this harness's + // re-anchored roof centroid at world (0,0,0) (see the note above). An + // earlier attempt used suffix 0x0011; CellTransit.AddAllOutsideCells' + // LandDefs.AdjustToOutside re-derives the (lx,ly) grid cell from the + // sphere's ACTUAL position and silently corrects a mismatched seed, so the + // registration landed in cell 0x00000001 regardless of the literal seed + // passed — GetObjectsInCell(0x00000011) found nothing (see the research + // doc's harness-commissioning note). + private const uint CellId = 0x00000001u; + private const uint LandblockId = 0x00000000u; + private const uint SyntheticGfxId = 0x265BEEF1u; + + private const int TicksPerSecond = 30; + private const float Gravity = -9.8f; + private const float SphereRadius = 0.48f; // production human Setup 0x02000001 + private const float SphereHeight = 1.835f; // production human Setup 0x02000001 + + // Real captured state, record index 3415 (session2, tick 3415) — 18 ticks + // before the landing/freeze at record 3433/3434. vx/vy are constant across + // this whole approach (pure ballistic fall, no further horizontal drive). + // Re-anchored: subtract RoofCentroid from the real captured world position + // (see the landblock-local note above) — the RELATIVE approach geometry + // (distance, direction, velocity) is preserved exactly. + private static readonly Vector3 ApproachStartPosReal = new(244.59f, -0.81f, 92.79f); + private static readonly Vector3 ApproachStartVel = new(11.1509495f, 14.129979f, -16.72f); + + // ShadowObjects.Register's broad-phase culls candidates by distance from + // `worldPos` within `radius` — registering at Vector3.Zero with the real + // (far-from-origin) captured world coordinates put the polygon ~264 units + // from the query point, well outside any sane radius, so the very first + // run of this harness found ZERO collisions at all (see the research doc's + // "harness commissioning" note). Fix: register the entity at the + // triangle's centroid and express the polygon in LOCAL coordinates + // relative to that centroid (identity rotation, scale 1 — world = local + + // worldPos reconstructs the exact real-world triangle). + private static readonly Vector3 RoofCentroid = (RoofV0 + RoofV1 + RoofV2) / 3f; + + private static PhysicsEngine MakeRoofEngine() + { + var resolved = new Dictionary(); + var verts = new[] { RoofV0 - RoofCentroid, RoofV1 - RoofCentroid, RoofV2 - RoofCentroid }; + var normal = Vector3.Normalize(Vector3.Cross(verts[1] - verts[0], verts[2] - verts[0])); + float d = -Vector3.Dot(normal, verts[0]); + resolved[1] = new ResolvedPolygon + { + Vertices = verts, + Plane = new Plane(normal, d), + NumPoints = 3, + SidesType = CullMode.None, + }; + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 30f }, + }; + leaf.Polygons.Add(1); + + var heights = new byte[81]; + var heightTab = new float[256]; + for (int i = 0; i < 256; i++) heightTab[i] = -1000f; // terrain never interferes + + var engine = new PhysicsEngine(); + engine.AddLandblock( + LandblockId, + new TerrainSurface(heights, heightTab), + System.Array.Empty(), + System.Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + + var cache = new PhysicsDataCache(); + var bspTree = new PhysicsBSPTree { Root = leaf }; + var physics = new GfxObjPhysics + { + BSP = bspTree, + PhysicsPolygons = new Dictionary(), + Vertices = new VertexArray(), + Resolved = resolved, + BoundingSphere = new Sphere { Origin = Vector3.Zero, Radius = 30f }, + }; + cache.RegisterGfxObjForTest(SyntheticGfxId, physics); + engine.DataCache = cache; + + // ShadowObjectRegistry is the per-cell shadow-object index (BR-7/A6.P4): + // Register() FLOODS from a SEED CELL outward and registers the entity + // into the resulting cell set; the query side (GetObjectsInCell) looks + // up strictly by the mover's CURRENT cell id. Leaving seedCellId at its + // default (0u) makes Register() call DeriveOutdoorSeed(worldPos, ...), + // which computes its OWN outdoor landcell id from world position — for + // the real captured coordinates used here (worldPos.X=256, well outside + // landblock 0xAAB40000's own 192 m span) that derives to a DIFFERENT + // cell than the literal CellId this harness resolves against, so the + // very first run of this fixture found zero collisions (see the + // research doc's harness-commissioning note). Passing seedCellId + // explicitly bypasses the derivation and floods from the exact cell + // the replay loop queries. + engine.ShadowObjects.Register( + entityId: SyntheticGfxId, + gfxObjId: SyntheticGfxId, + worldPos: Vector3.Zero, + rotation: Quaternion.Identity, + radius: 30f, + worldOffsetX: 0f, + worldOffsetY: 0f, + landblockId: LandblockId, + collisionType: ShadowCollisionType.BSP, + scale: 1.0f, + seedCellId: CellId); + + return engine; + } + + public sealed record TickSample( + int Tick, + Vector3 Pos, + float Advance, + bool CollisionNormalValid, + Vector3 CollisionNormal, + bool OnGround, + int FrozenStreak); + + /// + /// Replays the real captured ballistic approach + landing, then keeps + /// REQUESTING forward motion (simulating held input) for + /// additional ticks once grounded, to + /// see whether the engine allows continued advance across the roof surface + /// or wedges in place. Returns one per tick. + /// + public static List ReplayRealRoofLanding(int postLandingTicks = 60) + { + var engine = MakeRoofEngine(); + const float dt = 1f / TicksPerSecond; + + var body = new PhysicsBody { TransientState = TransientStateFlags.Active }; + + Vector3 pos = ApproachStartPosReal - RoofCentroid; + Vector3 vel = ApproachStartVel; + uint cell = CellId; + bool grounded = false; + int frozenStreak = 0; + int ticksSinceGrounded = -1; + + var samples = new List(); + + // Budget: enough ticks to cover the ~18-tick ballistic approach plus the + // requested post-landing continuation window. + int maxTicks = 18 + postLandingTicks + 20; + + for (int tick = 0; tick < maxTicks; tick++) + { + if (!grounded) + vel = new Vector3(vel.X, vel.Y, vel.Z + Gravity * dt); + // Once grounded, keep requesting the SAME horizontal advance each + // tick (simulating held forward input) — this is the "does a + // slide continue" probe. Vertical requested delta is zero (resting + // against the surface, not still falling). + Vector3 requestedVel = grounded ? new Vector3(vel.X, vel.Y, 0f) : vel; + Vector3 target = pos + requestedVel * dt; + + var result = engine.ResolveWithTransition( + currentPos: pos, + targetPos: target, + cellId: cell, + sphereRadius: SphereRadius, + sphereHeight: SphereHeight, + stepUpHeight: 0.6f, + stepDownHeight: 1.5f, + isOnGround: grounded, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x01000000u); + + float advance = Vector3.Distance(result.Position, pos); + if (advance < 0.001f) + frozenStreak++; + else + frozenStreak = 0; + + samples.Add(new TickSample( + tick, result.Position, advance, + result.CollisionNormalValid, result.CollisionNormal, + result.IsOnGround, frozenStreak)); + + pos = result.Position; + cell = result.CellId; + body.Position = pos; + + if (!grounded && result.IsOnGround) + { + grounded = true; + ticksSinceGrounded = 0; + } + else if (grounded) + { + ticksSinceGrounded++; + if (ticksSinceGrounded >= postLandingTicks) + break; + } + } + + return samples; + } + + /// + /// Characterization test: dumps the full per-tick trajectory so the A/B + /// bisect (this file's class doc) can diff HEAD vs the S1-reverted local + /// edit. Always passes — this is a diagnostic capture, matching the + /// project's existing LiveCompare_FirstCap_DiagnosticDump-style + /// tests. The actual pass/fail verdict is recorded in + /// docs/research/2026-07-30-265-capture-bisect.md, not as a + /// hardcoded assertion here, because the correct fix shape (and therefore + /// the correct future regression assertion) is still being decided. + /// + [Fact] + public void RealCapturedRoofLanding_CharacterizeCurrentBehavior() + { + PhysicsDiagnostics.ResetForTest(); + PhysicsDiagnostics.ProbeIndoorBspEnabled = true; + PhysicsDiagnostics.ProbeBuildingEnabled = true; + try + { + var samples = ReplayRealRoofLanding(); + + int maxFrozen = 0; + int landedAtTick = -1; + foreach (var s in samples) + { + maxFrozen = System.Math.Max(maxFrozen, s.FrozenStreak); + if (landedAtTick < 0 && s.OnGround) landedAtTick = s.Tick; + _out.WriteLine(string.Format( + System.Globalization.CultureInfo.InvariantCulture, + "t{0,3}: pos=({1:F3},{2:F3},{3:F3}) adv={4:F4} cnv={5} n=({6:F3},{7:F3},{8:F3}) onGround={9} frozen={10}", + s.Tick, s.Pos.X, s.Pos.Y, s.Pos.Z, s.Advance, + s.CollisionNormalValid, s.CollisionNormal.X, s.CollisionNormal.Y, s.CollisionNormal.Z, + s.OnGround, s.FrozenStreak)); + } + + _out.WriteLine($"=== landedAtTick={landedAtTick} maxFrozenStreak={maxFrozen} totalTicks={samples.Count} ==="); + + // Sanity-only assertion: the replay must actually reach the roof + // (land) within the ballistic approach window — if this fails the + // synthetic fixture itself is wrong, not a physics-engine finding. + Assert.True(landedAtTick is >= 0 and < 30, + $"Replay never reached the synthetic roof polygon (landedAtTick={landedAtTick}); " + + "fixture geometry or approach trajectory needs adjustment before this is a valid oracle."); + } + finally + { + PhysicsDiagnostics.ResetForTest(); + } + } +} diff --git a/tools/analyze_265_steep_slope_capture.py b/tools/analyze_265_steep_slope_capture.py new file mode 100644 index 00000000..a79f8e7b --- /dev/null +++ b/tools/analyze_265_steep_slope_capture.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Issue #265 segment miner: steep-slope response family (uphill-jump bounce, +lost roof slide, edge wedge) from ACDREAM_CAPTURE_RESOLVE JSONL captures. + +Companion to tools/analyze_resolve_capture.py (the #182 OK/partial/stuck +classifier). This script targets the #265 symptom set specifically: + + (a) uphill-jump bounce — jumping INTO an upward slope reflects velocity + upward instead of sliding (retail does not bounce here). + (b) lost roof slide — a body resting on a steep (non-walkable) roof + surface stops advancing instead of gliding/sliding off. + (c) edge wedge — the body oscillates near-motionless at a + collision point for many consecutive ticks. + +Each JSONL record (PhysicsResolveCapture.ResolveCaptureRecord) has: + input.{currentPos,targetPos,cellId,...} + bodyBefore/bodyAfter (PhysicsBodySnapshot incl. velocity, transientState — + bit 0 = Contact, bit 1 = OnWalkable) + result.{position,cellId,isOnGround,collisionNormalValid,collisionNormal} + +IMPORTANT ordering fact (verified against source, 2026-07-30): capture happens +INSIDE PhysicsEngine.ResolveWithTransition (PhysicsEngine.cs:1489), so +bodyAfter reflects state at the end of the resolve call — BEFORE +PhysicsObjUpdate.HandleAllCollisions runs (that happens later in +PlayerMovementController, using resolveResult.CollisionNormal directly). So a +velocity REFLECTION from HandleAllCollisions shows up as a jump in the NEXT +record's bodyBefore.velocity, not in the current record's bodyAfter.velocity. +This script's bounce heuristic accounts for that one-tick lag. + +Usage: py tools/analyze_265_steep_slope_capture.py capture1.jsonl [capture2.jsonl ...] +""" +import sys +import json +import math + +FLOOR_Z = 0.6642 # PhysicsGlobals.FloorZ — walkable/steep boundary +CONTACT_BIT = 0x1 # TransientStateFlags.Contact +WALKABLE_BIT = 0x2 # TransientStateFlags.OnWalkable + +STEEP_NORMAL_MIN = 0.02 # exclude near-vertical walls (normal.z ~ 0) +STEEP_NORMAL_MAX = FLOOR_Z # exclude walkable/floor-like surfaces + +BOUNCE_VZ_JUMP = 0.5 # m/s — next-tick upward Z-velocity jump considered a "bounce" +STALL_EPS = 0.01 # m — position barely advanced +STALL_MIN_RUN = 6 # consecutive stalled ticks to call it a "lost slide" / wedge + + +def vlen(v): + return math.sqrt(v["x"] ** 2 + v["y"] ** 2 + v["z"] ** 2) + + +def vsub(a, b): + return {"x": a["x"] - b["x"], "y": a["y"] - b["y"], "z": a["z"] - b["z"]} + + +def dist(a, b): + return vlen(vsub(a, b)) + + +def load(path): + records = [] + with open(path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + records.append(json.loads(line)) + except json.JSONDecodeError: + continue + return records + + +def is_airborne(bb): + return (bb.get("transientState", 0) & CONTACT_BIT) == 0 + + +def is_contact_not_walkable(bb): + ts = bb.get("transientState", 0) + return (ts & CONTACT_BIT) != 0 and (ts & WALKABLE_BIT) == 0 + + +def is_steep_normal(n): + z = n["z"] + return STEEP_NORMAL_MIN < z < STEEP_NORMAL_MAX + + +def find_uphill_bounce_candidates(records, path_label): + """Signature A: airborne mover hits a steep (non-floor, non-wall) normal, + then the NEXT record's bodyBefore.velocity.z jumps up noticeably while + still airborne — the fingerprint of an elastic reflection off a slope + (PhysicsObjUpdate.HandleAllCollisions, gated by CollisionNormalValid and + shouldReflect=true-while-airborne).""" + hits = [] + for i in range(len(records) - 1): + rec = records[i] + bb = rec.get("bodyBefore") or {} + res = rec.get("result") or {} + if not is_airborne(bb): + continue + if not res.get("collisionNormalValid"): + continue + n = res.get("collisionNormal") or {"x": 0, "y": 0, "z": 0} + if not is_steep_normal(n): + continue + + nxt = records[i + 1] + nbb = nxt.get("bodyBefore") or {} + vz_now = bb.get("velocity", {}).get("z", 0.0) + vz_next = nbb.get("velocity", {}).get("z", 0.0) + still_airborne_next = is_airborne(nbb) + + if still_airborne_next and (vz_next - vz_now) > BOUNCE_VZ_JUMP: + hits.append({ + "file": path_label, + "tick": rec.get("tick"), + "index": i, + "normal": n, + "vz_before": vz_now, + "vz_after_next_tick": vz_next, + "pos": rec.get("input", {}).get("currentPos"), + "cellId": rec.get("input", {}).get("cellId"), + }) + return hits + + +def find_lost_slide_runs(records, path_label): + """Signature B: a run of >= STALL_MIN_RUN consecutive ticks where the + body is in Contact-but-not-OnWalkable (resting against a steep surface, + the retail-faithful state for a roof/slope per the R6/#182 digest), a + move was requested each tick, but net advance stays near zero — the + "lost roof slide" / edge-wedge fingerprint. Runs adjacent in tick order + are merged; only runs of qualifying length are reported.""" + runs = [] + i = 0 + n = len(records) + while i < n: + rec = records[i] + bb = rec.get("bodyBefore") or {} + inp = rec.get("input") or {} + res = rec.get("result") or {} + + requested = dist(inp.get("targetPos", inp.get("currentPos", {"x":0,"y":0,"z":0})), + inp.get("currentPos", {"x": 0, "y": 0, "z": 0})) + advanced = dist(res.get("position", inp.get("currentPos", {"x":0,"y":0,"z":0})), + inp.get("currentPos", {"x": 0, "y": 0, "z": 0})) + + qualifies = (is_contact_not_walkable(bb) + and requested > STALL_EPS + and advanced <= STALL_EPS) + + if not qualifies: + i += 1 + continue + + start = i + while i < n: + rec2 = records[i] + bb2 = rec2.get("bodyBefore") or {} + inp2 = rec2.get("input") or {} + res2 = rec2.get("result") or {} + requested2 = dist(inp2.get("targetPos", inp2.get("currentPos", {"x":0,"y":0,"z":0})), + inp2.get("currentPos", {"x": 0, "y": 0, "z": 0})) + advanced2 = dist(res2.get("position", inp2.get("currentPos", {"x":0,"y":0,"z":0})), + inp2.get("currentPos", {"x": 0, "y": 0, "z": 0})) + ok2 = (is_contact_not_walkable(bb2) and requested2 > STALL_EPS and advanced2 <= STALL_EPS) + if not ok2: + break + i += 1 + end = i - 1 + + run_len = end - start + 1 + if run_len >= STALL_MIN_RUN: + first = records[start] + last = records[end] + runs.append({ + "file": path_label, + "start_tick": first.get("tick"), + "end_tick": last.get("tick"), + "start_index": start, + "end_index": end, + "length": run_len, + "cellId": first.get("input", {}).get("cellId"), + "pos_start": first.get("input", {}).get("currentPos"), + "pos_end": last.get("input", {}).get("currentPos"), + "contactPlaneNormal": (first.get("bodyBefore") or {}).get("contactPlane", {}).get("normal"), + }) + return runs + + +def main(paths): + all_bounce = [] + all_stall = [] + for path in paths: + records = load(path) + label = path + bounce = find_uphill_bounce_candidates(records, label) + stall = find_lost_slide_runs(records, label) + all_bounce.extend(bounce) + all_stall.extend(stall) + + print(f"=== {path}: {len(records)} records ===") + print(f" uphill-jump-bounce candidates: {len(bounce)}") + print(f" lost-slide/edge-wedge runs (>= {STALL_MIN_RUN} ticks): {len(stall)}") + + print() + print("=== Signature A: uphill-jump bounce (first 15) ===") + for h in all_bounce[:15]: + print(f" {h['file']} tick={h['tick']} idx={h['index']} cell=0x{h['cellId']:08X} " + f"normal=({h['normal']['x']:.3f},{h['normal']['y']:.3f},{h['normal']['z']:.3f}) " + f"vz {h['vz_before']:.3f} -> {h['vz_after_next_tick']:.3f} " + f"pos=({h['pos']['x']:.2f},{h['pos']['y']:.2f},{h['pos']['z']:.2f})") + + print() + print("=== Signature B: lost-slide / edge-wedge runs (first 15, sorted by length desc) ===") + all_stall.sort(key=lambda r: -r["length"]) + for r in all_stall[:15]: + cn = r["contactPlaneNormal"] or {"x": 0, "y": 0, "z": 0} + print(f" {r['file']} ticks=[{r['start_tick']}..{r['end_tick']}] " + f"idx=[{r['start_index']}..{r['end_index']}] len={r['length']} " + f"cell=0x{r['cellId']:08X} cpNormal=({cn['x']:.3f},{cn['y']:.3f},{cn['z']:.3f}) " + f"pos {r['pos_start']['x']:.2f},{r['pos_start']['y']:.2f},{r['pos_start']['z']:.2f} " + f"-> {r['pos_end']['x']:.2f},{r['pos_end']['y']:.2f},{r['pos_end']['z']:.2f}") + + print() + print(f"TOTAL: {len(all_bounce)} bounce candidates, {len(all_stall)} stall runs " + f"across {len(paths)} file(s).") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print(__doc__) + sys.exit(1) + main(sys.argv[1:])