mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
922e8c5a07
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
752 lines
27 KiB
Python
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
|