Files
psxrecomp/tools/gpu_parity.py
T
Alex Vanderveen 1f65200e6f bump
2026-08-19 23:35:17 -04:00

185 lines
7.5 KiB
Python

#!/usr/bin/env python3
"""gpu_parity.py -- frame-locked image parity between psx-runtime and DuckStation.
python3 gpu_parity.py --frame 41230 --out analysis/frames/parity-41230
Drives both emulators to the SAME guest frame, screenshots both, and reports how
far apart the presented images are. That answers the first question any render
bug has to answer:
images match -> the game code executed the same on both; a divergence
you can see on screen is in the renderer
images differ -> the guest ran differently; the GP0 stream is the place
to look, and tools/cosim.py will bracket where
Scope, stated honestly: this is IMAGE parity, not GP0-stream parity. The
DuckStation oracle patch (tools/duckstation/psxrecomp_oracle.patch) implements
screenshot / read_vram / gpu_state / run_to_frame / step / pause, but not
gpu_frame_dump, so there is no packet stream to compare on that side. Adding one
to the patch is the obvious next step if image parity keeps saying "the streams
must differ" without saying where.
Both instances must already be running with their debug servers up, on the same
disc, from the same starting state -- typically both loaded from the same
savestate, or both run from boot with the same input script. Parity between two
runs that were not driven identically means nothing.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import numpy as np # noqa: E402
from PIL import Image # noqa: E402
from psx_gpu_frame import ( # noqa: E402
DEFAULT_DUCKSTATION_PORT, DEFAULT_NATIVE_PORT, DebugConn, DebugError,
)
GPU_STATE_FIELDS = ("display_area_x", "display_area_y", "disp_w", "disp_h",
"da_x", "da_y", "display_enable")
def compare_images(pa, pb, out_path):
a = Image.open(pa).convert("RGB")
b = Image.open(pb).convert("RGB")
note = None
if a.size != b.size:
note = f"size mismatch: {a.size} vs {b.size}; compared over the common region"
w, h = min(a.width, b.width), min(a.height, b.height)
a, b = a.crop((0, 0, w, h)), b.crop((0, 0, w, h))
na = np.asarray(a, dtype=np.int16)
nb = np.asarray(b, dtype=np.int16)
d = np.abs(na - nb)
per_px = d.max(axis=2)
differing = int((per_px > 0).sum())
total = int(per_px.size)
# The PSX writes 5 bits per channel; the runtime and DuckStation both
# expand to 8 by shifting left 3, so an exact match is achievable and a
# 1-2 level difference is still a real difference, not rounding.
vis = np.zeros((*per_px.shape, 3), dtype=np.uint8)
vis[..., 0] = np.clip(per_px * 4, 0, 255)
vis[..., 1] = np.clip(per_px, 0, 255) // 2
Image.fromarray(vis, "RGB").save(out_path)
ys, xs = np.nonzero(per_px)
bbox = [int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())] if differing else None
return {
"width": a.width, "height": a.height,
"differing_pixels": differing,
"total_pixels": total,
"differing_pct": round(100.0 * differing / max(1, total), 4),
"max_channel_delta": int(d.max()),
"mean_abs_delta": round(float(d.mean()), 4),
"diff_bbox": bbox,
"note": note,
"diff_image": out_path,
}
def gpu_state_delta(a, b):
out = {}
for k in GPU_STATE_FIELDS:
if k in a or k in b:
av, bv = a.get(k), b.get(k)
if av != bv:
out[k] = {"native": av, "duckstation": bv}
return 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("--native-port", type=int, default=DEFAULT_NATIVE_PORT)
ap.add_argument("--ds-port", type=int, default=DEFAULT_DUCKSTATION_PORT)
ap.add_argument("--frame", type=int, default=None,
help="drive BOTH to this guest frame before capturing")
ap.add_argument("--out", default="analysis/frames/parity")
ap.add_argument("--timeout", type=float, default=60.0)
ap.add_argument("--dump-native", action="store_true",
help="also capture the native GP0 stream (no DuckStation counterpart)")
args = ap.parse_args(argv)
outdir = os.path.abspath(args.out)
os.makedirs(outdir, exist_ok=True)
result = {"kind": "psx-gpu-parity", "version": 1, "frame": args.frame}
try:
with DebugConn(args.host, args.native_port, args.timeout) as native, \
DebugConn(args.host, args.ds_port, args.timeout) as ds:
if args.frame is not None:
native.run_to_frame(args.frame)
ds.run_to_frame(args.frame)
fa, fb = native.frame(), ds.frame()
result["native_frame"] = fa
result["duckstation_frame"] = fb
if fa != fb:
result["frame_mismatch"] = True
print(f"warning: frames differ (native {fa}, duckstation {fb}); "
f"the comparison below is between DIFFERENT frames",
file=sys.stderr)
pa = os.path.join(outdir, "native.png")
pb = os.path.join(outdir, "duckstation.png")
native.screenshot(pa)
ds.screenshot(pb)
result["native_image"] = pa
result["duckstation_image"] = pb
try:
result["gpu_state"] = gpu_state_delta(native.cmd("gpu_state"),
ds.cmd("gpu_state"))
except DebugError as e:
result["gpu_state_error"] = str(e)
if args.dump_native:
from psx_gpu_frame import capture, save_dump
d = capture(native, frame=fa, label="native")
p = os.path.join(outdir, "native-gp0.json")
save_dump(d, p)
result["native_gp0"] = p
result["native_gp0_note"] = (
"no DuckStation counterpart: the oracle patch does not "
"implement gpu_frame_dump")
except DebugError as e:
print(f"error: {e}", file=sys.stderr)
return 2
result["image"] = compare_images(pa, pb, os.path.join(outdir, "diff.png"))
with open(os.path.join(outdir, "parity.json"), "w", encoding="utf-8") as f:
json.dump(result, f, indent=1)
img = result["image"]
print(f"native frame {result['native_frame']} duckstation frame "
f"{result['duckstation_frame']}")
if img["note"]:
print(f" {img['note']}")
print(f" {img['differing_pixels']}/{img['total_pixels']} pixels differ "
f"({img['differing_pct']}%), max channel delta {img['max_channel_delta']}")
if img["diff_bbox"]:
print(f" differences confined to bbox {img['diff_bbox']}")
if result.get("gpu_state"):
print(" display state differs:")
for k, v in result["gpu_state"].items():
print(f" {k}: native={v['native']} duckstation={v['duckstation']}")
print()
if img["differing_pixels"] == 0:
print("VERDICT: images identical -- the guest ran the same on both. A visible "
"render bug here is in the renderer, not the game code.")
else:
print("VERDICT: images differ -- the guest state or the GP0 stream diverged. "
"Capture both sides' frames and run tools/cosim.py to bracket where.")
print(f"wrote {outdir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())