tools: drive and decode a UI draw-order capture

`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.
This commit is contained in:
Sylpheed RE agent
2026-08-18 18:57:43 +00:00
parent 4df8e2ec40
commit 81b391d8d5
2 changed files with 212 additions and 0 deletions

View File

@@ -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)"

116
tools/re-capture/ui_draw_order.py Executable file
View File

@@ -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 <capture.log> <texture-dir> [frame]
`texture-dir` is the output of `sylpheed-cli pak textures`, whose filenames
carry `<name>_<W>x<H>.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())