Files
psxrecomp/tools/gpu_frame_capture.py
Alex Vanderveen 922e8c5a07 Rename the product from RetComM to Retro
The launcher and studio ship as Retro Launcher and Retro Studio now,
from the RetroPortingToolKit org, so the old product name is gone from
display text, documentation and release asset filenames.

The split is by case, and it is not cosmetic. CamelCase RetComM was
always the product; lowercase retcomm and RETCOMM_ are identifiers --
the C++ namespace, the binaries, the data directories, the CMake and
environment variables, the Cloudflare service names, the readme markers.
Renaming those would orphan existing installs for no benefit, so they
are untouched: 1101 lowercase retcomm and 205 RETCOMM_ still stand in
the launcher alone.

Four things are deliberately still spelled RetComM, and three of them
would have been real breakage:

- RetComM-Data is CamelCase but it is a directory on disk --
  <exe_dir>\RetComM-Data\{runtime,config,data} for a portable install.
  Renaming it strands every portable user's config, toolchains and
  catalog cache.

- TechnicallyComputers/RetComM-Launcher in readme_has_launcher() is the
  historical half of a both-slugs test. Renaming it makes it a duplicate
  of the line above and sends every port built before the transfer back
  to reporting its launcher section missing.

- _LEGACY_LAUNCHER_RE matched "^## RetComM Launcher". A sweep renamed it,
  which would have silently stopped it matching every README already out
  there; it now accepts both headings.

- The portable-stub lookup gained the pre-rename filenames back as
  fallbacks, because old zips stay on the releases page and a rollback
  downloads one.

Release assets become Retro-Launcher-* / Retro-Studio-*. That is safe
for installed clients: pick_launcher_asset() gates on *linux*/*windows*/
*macos* plus extension and arch, not on the product name, and the
Windows portable stub is identified by its RCM1 trailer rather than its
filename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013DpaQqVguHFpBuCmdoFYWY
2026-09-09 17:32:19 -04:00

174 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""gpu_frame_capture.py -- capture one frame's GP0 stream + screenshot, decoded
and attributed to the guest functions that issued it.
# the newest frame the ring holds, saved as the "bad" reference
python3 gpu_frame_capture.py --tag bad --out analysis/frames
# a specific past frame, and the four before it
python3 gpu_frame_capture.py --frame 41230 --frames 5 --tag bad
# what can I still ask for?
python3 gpu_frame_capture.py --ring
You do not pause the game to use this, and you cannot: pause / step /
run_to_frame were removed from psx-runtime (runtime/src/debug_server.c) because
pause-step-read synthesizes a snapshot instead of reading the history the
runtime already keeps. The GP0 ring holds ~1M packets -- several hundred frames
-- so the workflow is the other way round: play until the bug is on screen, then
capture that frame AND the ones leading up to it. Reaching backwards beats
freezing forwards, and it is the only thing that works for a glitch you cannot
stop on.
Writes <out>/<tag>.json (a psx-gpu-frame dump), <out>/<tag>.summary.json (the
compact attribution Retro Studio reads), <out>/<tag>.png (the presented
frame), and <out>/<tag>.opcodes.json, so a later diff or layer render needs
nothing still running.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from psx_gpu_frame import ( # noqa: E402
DEFAULT_NATIVE_PORT, DebugConn, DebugError, capture, save_dump, save_summary,
)
def summarise(dump, top=25, out=sys.stdout):
"""Per-function attribution table -- the thing you actually read."""
funcs = dump["funcs"]
order = sorted(funcs.values(), key=lambda r: (-r["drawing"], r["func"]))
total_draw = sum(r["drawing"] for r in funcs.values())
print(f"frame {dump['frame']} packets={dump['raw_count']} "
f"drawing={total_draw} functions={len(funcs)}", file=out)
if dump.get("capped"):
print(f" !! ring returned the full {dump['requested']} requested -- the frame "
f"may be truncated; re-run with a larger --count", file=out)
trunc = sum(1 for p in dump["prims"] if p.get("truncated"))
if trunc:
print(f" note: {trunc} packet(s) longer than the ring's word cap; "
f"decoded as far as recorded", file=out)
print(file=out)
print(f" {'func':<12} {'draw':>6} {'semi':>6} {'tex':>6} {'OT':>11} ops", file=out)
for r in order[:top]:
ot = "-" if r["ot_min"] is None else (
f"{r['ot_min']}" if r["ot_min"] == r["ot_max"] else f"{r['ot_min']}..{r['ot_max']}")
ops = ", ".join(f"{k}x{v}" for k, v in
sorted(r["ops"].items(), key=lambda kv: -kv[1])[:4])
print(f" {r['func']:<12} {r['drawing']:>6} {r['semi']:>6} "
f"{r['textured']:>6} {ot:>11} {ops}", file=out)
if len(order) > top:
print(f" ... {len(order) - top} more function(s)", file=out)
modes = {}
for r in funcs.values():
for m, n in r["stp_modes"].items():
modes[m] = modes.get(m, 0) + n
print(file=out)
if modes:
print(" semi-transparency modes in use: " +
", ".join(f"{m} x{n}" for m, n in sorted(modes.items())), file=out)
else:
print(" no semi-transparent primitives in this frame -- if the effect "
"you are chasing is a glow or a vignette, that is the finding",
file=out)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=DEFAULT_NATIVE_PORT)
ap.add_argument("--timeout", type=float, default=20.0)
ap.add_argument("--frame", type=int, default=None,
help="frame to dump (default: the newest one the ring holds)")
ap.add_argument("--frames", type=int, default=1,
help="also capture the N-1 frames BEFORE --frame, walking "
"backwards through the ring (tag gets a -NN suffix, "
"-00 being the newest)")
ap.add_argument("--ring", action="store_true",
help="print which frames the GP0 ring can still be asked "
"for, then exit")
ap.add_argument("--count", type=int, default=65536, help="max GP0 packets to pull")
ap.add_argument("--tag", default="frame", help="output basename")
ap.add_argument("--out", default="analysis/frames", help="output directory")
ap.add_argument("--no-save", action="store_true", help="print only, write nothing")
ap.add_argument("--no-shot", action="store_true", help="skip the screenshot")
ap.add_argument("--summary", action="store_true", help="print the attribution table")
ap.add_argument("--top", type=int, default=25)
args = ap.parse_args(argv)
try:
with DebugConn(args.host, args.port, args.timeout) as conn:
span = conn.ring_span()
if args.ring:
print(f"GP0 ring: {span['total']} packet(s) seen, capacity "
f"{span['capacity']}, {span['max_words']} words/packet")
if span["total"] == 0:
print(" nothing recorded yet -- is a game running?")
else:
print(f" capturable frames: {span['oldest']}..{span['newest']}")
return 0
outdir = os.path.abspath(args.out)
if not args.no_save:
os.makedirs(outdir, exist_ok=True)
base_frame = args.frame if args.frame is not None else span["newest"]
n = max(1, args.frames)
print(f"ring holds frames {span['oldest']}..{span['newest']}; "
f"capturing {n} frame(s) ending at {base_frame}")
for i in range(n):
# Walk BACKWARDS: the ring is history, and the frames before the
# one you noticed are the ones that explain it.
frame = base_frame - i
if frame < span["oldest"]:
print(f" stopping at {frame}: older than the ring holds "
f"({span['oldest']})", file=sys.stderr)
break
tag = args.tag if n == 1 else f"{args.tag}-{i:02d}"
dump = capture(conn, frame=frame, count=args.count, label=tag,
verify_ring=False)
if not args.no_save:
jp = os.path.join(outdir, f"{tag}.json")
save_dump(dump, jp)
save_summary(dump, os.path.join(outdir, f"{tag}.summary.json"),
dump_name=f"{tag}.json")
print(f"wrote {jp} ({dump['raw_count']} packets, "
f"{len(dump['funcs'])} functions)")
try:
oc = conn.cmd("gpu_opcodes")
with open(os.path.join(outdir, f"{tag}.opcodes.json"), "w",
encoding="utf-8") as f:
json.dump(oc.get("opcodes", {}), f, indent=1)
except DebugError as e:
print(f" (gpu_opcodes unavailable: {e})", file=sys.stderr)
if not args.no_shot:
if frame != span["newest"]:
print(" (no screenshot: the framebuffer holds the "
"live frame, not this historical one)")
else:
try:
r = conn.screenshot(os.path.join(outdir, f"{tag}.png"))
print(f"wrote {r.get('path')}")
except DebugError as e:
print(f" (screenshot failed: {e})", file=sys.stderr)
if args.summary or args.no_save:
summarise(dump, top=args.top)
except DebugError as e:
print(f"error: {e}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())