Files
psxrecomp/tools/psx_gpu_frame.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

752 lines
27 KiB
Python

#!/usr/bin/env python3
"""psx_gpu_frame.py -- capture and decode a frame's GP0 stream, with the guest
function that issued every primitive.
Why this exists
---------------
`gpu_frame_dump` on the runtime's TCP debug server already stamps every GP0
packet with the guest code that issued it (`func`, `pc`, `ra`) and its linked-
list OT rank -- see runtime/src/debug_server.c, handle_gpu_frame_dump(), and
GpuGp0RingEntry in runtime/include/gpu.h. Nothing consumed that attribution.
This module turns the raw ring into decoded primitives, so "which guest
function drew this, and was the semi-transparency bit set" has a mechanical
answer instead of a guess.
Provenance rule
---------------
Everything here is OBSERVED from a running game and is labelled as such. It is
never merged into a static claim. A `func` appearing in a frame dump is
evidence that code executed and issued a primitive -- it is not evidence about
the call graph, and the analyzer's static coverage gap stays exactly as wide as
it was.
Fidelity, stated up front
-------------------------
* Vertices are decoded exactly and the running GP0(E5) draw offset is applied,
because that is what the rasteriser applies (gpu.c, gp0_exec_* ).
* The texpage latch follows hardware: a textured polygon's own tpage word
overwrites draw-mode state (gpu.c set_tpage_from_poly), and later untextured
prims and rectangles consume whatever it left behind. Getting this wrong
mislabels exactly the semi-transparency mode you are usually chasing.
* Textures are NOT sampled. Texture pages, CLUTs and UVs are decoded and
reported, but a textured primitive renders as its command colour. The goal is
attribution and blend-state triage, not a second GPU implementation.
* The ring truncates each packet to GPU_GP0_RING_MAX_WORDS (12). A packet
longer than that is decoded as far as it goes and marked `truncated`.
"""
from __future__ import annotations
import json
import socket
from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
DUMP_KIND = "psx-gpu-frame"
DUMP_VERSION = 1
DEFAULT_NATIVE_PORT = 4370
DEFAULT_DUCKSTATION_PORT = 4371
# Semi-transparency modes, GPUSTAT bits 5-6. The names are the blend the
# hardware performs, because that is what you compare against the screenshot.
STP_MODES = {
0: "0.5B+0.5F",
1: "B+F",
2: "B-F",
3: "B+0.25F",
}
class DebugError(RuntimeError):
pass
class DebugConn:
"""JSON client for the runtime (and DuckStation) debug server.
ONE REQUEST PER CONNECTION. That is the server's actual contract, not a
conservative choice: runtime/src/debug_server.c's io_thread_main() accepts,
reads a single line, replies, and sock_close()s. A client that holds the
socket open and sends a second command gets silence and then EOF, which
looks exactly like a hung emulator. (`s_client` in that file is vestigial;
nothing ever assigns it a live socket.) tools/debug_client.py's query()
documents the same contract -- "connect, send, receive, close".
So each command opens its own socket and reads to EOF. Replies can be
megabytes (a full gpu_frame_dump), and the server's send is blocking with a
starvation watchdog behind it, so the read drains in large chunks and never
parses incrementally.
"""
def __init__(self, host: str = "127.0.0.1", port: int = DEFAULT_NATIVE_PORT,
timeout: float = 15.0):
self.host = host
self.port = port
self.timeout = timeout
self._next_id = 1
def __enter__(self) -> "DebugConn":
return self
def __exit__(self, *exc: Any) -> None:
self.close()
def connect(self) -> None:
"""Probe that a server is there. Not required before cmd()."""
try:
socket.create_connection((self.host, self.port),
timeout=self.timeout).close()
except OSError as e:
raise DebugError(f"no debug server on {self.host}:{self.port} ({e})") from e
def close(self) -> None:
"""No persistent socket to close; kept so callers can use `with`."""
def cmd(self, name: str, **fields: Any) -> Dict[str, Any]:
"""Send one command, return the parsed reply. Raises on ok:false."""
reply = self.raw(name, **fields)
if not reply.get("ok", False):
raise DebugError(f"{name}: {reply.get('error', reply.get('err', reply))}")
return reply
def raw(self, name: str, **fields: Any) -> Dict[str, Any]:
"""Like cmd() but returns ok:false replies instead of raising."""
req = {"id": self._next_id, "cmd": name}
self._next_id += 1
req.update(fields)
line = (json.dumps(req) + "\n").encode()
try:
sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
except OSError as e:
raise DebugError(f"{name}: no debug server on "
f"{self.host}:{self.port} ({e})") from e
try:
sock.settimeout(self.timeout)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
try:
sock.sendall(line)
except OSError as e:
raise DebugError(f"{name}: send failed ({e})") from e
buf = bytearray()
while True:
try:
chunk = sock.recv(1 << 20)
except socket.timeout as e:
raise DebugError(
f"{name}: timed out after {self.timeout}s "
f"({len(buf)} bytes received)") from e
except OSError as e:
raise DebugError(f"{name}: recv failed ({e})") from e
if not chunk:
break
buf += chunk
# The server ends every reply with a newline and then closes.
# Stopping at the newline avoids waiting on the close for peers
# (DuckStation) that keep the socket around.
if buf.endswith(b"\n") or b"\n" in buf:
break
finally:
sock.close()
if not buf:
raise DebugError(f"{name}: server closed without replying")
head = bytes(buf).split(b"\n", 1)[0]
try:
return json.loads(head.decode("utf-8", "replace"))
except json.JSONDecodeError as e:
raise DebugError(f"{name}: malformed reply ({e}); "
f"first 200 bytes: {head[:200]!r}") from e
# -- convenience wrappers used by the CLIs ------------------------------
def frame(self) -> int:
return int(self.cmd("ping").get("frame", 0))
def ring_span(self) -> Dict[str, Any]:
"""Which frames the GP0 ring can still be asked for.
This is the replacement for pausing. The ring holds ~1M packets, which
is several hundred frames, so the workflow is: let the game run, and
capture the frame AFTER you have seen the bug -- along with the frames
leading up to it. Reaching backwards beats freezing forwards.
"""
r = self.cmd("gpu_ring_stats")
return {
"oldest": int(r.get("oldest_frame", 0)),
"newest": int(r.get("newest_frame", 0)),
"total": int(r.get("total", 0)),
"capacity": int(r.get("capacity", 0)),
"max_words": int(r.get("max_words", 12)),
}
# pause / continue / step / run_to_frame were REMOVED from psx-runtime --
# runtime/src/debug_server.c registers them only as handlers that return an
# error, because pause-step-read synthesizes a snapshot instead of reading
# the history the runtime already records, and it once turned a dropped
# client into an apparent freeze. They still work on the DuckStation oracle,
# which is why these wrappers exist at all; against psx-runtime they raise
# with the server's own migration message.
def run_to_frame(self, frame: int) -> Dict[str, Any]:
"""DuckStation only. Raises against psx-runtime, which removed it."""
return self.cmd("run_to_frame", frame=int(frame))
def screenshot(self, path: str) -> Dict[str, Any]:
"""Write a PNG of the presented frame.
The native server calls this `screenshot_file`; the DuckStation oracle
patch calls the same operation `screenshot`. Try both so one caller
works against either end.
"""
r = self.raw("screenshot_file", path=path)
if r.get("ok"):
return r
r2 = self.raw("screenshot", path=path)
if r2.get("ok"):
return r2
raise DebugError(f"screenshot: {r2.get('error', r.get('error', 'failed'))}")
# ---------------------------------------------------------------------------
# GP0 decoding
# ---------------------------------------------------------------------------
def _sx11(v: int) -> int:
"""Sign-extend an 11-bit GPU coordinate."""
v &= 0x7FF
return v - 0x800 if v & 0x400 else v
def parse_vertex(word: int) -> Tuple[int, int]:
return _sx11(word & 0xFFFF), _sx11((word >> 16) & 0xFFFF)
def parse_color(word: int) -> Tuple[int, int, int]:
return word & 0xFF, (word >> 8) & 0xFF, (word >> 16) & 0xFF
def poly_flags(op: int) -> Dict[str, bool]:
return {
"gouraud": bool(op & 0x10),
"quad": bool(op & 0x08),
"textured": bool(op & 0x04),
"semi": bool(op & 0x02),
"raw_tex": bool(op & 0x01),
}
def poly_words(op: int) -> int:
f = poly_flags(op)
n = 4 if f["quad"] else 3
return 1 + n * (2 if f["textured"] else 1) + (n - 1 if f["gouraud"] else 0)
def rect_words(op: int) -> int:
variable = ((op >> 3) & 3) == 0
return 1 + 1 + (1 if op & 0x04 else 0) + (1 if variable else 0)
RECT_SIZES = {1: (1, 1), 2: (8, 8), 3: (16, 16)}
def op_name(op: int) -> str:
"""Human name for a GP0 opcode. Primitive names spell out their flags,
because the flags are the diagnosis."""
if op == 0x00:
return "nop"
if op == 0x01:
return "clear_cache"
if op == 0x02:
return "fill_rect"
if op == 0x03:
return "unknown_03"
if 0x20 <= op <= 0x3F:
f = poly_flags(op)
s = "PolyG" if f["gouraud"] else "PolyF"
if f["textured"]:
s += "T"
s += "4" if f["quad"] else "3"
if f["semi"]:
s += "+semi"
if f["raw_tex"] and f["textured"]:
s += "+raw"
return s
if 0x40 <= op <= 0x5F:
s = "LineG" if op & 0x10 else "LineF"
if op & 0x08:
s = "Poly" + s
if op & 0x02:
s += "+semi"
return s
if 0x60 <= op <= 0x7F:
size = (op >> 3) & 3
s = {0: "Rect", 1: "Rect1", 2: "Rect8", 3: "Rect16"}[size]
if op & 0x04:
s = "Sprite" + s[4:]
if op & 0x02:
s += "+semi"
if op & 0x01 and op & 0x04:
s += "+raw"
return s
if 0x80 <= op <= 0x9F:
return "copy_vram_vram"
if 0xA0 <= op <= 0xBF:
return "copy_cpu_vram"
if 0xC0 <= op <= 0xDF:
return "copy_vram_cpu"
return {
0xE1: "draw_mode(E1)",
0xE2: "tex_window(E2)",
0xE3: "draw_area_tl(E3)",
0xE4: "draw_area_br(E4)",
0xE5: "draw_offset(E5)",
0xE6: "mask_bit(E6)",
}.get(op, f"op_{op:02X}")
def op_kind(op: int) -> str:
if 0x20 <= op <= 0x3F:
return "poly"
if 0x40 <= op <= 0x5F:
return "line"
if 0x60 <= op <= 0x7F:
return "rect"
if op == 0x02:
return "fill"
if 0x80 <= op <= 0xDF:
return "copy"
if 0xE0 <= op <= 0xEF:
return "env"
return "other"
class GpuState:
"""Draw-mode state carried between packets while walking a frame.
This is the part that makes the decode faithful rather than plausible: the
semi-transparency mode of an untextured polygon or a rectangle comes from
whatever the last GP0(E1) -- or the last textured polygon's own tpage word
-- left in draw-mode state, not from the packet itself.
"""
def __init__(self) -> None:
self.offset = (0, 0)
self.area_tl = (0, 0)
self.area_br = (1023, 511)
self.stp = 0
self.texpage_x = 0
self.texpage_y = 0
self.tex_colors = 0
self.mask_set = False
self.mask_check = False
def set_tpage(self, tpage_word: int) -> None:
self.texpage_x = tpage_word & 0xF
self.texpage_y = (tpage_word >> 4) & 1
self.stp = (tpage_word >> 5) & 3
self.tex_colors = (tpage_word >> 7) & 3
def apply_env(self, op: int, w0: int) -> None:
if op == 0xE1:
self.set_tpage(w0 & 0xFFFF)
elif op == 0xE3:
self.area_tl = (w0 & 0x3FF, (w0 >> 10) & 0x1FF)
elif op == 0xE4:
self.area_br = (w0 & 0x3FF, (w0 >> 10) & 0x1FF)
elif op == 0xE5:
self.offset = (_sx11(w0 & 0x7FF), _sx11((w0 >> 11) & 0x7FF))
elif op == 0xE6:
self.mask_set = bool(w0 & 1)
self.mask_check = bool(w0 & 2)
def snapshot(self) -> Dict[str, Any]:
return {
"offset": list(self.offset),
"area": [self.area_tl[0], self.area_tl[1], self.area_br[0], self.area_br[1]],
"stp": self.stp,
"texpage": [self.texpage_x, self.texpage_y, self.tex_colors],
"mask": [self.mask_set, self.mask_check],
}
def _hexw(words: Sequence[Any]) -> List[int]:
out = []
for w in words:
out.append(int(w, 16) if isinstance(w, str) else int(w))
return out
def decode_entry(entry: Dict[str, Any], state: GpuState) -> Dict[str, Any]:
"""Decode one gpu_frame_dump ring entry against the running draw state.
`state` is mutated: env commands and textured-poly tpage latches change it
for everything that follows, exactly as on hardware.
"""
op = int(entry["op"], 16) if isinstance(entry["op"], str) else int(entry["op"])
words = _hexw(entry.get("w", []))
n_words = int(entry.get("n", len(words)))
kind = op_kind(op)
prim: Dict[str, Any] = {
"seq": int(entry.get("seq", 0)),
"op": op,
"op_name": op_name(op),
"kind": kind,
"n_words": n_words,
"have_words": len(words),
"truncated": n_words > len(words),
"func": entry.get("func", "0x00000000"),
"pc": entry.get("pc", "0x00000000"),
"ra": entry.get("ra", "0x00000000"),
"src": entry.get("src", "0x00000000"),
"ot": int(entry.get("ot", 0xFFFF)),
"words": [f"0x{w:08X}" for w in words],
"verts": [],
"colors": [],
"uvs": [],
"semi": False,
"stp": state.stp,
"textured": False,
"raw_tex": False,
"gouraud": False,
"size": None,
"clut": None,
"tpage": None,
}
if not words:
prim["state"] = state.snapshot()
return prim
if kind == "env":
state.apply_env(op, words[0])
prim["state"] = state.snapshot()
prim["stp"] = state.stp
return prim
ox, oy = state.offset
if kind == "poly":
f = poly_flags(op)
n = 4 if f["quad"] else 3
prim.update(semi=f["semi"], textured=f["textured"],
raw_tex=f["raw_tex"] and f["textured"], gouraud=f["gouraud"])
# Hardware latches the poly's own tpage word into draw-mode state, so
# do it before reading stp -- and do it even if the packet is truncated
# past the vertices, because the real GPU latched it too.
tp_idx = 5 if f["gouraud"] else 4
if f["textured"] and len(words) > tp_idx:
tpage_word = (words[tp_idx] >> 16) & 0xFFFF
state.set_tpage(tpage_word)
prim["tpage"] = tpage_word
prim["stp"] = state.stp
i = 0
color = parse_color(words[0])
i = 1
for v in range(n):
if f["gouraud"] and v > 0:
if i >= len(words):
break
color = parse_color(words[i])
i += 1
if i >= len(words):
break
prim["verts"].append([parse_vertex(words[i])[0] + ox,
parse_vertex(words[i])[1] + oy])
prim["colors"].append(list(color))
i += 1
if f["textured"]:
if i >= len(words):
break
uvw = words[i]
prim["uvs"].append([uvw & 0xFF, (uvw >> 8) & 0xFF])
if v == 0:
prim["clut"] = (uvw >> 16) & 0xFFFF
i += 1
elif kind == "rect":
variable = ((op >> 3) & 3) == 0
prim.update(semi=bool(op & 0x02), textured=bool(op & 0x04),
raw_tex=bool(op & 0x01) and bool(op & 0x04))
prim["stp"] = state.stp
color = parse_color(words[0])
if len(words) > 1:
x, y = parse_vertex(words[1])
prim["verts"].append([x + ox, y + oy])
prim["colors"].append(list(color))
i = 2
if prim["textured"] and len(words) > i:
uvw = words[i]
prim["uvs"].append([uvw & 0xFF, (uvw >> 8) & 0xFF])
prim["clut"] = (uvw >> 16) & 0xFFFF
i += 1
if variable:
if len(words) > i:
prim["size"] = [words[i] & 0x3FF, (words[i] >> 16) & 0x1FF]
else:
prim["size"] = list(RECT_SIZES[(op >> 3) & 3])
elif kind == "line":
gouraud = bool(op & 0x10)
polyline = bool(op & 0x08)
prim.update(semi=bool(op & 0x02), gouraud=gouraud)
prim["stp"] = state.stp
prim["polyline"] = polyline
i = 0
color = parse_color(words[0])
i = 1
while i < len(words):
if words[i] == 0x55555555:
break
if gouraud and prim["verts"]:
color = parse_color(words[i])
i += 1
if i >= len(words):
break
x, y = parse_vertex(words[i])
prim["verts"].append([x + ox, y + oy])
prim["colors"].append(list(color))
i += 1
elif kind == "fill":
# GP0(02): fill is in raw VRAM coordinates -- the draw offset and the
# draw area do NOT apply, and it ignores the mask bit.
prim["colors"].append(list(parse_color(words[0])))
if len(words) > 1:
prim["verts"].append([words[1] & 0x3FF, (words[1] >> 16) & 0x1FF])
if len(words) > 2:
prim["size"] = [words[2] & 0x3FF, (words[2] >> 16) & 0x1FF]
elif kind == "copy":
if len(words) > 1:
prim["verts"].append([words[1] & 0x3FF, (words[1] >> 16) & 0x1FF])
if op < 0xA0 and len(words) > 2:
prim["verts"].append([words[2] & 0x3FF, (words[2] >> 16) & 0x1FF])
if len(words) > 3:
prim["size"] = [words[3] & 0x3FF, (words[3] >> 16) & 0x1FF]
elif len(words) > 2:
prim["size"] = [words[2] & 0x3FF, (words[2] >> 16) & 0x1FF]
if entry.get("bld"):
prim["bld"] = entry["bld"]
prim["state"] = state.snapshot()
return prim
def decode_entries(entries: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Decode a whole frame in issue order. Order matters -- see GpuState."""
state = GpuState()
ordered = sorted(entries, key=lambda e: int(e.get("seq", 0)))
return [decode_entry(e, state) for e in ordered]
# ---------------------------------------------------------------------------
# Attribution
# ---------------------------------------------------------------------------
def attribute(prims: Sequence[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]:
"""Group decoded primitives by the guest function that issued them.
The per-function numbers are the whole point: a function whose primitives
all lost their semi-transparency bit between a good and a bad frame is the
function to go read.
"""
out: Dict[str, Dict[str, Any]] = {}
for p in prims:
fn = p.get("func", "0x00000000")
rec = out.setdefault(fn, {
"func": fn,
"packets": 0,
"drawing": 0,
"semi": 0,
"textured": 0,
"ops": {},
"stp_modes": {},
"ot_min": None,
"ot_max": None,
"ras": [],
"bbox": None,
})
rec["packets"] += 1
name = p["op_name"]
rec["ops"][name] = rec["ops"].get(name, 0) + 1
ra = p.get("ra")
if ra and ra not in rec["ras"] and len(rec["ras"]) < 16:
rec["ras"].append(ra)
if p["kind"] in ("poly", "rect", "line", "fill"):
rec["drawing"] += 1
if p.get("semi"):
rec["semi"] += 1
m = STP_MODES.get(p.get("stp", 0), "?")
rec["stp_modes"][m] = rec["stp_modes"].get(m, 0) + 1
if p.get("textured"):
rec["textured"] += 1
ot = p.get("ot", 0xFFFF)
if ot != 0xFFFF:
rec["ot_min"] = ot if rec["ot_min"] is None else min(rec["ot_min"], ot)
rec["ot_max"] = ot if rec["ot_max"] is None else max(rec["ot_max"], ot)
bb = prim_bbox(p)
if bb:
rec["bbox"] = bb if rec["bbox"] is None else _union(rec["bbox"], bb)
return out
def prim_bbox(p: Dict[str, Any]) -> Optional[List[int]]:
verts = p.get("verts") or []
if not verts:
return None
xs = [v[0] for v in verts]
ys = [v[1] for v in verts]
x0, y0, x1, y1 = min(xs), min(ys), max(xs), max(ys)
size = p.get("size")
if size and p["kind"] in ("rect", "fill"):
x1 = x0 + max(0, size[0] - 1)
y1 = y0 + max(0, size[1] - 1)
return [x0, y0, x1, y1]
def _union(a: Sequence[int], b: Sequence[int]) -> List[int]:
return [min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])]
# ---------------------------------------------------------------------------
# Capture / dump I/O
# ---------------------------------------------------------------------------
def capture(conn: DebugConn, frame: Optional[int] = None, count: int = 65536,
label: str = "", verify_ring: bool = True) -> Dict[str, Any]:
"""Pull one frame's GP0 ring and decode it into a dump dict.
Two failure modes are turned into errors rather than empty dumps, because
both look exactly like "that frame drew nothing", which is a conclusion you
might act on:
* the frame has fallen out of the ring (or has not happened yet), and
* the peer answered ok with no `entries` key at all -- which is what a
stub or a mismatched server does.
"""
span = None
if verify_ring:
try:
span = conn.ring_span()
except DebugError:
span = None # older server without gpu_ring_stats; carry on
if frame is None:
frame = span["newest"] if span else conn.frame()
if span and span["total"] > 0 and not (span["oldest"] <= frame <= span["newest"]):
raise DebugError(
f"frame {frame} is not in the GP0 ring (it holds {span['oldest']}.."
f"{span['newest']}). Capture closer to the moment, or raise the ring "
f"size; a dump of an evicted frame is empty, not zero-drawn.")
reply = conn.cmd("gpu_frame_dump", frame=int(frame), count=int(count))
if "entries" not in reply:
raise DebugError(
"gpu_frame_dump replied ok but carried no 'entries' -- this is not a "
"psx-runtime debug server. Check what is actually listening on "
f"{conn.host}:{conn.port}.")
entries = reply.get("entries", [])
prims = decode_entries(entries)
raw_count = int(reply.get("count", len(entries)))
return {
"kind": DUMP_KIND,
"version": DUMP_VERSION,
"label": label,
"frame": int(reply.get("frame", frame)),
"source": {"host": conn.host, "port": conn.port},
"ring": span,
"raw_count": raw_count,
"requested": int(count),
"capped": raw_count >= int(count),
"max_words": int(reply.get("max_words", 12)),
"prims": prims,
"funcs": attribute(prims),
}
SUMMARY_KIND = "psx-gpu-frame-summary"
def summarise_dump(dump: Dict[str, Any], dump_name: str = "") -> Dict[str, Any]:
"""A compact view of a dump: totals, opcode and blend-mode histograms, and
the per-function attribution -- everything except the primitives.
A busy frame's full dump runs to tens of megabytes. Nothing that only wants
to know *who drew what* should have to parse that, so the summary is a
separate artifact and Retro Studio reads this rather than the dump.
"""
ops: Dict[str, int] = {}
modes: Dict[str, int] = {}
drawing = 0
truncated = 0
for p in dump.get("prims", []):
ops[p["op_name"]] = ops.get(p["op_name"], 0) + 1
if p.get("truncated"):
truncated += 1
if p["kind"] in ("poly", "rect", "line", "fill"):
drawing += 1
if p.get("semi"):
m = STP_MODES.get(p.get("stp", 0), "?")
modes[m] = modes.get(m, 0) + 1
funcs = sorted(dump.get("funcs", {}).values(),
key=lambda r: (-r.get("drawing", 0), r.get("func", "")))
return {
"kind": SUMMARY_KIND,
"version": 1,
"label": dump.get("label", ""),
"frame": dump.get("frame", 0),
"dump": dump_name,
"packets": dump.get("raw_count", 0),
"drawing": drawing,
"capped": bool(dump.get("capped")),
"truncated": truncated,
"area": list(draw_area(dump)),
"ops": ops,
"modes": modes,
"funcs": funcs,
}
def save_summary(dump: Dict[str, Any], path: str, dump_name: str = "") -> Dict[str, Any]:
summary = summarise_dump(dump, dump_name)
with open(path, "w", encoding="utf-8") as f:
json.dump(summary, f, indent=1)
return summary
def save_dump(dump: Dict[str, Any], path: str) -> None:
with open(path, "w", encoding="utf-8") as f:
json.dump(dump, f, indent=1)
def load_dump(path: str) -> Dict[str, Any]:
with open(path, "r", encoding="utf-8") as f:
dump = json.load(f)
if dump.get("kind") != DUMP_KIND:
raise ValueError(f"{path}: not a {DUMP_KIND} dump")
return dump
def draw_area(dump: Dict[str, Any]) -> Tuple[int, int, int, int]:
"""The frame's drawing clip rect, from the last GP0(E3)/GP0(E4) seen.
Falls back to the bounding box of everything drawn, then to 320x240, so a
dump that never set a draw area still renders something honest.
"""
area = None
for p in dump.get("prims", []):
st = p.get("state")
if st and st.get("area"):
area = st["area"]
if area and area[2] > area[0] and area[3] > area[1]:
return int(area[0]), int(area[1]), int(area[2]) + 1, int(area[3]) + 1
bb = None
for p in dump.get("prims", []):
b = prim_bbox(p)
if b:
bb = b if bb is None else _union(bb, b)
if bb:
return max(0, bb[0]), max(0, bb[1]), min(1024, bb[2] + 1), min(512, bb[3] + 1)
return 0, 0, 320, 240