mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-27 18:52:11 -04:00
b5764b6e9a
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>
179 lines
7.0 KiB
Python
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())
|