#!/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:])