216 lines
8.6 KiB
Python
216 lines
8.6 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
fr_capture.py — end-to-end 120 Hz scroll-jank capture for FR-line apps
|
|
(egtest / sltest family).
|
|
|
|
The jank_eval/jank_capture pair needs Slint's vendored PRES/VSYNC lines;
|
|
FR-line harnesses (sltest, egtest) log app-side frame gaps instead. This
|
|
tool runs the same disciplined capture pipeline and the same referee that
|
|
produced the sltest reference numbers (drops = present gaps > 1.8x the
|
|
median, within one process's log):
|
|
|
|
fr_capture.py capture --app wow.gmp.egtest --flicks 5 [--tag egui031]
|
|
|
|
1. device + build identity (apk sha256, versionName) -> PROVENANCE
|
|
2. force-stop, DELETE fr.log (one process per file)
|
|
3. cold launch, verify ResumedActivity
|
|
4. scripted flicks: N fast swipes (350 ms, 1600 px) with 2 s settles,
|
|
one slow drag (1200 ms) in the middle
|
|
5. verify still ResumedActivity, force-stop
|
|
6. pull the complete log (exactly one process)
|
|
7. report: provenance + gap verdict + per-drop context
|
|
(t, gap, neighbours — isolated vs chain) + render-cost stats
|
|
|
|
Exit: 0 = no drops, 1 = drops found / invalid capture.
|
|
fr_capture.py report FILE.log re-analyze a pulled log
|
|
fr_capture.py --selftest offline validation
|
|
|
|
Note (honesty): the gap clock is the APP's clock (swap-completion to
|
|
swap-completion), not the display slot grid. That is the same referee
|
|
used for all sltest A/B numbers, which keeps egtest-vs-sltest
|
|
apples-to-apples; jank_eval's slot grid remains the stricter referee
|
|
for builds that emit PRES/VSYNC.
|
|
"""
|
|
import argparse, hashlib, os, re, subprocess, sys, time
|
|
|
|
ADB_SERIAL = os.environ.get("ADB_SERIAL", "")
|
|
FR_RE = re.compile(r"^FR\s+([\d.]+)\s+gap=\s*([\d.]+)\s+cost=\s*([\d.]+)\s+drew=(\d)")
|
|
|
|
def adb(*args, timeout=120):
|
|
cmd = ["adb"]
|
|
if ADB_SERIAL: cmd += ["-s", ADB_SERIAL]
|
|
cmd += list(args)
|
|
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
|
|
def adb_shell(cmd, timeout=120):
|
|
return adb("shell", cmd, timeout=timeout)
|
|
|
|
# ------------------------------------------------------------------ analysis
|
|
def analyze(lines, label="log"):
|
|
frames = []
|
|
for ln in lines:
|
|
m = FR_RE.match(ln.strip())
|
|
if m:
|
|
frames.append((float(m.group(1)), float(m.group(2)),
|
|
float(m.group(3)), int(m.group(4))))
|
|
if len(frames) < 30:
|
|
print(f" {label}: only {len(frames)} FR lines — nothing to judge");
|
|
return None
|
|
# drop the first frame (gap=0) and process-suspend artifacts
|
|
gaps = [(t, g, c) for (t, g, c, d) in frames[1:] if 0 < g < 200]
|
|
if len(gaps) < 20:
|
|
print(f" {label}: only {len(gaps)} usable gaps — nothing to judge");
|
|
return None
|
|
gs = sorted(g for (_, g, _) in gaps)
|
|
med = gs[len(gs) // 2]
|
|
p90 = gs[int(len(gs) * 0.90)]
|
|
p99 = gs[int(len(gs) * 0.99)]
|
|
mx = gs[-1]
|
|
thr = 1.8 * med
|
|
drops = [(t, g) for (t, g, _) in gaps if g > thr]
|
|
cs = sorted(c for (_, _, c) in gaps)
|
|
return {
|
|
"n": len(gaps), "med": med, "p90": p90, "p99": p99, "max": mx,
|
|
"thr": thr, "drops": drops,
|
|
"fps": 1000.0 / med,
|
|
"cost_med": cs[len(cs) // 2], "cost_p99": cs[int(len(cs) * 0.99)],
|
|
"cost_max": cs[-1],
|
|
"frames": frames,
|
|
}
|
|
|
|
def print_report(res, title=""):
|
|
if res is None:
|
|
return 1
|
|
drops = res["drops"]
|
|
pct = 100.0 * len(drops) / res["n"]
|
|
print(f"\n=== JANK (gap referee, {title}) ===")
|
|
print(f"frames={res['n']} median gap={res['med']:.2f} ms (~{res['fps']:.0f} fps)")
|
|
print(f"p90={res['p90']:.2f} p99={res['p99']:.2f} max={res['max']:.2f} "
|
|
f"threshold={res['thr']:.2f} ms (1.8x med)")
|
|
print(f"render cost: med={res['cost_med']:.2f} ms p99={res['cost_p99']:.2f} "
|
|
f"max={res['cost_max']:.2f}")
|
|
print(f"DROPS: {len(drops)} of {res['n']} ({pct:.2f}%)")
|
|
if drops:
|
|
print(f"{'t(ms)':>10} {'gap(ms)':>9} context (prev, next gap)")
|
|
shown = 0
|
|
for i, (t, g, c, d) in enumerate(res["frames"][1:], start=1):
|
|
if g > res["thr"]:
|
|
p = res["frames"][i - 1][1] if i >= 1 else 0.0
|
|
n = res["frames"][i + 1][1] if i + 1 < len(res["frames"]) else 0.0
|
|
kind = "chain" if (p > res["thr"] or n > res["thr"]) else "isolated"
|
|
print(f"{t:10.1f} {g:9.2f} prev={p:7.2f} next={n:7.2f} {kind}")
|
|
shown += 1
|
|
if shown >= 25:
|
|
print(f"... {len(drops) - 25} more")
|
|
break
|
|
return 0 if not drops else 1
|
|
|
|
# -------------------------------------------------------------------- capture
|
|
def capture(app, flicks, tag=""):
|
|
ok = True
|
|
r = adb_shell("getprop ro.boot.cpu; getprop ro.product.model")
|
|
print("=== CAPTURE ===")
|
|
print(f"device: serial={ADB_SERIAL or '(default)'} model={r.stdout.strip()!r}")
|
|
|
|
# build identity
|
|
path = adb_shell(f"pm path {app}").stdout.strip().replace("package:", "").strip()
|
|
sha = adb_shell(f"sha256sum {path}").stdout.split()[0] if path else "?"
|
|
ver = adb_shell(f"dumpsys package {app} | grep versionName | head -1").stdout.strip()
|
|
print(f"app: {app} {ver} apk sha256={sha[:16]}… tag={tag or '-'}")
|
|
if not path:
|
|
print("INVALID: package not installed"); return 1
|
|
|
|
log = f"/sdcard/Android/data/{app}/files/fr.log"
|
|
|
|
adb_shell(f"am force-stop {app}"); time.sleep(1.5)
|
|
adb_shell(f"rm -f {log}"); time.sleep(0.5)
|
|
adb_shell(f"am start -n {app}/android.app.NativeActivity")
|
|
time.sleep(6)
|
|
r = adb_shell("dumpsys activity activities | grep ResumedActivity")
|
|
if app not in r.stdout:
|
|
print("INVALID: app not ResumedActivity after cold launch"); return 1
|
|
print("launched (ResumedActivity verified)")
|
|
|
|
# scripted drags
|
|
H = 2856
|
|
flick = f"input swipe 640 {int(H*0.77)} 640 {int(H*0.21)} 350"
|
|
slow = f"input swipe 640 {int(H*0.77)} 640 {int(H*0.21)} 1200"
|
|
for i in range(flicks):
|
|
if i == flicks // 2:
|
|
adb_shell(slow); time.sleep(3.0)
|
|
else:
|
|
adb_shell(flick); time.sleep(2.0)
|
|
if i % 2 == 0: # flip direction to cover up-scrolls too
|
|
adb_shell("input swipe 640 600 640 2200 350"); time.sleep(2.0)
|
|
time.sleep(2.0)
|
|
|
|
r = adb_shell("dumpsys activity activities | grep ResumedActivity")
|
|
adb_shell(f"am force-stop {app}"); time.sleep(1.0)
|
|
if app not in r.stdout:
|
|
print("WARNING: app not resumed right before stop (backgrounded?)")
|
|
|
|
dest = os.path.join(os.path.dirname(os.path.abspath(__file__)), f".fr_{app.replace('.','_')}.log")
|
|
r = adb("pull", log, dest, timeout=300)
|
|
if r.returncode != 0:
|
|
print(f"INVALID: log pull failed: {r.stderr.strip()}"); return 1
|
|
with open(dest) as f:
|
|
lines = f.readlines()
|
|
print(f"pulled {len(lines)} lines -> {dest}")
|
|
res = analyze(lines, label="capture")
|
|
rc = print_report(res, title=tag or app)
|
|
return rc
|
|
|
|
# -------------------------------------------------------------------- selftest
|
|
def selftest():
|
|
# synthetic: 1200 frames at 8.33 ms, 4 injected drops (two chains)
|
|
lines, t, prev = [], 0.0, None
|
|
import random; random.seed(7)
|
|
for i in range(1200):
|
|
gap = 8.33 + random.uniform(-0.15, 0.15)
|
|
if i in (300, 301, 700, 1100):
|
|
gap = 25.0
|
|
t += gap
|
|
lines.append(f"FR {t:11.2f} gap={gap:7.2f} cost={2.0:6.2f} drew=1")
|
|
lines.insert(0, "FR 0.00 gap= 0.00 cost=180.00 drew=1")
|
|
res = analyze(lines)
|
|
assert res is not None
|
|
assert res["n"] == 1200, res["n"]
|
|
assert len(res["drops"]) == 4, res["drops"]
|
|
assert abs(res["med"] - 8.33) < 0.05
|
|
# no-drop case
|
|
lines2, t = [], 0.0
|
|
for i in range(1200):
|
|
gap = 8.33 + random.uniform(-0.1, 0.1)
|
|
t += gap
|
|
lines2.append(f"FR {t:11.2f} gap={gap:7.2f} cost=2.00 drew=1")
|
|
res2 = analyze(lines2)
|
|
assert len(res2["drops"]) == 0
|
|
# too-small case
|
|
assert analyze(["FR 0 0 0 1"] * 5) is None
|
|
print("selftest OK (drop detection, clean case, short-log case)")
|
|
return 0
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
sub = ap.add_subparsers(dest="cmd")
|
|
c = sub.add_parser("capture")
|
|
c.add_argument("--app", required=True)
|
|
c.add_argument("--flicks", type=int, default=6)
|
|
c.add_argument("--tag", default="")
|
|
r = sub.add_parser("report")
|
|
r.add_argument("file")
|
|
ap.add_argument("--selftest", action="store_true")
|
|
a = ap.parse_args()
|
|
if a.selftest:
|
|
return selftest()
|
|
if a.cmd == "capture":
|
|
return capture(a.app, a.flicks, a.tag)
|
|
if a.cmd == "report":
|
|
with open(a.file) as f:
|
|
return print_report(analyze(f.readlines()), title=a.file)
|
|
ap.print_help()
|
|
return 2
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|