#!/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