Files
psxrecomp/tools/gpu_frame_scan.py
Alex Vanderveen b5764b6e9a debug: restore pause/step, repaint hold, and the GPU analysis tools
All of this existed and was lost to a `git pull --rebase` in this submodule
while uncommitted. Committing so it survives the next one.

Engine:
  * pause / continue / step / run_to_frame are real again, debug-server only —
    no key, button, or menu reaches them, so a shipped build cannot be frozen.
    The wedge that got them removed the first time is fixed: the park loop
    keeps calling debug_server_poll() (which delivers `continue` and feeds the
    4-second starvation watchdog) and auto-resumes after s_pause_timeout_ms of
    client silence, so a debugger that dies cannot take the game with it.
  * present_hold_tick() / is_window_repaint_event(): a frame held on screen no
    longer rots when another window crosses it. Previously nothing presented
    while stalled, so the expose went unanswered and the frame smeared.

Tools:
  * psx_gpu_frame.py: frame signatures (compare frames by opcode+blend rather
    than by function — a game that DMAs an ordering table reports one submit PC
    for every packet, so function attribution has one answer per frame), plus
    reading display lists out of guest RAM.
  * gpu_display_list.py: walk an ordering table and decode it. Finds the list
    by scanning for chained nodes whose payload is a real GP0 command of
    exactly the right length. Following DMA ch2's MADR does NOT work: a
    finished linked-list transfer leaves the 0xFFFFFF terminator there, which
    masks to 0x1FFFFF and looks like a valid address.
  * gpu_colour_parity.py: compare vertex-colour distributions across emulators.
    Distributions rather than a diff because two emulators cannot be aligned to
    the same instant without deterministic replay, and an animating effect
    sampled at two phases differs everywhere — which says nothing.
  * read_ram is capped at 16 KB/call: the oracle advertises 65536 but replies
    over a socket that truncates it, and the read then hangs. Measured.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 12:55:57 -04:00

179 lines
7.0 KiB
Python

#!/usr/bin/env python3
"""gpu_frame_scan.py -- find the frame where the rendering turns.
python3 gpu_frame_scan.py --last 400
python3 gpu_frame_scan.py --from 43000 --to 43300 --out analysis/frames/scan.json
Why this exists
---------------
Diffing one "good" frame against one "bad" frame only works if you already know
which two frames those are. Pick them wrong -- one before an effect starts and
one during it -- and the diff faithfully reports that the effect exists, which
is true and useless. That is exactly what a first attempt produces.
This walks the GP0 ring instead and asks a different question: between which two
consecutive frames did the picture change most? The answer is the frame the bug
happens on, and you did not have to know it in advance.
It also does not depend on function attribution, which does not work on a game
that builds an ordering table and DMAs it -- every packet in such a frame
reports the same submit PC, so "which function drew this" has one answer for the
whole screen. Frames are compared by (opcode, blend mode) instead, and each
change carries the packet's source address in guest RAM, which is the thing you
hand to wtrace to find the code that built it.
Two passes: a coarse sweep to bracket the transition, then frame-by-frame inside
the winning interval. Scanning a few hundred frames one at a time would move
hundreds of megabytes over the socket for no extra information.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from psx_gpu_frame import ( # noqa: E402
DEFAULT_NATIVE_PORT, DebugConn, DebugError, capture, frame_signature,
signature_delta,
)
SCAN_KIND = "psx-gpu-frame-scan"
def sample(conn: DebugConn, frame: int, count: int):
"""Signature for one frame, or None if the ring no longer has it."""
try:
dump = capture(conn, frame=frame, count=count, verify_ring=False)
except DebugError:
return None
if dump["raw_count"] == 0:
return None
return frame_signature(dump)
def sweep(conn, frames, count, label, quiet=False):
"""Signatures for a list of frames, skipping ones the ring cannot serve."""
out = []
for i, f in enumerate(frames):
sig = sample(conn, f, count)
if sig is not None:
out.append(sig)
if not quiet:
print(f"\r {label}: {i + 1}/{len(frames)} frames", end="", flush=True)
if not quiet:
print()
return out
def rank(sigs):
"""Consecutive deltas, worst first."""
return sorted((signature_delta(sigs[i], sigs[i + 1])
for i in range(len(sigs) - 1)),
key=lambda d: -d["score"])
def report(deltas, top, out=sys.stdout):
if not deltas:
print("nothing to compare — the range produced fewer than two frames "
"with packets", file=out)
return
print(f"\n{'frames':>16} {'score':>9} biggest change", file=out)
for d in deltas[:top]:
head = d["changes"][0] if d["changes"] else None
desc = (f"{head['key']} {head['a']}->{head['b']}"
if head else "(counts equal)")
print(f"{d['a']:>7} -> {d['b']:<6} {d['score']:>9.1f} {desc}", file=out)
best = deltas[0]
print(f"\n=== biggest transition: frame {best['a']} -> {best['b']} ===", file=out)
for c in best["changes"][:8]:
print(f" {c['key']:<26} {c['a']:>5} -> {c['b']:<5} ({c['delta']:+d})", file=out)
if c["bbox_a"] != c["bbox_b"]:
print(f" {'':<26} bbox {c['bbox_a']} -> {c['bbox_b']}", file=out)
if c["cmax_a"] != c["cmax_b"]:
print(f" {'':<26} peak colour {c['cmax_a']} -> {c['cmax_b']}", file=out)
if c["src_b"]:
print(f" {'':<26} packets built at {c['src_b']} — trace the builder "
f"with: wtrace_add addr={c['src_b']}", file=out)
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--host", default="127.0.0.1")
ap.add_argument("--port", type=int, default=DEFAULT_NATIVE_PORT)
ap.add_argument("--timeout", type=float, default=30.0)
ap.add_argument("--from", dest="lo", type=int, default=None)
ap.add_argument("--to", dest="hi", type=int, default=None)
ap.add_argument("--last", type=int, default=0,
help="scan the last N frames the ring holds")
ap.add_argument("--coarse", type=int, default=24,
help="samples in the bracketing pass")
ap.add_argument("--count", type=int, default=20000, help="packet cap per frame")
ap.add_argument("--top", type=int, default=6)
ap.add_argument("--out", default=None, help="write the scan as JSON here")
ap.add_argument("--no-refine", action="store_true",
help="skip the frame-by-frame pass")
args = ap.parse_args(argv)
try:
with DebugConn(args.host, args.port, args.timeout) as conn:
span = conn.ring_span()
lo, hi = args.lo, args.hi
if args.last:
hi = span["newest"]
lo = max(span["oldest"], hi - args.last)
if lo is None:
lo = span["oldest"]
if hi is None:
hi = span["newest"]
lo = max(lo, span["oldest"])
hi = min(hi, span["newest"])
if hi - lo < 2:
print(f"error: range {lo}..{hi} is too small (ring holds "
f"{span['oldest']}..{span['newest']})", file=sys.stderr)
return 2
print(f"ring holds {span['oldest']}..{span['newest']}; scanning {lo}..{hi}")
step = max(1, (hi - lo) // max(2, args.coarse))
coarse_frames = list(range(lo, hi + 1, step))
coarse = sweep(conn, coarse_frames, args.count, "coarse")
deltas = rank(coarse)
if not deltas:
print("no comparable frames in that range", file=sys.stderr)
return 1
fine = []
if not args.no_refine and step > 1:
a, b = deltas[0]["a"], deltas[0]["b"]
print(f" refining {a}..{b} frame by frame")
fine = sweep(conn, list(range(a, b + 1)), args.count, "refine")
fdeltas = rank(fine)
if fdeltas:
deltas = fdeltas
report(deltas, args.top)
if args.out:
doc = {
"kind": SCAN_KIND, "version": 1,
"range": [lo, hi], "ring": span,
"coarse_step": step,
"transitions": deltas[:args.top],
}
with open(args.out, "w", encoding="utf-8") as f:
json.dump(doc, f, indent=1)
print(f"\nwrote {args.out}")
except DebugError as e:
print(f"error: {e}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())