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:])