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:
96
tools/re-capture/ui_draw_capture.sh
Executable file
96
tools/re-capture/ui_draw_capture.sh
Executable 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
116
tools/re-capture/ui_draw_order.py
Executable 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())
|
||||
Reference in New Issue
Block a user