From 9ae8c23265a2fe70f94fbef65beb02754d56398d Mon Sep 17 00:00:00 2001 From: Sylpheed RE agent Date: Tue, 18 Aug 2026 18:57:43 +0000 Subject: [PATCH] tools: drive and decode a UI draw-order capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ui_draw_capture.sh` boots to the title screen and arms Canary's new `log_ui_draws` there — deliberately WITHOUT tapping A on the title, which is the subject and which sends the guest into a save-data probe. ARM=early presses F10 before the title exists, for the frames in which a screen is built. A grab that is not full-width is a hard error rather than something to classify. `ui_draw_order.py` turns the capture into a named paint order: the UI shader emits NDC, so a quad's pixel rect is exact, and the disc's sprites have near-unique decoded sizes, so the rect's SIZE names the sprite. Matching is nearest-within-6px with the distance printed, because a quad comes back a few pixels under its sprite for a reason that is not yet measured. --- tools/re-capture/ui_draw_capture.sh | 96 +++++++++++++++++++++++ tools/re-capture/ui_draw_order.py | 116 ++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100755 tools/re-capture/ui_draw_capture.sh create mode 100755 tools/re-capture/ui_draw_order.py diff --git a/tools/re-capture/ui_draw_capture.sh b/tools/re-capture/ui_draw_capture.sh new file mode 100755 index 0000000..ea05859 --- /dev/null +++ b/tools/re-capture/ui_draw_capture.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# ONE blocking session: boot -> title screen -> arm the UI draw-order capture. +# +# Unlike skip_intro.sh this deliberately does NOT tap A on the title: the title +# screen IS the subject, and a stray A there sends the guest into the save-data +# probe (which crashed the guest on run 1 of 2026-08-18). It taps A only while a +# movie is playing, and the instant the title is classified it presses F10, +# which arms `log_ui_draws`' 3-frame submission-order dump. +# +# The log lands in the emulator's CWD, so the emulator is launched from $OUT. +# +# A grab that is not full-width is treated as a HARD ERROR rather than +# classified. Run 1 spent 13 minutes acting on a 10-pixel sliver: the screenshot +# wrapper had picked a 10x10 helper window that shares the class "xenia_canary" +# with the real one. Nothing failed loudly; the classifier just returned noise. +# +# Usage: ui_draw_capture.sh [timeout_s] [out_dir] +set -u +export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98 +SD="$(cd "$(dirname "$0")" && pwd)" +TIMEOUT="${1:-900}" +OUT="${2:-/sylph-home/re/uicap}" +mkdir -p "$OUT" +rm -f "$OUT"/xenia_re_ui_draws_*.log + +alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; } +wide(){ [ "$(identify -format '%w' "$1" 2>/dev/null || echo 0)" -gt 1000 ]; } + +xsetroot -solid black 2>/dev/null || true +# ATTACH=1 watches an emulator that is already up instead of launching one. +if [ "${ATTACH:-0}" != "1" ]; then + ( cd "$OUT" && nohup run-canary --log_ui_draws=true --ui_draw_capture_frames="${FRAMES:-3}" --ui_draw_capture_max="${MAXDRAWS:-20000}" >"$OUT/canary.stdout" 2>"$OUT/canary.stderr" & ) + # Grace period before the liveness check: run-canary is a shell that execs the + # binary, and polling `ps -C xenia_canary` in the first moments reports GONE + # for a process that is merely not exec'd yet. + sleep 8 +fi + +until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do + [ -n "$(alive)" ] || { echo "EMULATOR GONE before the window appeared"; exit 4; } + sleep 1 +done +win="$(xdotool search --name "Xenia-canary" | tail -1)" +echo "WINDOW=$win" +xdotool windowactivate "$win" 2>/dev/null; xdotool windowfocus "$win" 2>/dev/null + +# ARM=early presses F10 before the title exists, so a long window contains the +# frame in which the screen is BUILT. Armed at the title instead, a capture only +# ever sees the steady state — and on this title screen the steady state is 11 +# draws a frame that never mention a sprite. +if [ "${ARM:-title}" = "early" ]; then + xdotool windowactivate "$win" 2>/dev/null + xdotool key --window "$win" F10 + xdotool key F10 + echo "ARMED EARLY at ${SECONDS}s" +fi + +deadline=$(( SECONDS + TIMEOUT )) +while [ $SECONDS -lt $deadline ]; do + rm -f /tmp/u1.png /tmp/u2.png + screenshot /tmp/u1.png >/dev/null 2>&1; sleep 0.6 + screenshot /tmp/u2.png >/dev/null 2>&1 + [ -s /tmp/u1.png ] && [ -s /tmp/u2.png ] || { echo "SCREENSHOT FAILED at ${SECONDS}s"; exit 5; } + wide /tmp/u2.png || { echo "GRAB IS NOT THE GAME SURFACE ($(identify -format '%wx%h' /tmp/u2.png)) at ${SECONDS}s"; exit 6; } + [ -n "$(alive)" ] || { echo "EMULATOR GONE at ${SECONDS}s"; exit 4; } + d=$(compare -metric RMSE /tmp/u1.png /tmp/u2.png null: 2>&1 | sed 's/ .*//' | cut -d. -f1); d=${d:-0} + s=$(python3 "$SD/screen_id.py" /tmp/u2.png | awk '{print $1}') + echo "t=${SECONDS}s screen=$s rmse=$d" + if [ "$s" = "title" ] && [ "${ARM:-title}" = "early" ]; then + cp /tmp/u2.png "$OUT/title-reached.png" + echo "TITLE REACHED at ${SECONDS}s (capture was armed early)" + break + elif [ "$s" = "title" ]; then + cp /tmp/u2.png "$OUT/title-before-f10.png" + xdotool windowactivate "$win" 2>/dev/null + xdotool key --window "$win" F10 + xdotool key F10 + sleep 2 + screenshot "$OUT/title-after-f10.png" >/dev/null 2>&1 + sleep 2 + if ls "$OUT"/xenia_re_ui_draws_*.log >/dev/null 2>&1; then + echo "ARMED and writing at ${SECONDS}s" + # A long window is deliberately NOT waited out here: the caller polls the + # log, because the interesting frames may be many seconds away. + [ "${WAIT_DONE:-0}" = "1" ] || break + fi + echo "F10 pressed; window still open" + elif [ "$d" -gt 1500 ]; then + echo "movie (rmse $d) -> skip A"; python3 "$SD/pad.py" tap A 0.25; sleep 3 + fi + sleep 1 +done + +grep -i "UI-CAP" "$OUT/canary.stdout" 2>/dev/null | tail -5 +ls -l "$OUT"/xenia_re_ui_draws_*.log 2>/dev/null || echo "NO UI DRAW LOG" +echo "UI DRAW CAPTURE SESSION DONE (emulator left running on purpose)" diff --git a/tools/re-capture/ui_draw_order.py b/tools/re-capture/ui_draw_order.py new file mode 100755 index 0000000..b5c5958 --- /dev/null +++ b/tools/re-capture/ui_draw_order.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Turn a `log_ui_draws` capture into a per-frame PAINT ORDER, named by sprite. + +The capture records every draw of a frame in submission order with, for the UI +shader, the quad's vertex positions. Those positions are NDC, so a quad's pixel +rect is recoverable exactly; and a screen's sprites decode to near-unique sizes, +so a rect's SIZE names the sprite it draws — no texture identity needed. + + ui_draw_order.py [frame] + +`texture-dir` is the output of `sylpheed-cli pak textures`, whose filenames +carry `_x.png`. Matching is on size within a pixel of slack, so a +size shared by two sprites reports BOTH rather than guessing. +""" +import re +import sys +import os +import glob +from collections import defaultdict + +W, H = 1280, 720 +VERT = re.compile(r"\[([0-9A-F]{8}),([0-9A-F]{8})=(-?\d+\.\d+),(-?\d+\.\d+)\]") + + +def sprite_index(texdir): + idx = defaultdict(list) + for f in glob.glob(os.path.join(texdir, "*.png")): + m = re.match(r"[0-9a-f]{8}_(.+?)_(\d+)x(\d+)\.png$", os.path.basename(f)) + if m: + idx[(int(m.group(2)), int(m.group(3)))].append(m.group(1)) + return idx + + +# Slack, and why it is not 0: a quad is a few pixels smaller than the sprite it +# draws (ptlogo1 919x113 comes back as 915x115, ptcopyright 694x20 as 691x18). +# The cause is unmeasured — a scale just under 1, or a UV inset — so match on +# nearest size within a few pixels and print the distance rather than pretend to +# an exact hit. +SLACK = 6 + + +def name_for(idx, w, h): + hits = [] + for (sw, sh), names in idx.items(): + d = max(abs(sw - w), abs(sh - h)) + if d <= SLACK: + for n in names: + hits.append((d, f"{n} {sw}x{sh}" + (f" (±{d})" if d else ""))) + hits.sort() + # The same sprite appears once per language build of the pak, under a + # different hash prefix — one name is one candidate. + seen, out = set(), [] + for _, t in hits: + if t not in seen: + seen.add(t) + out.append(t) + return out + + +def main(): + log, texdir = sys.argv[1], sys.argv[2] + want = sys.argv[3] if len(sys.argv) > 3 else None + idx = sprite_index(texdir) + frame = None + pending = None + first = None + for line in open(log): + if line.startswith("# every draw"): + first = line.rstrip().split()[-1].split("..")[0] + frame = first + continue + if line.startswith("--- frame"): + frame = line.split()[2] + continue + m = re.match(r"\s*(\d+) (prim=.*)", line) + if m: + pending = (m.group(1), m.group(2)) + continue + if "vb=0x" in line and pending: + if want is None or frame == want or frame is None: + verts = [(float(a), float(b)) for _, _, a, b in VERT.findall(line)] + if verts: + # Two conventions in one log: the UI sprite shader emits NDC, + # the full-screen pass emits pixels already (-0.5 .. 1279.5). + if max(abs(v) for xy in verts for v in xy) > 4.0: + xs = [x for x, _ in verts] + ys = [y for _, y in verts] + else: + xs = [(x + 1) / 2 * W for x, _ in verts] + ys = [(1 - y) / 2 * H for _, y in verts] + n = len(verts) + # A quad list stores 4 verts per quad; report each quad. + per = 4 if "prim=13" in pending[1] else n + for q in range(0, n, per): + qx, qy = xs[q:q + per], ys[q:q + per] + if not qx: + continue + x0, x1 = min(qx), max(qx) + y0, y1 = min(qy), max(qy) + w, h = round(x1 - x0), round(y1 - y0) + # Axis-aligned iff every vertex sits on the bbox edge. + rot = "" if all( + (abs(x - x0) < 1.0 or abs(x - x1) < 1.0) + and (abs(y - y0) < 1.0 or abs(y - y1) < 1.0) + for x, y in zip(qx, qy) + ) else " ROT" + names = name_for(idx, w, h) + print(f"frame {frame} draw {pending[0]:>4} " + f"({round(x0):4},{round(y0):4}) {w:4}x{h:<4}{rot} " + f"→ {', '.join(names) if names else '?'}") + pending = None + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())