mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-25 18:05:12 -04:00
922e8c5a07
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
247 lines
10 KiB
Python
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())
|