#!/usr/bin/env python3 """gpu_frame_layers.py -- render a captured frame as one image per guest function. python3 gpu_frame_layers.py analysis/frames/bad.json --out analysis/frames/bad-layers You get: composite.png every primitive, in issue order layer-.png that function's contribution alone (RGBA, alpha=coverage) sheet.png a labelled contact sheet of all layers layers.json the index: per-function stats, bbox, file names What this is and is not ----------------------- It rasterises geometry and shading, and it applies the four real semi- transparency blends, because "the glow is opaque" and "the vignette never got drawn" are both blend-state findings and both invisible in a wireframe. It does NOT sample textures. A textured primitive is filled with its command colour (or flat grey when the raw-texture bit says the command colour is ignored). Texture pages, CLUTs and UVs are decoded and reported in layers.json, so a wrong-CLUT hypothesis is testable, but this is not a second GPU. That trade is deliberate: the question these layers answer is *which function drew what, and with which blend*, and re-implementing texture sampling would buy fidelity the question does not need while adding a whole new way to be wrong. """ from __future__ import annotations import argparse import json import os import re import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import numpy as np # noqa: E402 from PIL import Image, ImageDraw # noqa: E402 from psx_gpu_frame import STP_MODES, draw_area, load_dump, prim_bbox # noqa: E402 RAW_TEXTURE_GREY = (128.0, 128.0, 128.0) TEX_TINT = (255.0, 0.0, 255.0) # Isolated layers render over a neutral grey, not black. A B-F subtractive # vignette against black is black, and an additive glow against black is just # the glow -- both blends become invisible in exactly the view meant to show # them. The composite still starts from black, which is what the GPU sees. LAYER_BACKDROP = 128.0 class Canvas: """Float RGB canvas in VRAM coordinates, offset to the frame's draw area.""" def __init__(self, x0: int, y0: int, w: int, h: int, backdrop: float = 0.0): self.x0, self.y0 = x0, y0 self.w, self.h = w, h self.backdrop = float(backdrop) self.rgb = np.full((h, w, 3), float(backdrop), dtype=np.float32) self.cov = np.zeros((h, w), dtype=bool) def _blend(self, mask, src, semi, stp): """Apply one primitive's pixels. src is (...,3) float or a 3-vector. The four modes are GPUSTAT bits 5-6 exactly: 0.5B+0.5F, B+F, B-F, B+0.25F. Getting these right is the point of rendering at all. """ dst = self.rgb[mask] if not semi: out = src elif stp == 0: out = 0.5 * dst + 0.5 * src elif stp == 1: out = dst + src elif stp == 2: out = dst - src else: out = dst + 0.25 * src self.rgb[mask] = np.clip(out, 0.0, 255.0) self.cov[mask] = True def triangle(self, pts, cols, semi, stp): (ax, ay), (bx, by), (cx, cy) = pts den = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy) if den == 0: return minx = max(0, int(np.floor(min(ax, bx, cx))) - self.x0) maxx = min(self.w - 1, int(np.ceil(max(ax, bx, cx))) - self.x0) miny = max(0, int(np.floor(min(ay, by, cy))) - self.y0) maxy = min(self.h - 1, int(np.ceil(max(ay, by, cy))) - self.y0) if minx > maxx or miny > maxy: return # Oversize primitives are rejected by the hardware too (see # psx_gpu_triangle_oversize in gpu.c); a 1000px triangle here is almost # always a decode or a data bug, and filling it would hide the real ones. if (maxx - minx) > 1024 or (maxy - miny) > 512: return xs = np.arange(minx, maxx + 1, dtype=np.float32) + self.x0 ys = np.arange(miny, maxy + 1, dtype=np.float32) + self.y0 X, Y = np.meshgrid(xs, ys) l0 = ((by - cy) * (X - cx) + (cx - bx) * (Y - cy)) / den l1 = ((cy - ay) * (X - cx) + (ax - cx) * (Y - cy)) / den l2 = 1.0 - l0 - l1 inside = (l0 >= 0) & (l1 >= 0) & (l2 >= 0) if not inside.any(): return c = np.asarray(cols, dtype=np.float32) src = (l0[..., None] * c[0] + l1[..., None] * c[1] + l2[..., None] * c[2])[inside] full = np.zeros((self.h, self.w), dtype=bool) full[miny:maxy + 1, minx:maxx + 1] = inside self._blend(full, src, semi, stp) def rect(self, x, y, w, h, color, semi, stp): x0 = max(0, x - self.x0) y0 = max(0, y - self.y0) x1 = min(self.w, x - self.x0 + max(0, w)) y1 = min(self.h, y - self.y0 + max(0, h)) if x1 <= x0 or y1 <= y0: return mask = np.zeros((self.h, self.w), dtype=bool) mask[y0:y1, x0:x1] = True self._blend(mask, np.asarray(color, dtype=np.float32), semi, stp) def line(self, p0, p1, c0, c1, semi, stp): (x0, y0), (x1, y1) = p0, p1 n = int(max(abs(x1 - x0), abs(y1 - y0))) + 1 if n > 4096: return t = np.linspace(0.0, 1.0, n) xs = np.rint(x0 + (x1 - x0) * t).astype(int) - self.x0 ys = np.rint(y0 + (y1 - y0) * t).astype(int) - self.y0 ok = (xs >= 0) & (xs < self.w) & (ys >= 0) & (ys < self.h) if not ok.any(): return cols = (np.asarray(c0, dtype=np.float32) * (1 - t)[:, None] + np.asarray(c1, dtype=np.float32) * t[:, None]) mask = np.zeros((self.h, self.w), dtype=bool) mask[ys[ok], xs[ok]] = True # Duplicated pixels along a steep line would blend twice; take the # first colour per pixel instead, which is what a scanline does. acc = np.zeros((self.h, self.w, 3), dtype=np.float32) acc[ys[ok], xs[ok]] = cols[ok] self._blend(mask, acc[mask], semi, stp) def to_rgb_image(self): return Image.fromarray(self.rgb.astype(np.uint8), "RGB") def to_rgba_image(self): rgba = np.zeros((self.h, self.w, 4), dtype=np.uint8) rgba[..., :3] = self.rgb.astype(np.uint8) rgba[..., 3] = np.where(self.cov, 255, 0) return Image.fromarray(rgba, "RGBA") def prim_color(p, tex_tint: bool): if p.get("textured"): if tex_tint: return TEX_TINT if p.get("raw_tex"): return RAW_TEXTURE_GREY return None def draw_prim(canvas: Canvas, p, tex_tint: bool): """Rasterise one decoded primitive. Returns True if it put down pixels.""" kind = p["kind"] if kind not in ("poly", "rect", "line", "fill"): return False verts = p.get("verts") or [] cols = p.get("colors") or [] if not verts: return False override = prim_color(p, tex_tint) if override is not None: cols = [list(override)] * len(verts) while len(cols) < len(verts): cols.append(cols[-1] if cols else [128, 128, 128]) semi = bool(p.get("semi")) stp = int(p.get("stp", 0)) if kind == "poly": if len(verts) >= 3: canvas.triangle(verts[0:3], cols[0:3], semi, stp) if len(verts) >= 4: canvas.triangle([verts[1], verts[2], verts[3]], [cols[1], cols[2], cols[3]], semi, stp) return len(verts) >= 3 if kind in ("rect", "fill"): size = p.get("size") or [1, 1] # GP0(02) fill is opaque and ignores blending entirely. f_semi = semi and kind != "fill" canvas.rect(verts[0][0], verts[0][1], size[0], size[1], cols[0], f_semi, stp) return True if kind == "line": drew = False for i in range(len(verts) - 1): canvas.line(verts[i], verts[i + 1], cols[i], cols[i + 1], semi, stp) drew = True return drew return False def safe_name(func: str) -> str: return re.sub(r"[^0-9A-Za-z_.-]", "_", func) def render(dump, out_dir, scale=1, tex_tint=False, order="issue", max_layers=64, only=None, exclude=None, sheet=True, layer_backdrop=LAYER_BACKDROP): x0, y0, x1, y1 = draw_area(dump) w, h = min(1024, x1 - x0), min(512, y1 - y0) if w <= 0 or h <= 0: raise SystemExit("frame has no drawable area") prims = list(dump["prims"]) if order == "ot": prims.sort(key=lambda p: (p.get("ot", 0xFFFF), p.get("seq", 0))) if only: prims = [p for p in prims if p.get("func") in only] if exclude: prims = [p for p in prims if p.get("func") not in exclude] os.makedirs(out_dir, exist_ok=True) composite = Canvas(x0, y0, w, h) per_func = {} for p in prims: drew = draw_prim(composite, p, tex_tint) if drew: per_func.setdefault(p.get("func", "0x00000000"), []).append(p) def save(img, name): if scale > 1: img = img.resize((img.width * scale, img.height * scale), Image.NEAREST) path = os.path.join(out_dir, name) img.save(path) return path save(composite.to_rgb_image(), "composite.png") src_funcs = dump.get("funcs", {}) ranked = sorted(per_func.items(), key=lambda kv: -len(kv[1])) index = { "kind": "psx-gpu-layers", "version": 1, "frame": dump["frame"], "label": dump.get("label", ""), "area": [x0, y0, x1, y1], "scale": scale, "order": order, "tex_tint": tex_tint, "layer_backdrop": layer_backdrop, "composite": "composite.png", "sheet": "sheet.png" if sheet else None, "rendered": sum(len(v) for v in per_func.values()), "non_drawing": len(prims) - sum(len(v) for v in per_func.values()), "layers": [], } thumbs = [] for func, plist in ranked[:max_layers]: c = Canvas(x0, y0, w, h, backdrop=layer_backdrop) for p in plist: draw_prim(c, p, tex_tint) name = f"layer-{safe_name(func)}.png" save(c.to_rgba_image(), name) stats = src_funcs.get(func, {}) bbox = None for p in plist: b = prim_bbox(p) if b: bbox = b if bbox is None else [min(bbox[0], b[0]), min(bbox[1], b[1]), max(bbox[2], b[2]), max(bbox[3], b[3])] modes = stats.get("stp_modes", {}) index["layers"].append({ "func": func, "file": name, "prims": len(plist), "semi": sum(1 for p in plist if p.get("semi")), "textured": sum(1 for p in plist if p.get("textured")), "pixels": int(c.cov.sum()), "bbox": bbox, "ot_min": stats.get("ot_min"), "ot_max": stats.get("ot_max"), "ops": stats.get("ops", {}), "stp_modes": modes, "ras": stats.get("ras", []), }) thumbs.append((func, c, len(plist), sum(1 for p in plist if p.get("semi")))) if len(ranked) > max_layers: index["truncated_layers"] = len(ranked) - max_layers if sheet and thumbs: save_sheet(thumbs, os.path.join(out_dir, "sheet.png"), w, h, backdrop=layer_backdrop) with open(os.path.join(out_dir, "layers.json"), "w", encoding="utf-8") as f: json.dump(index, f, indent=1) return index def save_sheet(thumbs, path, w, h, cols=4, pad=8, label_h=16, backdrop=LAYER_BACKDROP): """Labelled contact sheet -- the whole frame's authorship on one page.""" tw = max(96, min(240, w)) th = max(1, int(round(h * tw / max(1, w)))) rows = (len(thumbs) + cols - 1) // cols W = cols * (tw + pad) + pad H = rows * (th + label_h + pad) + pad sheet = Image.new("RGB", (W, H), (18, 20, 24)) d = ImageDraw.Draw(sheet) for i, (func, c, n, semi) in enumerate(thumbs): r, col = divmod(i, cols) x = pad + col * (tw + pad) y = pad + r * (th + label_h + pad) bg = int(round(backdrop * 0.35)) tile = Image.new("RGB", (c.w, c.h), (bg, bg, bg)) rgba = c.to_rgba_image() tile.paste(rgba.convert("RGB"), (0, 0), rgba.split()[3]) sheet.paste(tile.resize((tw, th), Image.NEAREST), (x, y)) d.rectangle([x, y, x + tw - 1, y + th - 1], outline=(70, 78, 90)) d.text((x + 2, y + th + 2), f"{func} {n}p" + (f" {semi} semi" if semi else ""), fill=(200, 210, 220)) sheet.save(path) def main(argv=None): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("dump", help="a psx-gpu-frame JSON written by gpu_frame_capture.py") ap.add_argument("--out", default=None, help="output directory (default: -layers)") ap.add_argument("--scale", type=int, default=1) ap.add_argument("--order", choices=("issue", "ot"), default="issue", help="issue order (what the GPU saw) or OT rank order") ap.add_argument("--tex-tint", action="store_true", help="draw textured prims flat magenta so texture coverage stands out") ap.add_argument("--max-layers", type=int, default=64) ap.add_argument("--only", action="append", default=None, metavar="0x8004ABCD") ap.add_argument("--exclude", action="append", default=None, metavar="0x8004ABCD") ap.add_argument("--no-sheet", action="store_true") ap.add_argument("--layer-backdrop", type=float, default=LAYER_BACKDROP, help="grey level layers are rendered over so additive and " "subtractive blends stay visible (0 = black, as the " "composite is drawn)") args = ap.parse_args(argv) dump = load_dump(args.dump) out = args.out or os.path.splitext(args.dump)[0] + "-layers" idx = render(dump, out, scale=args.scale, tex_tint=args.tex_tint, order=args.order, max_layers=args.max_layers, only=set(args.only) if args.only else None, exclude=set(args.exclude) if args.exclude else None, sheet=not args.no_sheet, layer_backdrop=args.layer_backdrop) print(f"frame {idx['frame']} area={idx['area']} " f"rendered={idx['rendered']} non-drawing={idx['non_drawing']} " f"layer backdrop={idx['layer_backdrop']:g}") print(f" {len(idx['layers'])} layer(s) -> {out}") for l in idx["layers"][:20]: modes = ", ".join(f"{k}x{v}" for k, v in l["stp_modes"].items()) or "-" print(f" {l['func']:<12} {l['prims']:>5}p {l['semi']:>4}semi " f"{l['pixels']:>8}px bbox={l['bbox']} stp={modes}") if idx.get("truncated_layers"): print(f" ... {idx['truncated_layers']} more function(s) not rendered " f"(raise --max-layers)") return 0 if __name__ == "__main__": raise SystemExit(main())