The WM runs on the HAL direct shell: own cursor, per-output modes with try/confirm/revert, Extended and Mirror (optimise for a screen), pacing setting, a redesigned Display menu with a snapping scale slider, a lower dock whose hits reach the screen edge, clicks only to the topmost window, and asking the AI hub for GPU memory under pressure. Hosted apps state their wire protocol (stale ones are rebuilt), and phone builds list only mobile apps with live home tiles. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
380 lines
17 KiB
Python
380 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
"""Frame-exact motion checker for MAKEPAD_WM_FRAMETRACE traces.
|
|
|
|
analyze.py frames.jsonl [--marks marks.json] [--from N] [--to N] [--json]
|
|
|
|
For every window it follows what the person SEES each frame: the live
|
|
record, or during a slide its record inside the space capture, moved by the
|
|
slide offset. Then it flags:
|
|
|
|
SIZE-NO-TWEEN the seen size changed and nothing governs it (no spring,
|
|
popin, pull-out/edge/unfold ease, compact fade)
|
|
SIZE-JUMP a governed size change whose step is >2.5x its neighbours'
|
|
(a snap, a pose switch mid-tween)
|
|
POS-JUMP the seen position moved >40 pt in one frame with nothing
|
|
governing it (a drag or a slide governs position)
|
|
HANDOFF the frame a slide starts or ends: the capture and the live
|
|
window disagree (seen rect differs by >1 pt)
|
|
ORIGIN a window seen at the desk origin (x<=1, y<=desk.y+1) that
|
|
was elsewhere the frame before
|
|
DEGENERATE a drawn rect of w or h <= 1
|
|
CFG-HIDDEN a client reconfigured while it is not on a shown space
|
|
(drawn into a capture) or during a slide
|
|
CFG-CHURN a client's configured size changed while its target did not
|
|
CFG-TWICE a client reconfigured twice within 3 s (one action, two resizes)
|
|
STRETCH a live window drawn from a frame of another aspect
|
|
(presented px vs drawn rect, >3%) -- runs of frames
|
|
SHELF-SHOWN a parked window seen on screen while its shelf is hidden (hide>=0.99)
|
|
BLANK-SHOT a slide frame drew no capture for a space it shows
|
|
NO-FRAME a live window drawn with no client frame (startup excluded)
|
|
LONG-FRAME (pacing, real dumps) the gap to the previous desk frame while
|
|
something animates is >1.5x the display interval (--hz)
|
|
SLOW-DRAW (pacing) the desk draw itself took >50% of the interval
|
|
RESIZE-STALL (pacing) a client's new configured size took >100 ms to come
|
|
back as a presented frame of that size (stretched meanwhile)
|
|
|
|
`--hz 240` sets the display rate for the pacing checks; `--no-pacing` skips
|
|
them (virtual-clock rig runs). The end prints the worst frames with their
|
|
cause tags.
|
|
|
|
Exit status 1 when anything is flagged (a regression gate).
|
|
"""
|
|
import argparse, json, sys
|
|
|
|
SIZE_TOL = 1.0
|
|
JUMP_FRAC, JUMP_PT = 0.06, 16.0
|
|
POS_TOL = 40.0
|
|
|
|
|
|
def load(path):
|
|
out = []
|
|
with open(path) as f:
|
|
for line in f:
|
|
line = line.strip()
|
|
if line:
|
|
try:
|
|
out.append(json.loads(line))
|
|
except ValueError:
|
|
pass
|
|
return out
|
|
|
|
|
|
def seen_rects(frames):
|
|
"""Per frame: client -> (rect, kind, record). kind 'live' or 'shot'."""
|
|
last_cap = {} # (disp,slot) -> {client: record}
|
|
out = []
|
|
for fr in frames:
|
|
for w in fr["w"]:
|
|
if w["cap"] is not None:
|
|
last_cap.setdefault(tuple(w["cap"]), {})
|
|
# captures made this frame replace the old records of that key
|
|
made = {tuple(m["key"]) for m in fr["made"]}
|
|
for k in made:
|
|
last_cap[k] = {}
|
|
for w in fr["w"]:
|
|
if w["cap"] is not None and tuple(w["cap"]) in made:
|
|
last_cap[tuple(w["cap"])][w["c"]] = w
|
|
seen = {}
|
|
for w in fr["w"]:
|
|
if w["cap"] is None:
|
|
seen[w["c"]] = (w["s"], "live", w)
|
|
disp = {d["i"]: d for d in fr["disp"]}
|
|
for sh in fr["shots"]:
|
|
if not sh["drawn"]:
|
|
continue
|
|
key = tuple(sh["key"])
|
|
d = disp.get(key[0])
|
|
if not d:
|
|
continue
|
|
sw = d["r"][2]
|
|
dx = sh["off"] * sw
|
|
for c, w in last_cap.get(key, {}).items():
|
|
s = w["s"]
|
|
r = [s[0] + dx, s[1], s[2], s[3]]
|
|
# cropped away entirely: not seen
|
|
if r[0] + r[2] <= d["r"][0] or r[0] >= d["r"][0] + sw:
|
|
continue
|
|
if c not in seen:
|
|
seen[c] = (r, "shot", w)
|
|
out.append(seen)
|
|
return out
|
|
|
|
|
|
def governed(fr, w, c):
|
|
a = w.get("anim") or {}
|
|
g = []
|
|
if a.get("mv"):
|
|
g.append("spring")
|
|
if a.get("open", 1.0) < 1.0:
|
|
g.append("open")
|
|
if a.get("close") is not None:
|
|
g.append("close")
|
|
if fr["leave"] and fr["leave"]["c"] == c:
|
|
g.append("leave")
|
|
if fr["enter"] and fr["enter"]["c"] == c:
|
|
g.append("enter")
|
|
if fr["unfold"] and fr["unfold"]["c"] == c:
|
|
g.append("unfold")
|
|
if w.get("frz"):
|
|
g.append("compact")
|
|
if w.get("swl"):
|
|
g.append("swell")
|
|
if w.get("src") == "dockwarp" or w.get("min"):
|
|
g.append("minimise")
|
|
if c in fr.get("dragging", []):
|
|
g.append("pointer") # a move or resize drag: the size follows the hand
|
|
return g
|
|
|
|
|
|
def dragging(fr, c):
|
|
return c in fr.get("dragging", [])
|
|
|
|
|
|
def sliding(fr):
|
|
return any(d["slide"] for d in fr["disp"])
|
|
|
|
|
|
def pacing(frames, lo, hi, hz, flag):
|
|
iv = 1.0 / hz
|
|
for i in range(max(lo, 1), hi):
|
|
fr, pv = frames[i], frames[i - 1]
|
|
dt = fr["t"] - pv["t"]
|
|
moving = pv.get("anim") or any(d["slide"] for d in pv["disp"]) or pv.get("dragging")
|
|
if moving and dt > 1.5 * iv:
|
|
flag(i, "LONG-FRAME", "desk", f"{dt * 1000:.1f} ms since #{pv['n']} ({dt / iv:.1f} intervals at {hz:.0f} Hz)")
|
|
if fr.get("draw_ms", 0.0) > 500.0 * iv:
|
|
flag(i, "SLOW-DRAW", "desk", f"desk draw {fr['draw_ms']:.2f} ms")
|
|
# Resize round trips: cfg change -> first presented frame at the new aspect.
|
|
pending = {}
|
|
for i in range(lo, hi):
|
|
fr = frames[i]
|
|
for w in fr["w"]:
|
|
h = w.get("host")
|
|
if not h or w["cap"] is not None:
|
|
continue
|
|
c = w["c"]
|
|
cfg = tuple(h["cfg"])
|
|
if c in pending and pending[c][1] != cfg:
|
|
pending.pop(c)
|
|
if c not in pending:
|
|
prev = pending.get(("last", c))
|
|
pending[("last", c)] = cfg
|
|
if prev is not None and prev != cfg:
|
|
pending[c] = (i, cfg, h["ser"])
|
|
continue
|
|
i0, want, ser0 = pending[c]
|
|
px = h["px"]
|
|
if px and h["ser"] != ser0 and abs(px[0] / max(px[1], 1) - want[0] / want[1]) < 0.02 * (want[0] / want[1]):
|
|
dt = fr["t"] - frames[i0]["t"]
|
|
if dt > 0.1:
|
|
flag(i, "RESIZE-STALL", f'{c}:{w.get("app", "")}', f"configured {want} at #{frames[i0]['n']}, first frame of that size {dt * 1000:.0f} ms later")
|
|
pending.pop(c)
|
|
|
|
|
|
def analyze(frames, lo=0, hi=None, marks=None, hz=None):
|
|
hi = len(frames) if hi is None else hi
|
|
seen = seen_rects(frames)
|
|
issues = []
|
|
|
|
def flag(i, kind, c, msg):
|
|
issues.append({"n": frames[i]["n"], "t": frames[i]["t"], "kind": kind, "c": c, "msg": msg})
|
|
|
|
first_frame_seen = {}
|
|
cfgs = {} # client -> configure changes
|
|
steps = {} # client -> governed size steps (frame index, dw, dh, ...)
|
|
for i in range(max(lo, 1), hi):
|
|
fr, pv = frames[i], frames[i - 1]
|
|
S, P = seen[i], seen[i - 1]
|
|
desk = fr["desk"]
|
|
apps = {w["c"]: w.get("app", "") for w in fr["w"]}
|
|
for c, (r, kind, w) in S.items():
|
|
name = f'{c}:{apps.get(c, "")}'
|
|
if r[2] <= 1 or r[3] <= 1:
|
|
flag(i, "DEGENERATE", name, f"seen {r} src={w['src']}")
|
|
if c not in P:
|
|
continue
|
|
q, pkind, pw = P[c]
|
|
dw, dh = r[2] - q[2], r[3] - q[3]
|
|
dx, dy = r[0] - q[0], r[1] - q[1]
|
|
g = governed(fr, w, c) + governed(pv, pw, c)
|
|
if kind != pkind:
|
|
if max(abs(dw), abs(dh), abs(dx), abs(dy)) > 1.0:
|
|
flag(i, "HANDOFF", name, f"{pkind}->{kind}: {fmt(q)} -> {fmt(r)} src={w['src']}")
|
|
continue
|
|
if abs(dw) > SIZE_TOL or abs(dh) > SIZE_TOL:
|
|
if not g:
|
|
flag(i, "SIZE-NO-TWEEN", name, f"{fmt(q)} -> {fmt(r)} d=({dw:+.1f},{dh:+.1f}) src={pw['src']}->{w['src']}")
|
|
else:
|
|
steps.setdefault(c, []).append((i, dw, dh, q, r, "+".join(g), pw["src"], w["src"], name, min(max(fr["t"] - pv["t"], 1 / 120), 0.025) if "pointer" not in g else 1 / 60))
|
|
if (abs(dx) > POS_TOL or abs(dy) > POS_TOL) and not g and not dragging(fr, c) and not dragging(pv, c) and kind == "live":
|
|
flag(i, "POS-JUMP", name, f"{fmt(q)} -> {fmt(r)} src={pw['src']}->{w['src']}")
|
|
tgt = (w.get("anim") or {}).get("tgt") or [9e9, 9e9]
|
|
at_origin_by_layout = tgt[0] <= desk[0] + 1 and tgt[1] <= desk[1] + 1
|
|
if r[0] <= desk[0] + 1 and r[1] <= desk[1] + 1 and not (q[0] <= desk[0] + 1 and q[1] <= desk[1] + 1) and not at_origin_by_layout:
|
|
flag(i, "ORIGIN", name, f"{fmt(q)} -> {fmt(r)} src={w['src']}")
|
|
# client configuration
|
|
prev_cfg = {}
|
|
for w in pv["w"]:
|
|
if w.get("host"):
|
|
prev_cfg[w["c"]] = (w["host"]["cfg"], (w.get("anim") or {}).get("tgt"))
|
|
for w in fr["w"]:
|
|
h = w.get("host")
|
|
if not h or w["c"] not in prev_cfg:
|
|
continue
|
|
pc, ptgt = prev_cfg[w["c"]]
|
|
if any(abs(a - b) > 0.5 for a, b in zip(h["cfg"], pc)):
|
|
name = f'{w["c"]}:{w.get("app", "")}'
|
|
why = []
|
|
if w["cap"] is not None:
|
|
why.append("in a space capture")
|
|
if sliding(fr):
|
|
why.append("during a slide")
|
|
if why:
|
|
flag(i, "CFG-HIDDEN", name, f"configured {pc} -> {h['cfg']} ({', '.join(why)})")
|
|
if w["c"] not in fr.get("dragging", []): # a resize drag reconfigures as the hand moves
|
|
cfgs.setdefault(w["c"], []).append((i, pc, h["cfg"], name))
|
|
tgt = (w.get("anim") or {}).get("tgt")
|
|
if ptgt and tgt and ptgt == tgt and not w.get("half") and not w.get("frz") and not (fr["leave"] and fr["leave"]["c"] == w["c"]):
|
|
flag(i, "CFG-CHURN", name, f"configured {pc} -> {h['cfg']} with target unchanged {fmt(tgt)}")
|
|
# slide frames: every space in view must have its capture drawn
|
|
for d in fr["disp"]:
|
|
if d["slide"]:
|
|
drawn = [s for s in fr["shots"] if s["key"][0] == d["i"] and s["drawn"]]
|
|
if not drawn:
|
|
flag(i, "BLANK-SHOT", f'disp{d["i"]}', f"slide pos {d['slide']['pos']:.3f} drew no capture")
|
|
for s in fr["shots"]:
|
|
if s["key"][0] == d["i"] and not s["drawn"]:
|
|
flag(i, "BLANK-SHOT", f'disp{d["i"]}', f"capture {s['key']} missing at off {s['off']:.3f}")
|
|
# shelf items while the shelf is hidden
|
|
for w in fr["w"]:
|
|
if w["cap"] is not None:
|
|
continue
|
|
p = w.get("pose") or {}
|
|
side = p.get("side", 0)
|
|
if side == 0 or dragging(fr, w["c"]) or (fr["leave"] and fr["leave"]["c"] == w["c"]) or w.get("half"):
|
|
continue
|
|
d0 = fr["disp"][0]
|
|
hide = d0["hide"][0 if side < 0 else 1]
|
|
s = w["s"]
|
|
scr = d0["r"]
|
|
on = s[0] < scr[0] + scr[2] - 0.5 and s[0] + s[2] > scr[0] + 0.5
|
|
if hide >= 0.99 and on:
|
|
flag(i, "SHELF-SHOWN", f'{w["c"]}:{w.get("app", "")}', f"side {side} hide {hide:.2f} seen {fmt(s)} swell={w.get('swl')}")
|
|
# stretched live windows and windows without frames
|
|
for w in fr["w"]:
|
|
h = w.get("host")
|
|
if w["cap"] is not None or not h or w["src"] in ("reuse", "frozen", "dockwarp", "min-gone"):
|
|
continue
|
|
if not h["has"]:
|
|
c = w["c"]
|
|
if c in first_frame_seen:
|
|
flag(i, "NO-FRAME", f'{c}:{w.get("app", "")}', "live window has no client frame")
|
|
continue
|
|
first_frame_seen.setdefault(w["c"], i)
|
|
px = h["px"]
|
|
req = w.get("req")
|
|
if px and req and req[0] > 1 and req[1] > 1:
|
|
a_px = px[0] / max(px[1], 1)
|
|
a_req = req[0] / req[1]
|
|
if abs(a_px / a_req - 1.0) > 0.03:
|
|
flag(i, "STRETCH", f'{w["c"]}:{w.get("app", "")}', f"frame {px} px for {req} pt (aspect {a_px:.3f} vs {a_req:.3f}) src={w['src']}")
|
|
if hz:
|
|
pacing(frames, max(lo, 0), hi, hz, flag)
|
|
# A governed size change is a tween: its steps change smoothly. A step far
|
|
# bigger than its neighbours (a snap to the end, a pose switch) is a jump.
|
|
for c, st in steps.items():
|
|
# A tween advances by real time: compare speeds (per 1/60 s), so a frame
|
|
# that came late (load) and the short one after it are not a jump; the
|
|
# animation clock itself is capped at 25 ms a frame, so is dt here;
|
|
# a frame under half a frame long (a tween's first, nominal step) is judged as half. A step
|
|
# that follows the pointer moves with the hand, not the clock: dt is 1.
|
|
for k, (i, dw, dh, q, r, g, ps, cs, name, dt) in enumerate(st):
|
|
m = max(abs(dw), abs(dh))
|
|
if m < JUMP_PT * 0.5:
|
|
continue
|
|
nb = []
|
|
if k > 0 and st[k - 1][0] >= i - 12:
|
|
nb.append(max(abs(st[k - 1][1]), abs(st[k - 1][2])) * (dt / st[k - 1][9]))
|
|
if k + 1 < len(st) and st[k + 1][0] <= i + 12:
|
|
nb.append(max(abs(st[k + 1][1]), abs(st[k + 1][2])) * (dt / st[k + 1][9]))
|
|
ref = max(nb) if nb else 0.0
|
|
if (nb and m > 2.5 * max(ref, 2.0)) or (not nb and m > 3 * JUMP_PT):
|
|
flag(i, "SIZE-JUMP", name, f"{fmt(q)} -> {fmt(r)} d=({dw:+.1f},{dh:+.1f}) neighbour steps {ref:.1f} by={g} src={ps}->{cs}")
|
|
# One action, one configure: two size changes of a client within 1.5 s.
|
|
for c, ch in cfgs.items():
|
|
for (i0, a0, b0, name), (i1, a1, b1, _) in zip(ch, ch[1:]):
|
|
if frames[i1]["t"] - frames[i0]["t"] < 3.0:
|
|
flag(i1, "CFG-TWICE", name, f"{fmt(a0)} -> {fmt(b0)} at #{frames[i0]['n']}, then -> {fmt(b1)} {frames[i1]['t'] - frames[i0]['t']:.2f} s later")
|
|
issues.sort(key=lambda it: it["n"])
|
|
return issues
|
|
|
|
|
|
def fmt(r):
|
|
return "[" + ",".join(f"{v:.1f}" for v in r) + "]"
|
|
|
|
|
|
def collapse(issues):
|
|
"""Runs of the same (kind, client) on consecutive frames become one line."""
|
|
out = []
|
|
for it in issues:
|
|
if out and out[-1]["kind"] == it["kind"] and out[-1]["c"] == it["c"] and it["n"] - out[-1]["n_end"] <= 1 and it["kind"] in ("STRETCH", "SHELF-SHOWN", "NO-FRAME", "CFG-HIDDEN"):
|
|
out[-1]["n_end"] = it["n"]
|
|
out[-1]["count"] += 1
|
|
continue
|
|
out.append(dict(it, n_end=it["n"], count=1))
|
|
return out
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("trace")
|
|
ap.add_argument("--marks")
|
|
ap.add_argument("--json", action="store_true")
|
|
ap.add_argument("--from", dest="lo", type=int, default=0)
|
|
ap.add_argument("--to", dest="hi", type=int, default=None)
|
|
ap.add_argument("--ignore", default="", help="comma list of kinds not to fail on")
|
|
ap.add_argument("--hz", type=float, default=240.0, help="display rate for the pacing checks")
|
|
ap.add_argument("--no-pacing", action="store_true", help="skip pacing checks (virtual-clock runs)")
|
|
a = ap.parse_args()
|
|
frames = load(a.trace)
|
|
marks = []
|
|
if a.marks:
|
|
try:
|
|
marks = json.load(open(a.marks))
|
|
except (OSError, ValueError):
|
|
marks = []
|
|
lo = next((i for i, f in enumerate(frames) if f["n"] >= a.lo), 0)
|
|
hi = None if a.hi is None else next((i for i, f in enumerate(frames) if f["n"] > a.hi), len(frames))
|
|
issues = collapse(analyze(frames, lo, hi, hz=None if a.no_pacing else a.hz))
|
|
ignore = set(x for x in a.ignore.split(",") if x)
|
|
if a.json:
|
|
print(json.dumps(issues))
|
|
else:
|
|
mi = 0
|
|
for it in issues:
|
|
while mi < len(marks) and marks[mi][0] <= it["n"]:
|
|
print(f"--- frame {marks[mi][0]}: {marks[mi][1]}")
|
|
mi += 1
|
|
rng = f'{it["n"]}' if it["count"] == 1 else f'{it["n"]}-{it["n_end"]} ({it["count"]}x)'
|
|
print(f'{rng:>16} {it["kind"]:<14} {it["c"]:<14} {it["msg"]}')
|
|
counts = {}
|
|
for it in issues:
|
|
counts[it["kind"]] = counts.get(it["kind"], 0) + 1
|
|
# The worst frames: every tag a frame collected, the most first.
|
|
tags = {}
|
|
for it in issues:
|
|
tags.setdefault(it["n"], []).append(it["kind"])
|
|
worst = sorted(tags.items(), key=lambda kv: -len(kv[1]))[:10]
|
|
if worst:
|
|
print("WORST", "; ".join(f"#{n}: {'+'.join(sorted(set(k)))}" for n, k in worst))
|
|
if frames and not a.no_pacing:
|
|
dts = sorted(frames[i]["t"] - frames[i - 1]["t"] for i in range(1, len(frames)) if frames[i - 1].get("anim"))
|
|
if dts:
|
|
p = lambda q: dts[min(len(dts) - 1, int(q * len(dts)))] * 1000
|
|
print(f"PACING animating frames {len(dts)}: median {p(0.5):.1f} ms, p95 {p(0.95):.1f} ms, max {dts[-1] * 1000:.1f} ms (display {1000 / a.hz:.2f} ms)")
|
|
print("SUMMARY", len(frames), "frames;", ", ".join(f"{k} {v}" for k, v in sorted(counts.items())) or "clean")
|
|
sys.exit(1 if any(it["kind"] not in ignore for it in issues) else 0)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|