mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 10:59:16 -04:00
a850110b45
Two defects the last run exposed. The oracle appeared to pause because every sample called walk_side, which snapshots all 2 MB of RAM -- about 128 socket round trips serviced on DuckStation's emulator thread. Nothing was paused; it was being read to a standstill, repeatedly, and rarely landing in the animation as a result. The list address is now cached after the first successful walk and subsequent samples re-read only a 128 KB window around it. Worse, the run reported 'signatures-agree' on 144 quads/155 lines while psx-runtime had sampled the effect object ZERO times. Falling back to a smaller object that both sides happened to see, and calling that agreement, is the same failure as ranking 120 identical frames and naming a winner. The biggest object either side saw is the effect; if only one side sampled it, the verdict is now effect-object-one-sided and says explicitly that smaller shared objects are not a substitute. Also records that the oracle's effect object carries 3 distinct colours at peak 3 AND at peak 220 -- colour count does not move with brightness, so the 153-vs-3 difference is not a fade artifact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1485 lines
56 KiB
Python
1485 lines
56 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
|
|
import sys
|
|
import time
|
|
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
|
|
|
|
|
|
def _looks_complete(buf: bytes) -> bool:
|
|
"""Is this a whole JSON document yet?
|
|
|
|
Brace counting rather than a parse attempt per chunk: a reply can be a
|
|
megabyte of hex, and re-parsing it on every 1 MB recv would dominate the
|
|
read. Quotes and escapes are tracked so a brace inside a string does not
|
|
count.
|
|
"""
|
|
depth = 0
|
|
in_str = False
|
|
esc = False
|
|
seen = False
|
|
for c in buf:
|
|
if esc:
|
|
esc = False
|
|
continue
|
|
if in_str:
|
|
if c == 0x5C: # backslash
|
|
esc = True
|
|
elif c == 0x22: # quote
|
|
in_str = False
|
|
continue
|
|
if c == 0x22:
|
|
in_str = True
|
|
elif c == 0x7B: # {
|
|
depth += 1
|
|
seen = True
|
|
elif c == 0x7D: # }
|
|
depth -= 1
|
|
if seen and depth <= 0:
|
|
return True
|
|
return False
|
|
|
|
|
|
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
|
|
# Most replies are one line, and stopping at the newline avoids
|
|
# waiting on the close for peers (DuckStation) that keep the
|
|
# socket around.
|
|
#
|
|
# But not all of them are. pc_probe_dump builds its JSON with
|
|
# repeated send_line() calls -- header, one line per slot, one
|
|
# per sample -- so the first newline lands in the MIDDLE of the
|
|
# document. Reading one line there returns a truncated object
|
|
# that fails to parse, and the error ("malformed reply") points
|
|
# at the server rather than at this loop.
|
|
#
|
|
# So: stop at a newline only if what has arrived so far is a
|
|
# complete JSON document. Otherwise keep reading.
|
|
if b"\n" in buf and _looks_complete(buf):
|
|
break
|
|
finally:
|
|
sock.close()
|
|
|
|
if not buf:
|
|
raise DebugError(f"{name}: server closed without replying")
|
|
# Parse the WHOLE buffer, not just the first line.
|
|
#
|
|
# Fixing the read loop to keep going until the document was complete and
|
|
# then still splitting on the first newline here left the bug exactly
|
|
# where it was: pc_probe_dump spreads one JSON object across several
|
|
# send_line() calls, so the first line is a fragment. JSON treats the
|
|
# embedded newlines as whitespace, so the joined buffer parses directly.
|
|
#
|
|
# The first line is kept as a fallback for a peer that sends more than
|
|
# one document down the same connection (an event followed by a reply,
|
|
# say), where the whole buffer would not parse but the first line does.
|
|
whole = bytes(buf).decode("utf-8", "replace")
|
|
try:
|
|
return json.loads(whole)
|
|
except json.JSONDecodeError:
|
|
pass
|
|
head = whole.split("\n", 1)[0]
|
|
try:
|
|
return json.loads(head)
|
|
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:
|
|
"""Current frame number, from whichever command this peer answers.
|
|
|
|
NOT from `ping`. psx-runtime intercepts ping in a lock-free fast path
|
|
that never reaches the command dispatcher, so its reply carries only
|
|
{ok, pong, io_thread} -- no frame. Asking ping for a frame therefore
|
|
returned 0 for every native run, silently, and anything that aligned two
|
|
emulators by frame number was comparing against zero. The DuckStation
|
|
oracle does put a frame in its ping, which is what made the bug look
|
|
like an oracle-only quirk instead of a native-side blind spot.
|
|
"""
|
|
r = self.raw("frame")
|
|
if r.get("ok") and "frame" in r:
|
|
return int(r["frame"])
|
|
return int(self.raw("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 exist on both ends, but they are
|
|
# debug-server-only -- no key, button, or menu reaches them, so a shipped
|
|
# build cannot be frozen by them. psx-runtime also arms a timeout when it
|
|
# parks (see pause_arm in runtime/src/debug_server.c): if the client stops
|
|
# talking, it resumes on its own, because a debugger that dies must not
|
|
# leave the game stopped forever.
|
|
#
|
|
# Prefer ring_span() + capture() anyway when you want to look at a frame.
|
|
# Reaching backwards through recorded history beats freezing forwards, and
|
|
# it does not perturb the thing you are measuring. Pause is for the oracle,
|
|
# which records nothing, and for holding RAM still across a snapshot.
|
|
|
|
def pause(self) -> Dict[str, Any]:
|
|
"""Park the emulator. Auto-resumes if this client goes quiet."""
|
|
return self.cmd("pause")
|
|
|
|
def resume(self) -> Dict[str, Any]:
|
|
return self.cmd("continue")
|
|
|
|
def step(self, frames: int = 1) -> Dict[str, Any]:
|
|
return self.cmd("step", frames=int(frames))
|
|
|
|
def run_to_frame(self, frame: int) -> Dict[str, Any]:
|
|
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])]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Frame signatures — comparing frames without needing function attribution
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# `func` attribution is worthless on a game that builds an ordering table and
|
|
# DMAs it: no guest code is executing when the GP0 writes happen, so every
|
|
# packet in the frame reports the same submit PC. Legend of Mana is one of
|
|
# these — 1217 packets, one func, one ra, one pc.
|
|
#
|
|
# Two handles survive that, and both are already in the dump:
|
|
#
|
|
# * (opcode, blend mode) — stable across frames, and it is what a rendering
|
|
# regression actually moves: a layer that loses its semi-transparency bit,
|
|
# a burst that doubles, geometry that escapes its box.
|
|
# * the packet's SOURCE ADDRESS in guest RAM. Packets for one effect are
|
|
# built into one contiguous array (LoM's additive burst lives at
|
|
# 0x0010D05C..0x0010EC78, stride 0x1C), so a run of consecutive addresses
|
|
# identifies the effect even when the code that built it does not appear.
|
|
# That address is also what you hand to wtrace to find the builder.
|
|
|
|
SIGNATURE_KIND = "psx-gpu-frame-signature"
|
|
|
|
|
|
def signature_key(prim: Dict[str, Any]) -> str:
|
|
"""Stable identity for a class of primitive within a frame."""
|
|
mode = STP_MODES.get(prim.get("stp", 0), "?") if prim.get("semi") else "opaque"
|
|
return f"{prim['op_name']}|{mode}"
|
|
|
|
|
|
def frame_signature(dump: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Reduce a frame to numbers that can be compared against another frame."""
|
|
keys: Dict[str, Any] = {}
|
|
drawing = semi = textured = 0
|
|
for p in dump.get("prims", []):
|
|
if p["kind"] not in ("poly", "rect", "line", "fill"):
|
|
continue
|
|
drawing += 1
|
|
if p.get("semi"):
|
|
semi += 1
|
|
if p.get("textured"):
|
|
textured += 1
|
|
k = signature_key(p)
|
|
rec = keys.setdefault(k, {"n": 0, "bbox": None, "cmax": 0, "csum": 0.0,
|
|
"cn": 0, "ots": set(), "src_lo": None,
|
|
"src_hi": None})
|
|
rec["n"] += 1
|
|
bb = prim_bbox(p)
|
|
if bb:
|
|
rec["bbox"] = bb if rec["bbox"] is None else _union(rec["bbox"], bb)
|
|
for c in p.get("colors") or []:
|
|
rec["cmax"] = max(rec["cmax"], max(c))
|
|
rec["csum"] += sum(c) / 3.0
|
|
rec["cn"] += 1
|
|
ot = p.get("ot", 0xFFFF)
|
|
if ot != 0xFFFF:
|
|
rec["ots"].add(ot)
|
|
try:
|
|
src = int(p.get("src", "0x0"), 16)
|
|
except ValueError:
|
|
src = 0
|
|
if src:
|
|
rec["src_lo"] = src if rec["src_lo"] is None else min(rec["src_lo"], src)
|
|
rec["src_hi"] = src if rec["src_hi"] is None else max(rec["src_hi"], src)
|
|
for rec in keys.values():
|
|
rec["cmean"] = round(rec["csum"] / rec["cn"], 1) if rec["cn"] else 0.0
|
|
rec["ots"] = sorted(rec["ots"])[:8]
|
|
del rec["csum"], rec["cn"]
|
|
return {
|
|
"kind": SIGNATURE_KIND,
|
|
"frame": dump.get("frame", 0),
|
|
"drawing": drawing,
|
|
"semi": semi,
|
|
"textured": textured,
|
|
"packets": dump.get("raw_count", 0),
|
|
"keys": keys,
|
|
}
|
|
|
|
|
|
def _bbox_area(bb: Optional[Sequence[int]]) -> int:
|
|
if not bb:
|
|
return 0
|
|
return max(0, bb[2] - bb[0] + 1) * max(0, bb[3] - bb[1] + 1)
|
|
|
|
|
|
def signature_delta(a: Dict[str, Any], b: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""What moved between two frame signatures, and by how much.
|
|
|
|
`score` exists so a scan can rank frames: it weights the things a rendering
|
|
break actually does — primitives appearing or vanishing, and geometry
|
|
escaping the box it lived in — rather than the steady churn of an animation.
|
|
"""
|
|
changes = []
|
|
score = 0.0
|
|
for k in sorted(set(a["keys"]) | set(b["keys"])):
|
|
ka = a["keys"].get(k)
|
|
kb = b["keys"].get(k)
|
|
na = ka["n"] if ka else 0
|
|
nb = kb["n"] if kb else 0
|
|
area_a = _bbox_area(ka["bbox"] if ka else None)
|
|
area_b = _bbox_area(kb["bbox"] if kb else None)
|
|
# Relative growth, so a burst going 1 -> 258 outranks 700 -> 760.
|
|
rel = abs(nb - na) / max(1.0, float(min(na, nb) or 1))
|
|
area_rel = abs(area_b - area_a) / max(1.0, float(min(area_a, area_b) or 1))
|
|
if na == nb and area_a == area_b:
|
|
continue
|
|
w = rel + 0.5 * area_rel
|
|
score += w
|
|
changes.append({
|
|
"key": k, "a": na, "b": nb, "delta": nb - na,
|
|
"bbox_a": ka["bbox"] if ka else None,
|
|
"bbox_b": kb["bbox"] if kb else None,
|
|
"area_a": area_a, "area_b": area_b,
|
|
"cmax_a": ka["cmax"] if ka else 0, "cmax_b": kb["cmax"] if kb else 0,
|
|
"src_a": f"0x{ka['src_lo']:08X}" if ka and ka["src_lo"] else None,
|
|
"src_b": f"0x{kb['src_lo']:08X}" if kb and kb["src_lo"] else None,
|
|
"weight": round(w, 3),
|
|
})
|
|
changes.sort(key=lambda c: -c["weight"])
|
|
return {"a": a["frame"], "b": b["frame"], "score": round(score, 3),
|
|
"changes": changes}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 RetComM 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Reading display lists out of guest RAM
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# A PSX game rarely pokes GP0 directly. It builds a linked list in RAM and
|
|
# hands it to DMA channel 2, so the display list is a data structure in memory,
|
|
# and reading it is a different act from watching commands go by. Two things
|
|
# need that: the DuckStation oracle records no GP0 packets at all, so RAM is
|
|
# the only place its display list exists; and for decompilation, the buffer a
|
|
# routine fills is what tells you what the routine IS.
|
|
|
|
RAM_SIZE = 2 * 1024 * 1024
|
|
OT_END = 0xFFFFFF
|
|
GPU_GP0_RING_MAX_WORDS = 12
|
|
|
|
# Largest read_ram both ends actually answer. The oracle's cap is not the 65536
|
|
# its handler advertises: DuckStation replies over a BufferedStreamSocket whose
|
|
# send buffer does not hold a reply that big, and Write() copies only what fits
|
|
# while SendJSON ignores the short count -- so the reply is truncated and the
|
|
# read blocks forever waiting for a newline. Measured against a live oracle:
|
|
# 16384 round-trips, 32768 stalls (and appears to take the emulator with it).
|
|
# Our own runtime handles more, but 128 reads for 2 MB is fast on both, so one
|
|
# number keeps the two paths identical.
|
|
MAX_READ_RAM = 16384
|
|
|
|
|
|
# How often a PAUSED DuckStation oracle can answer.
|
|
#
|
|
# It pumps its debug socket from the emulation loop, so once parked the
|
|
# only pump left is a Qt idle timer: 100 ms with a gamepad attached, and
|
|
# 1000 ms without. A headless oracle on a machine with no pad is therefore
|
|
# a ~1 Hz server, and anything polling it faster stacks up connections it
|
|
# cannot accept -- which surfaces as "server closed without replying"
|
|
# rather than as a timeout, so it does not read like a pacing problem.
|
|
ORACLE_PAUSED_POLL_S = 1.5
|
|
|
|
|
|
def oracle_resume(conn) -> bool:
|
|
"""Get a paused oracle running again; True if it is running after.
|
|
|
|
Worth calling BEFORE arming anything, not just after. A tool that
|
|
parked the oracle and exited without resuming leaves every later run
|
|
talking to a 1 Hz server, and the symptom -- everything times out --
|
|
looks nothing like the cause.
|
|
"""
|
|
try:
|
|
conn.cmd("continue")
|
|
return True
|
|
except DebugError:
|
|
return False
|
|
|
|
|
|
def oracle_break_list(conn) -> list:
|
|
"""Addresses the oracle currently has execute breakpoints on."""
|
|
try:
|
|
rep = conn.raw("pc_break_list")
|
|
except DebugError:
|
|
return []
|
|
out = []
|
|
for a in rep.get("breaks") or rep.get("addrs") or []:
|
|
try:
|
|
out.append(int(a, 16) if isinstance(a, str) else int(a))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return out
|
|
|
|
|
|
def oracle_clear_breaks(conn, tries: int = 6) -> int:
|
|
"""Remove every execute breakpoint the oracle holds. Returns how many.
|
|
|
|
Stale breakpoints are not inert. DuckStation's AddBreakpointWithCallback
|
|
refuses a duplicate address, so a leaked breakpoint makes the NEXT run's
|
|
pc_break return ok:false -- while the leaked one keeps firing and pausing
|
|
the emulator. Resuming then re-pauses immediately, which is exactly the
|
|
"oracle gets paused and never unpaused" symptom, and it survives across
|
|
tool invocations because it lives in the oracle, not in the tool.
|
|
|
|
Removal is VERIFIED against pc_break_list rather than assumed, and
|
|
retried: a paused oracle serves its socket from a Qt idle timer at about
|
|
1 Hz, so an unbreak can simply time out. Swallowing that failure is how a
|
|
breakpoint outlives the tool that set it.
|
|
"""
|
|
n = 0
|
|
for _ in range(max(1, tries)):
|
|
remaining = oracle_break_list(conn)
|
|
if not remaining:
|
|
return n
|
|
for addr in remaining:
|
|
try:
|
|
conn.raw("pc_unbreak", addr=f"0x{addr:08X}")
|
|
n += 1
|
|
except DebugError:
|
|
pass
|
|
# A paused oracle answers slowly; give it room before re-checking.
|
|
oracle_resume(conn)
|
|
time.sleep(0.2)
|
|
left = oracle_break_list(conn)
|
|
if left:
|
|
raise DebugError(
|
|
"could not remove oracle breakpoint(s) at "
|
|
+ ", ".join(f"0x{a:08X}" for a in left)
|
|
+ ". They will keep pausing it; restart DuckStation to clear them.")
|
|
return n
|
|
|
|
|
|
class OracleBreak:
|
|
"""Arm one execute breakpoint on the oracle and guarantee its removal.
|
|
|
|
Use as a context manager. On exit the breakpoint is removed and the
|
|
oracle is resumed on EVERY path -- normal return, exception, or the
|
|
caller breaking out of a loop mid-way. Tools in this tree have leaked a
|
|
parked oracle repeatedly; the guard is the fix, not remembering to
|
|
resume.
|
|
"""
|
|
|
|
def __init__(self, conn, addr, clear_stale: bool = True):
|
|
self.conn = conn
|
|
self.addr = addr if isinstance(addr, str) else f"0x{addr:08X}"
|
|
self.clear_stale = clear_stale
|
|
self.armed = False
|
|
self.cleared = 0
|
|
|
|
def __enter__(self):
|
|
oracle_resume(self.conn)
|
|
if self.clear_stale:
|
|
self.cleared = oracle_clear_breaks(self.conn)
|
|
try:
|
|
self.conn.raw("pc_hit_clear")
|
|
except DebugError:
|
|
pass
|
|
rep = self.conn.raw("pc_break", addr=self.addr)
|
|
self.armed = bool(rep.get("ok"))
|
|
if not self.armed:
|
|
# Removing our own address and retrying covers the case where the
|
|
# list command is unavailable, so clear_stale found nothing.
|
|
try:
|
|
self.conn.raw("pc_unbreak", addr=self.addr)
|
|
except DebugError:
|
|
pass
|
|
rep = self.conn.raw("pc_break", addr=self.addr)
|
|
self.armed = bool(rep.get("ok"))
|
|
if not self.armed:
|
|
# Make the claim in the message true before making it. An error
|
|
# path that leaves the emulator parked is how this whole class of
|
|
# bug started.
|
|
try:
|
|
self.conn.raw("pc_unbreak", addr=self.addr)
|
|
except DebugError:
|
|
pass
|
|
oracle_resume(self.conn)
|
|
raise DebugError(
|
|
f"pc_break {self.addr} refused ({rep}). The oracle would not "
|
|
f"arm, so nothing was sampled; it has been left running.")
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb):
|
|
# Verify, do not assume. This runs on the exception path too, which is
|
|
# exactly when a swallowed failure leaves the emulator wedged.
|
|
try:
|
|
oracle_clear_breaks(self.conn)
|
|
except DebugError as e:
|
|
print(f"WARNING: {e}", file=sys.stderr)
|
|
oracle_resume(self.conn)
|
|
return False
|
|
|
|
|
|
def read_ram_range(conn: "DebugConn", addr: int, length: int,
|
|
chunk: int = MAX_READ_RAM) -> bytes:
|
|
"""Read guest RAM in chunks, tolerating a peer that returns short."""
|
|
chunk = max(1, min(chunk, MAX_READ_RAM))
|
|
out = bytearray()
|
|
off = 0
|
|
while off < length:
|
|
n = min(chunk, length - off)
|
|
rep = conn.cmd("read_ram", addr=f"0x{addr + off:08X}", len=n)
|
|
hexs = rep.get("hex")
|
|
if hexs is None:
|
|
raise DebugError("read_ram replied without 'hex'")
|
|
blob = bytes.fromhex(hexs)
|
|
if not blob:
|
|
break
|
|
out += blob
|
|
off += len(blob)
|
|
return bytes(out)
|
|
|
|
|
|
def snapshot_ram(conn: "DebugConn", size: int = RAM_SIZE) -> bytes:
|
|
"""Whole of main RAM, so a list can be walked offline.
|
|
|
|
Walking node by node over the wire would be a round trip per node and a
|
|
display list runs to hundreds of them.
|
|
"""
|
|
return read_ram_range(conn, 0x80000000, size)
|
|
|
|
|
|
def snapshot_ram_window(conn: "DebugConn", base: int, length: int,
|
|
size: int = RAM_SIZE) -> bytes:
|
|
"""A RAM image with only [base, base+length) populated.
|
|
|
|
Walking an ordering table touches only the list's own span, so snapshotting
|
|
the whole 2 MB to reach something already located is ~128 round trips for
|
|
nothing. Against DuckStation those reads are serviced on the emulator
|
|
thread, so a caller that samples repeatedly makes the emulator appear to
|
|
freeze even though nothing is paused.
|
|
"""
|
|
buf = bytearray(size)
|
|
base &= 0x1FFFFF
|
|
length = max(0, min(length, size - base))
|
|
if length:
|
|
data = read_ram_range(conn, 0x80000000 + base, length)
|
|
buf[base:base + len(data)] = data
|
|
return bytes(buf)
|
|
|
|
|
|
def walk_ordering_table(ram: bytes, root: int, max_nodes: int = 8192,
|
|
max_words: int = GPU_GP0_RING_MAX_WORDS) -> List[Dict[str, Any]]:
|
|
"""Follow an ordering table, returning entries shaped like gpu_frame_dump's.
|
|
|
|
Defensive by construction: a corrupt tag can point anywhere, and a cycle
|
|
would hang the walk, so nodes are bounded, revisits stop it, and an address
|
|
outside RAM ends it rather than raising.
|
|
"""
|
|
entries: List[Dict[str, Any]] = []
|
|
seen = set()
|
|
addr = root & 0x1FFFFF
|
|
seq = 0
|
|
# Bound on nodes VISITED, not entries emitted. An ordering table is mostly
|
|
# empty link entries -- a 1024-entry OT chaining to 30 primitives is 1024
|
|
# nodes and 30 entries -- so bounding the emitted count would leave the
|
|
# traversal itself effectively unbounded.
|
|
visited = 0
|
|
while addr != OT_END and visited < max_nodes:
|
|
visited += 1
|
|
if addr in seen or addr + 4 > len(ram):
|
|
break
|
|
seen.add(addr)
|
|
tag = int.from_bytes(ram[addr:addr + 4], "little")
|
|
n_words = (tag >> 24) & 0xFF
|
|
nxt = tag & 0xFFFFFF
|
|
if n_words:
|
|
end = addr + 4 + n_words * 4
|
|
if end > len(ram):
|
|
break
|
|
words = [int.from_bytes(ram[addr + 4 + i * 4: addr + 8 + i * 4], "little")
|
|
for i in range(n_words)]
|
|
op = (words[0] >> 24) & 0xFF
|
|
entries.append({
|
|
"seq": seq,
|
|
"op": f"0x{op:02X}",
|
|
"n": n_words,
|
|
# The payload address, matching what the GP0 ring records as the
|
|
# command header -- so the two sources are directly comparable.
|
|
"src": f"0x{addr + 4:08X}",
|
|
"ot": 0xFFFF,
|
|
"pc": "0x00000000",
|
|
"func": "0x00000000",
|
|
"ra": "0x00000000",
|
|
"w": [f"0x{w:08X}" for w in words[:max_words]],
|
|
})
|
|
seq += 1
|
|
addr = nxt & 0x1FFFFF if nxt != OT_END else OT_END
|
|
return entries
|
|
|
|
|
|
def expected_words(op: int) -> Optional[int]:
|
|
"""Payload length a GP0 opcode must have, or None if it is not one.
|
|
|
|
This is the filter that makes scanning for display lists work at all. A
|
|
2 MB RAM image contains half a million words, and "looks like a tag" alone
|
|
matches a large fraction of them; requiring the payload to also be a GP0
|
|
command of exactly the right length is what separates a real ordering-table
|
|
node from a coincidence.
|
|
"""
|
|
kind = op_kind(op)
|
|
if kind == "poly":
|
|
return poly_words(op)
|
|
if kind == "rect":
|
|
return rect_words(op)
|
|
if kind == "line":
|
|
return None # polylines are variable-length; do not seed on them
|
|
if kind in ("state", "env"):
|
|
return 1
|
|
return None
|
|
|
|
|
|
def find_display_lists(ram: bytes, min_prims: int = 4, limit: int = 8,
|
|
near: Optional[int] = None,
|
|
max_nodes: int = 65536) -> List[Dict[str, Any]]:
|
|
"""Find ordering-table chains by scanning RAM. Returns best-first.
|
|
|
|
Following DMA channel 2's MADR does NOT work for this, which is worth
|
|
stating plainly because it is the obvious thing to try. A linked-list DMA
|
|
advances MADR as it consumes the list and leaves it holding the terminator,
|
|
0xFFFFFF, once the transfer completes. Read between frames -- the only time
|
|
you can pause -- MADR names the end of the list, not its start. Masked into
|
|
2 MB that reads back as 0x1FFFFF, which looks like a plausible address and
|
|
is not one.
|
|
|
|
So the list is found by its own structure instead: nodes whose payload is a
|
|
real GP0 command of exactly the right length, chained, and not pointed at by
|
|
any other such node -- i.e. the head of a chain rather than its middle.
|
|
"""
|
|
n = len(ram) - (len(ram) % 4)
|
|
words = memoryview(ram)[:n].cast("I")
|
|
|
|
# Two node kinds, and both matter.
|
|
#
|
|
# A PRIMITIVE node carries a GP0 packet, and its payload length must match
|
|
# the opcode exactly -- that check is what keeps a 2 MB scan from matching
|
|
# noise.
|
|
#
|
|
# A LINK node carries no payload; it is one bucket of the ordering table
|
|
# array. These were originally skipped, which was the bug: an OT bucket
|
|
# points INTO a primitive chain, so if links are left out of the graph every
|
|
# bucket's first primitive looks like an unpointed head. The scan then
|
|
# reports one bucket per chain -- a partial frame that can easily omit the
|
|
# very effect being investigated -- instead of the whole display list.
|
|
succ: Dict[int, int] = {} # addr -> next addr (or OT_END)
|
|
prim_nodes = set()
|
|
pointed = set()
|
|
for i in range(len(words) - 1):
|
|
tag = words[i]
|
|
n_words = (tag >> 24) & 0xFF
|
|
nxt = tag & 0xFFFFFF
|
|
if nxt != OT_END and (nxt & 3 or nxt >= n):
|
|
continue
|
|
if n_words:
|
|
if i + 1 + n_words > len(words):
|
|
continue
|
|
want = expected_words(int(words[i + 1]) >> 24 & 0xFF)
|
|
if want is None or want != n_words:
|
|
continue
|
|
prim_nodes.add(i * 4)
|
|
else:
|
|
# A link entry is only plausible if it points somewhere real.
|
|
# Without this every zeroed word in RAM claims to be a node
|
|
# pointing at address 0, and the graph drowns.
|
|
if nxt == OT_END or nxt == 0:
|
|
continue
|
|
succ[i * 4] = nxt
|
|
if nxt != OT_END:
|
|
pointed.add(nxt)
|
|
|
|
heads = [a for a in succ if a not in pointed]
|
|
# A head that reaches no primitive at all is noise; one that reaches some is
|
|
# a candidate. Walking from a LINK head is how the whole table gets covered.
|
|
out: List[Dict[str, Any]] = []
|
|
for h in heads:
|
|
entries = walk_ordering_table(ram, h, max_nodes=max_nodes)
|
|
prims = [p for p in decode_entries(entries)
|
|
if p["kind"] in ("poly", "rect", "line", "fill")]
|
|
if len(prims) < min_prims:
|
|
continue
|
|
addrs = [int(e["src"], 16) for e in entries]
|
|
out.append({
|
|
"root": h,
|
|
"nodes": len(entries),
|
|
"prims": len(prims),
|
|
"lo": min(addrs) if addrs else h,
|
|
"hi": max(addrs) if addrs else h,
|
|
"classes": sorted({p["op_name"] for p in prims}),
|
|
})
|
|
|
|
def score(c: Dict[str, Any]) -> Tuple[int, int]:
|
|
# A chain containing an address you already care about wins outright:
|
|
# the caller knows something the scan cannot.
|
|
hit = 1 if (near is not None and c["lo"] <= (near & 0x1FFFFF) <= c["hi"]) else 0
|
|
return (hit, c["prims"])
|
|
|
|
out.sort(key=score, reverse=True)
|
|
return out[:limit]
|
|
|
|
|
|
def class_on_screen(conn, op_name: str) -> Tuple[bool, Dict[str, int]]:
|
|
"""Is this primitive class currently being drawn? Also returns what is.
|
|
|
|
Several tools depend on a routine actually RUNNING -- a write trace over its
|
|
packets, a block probe at one of its instructions -- and when it is not,
|
|
every one of them reports something that sounds like a different problem:
|
|
no writes recorded, no candidate fired, no table found. Checking first turns
|
|
all of those into one plain statement.
|
|
"""
|
|
paused = False
|
|
try:
|
|
try:
|
|
conn.cmd("pause")
|
|
paused = True
|
|
except DebugError:
|
|
pass
|
|
ram = snapshot_ram(conn)
|
|
finally:
|
|
if paused:
|
|
try:
|
|
conn.cmd("continue")
|
|
except DebugError:
|
|
pass
|
|
cands = find_display_lists(ram)
|
|
if not cands:
|
|
return False, {}
|
|
prims = decode_entries(walk_ordering_table(ram, cands[0]["root"]))
|
|
counts: Dict[str, int] = {}
|
|
for p in prims:
|
|
if p["kind"] == "poly":
|
|
counts[p["op_name"]] = counts.get(p["op_name"], 0) + 1
|
|
return counts.get(op_name, 0) > 0, counts
|
|
|
|
|
|
def wait_for_class(conn, op_name: str, timeout: float = 120.0,
|
|
poll: float = 0.6, out=None,
|
|
check=None) -> Tuple[bool, Dict[str, int]]:
|
|
"""Block until a primitive class appears on screen, or time out.
|
|
|
|
Every tool here needs the effect RUNNING, and requiring it to be on screen
|
|
at the instant a button is clicked means racing a transient animation.
|
|
Waiting inverts that: start the tool, then trigger the effect.
|
|
|
|
The cost of the check is the whole design problem. class_on_screen()
|
|
snapshots all 2 MB of RAM -- 128 reads -- and against a PAUSED oracle,
|
|
whose socket falls back to a ~1 Hz idle timer, that is 128 seconds for ONE
|
|
poll. A 120-second wait then completes a single partial read and reports
|
|
"nothing", which is exactly what it did.
|
|
|
|
So: resume first (the oracle may be parked from an earlier breakpoint), pay
|
|
for the full scan once, then re-read only the span the display list
|
|
occupies -- a couple of KB, one or two reads. A cached root that stops
|
|
walking means the list moved, and the next poll pays for a rescan.
|
|
"""
|
|
# `check` is a seam: a caller (or a test) can supply its own
|
|
# "what is drawing" probe. The default below is the expensive-then-cheap
|
|
# scan described above.
|
|
if check is not None:
|
|
deadline = time.time() + timeout
|
|
drawing: Dict[str, int] = {}
|
|
said = False
|
|
while time.time() < deadline:
|
|
try:
|
|
on, drawing = check(conn, op_name)
|
|
if on:
|
|
return True, drawing
|
|
except DebugError:
|
|
pass
|
|
if out and not said:
|
|
said = True
|
|
print(f" waiting for {op_name} to appear (up to {timeout:.0f}s) "
|
|
f"— trigger the effect now", file=out)
|
|
time.sleep(poll)
|
|
return False, drawing
|
|
|
|
# A parked emulator answers about once a second; nothing below works at that
|
|
# rate, and a tool that parked it earlier is the usual reason.
|
|
try:
|
|
conn.cmd("continue")
|
|
except DebugError:
|
|
pass
|
|
|
|
deadline = time.time() + timeout
|
|
said = False
|
|
root: Optional[int] = None
|
|
span: Optional[Tuple[int, int]] = None
|
|
drawing: Dict[str, int] = {}
|
|
|
|
while time.time() < deadline:
|
|
try:
|
|
if root is None:
|
|
ram = snapshot_ram(conn)
|
|
cands = find_display_lists(ram)
|
|
if cands:
|
|
root = cands[0]["root"]
|
|
span = (cands[0]["lo"], cands[0]["hi"])
|
|
else:
|
|
lo = max(0, (span[0] - 0x400) & ~3)
|
|
hi = min(RAM_SIZE, span[1] + 0x400)
|
|
blob = read_ram_range(conn, 0x80000000 + lo, hi - lo)
|
|
buf = bytearray(RAM_SIZE)
|
|
buf[lo:lo + len(blob)] = blob
|
|
ram = bytes(buf)
|
|
|
|
prims = (decode_entries(walk_ordering_table(ram, root))
|
|
if root is not None else [])
|
|
if not prims:
|
|
root = span = None # the list moved; rescan next poll
|
|
drawing = {}
|
|
for p in prims:
|
|
if p["kind"] == "poly":
|
|
drawing[p["op_name"]] = drawing.get(p["op_name"], 0) + 1
|
|
if drawing.get(op_name, 0) > 0:
|
|
return True, drawing
|
|
except DebugError:
|
|
root = span = None
|
|
if out and not said:
|
|
said = True
|
|
print(f" waiting for {op_name} to appear (up to {timeout:.0f}s) — "
|
|
f"trigger the effect now", file=out)
|
|
time.sleep(poll)
|
|
return False, drawing
|
|
|
|
|
|
def dma_gpu_list_root(conn: "DebugConn") -> Optional[int]:
|
|
"""Channel 2's MADR. Almost never the root -- see find_display_lists.
|
|
|
|
Kept because it is occasionally right if you catch a transfer in flight, and
|
|
because knowing WHY it fails is worth more than not having it: a completed
|
|
linked-list DMA leaves MADR holding the 0xFFFFFF terminator, so between
|
|
frames this returns the end of the list. Returns None in that case rather
|
|
than handing back 0x1FFFFF as if it were an address.
|
|
"""
|
|
try:
|
|
rep = conn.cmd("dma_state")
|
|
except DebugError:
|
|
return None
|
|
for ch in rep.get("channels", []) or []:
|
|
if ch.get("ch") == 2 or ch.get("name", "").lower().startswith("gpu"):
|
|
madr = ch.get("madr")
|
|
if isinstance(madr, str):
|
|
madr = int(madr, 16)
|
|
if not isinstance(madr, int):
|
|
return None
|
|
if (madr & 0xFFFFFF) == OT_END:
|
|
return None # the transfer finished; this is the end marker
|
|
return madr & 0x1FFFFF
|
|
return None
|