Files
psxrecomp/tools/new_project_layout/verify_disc_set.py
T
Alex Vanderveen c4b8fdeb11 setup: re-sync the vendored disc tooling, dropping a stale status claim
Both vendored tools reported that "the runtime mounts the boot disc only
today" and named MULTI_DISC.md P1 + P3 as outstanding. P1 has landed --
`discs` is a first-class config entry, the runtime builds a roster from
it and mounts the SELECTED disc, remembered in disc_index -- so the
message was telling anyone scaffolding a set that the config they had
just written did nothing.

Fixed upstream first and re-synced, rather than patched here: these files
are vendored, and correcting only the copy would fork it from the MIT
original the attribution pins. Both files are again byte-identical to
retcomm-studio, and the pin moves a2b71ed9 -> 06bb918b.

The corrected text is also narrower and therefore more useful: what is
missing is specifically P3, a faithful lid in cdrom.c. Choosing a disc
means choosing it before the game starts; swapping mid-session is not
implemented. Worth stating plainly, because the roster this tooling
writes is what makes launcher-time disc selection work and it is easy to
read that as in-game hot-swap.

update_disc_set.py imports probe_disc, so the re-sync was re-verified:
still an exact no-op against Final Fantasy VII's already-correct 3-disc
game.toml, and --check agrees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWiDNcWuNtDWHE7Pnhq4jp
2026-08-29 19:12:37 -04:00

320 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Check that N probed discs form one coherent multi-disc set, and say which kind.
Used by tools/new_project_layout/setup_project.{sh,ps1} when --disc is passed
more than once. Consumes probe_disc.py JSON dumps in disc order (disc 1 first)
and answers one question: can this framework build the set today?
Two kinds of multi-disc title (see docs/MULTI_DISC.md "Two axes, not one"):
data-only every disc boots the same program; the later discs are just more
data. One recompiled program covers the set.
N-programs each disc carries its own boot executable, so the set needs N
statically recompiled programs selected by mounted serial.
The framework can scaffold the first kind today. It cannot build the second:
the game emitter still emits unprefixed func_XXXXXXXX / k_psx_game_dispatch
(recompiler/src/main_psx.cpp), so two programs collide at link. That is P2 of
docs/MULTI_DISC.md. This script refuses an N-programs set by name rather than
quietly scaffolding a one-program project that covers only disc 1.
Exit codes:
0 set is coherent and data-only (or a single disc) — safe to scaffold
2 usage error
3 set needs N programs — not buildable yet (docs/MULTI_DISC.md P1+P2)
4 set is incoherent (duplicate disc, mixed release, unreadable probe)
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# Fields that together identify the *program* a disc boots. If these agree
# across every disc, one recompiled program covers the whole set.
#
# `serial` and `boot_exe` are deliberately NOT here. They name the DISC, not
# the program: Final Fantasy VII ships ONE byte-identical executable on three
# discs as SCUS_941.63/.64/.65 under serials SCUS-94163/64/65, so comparing
# those strings reported three programs where there is one, and refused a set
# the framework can already build. `boot_exe_sha256` is the program's own
# identity and answers the question the refusal is actually asking.
PROGRAM_FIELDS = (
"boot_exe_sha256",
"entry_pc",
"load_address",
"text_size",
"stack_base",
)
# Per-disc identity. Recorded in disc_set.json and shown in the summary, but
# never used to decide how many programs a set needs.
DISC_IDENTITY_FIELDS = ("serial", "boot_exe")
EXIT_OK = 0
EXIT_USAGE = 2
EXIT_NEEDS_N_PROGRAMS = 3
EXIT_INCOHERENT = 4
def load_probe(path: Path) -> dict:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except OSError as e:
raise SystemExit(f"cannot read probe JSON {path}: {e}")
except json.JSONDecodeError as e:
raise SystemExit(f"probe JSON {path} is not valid JSON: {e}")
if not isinstance(data, dict):
raise SystemExit(f"probe JSON {path} is not an object")
return data
def content_key(p: dict) -> tuple:
"""What makes two probes the same *image*.
The TOC fingerprint alone is not enough: it is derived from the track
layout, so two different discs of one release that happen to share a
layout produce the same fp. Requiring the volume id and data-track size to
agree as well keeps a legitimate sibling disc from being called a
duplicate, while still catching the same image passed twice.
"""
return (
str(p.get("required_disc_fp") or ""),
str(p.get("data_track_sha1") or ""),
str(p.get("volume_id") or ""),
p.get("data_track_size"),
)
def serial_prefix(serial: str) -> str:
"""SCUS-94163 -> SCUS. The publisher/region family of the release."""
s = str(serial or "").strip().upper()
return s[:4] if len(s) >= 4 else s
def describe(index: int, p: dict) -> str:
return (
f" disc {index}: {p.get('serial') or '<no serial>'}"
f" boot={p.get('boot_exe') or '?'}"
f" entry={p.get('entry_pc') or '?'}"
f" tracks={p.get('track_count', '?')}"
f" {p.get('cue_name') or ''}"
)
def main() -> int:
ap = argparse.ArgumentParser(
description="Verify N probed discs form one buildable set."
)
ap.add_argument(
"probe_json",
nargs="+",
help="probe_disc.py --json-out dumps, in disc order (disc 1 first)",
)
ap.add_argument(
"--json-out",
default="",
help="write the verdict as JSON (disc_set.json)",
)
args = ap.parse_args()
paths = [Path(a).expanduser() for a in args.probe_json]
probes = [load_probe(p) for p in paths]
count = len(probes)
print(f"== Disc set ({count} disc{'s' if count != 1 else ''}) ==")
for i, p in enumerate(probes, start=1):
print(describe(i, p))
problems: list[str] = []
warnings: list[str] = []
# --- every disc must have identified a program -------------------------
for i, p in enumerate(probes, start=1):
if not str(p.get("serial") or "").strip():
problems.append(f"disc {i} has no serial — probe could not read SYSTEM.CNF")
if not str(p.get("entry_pc") or "").strip():
problems.append(f"disc {i} has no entry_pc — probe could not read the PS-X EXE")
# --- the same disc passed twice ----------------------------------------
seen_path: dict[str, int] = {}
seen_content: dict[tuple, int] = {}
for i, p in enumerate(probes, start=1):
cue = str(p.get("cue_path") or "")
if cue and cue in seen_path:
problems.append(
f"disc {i} is the same file as disc {seen_path[cue]} ({cue}) — "
"pass each disc once"
)
elif cue:
seen_path[cue] = i
key = content_key(p)
if key in seen_content:
problems.append(
f"disc {i} ({p.get('cue_name')}) is the same image as disc "
f"{seen_content[key]} — same fingerprint, volume id and size"
)
else:
seen_content[key] = i
# A shared TOC fingerprint across discs is legal but defeats the runtime's
# disc identity check, which is what [netplay] required_disc_fp gates on.
fps = [str(p.get("required_disc_fp") or "") for p in probes]
if len(probes) > 1 and len({f for f in fps if f}) == 1 and fps[0]:
warnings.append(
"every disc has the same TOC fingerprint — [netplay] "
"required_disc_fp cannot tell these discs apart"
)
# --- mixed release (US disc 1 + PAL disc 2, etc.) ----------------------
prefixes = {serial_prefix(p.get("serial", "")) for p in probes}
prefixes.discard("")
if len(prefixes) > 1:
listed = ", ".join(
f"disc {i} {serial_prefix(p.get('serial', '')) or '?'}"
for i, p in enumerate(probes, start=1)
)
problems.append(
f"discs are from different releases ({listed}) — a set must be one "
"release; check you have not mixed regions"
)
if problems:
print()
sys.stdout.flush()
print("error: this is not a coherent disc set:", file=sys.stderr)
for m in problems:
print(f" - {m}", file=sys.stderr)
return EXIT_INCOHERENT
# --- data-only vs N-programs -------------------------------------------
first = probes[0]
# A missing hash must not read as "these agree". Empty compares equal to
# empty, so a probe that failed to extract the executable would silently
# turn N programs into one -- the exact silent-wrong-answer this gate is
# here to prevent. Refuse to judge instead.
missing = [i for i, p in enumerate(probes, start=1)
if not str(p.get("boot_exe_sha256") or "").strip()]
if missing:
print()
sys.stdout.flush()
print("error: cannot tell how many programs this set needs.", file=sys.stderr)
print(f" disc(s) {', '.join(str(i) for i in missing)} have no "
f"boot_exe_sha256 — the probe could not read the boot executable.",
file=sys.stderr)
print(" Re-probe with a current probe_disc.py; refusing to guess.",
file=sys.stderr)
return EXIT_INCOHERENT
differing: list[str] = []
for field in PROGRAM_FIELDS:
values = {str(p.get(field) or "") for p in probes}
if len(values) > 1:
differing.append(field)
track_counts = {p.get("track_count") for p in probes}
if len(track_counts) > 1:
warnings.append(
"discs have different track counts "
f"({sorted(str(t) for t in track_counts)}) — [netplay] required_tracks "
"is a single value today and will match the boot disc only"
)
verdict = "single" if count == 1 else ("n-programs" if differing else "data-only")
result = {
"disc_count": count,
"verdict": verdict,
"differing_program_fields": differing,
"warnings": warnings,
"discs": [
{
"index": i,
"cue_name": p.get("cue_name"),
"cue_path": p.get("cue_path"),
"serial": p.get("serial"),
"boot_exe": p.get("boot_exe"),
"entry_pc": p.get("entry_pc"),
"track_count": p.get("track_count"),
"required_disc_fp": p.get("required_disc_fp"),
}
for i, p in enumerate(probes, start=1)
],
}
if args.json_out:
Path(args.json_out).write_text(
json.dumps(result, indent=2) + "\n", encoding="utf-8"
)
sys.stdout.flush()
for w in warnings:
print(f" warning: {w}", file=sys.stderr)
sys.stderr.flush()
if verdict == "n-programs":
print()
sys.stdout.flush()
print(
"error: these discs each boot their own program, which this "
"framework cannot build yet.",
file=sys.stderr,
)
print(f" differing: {', '.join(differing)}", file=sys.stderr)
print(
f" disc 1 boots {first.get('boot_exe')} at {first.get('entry_pc')}; "
"the others do not.",
file=sys.stderr,
)
print(
" One binary linking N recompiled programs is P2 of "
"docs/MULTI_DISC.md, and the game emitter still emits unprefixed "
"symbols that would collide at link.",
file=sys.stderr,
)
print(
" Scaffold disc 1 alone (pass a single --disc) until that lands. "
"Refusing rather than writing a one-program project that silently "
"covers only disc 1.",
file=sys.stderr,
)
return EXIT_NEEDS_N_PROGRAMS
if verdict == "data-only":
print()
serials = [str(p.get("serial") or "?") for p in probes]
if len(set(serials)) > 1:
print(
f" note: {len(set(serials))} serials in this set "
f"({', '.join(serials)}) carrying one identical executable — "
"normal for a multi-disc title, and why the program hash and "
"not the serial decides this."
)
print(
f" all {count} discs boot the same program "
f"(sha256 {str(first.get('boot_exe_sha256') or '')[:12]}…) — one "
f"program covers the set."
)
print(
" Scaffolding that program now. P1 has since landed: `discs` is a "
"first-class config entry, the runtime builds a roster from it, "
"and it mounts the SELECTED disc — remembered in disc_index — not "
"just the boot disc. Choosing a disc still means choosing it "
"before the game starts; swapping one mid-session is "
"docs/MULTI_DISC.md P3 (a faithful lid in cdrom.c), which has not "
"landed."
)
return EXIT_OK
if __name__ == "__main__":
try:
sys.exit(main())
except SystemExit as e:
if isinstance(e.code, int):
raise
print(f"error: {e}", file=sys.stderr)
sys.exit(EXIT_INCOHERENT)