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>
234 lines
9.1 KiB
Python
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:])
|