Files
psxrecomp/tools/gpu_parity.py
Alex Vanderveen 922e8c5a07 Rename the product from RetComM to Retro
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
2026-09-09 17:32:19 -04:00

247 lines
10 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.
Only DuckStation gets driven. psx-runtime removed pause / step / run_to_frame
(runtime/src/debug_server.c) -- it is designed to be read from, not steered --
so the native side is sampled where it already is, and DuckStation is advanced
to meet it. If the native run has already passed the frame DuckStation is on,
there is nothing to do but restart the DuckStation side; that is reported rather
than papered over.
Getting the oracle: `python3 duckstation_oracle.py all` builds and installs a
patched DuckStation into the Retro data root (once, ~10 minutes), and
`duckstation_oracle.py start --disc <cue>` runs it headless on 4371. Pass
--start-oracle here to have this tool do that for you when nothing is listening.
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 ensure_oracle(args) -> int:
"""Start the installed DuckStation oracle if nothing is answering yet.
Opt-in (--start-oracle), because launching an emulator as a side effect of
running a diff tool is a surprise nobody asked for. When it is not
requested, a missing oracle just reports how to get one.
"""
import subprocess as sp
probe = DebugConn(args.host, args.ds_port, timeout=2.0)
try:
probe.cmd("ping")
return 0
except DebugError:
pass
if not args.disc:
print("error: --start-oracle needs --disc (the same image psx-runtime "
"is running)", file=sys.stderr)
return 2
tool = os.path.join(os.path.dirname(os.path.abspath(__file__)),
"duckstation_oracle.py")
cmd = [sys.executable, tool, "start", "--disc", args.disc,
"--port", str(args.ds_port), "--wait", "90"]
print("starting the DuckStation oracle: " + " ".join(cmd))
if sp.run(cmd).returncode != 0:
print("error: could not start the oracle. `python3 duckstation_oracle.py "
"status` will say what is missing.", file=sys.stderr)
return 2
return 0
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="target guest frame (default: wherever the native run "
"already is). Only DuckStation is driven to it.")
ap.add_argument("--out", default="analysis/frames/parity")
ap.add_argument("--timeout", type=float, default=60.0)
ap.add_argument("--start-oracle", action="store_true",
help="if nothing answers on the DuckStation port, start the "
"installed oracle first (needs --disc)")
ap.add_argument("--disc", default=None,
help="disc image for --start-oracle; must be the same one "
"psx-runtime is running")
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}
if args.start_oracle:
rc = ensure_oracle(args)
if rc:
return rc
try:
with DebugConn(args.host, args.native_port, args.timeout) as native, \
DebugConn(args.host, args.ds_port, args.timeout) as ds:
# The native side is read where it is; only DuckStation is steered.
target = args.frame if args.frame is not None else native.frame()
result["target_frame"] = target
try:
ds.run_to_frame(target)
except DebugError as e:
result["duckstation_drive_error"] = str(e)
print(f"warning: could not drive DuckStation to frame {target}: {e}",
file=sys.stderr)
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 native run cannot be paused, so it keeps advancing; "
f"pass --frame to pin a target, or accept that the two "
f"images below are of 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())