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>
This commit is contained in:
parent
4880d7d9cf
commit
909bff0aa5
2 changed files with 600 additions and 0 deletions
234
tools/analyze_265_steep_slope_capture.py
Normal file
234
tools/analyze_265_steep_slope_capture.py
Normal file
|
|
@ -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:])
|
||||
Loading…
Add table
Add a link
Reference in a new issue