acdream/tools/analyze_265_steep_slope_capture.py
Erik 909bff0aa5 test(physics): #265 mining tool + real-trajectory replay harness for the steep-slope response family
Adds tools/analyze_265_steep_slope_capture.py (segment miner for the
ACDREAM_CAPTURE_RESOLVE JSONL captures: uphill-jump-bounce and
lost-slide/edge-wedge signature scans) and
tests/AcDream.Core.Tests/Physics/Issue265SteepSlopeCaptureBisectTests.cs (a
synthetic single-polygon PhysicsEngine that replays the EXACT real captured
ballistic approach + landing from artifacts/matrix-session2-resolve.jsonl
records 3415-3434, driving PhysicsEngine.ResolveWithTransition directly at
the Core boundary).

Mining found two dramatic real "velocity annihilation + permanent freeze"
events (records 3153/3159 and 3433/3434): a high-speed fall lands on a
moderate roof slope (normal.Z=0.857, ABOVE PhysicsGlobals.FloorZ — walkable
by threshold), and the very next tick shows Velocity forced to exactly
(0,0,0) with the position frozen byte-identical for the rest of the capture
(12,292 ticks to EOF for the second event).

No production code changes. Full Core.Tests suite: 4070 passed / 2 skipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:55:49 +02:00

234 lines
9.1 KiB
Python

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