diff --git a/docs/GAME_PROJECT_SETUP.md b/docs/GAME_PROJECT_SETUP.md index 9e4a2888..2199e938 100644 --- a/docs/GAME_PROJECT_SETUP.md +++ b/docs/GAME_PROJECT_SETUP.md @@ -107,8 +107,8 @@ What runs after answers: the scaffold force-publishes when visibility is public and nudges with a tiny follow-up commit that retouches the workflow file. -Standalone helpers: `probe_disc.py`, `fetch_boxart.py`, `fill_tokens.py`, -`sync_symbols.py`. +Standalone helpers: `probe_disc.py`, `verify_disc_set.py`, `fetch_boxart.py`, +`fill_tokens.py`, `sync_symbols.py`. ### Disc autofill (`probe_disc.py`) @@ -134,6 +134,39 @@ python3 tools/new_project_layout/probe_disc.py disc/game.cue \ --disc-rel disc/game.cue --out-dir disc --players 2 ``` +`--identity-only` skips the data-track md5/sha1 pass for a fast identity read. +It cannot be combined with `--write-game-toml` / `--write-catalog`, which need +those digests. + +### Multi-disc titles + +Pass `--disc` once per disc, in disc order, boot disc first (PowerShell has no +repeatable parameters, so the `.ps1` takes the array form +`-Disc d1.cue,d2.cue`): + +```bash +sh tools/new_project_layout/setup_project.sh --name "My Game" \ + --disc "My Game (Disc 1).cue" --disc "My Game (Disc 2).cue" +``` + +Every disc is probed and the set checked by `verify_disc_set.py` **before +anything is created**, so a set that cannot be built refuses cleanly rather +than leaving a half-scaffolded directory. There are two outcomes: + +| Set | What happens | +| --- | --- | +| Every disc boots the **same** program (data-only) | Scaffolds that one program from the boot disc. Each disc's identity is recorded in `disc_probe.N.json`, and the verdict in `disc_set.json`. | +| Each disc boots **its own** program | **Refused by name.** One binary linking N recompiled programs is P2 of [`MULTI_DISC.md`](MULTI_DISC.md); the game emitter still emits unprefixed symbols that would collide at link. Scaffold the boot disc alone until that lands. | + +It also refuses a set that is incoherent: the same image passed twice, or discs +from different releases (a serial-prefix mismatch, e.g. `SCUS` with `SCES`). + +`game.toml` still describes the boot disc only — it keeps the singular `disc` +key, because `psxrecomp_cli.py generate` reads only that. **Scaffolding a disc +set is not the same as playing it:** the runtime mounts one disc and has no +lid/shell state machine, so swapping discs at runtime needs P1 and P3 of +[`MULTI_DISC.md`](MULTI_DISC.md). + Prefer a **full multi-track** Redump cue. A single-TRACK dump will warn and will fail online multi-track gates (see `[NETPLAY.md](NETPLAY.md)`). diff --git a/docs/MULTI_DISC.md b/docs/MULTI_DISC.md index 894451f8..2d048c99 100644 --- a/docs/MULTI_DISC.md +++ b/docs/MULTI_DISC.md @@ -309,6 +309,17 @@ do first. Resist that: the flag is the *last* five percent. Everything the flag would feed is singular, and a scaffold that accepts three discs and then writes one program's project is worse than one that refuses. +**Partially landed — the verification half only.** `--disc` is repeatable today +(`-Disc a,b` on PowerShell, which has no repeatable parameters), and +`verify_disc_set.py` probes every disc with `probe_disc.py --identity-only` and +checks the set *before* `$ROOT` exists. A data-only set scaffolds its one +program and records `disc_probe.N.json` + `disc_set.json`; an N-programs set, +a duplicate image, or a mixed-release set is refused by name with nothing +created. `game.toml` still carries the singular `disc` key, so `generate` is +unaffected and single-disc scaffolding is byte-identical to before. **None of +the generation half below has landed** — no per-disc `[[game.disc]]`, no +per-program seeds, no N-program build. + **The arg parsing is genuinely cheap.** There is no interactive disc prompt to extend — the disc is a hard CLI requirement checked before prompting begins (`setup_project.sh:221-230`), deliberately excluded from the `prompt_line` flow diff --git a/tools/new_project_layout/probe_disc.py b/tools/new_project_layout/probe_disc.py index 0cc10fc6..96c8a6e3 100755 --- a/tools/new_project_layout/probe_disc.py +++ b/tools/new_project_layout/probe_disc.py @@ -324,17 +324,25 @@ def display_name_from_cue(cue_path: Path, volume_id: str) -> str: return name.strip() or stem -def probe(cue_path: Path) -> DiscProbe: +def probe(cue_path: Path, *, identity_only: bool = False) -> DiscProbe: cue_path = cue_path.resolve() if cue_path.suffix.lower() != ".cue": raise SystemExit("probe_disc expects a .cue (Redump multi-track layout)") tracks, files_order = parse_cue(cue_path) bin_path = first_binary_bin(cue_path, files_order, tracks) - md5, sha1, size = file_hashes(bin_path) + if identity_only: + md5, sha1, size = "", "", bin_path.stat().st_size + else: + md5, sha1, size = file_hashes(bin_path) warnings: list[str] = [] notes: list[str] = [] + if identity_only: + notes.append( + "identity-only probe: data-track md5/sha1 skipped. Not usable for " + "[prepare_disc] digests or the catalog." + ) if len(tracks) == 1: warnings.append( "cue has only 1 TRACK — Track-01-only dumps fail multi-track " @@ -624,14 +632,28 @@ def main() -> int: ap.add_argument("--publisher", default="", help="marketing publisher") ap.add_argument("--year", default="", help="marketing year (string ok)") ap.add_argument("--region", default="", help="marketing region (e.g. USA)") + ap.add_argument( + "--identity-only", + action="store_true", + help="skip the data-track md5/sha1 pass (fast identity check; not " + "usable with --write-game-toml / --write-catalog)", + ) args = ap.parse_args() + if args.identity_only and (args.write_game_toml or args.write_catalog): + print( + "--identity-only cannot be combined with --write-game-toml / " + "--write-catalog: both need the data-track digests it skips.", + file=sys.stderr, + ) + return 2 + cue = Path(args.cue).expanduser() if not cue.is_file(): print(f"cue not found: {cue}", file=sys.stderr) return 1 - p = probe(cue) + p = probe(cue, identity_only=args.identity_only) if args.display_name: p.display_name = args.display_name diff --git a/tools/new_project_layout/setup_project.ps1 b/tools/new_project_layout/setup_project.ps1 index 061a43b0..9609f4e1 100644 --- a/tools/new_project_layout/setup_project.ps1 +++ b/tools/new_project_layout/setup_project.ps1 @@ -1,7 +1,11 @@ # New Project Layout scaffolding (Windows). # # Path params (pass on the CLI -- tab-complete in your shell): -# -Disc REQUIRED legal Redump .cue (kept on your dump drive) +# -Disc [,] REQUIRED legal Redump .cue (kept on your dump drive). +# For a multi-disc title pass a comma-separated list in +# disc order, boot disc first. PowerShell parameters +# cannot be repeated, so this is the array form of the +# shell script's repeated --disc. See "Multi-disc". # -Dir Parent directory (default: .) # -Bios Optional retail BIOS for Generate # -StageDisc Copy full cue+bins into repo disc/ (large; optional) @@ -9,15 +13,24 @@ # # Other options are prompted when interactive, or set via switches / -Yes. # +# Multi-disc: every disc is probed and the set checked before anything is +# created (verify_disc_set.py). A set whose discs each boot their own program +# is REFUSED -- one binary linking N recompiled programs is P2 of +# docs/MULTI_DISC.md and does not exist yet. A set where every disc boots the +# same program scaffolds that one program and records each disc's identity in +# disc_probe.N.json + disc_set.json. Mounting the later discs at runtime still +# needs P1 + P3; the runtime mounts the boot disc only today. +# # Usage: # powershell -File setup_project.ps1 -Disc C:\dumps\game.cue # powershell -File setup_project.ps1 -Disc game.cue -Name Foo -Yes # powershell -File setup_project.ps1 -Disc game.cue -StageDisc +# powershell -File setup_project.ps1 -Disc d1.cue,d2.cue [CmdletBinding()] param( [string]$Name = "", - [Parameter(Mandatory = $true)][string]$Disc, + [Parameter(Mandatory = $true)][string[]]$Disc, [string]$Dir = ".", [string]$BootExe = "SLUS_01234", [int]$Players = 0, @@ -60,6 +73,7 @@ $ErrorActionPreference = "Stop" $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path $TemplateDir = Join-Path $ScriptDir "templates" $ProbeDisc = Join-Path $ScriptDir "probe_disc.py" +$VerifyDiscSet = Join-Path $ScriptDir "verify_disc_set.py" $FillTokens = Join-Path $ScriptDir "fill_tokens.py" $FetchBoxartPy = Join-Path $ScriptDir "fetch_boxart.py" $DefaultLobbyHost = "netplay.retcomm.net" @@ -108,8 +122,44 @@ function Prompt-Yn([string]$Question, [bool]$DefaultYes) { } } -if (-not (Test-Path -LiteralPath $Disc)) { - throw "-Disc not found: $Disc (pass the path on the CLI so your shell can tab-complete it)" +# $Disc is an array (disc order, boot disc first). Everything downstream works +# on the boot disc, so collapse to it once the whole set has been validated. +$DiscAll = @($Disc) +$DiscCount = $DiscAll.Count +foreach ($_d in $DiscAll) { + if (-not (Test-Path -LiteralPath $_d)) { + throw "-Disc not found: $_d (pass the path on the CLI so your shell can tab-complete it)" + } +} +$Disc = $DiscAll[0] + +# --- Multi-disc: verify the whole set before creating anything ------------- +# Runs ahead of every prompt and before $Root exists, so a set we cannot build +# refuses cleanly instead of leaving a half-scaffolded directory behind. +$DiscSetTmp = "" +$DiscSetJson = "" +if ($DiscCount -gt 1) { + Write-Host "== Verifying $DiscCount-disc set (identity only) ==" + $DiscSetTmp = Join-Path ([System.IO.Path]::GetTempPath()) ("psxrecomp-discset-" + [System.Guid]::NewGuid().ToString("N")) + New-Item -ItemType Directory -Path $DiscSetTmp -Force | Out-Null + $setArgs = @() + for ($i = 0; $i -lt $DiscCount; $i++) { + $probeOut = Join-Path $DiscSetTmp ("disc_probe." + ($i + 1) + ".json") + $probeCue = $DiscAll[$i] + & python $ProbeDisc $probeCue --identity-only --json-out $probeOut | Out-Null + if ($LASTEXITCODE -ne 0) { + Remove-Item -Recurse -Force $DiscSetTmp -ErrorAction SilentlyContinue + throw ("disc " + ($i + 1) + " failed to probe: " + $DiscAll[$i]) + } + $setArgs += $probeOut + } + $DiscSetJson = Join-Path $DiscSetTmp "disc_set.json" + & python $VerifyDiscSet @setArgs --json-out $DiscSetJson + if ($LASTEXITCODE -ne 0) { + Remove-Item -Recurse -Force $DiscSetTmp -ErrorAction SilentlyContinue + Write-Host "" + throw "Nothing was created. See docs/MULTI_DISC.md for what this needs." + } } $interactive = Test-Interactive @@ -365,6 +415,9 @@ function Fill-Template([string]$src, [string]$dst) { Write-Host "== New Project Layout ==" Write-Host " repo: $Root" Write-Host " disc: $Disc" +if ($DiscCount -gt 1) { + Write-Host (" (+" + ($DiscCount - 1) + " more; verified as one program)") +} Write-Host " zip prefix: $ZipPrefix" Write-Host " gh slug: $GithubOwner/$GithubRepo" Write-Host " players: $Players" @@ -619,6 +672,27 @@ if ($doBoxart) { } } +# --- Multi-disc: record the set alongside the boot disc's probe ------------ +# The scaffolded project is still one program. These files are the provenance +# of the other discs, so a later session does not have to re-derive which +# images the set was verified against. +if ($DiscCount -gt 1 -and $DiscSetJson -and (Test-Path -LiteralPath $DiscSetJson)) { + Copy-Item -LiteralPath $DiscSetJson -Destination (Join-Path $Root "disc_set.json") -Force + for ($i = 2; $i -le $DiscCount; $i++) { + $src = Join-Path $DiscSetTmp ("disc_probe.$i.json") + if (Test-Path -LiteralPath $src) { + Copy-Item -LiteralPath $src -Destination (Join-Path $Root "disc_probe.$i.json") -Force + } + } + Write-Host "== Multi-disc set recorded ==" + Write-Host " disc_set.json + disc_probe.2..$DiscCount.json" + Write-Host " game.toml describes the boot disc only — the runtime mounts one disc" + Write-Host " today (docs/MULTI_DISC.md P1 + P3 add the rest)." +} +if ($DiscSetTmp -and (Test-Path -LiteralPath $DiscSetTmp)) { + Remove-Item -Recurse -Force $DiscSetTmp -ErrorAction SilentlyContinue +} + Write-Host "== Sync symbols header ==" Push-Location $Root try { python tools/sync_symbols.py --game $GameName } catch { Write-Warning "sync_symbols failed" } @@ -634,6 +708,14 @@ if (Test-Path (Join-Path $Root "catalog_identity.json")) { if (Test-Path (Join-Path $Root "disc_probe.json")) { git add disc_probe.json 2>$null } +if (Test-Path (Join-Path $Root "disc_set.json")) { + git add disc_set.json 2>$null + for ($i = 2; $i -le $DiscCount; $i++) { + if (Test-Path (Join-Path $Root "disc_probe.$i.json")) { + git add "disc_probe.$i.json" 2>$null + } + } +} if ($HasBoxart) { git add launcher_assets/img/boxart.tga launcher_assets/img/boxart.png launcher_assets/img/BOXART_SOURCE.txt 2>$null } diff --git a/tools/new_project_layout/setup_project.sh b/tools/new_project_layout/setup_project.sh index 6f7a658f..4db08e3c 100755 --- a/tools/new_project_layout/setup_project.sh +++ b/tools/new_project_layout/setup_project.sh @@ -2,7 +2,9 @@ # New Project Layout scaffolding (Linux / macOS). # # Path flags (pass on the CLI — use your shell's path completion): -# --disc REQUIRED legal Redump .cue (kept on your dump drive) +# --disc REQUIRED legal Redump .cue (kept on your dump drive). +# Repeatable for a multi-disc title — pass in disc order, +# boot disc first. See "Multi-disc" below. # --dir Parent directory for the new repo (default: .) # --bios Optional retail BIOS for --generate / generate prompt # --boot-exe Optional override until disc probe runs @@ -25,6 +27,15 @@ # only the small boot EXE is written under disc/. Use --stage-disc to copy # the full multi-track dump into the repo (gitignored) instead. # +# Multi-disc: pass --disc once per disc, in disc order. Every disc is probed and +# the set is checked before anything is created (verify_disc_set.py). A set +# whose discs each boot their own program is REFUSED — one binary linking N +# recompiled programs is P2 of docs/MULTI_DISC.md and does not exist yet. +# A set where every disc boots the same program scaffolds that one program and +# records each disc's identity in disc_probe.N.json + disc_set.json. Mounting +# the later discs at runtime still needs P1 + P3; the runtime mounts the boot +# disc only today. +# # GitHub publish order (avoids competing "initial" commits / missing CI): # scaffold + CI workflow → commit → gh repo create (no push) → generate/build # → single git push -u origin HEAD → verify Actions workflows if CI enabled. @@ -33,17 +44,23 @@ # sh tools/new_project_layout/setup_project.sh --disc /path/to/game.cue # sh tools/new_project_layout/setup_project.sh --disc game.cue --name Foo --yes # sh tools/new_project_layout/setup_project.sh --disc game.cue --stage-disc +# sh tools/new_project_layout/setup_project.sh --disc d1.cue --disc d2.cue set -eu SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) TEMPLATE_DIR="$SCRIPT_DIR/templates" PROBE_DISC="$SCRIPT_DIR/probe_disc.py" +VERIFY_DISC_SET="$SCRIPT_DIR/verify_disc_set.py" FILL_TOKENS="$SCRIPT_DIR/fill_tokens.py" FETCH_BOXART="$SCRIPT_DIR/fetch_boxart.py" NAME="" DISC="" +# Extra discs, newline-delimited (disc 2..N). Cue paths routinely contain +# spaces, so never word-split this — iterate with `while IFS= read -r`. +EXTRA_DISCS="" +DISC_COUNT=0 PARENT="." BOOT_EXE="SLUS_01234" PLAYERS="" @@ -164,7 +181,16 @@ normalize_lobby_url() { while [ $# -gt 0 ]; do case "$1" in --name) NAME=$2; shift 2 ;; - --disc) DISC=$2; shift 2 ;; + --disc) + if [ -z "$DISC" ]; then + DISC=$2 + else + EXTRA_DISCS="${EXTRA_DISCS}$2 +" + fi + DISC_COUNT=$((DISC_COUNT + 1)) + shift 2 + ;; --dir) PARENT=$2; shift 2 ;; --boot-exe) BOOT_EXE=$2; shift 2 ;; --players) PLAYERS=$2; shift 2 ;; @@ -218,6 +244,20 @@ need_cmd() { need_cmd git need_cmd python3 +# Single owner for every temp path the script creates. The disc-set temp dir is +# made long before the template block files, so one trap has to cover both. +SET_TMP="" +cleanup_tmp() { + [ -n "${SET_TMP:-}" ] && rm -rf "$SET_TMP" + for _f in "${NETPLAY_BLOCK_FILE:-}" "${WIZARD_BLOCK_FILE:-}" \ + "${BOXART_BLOCK_FILE:-}" "${APP_ICON_BLOCK_FILE:-}" \ + "${RECOMP_UI_BLOCK_FILE:-}" "${CODEGEN_ARG_FILE:-}"; do + [ -n "$_f" ] && rm -f "$_f" + done + return 0 +} +trap cleanup_tmp EXIT INT TERM + # --- Required path: disc --------------------------------------------------- if [ -z "$DISC" ]; then echo "error: --disc is required (use your shell to tab-complete the path)." >&2 @@ -228,6 +268,56 @@ if [ ! -f "$DISC" ]; then echo "error: --disc not found: $DISC" >&2 exit 1 fi +printf '%s\n' "$EXTRA_DISCS" | while IFS= read -r _extra; do + [ -n "$_extra" ] || continue + if [ ! -f "$_extra" ]; then + echo "error: --disc not found: $_extra" >&2 + exit 1 + fi +done || exit 1 + +# --- Multi-disc: verify the whole set before creating anything ------------- +# Runs here, ahead of every prompt and before $ROOT exists, so a set we cannot +# build refuses cleanly instead of leaving a half-scaffolded directory behind. +DISC_SET_JSON="" +if [ "$DISC_COUNT" -gt 1 ]; then + echo "== Verifying $DISC_COUNT-disc set (identity only) ==" + SET_TMP=$(mktemp -d "${TMPDIR:-/tmp}/psxrecomp-discset.XXXXXX") + _i=1 + if ! python3 "$PROBE_DISC" "$DISC" --identity-only \ + --json-out "$SET_TMP/disc_probe.1.json" >/dev/null; then + echo "error: disc 1 failed to probe: $DISC" >&2 + exit 1 + fi + printf '%s\n' "$EXTRA_DISCS" | while IFS= read -r _extra; do + [ -n "$_extra" ] || continue + _i=$((_i + 1)) + if ! python3 "$PROBE_DISC" "$_extra" --identity-only \ + --json-out "$SET_TMP/disc_probe.$_i.json" >/dev/null; then + echo "error: disc $_i failed to probe: $_extra" >&2 + exit 1 + fi + done || exit 1 + # Order matters — feed the verifier disc 1..N, not shell glob order. + _set_args="" + _n=1 + while [ "$_n" -le "$DISC_COUNT" ]; do + if [ ! -f "$SET_TMP/disc_probe.$_n.json" ]; then + echo "error: disc $_n probe missing — aborting." >&2 + exit 1 + fi + _set_args="$_set_args $SET_TMP/disc_probe.$_n.json" + _n=$((_n + 1)) + done + # shellcheck disable=SC2086 + if ! python3 "$VERIFY_DISC_SET" $_set_args \ + --json-out "$SET_TMP/disc_set.json"; then + echo >&2 + echo "Nothing was created. See docs/MULTI_DISC.md for what this needs." >&2 + exit 1 + fi + DISC_SET_JSON="$SET_TMP/disc_set.json" +fi # --- Interactive / flag config --------------------------------------------- if [ -z "$NAME" ]; then @@ -494,7 +584,7 @@ BOXART_BLOCK_FILE=$(mktemp) APP_ICON_BLOCK_FILE=$(mktemp) RECOMP_UI_BLOCK_FILE=$(mktemp) CODEGEN_ARG_FILE=$(mktemp) -trap 'rm -f "$NETPLAY_BLOCK_FILE" "$WIZARD_BLOCK_FILE" "$BOXART_BLOCK_FILE" "$APP_ICON_BLOCK_FILE" "$RECOMP_UI_BLOCK_FILE" "$CODEGEN_ARG_FILE"' EXIT +# cleanup_tmp (installed above) already covers these plus the disc-set temp dir. if [ "$ENABLE_RECOMP_UI" -eq 1 ]; then printf '%s\n' '# recomp-ui submodule present (PSX_RECOMP_UI defaults ON).' \ @@ -584,6 +674,9 @@ fill_template() { echo "== New Project Layout ==" echo " repo: $ROOT" echo " disc: $DISC" +if [ "$DISC_COUNT" -gt 1 ]; then + echo " (+$((DISC_COUNT - 1)) more; verified as one program)" +fi echo " zip prefix: $ZIP_PREFIX" echo " gh slug: $GITHUB_OWNER/$GITHUB_REPO" echo " players: $PLAYERS" @@ -828,6 +921,25 @@ print(m.group(1) if m else "") ;; esac +# --- Multi-disc: record the set alongside the boot disc's probe ------------ +# The scaffolded project is still one program (see the header note). These +# files are the provenance of the other discs, so a later session does not have +# to re-derive which images the set was verified against. +if [ "$DISC_COUNT" -gt 1 ] && [ -n "$DISC_SET_JSON" ] && [ -f "$DISC_SET_JSON" ]; then + cp "$DISC_SET_JSON" "$ROOT/disc_set.json" + _n=2 + while [ "$_n" -le "$DISC_COUNT" ]; do + if [ -f "$SET_TMP/disc_probe.$_n.json" ]; then + cp "$SET_TMP/disc_probe.$_n.json" "$ROOT/disc_probe.$_n.json" + fi + _n=$((_n + 1)) + done + echo "== Multi-disc set recorded ==" + echo " disc_set.json + disc_probe.2..$DISC_COUNT.json" + echo " game.toml describes the boot disc only — the runtime mounts one disc" + echo " today (docs/MULTI_DISC.md P1 + P3 add the rest)." +fi + if [ "$FETCH_BOXART_FLAG" -eq 1 ]; then echo "== Fetching libretro boxart ==" CUE_HINT="${DISC_BASENAME:-$GAME_NAME}" @@ -866,6 +978,14 @@ fi if [ -f "$ROOT/disc_probe.json" ]; then git add disc_probe.json || true fi +if [ -f "$ROOT/disc_set.json" ]; then + git add disc_set.json || true + _n=2 + while [ "$_n" -le "$DISC_COUNT" ]; do + [ -f "$ROOT/disc_probe.$_n.json" ] && git add "disc_probe.$_n.json" || true + _n=$((_n + 1)) + done +fi if [ "$HAS_BOXART" -eq 1 ]; then git add launcher_assets/img/boxart.tga launcher_assets/img/boxart.png launcher_assets/img/BOXART_SOURCE.txt || true fi diff --git a/tools/new_project_layout/templates/gitignore.in b/tools/new_project_layout/templates/gitignore.in index a80bc026..df44f360 100644 --- a/tools/new_project_layout/templates/gitignore.in +++ b/tools/new_project_layout/templates/gitignore.in @@ -79,3 +79,7 @@ starvation_dump.jsonl /*.png !/catalog_identity.json !/disc_probe.json +# Multi-disc: per-disc identity probes and the verified set verdict. +!/disc_probe.[0-9].json +!/disc_probe.[0-9][0-9].json +!/disc_set.json diff --git a/tools/new_project_layout/verify_disc_set.py b/tools/new_project_layout/verify_disc_set.py new file mode 100644 index 00000000..003fd985 --- /dev/null +++ b/tools/new_project_layout/verify_disc_set.py @@ -0,0 +1,278 @@ +#!/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. +PROGRAM_FIELDS = ( + "serial", + "boot_exe", + "entry_pc", + "load_address", + "text_size", + "stack_base", +) + +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 ''}" + 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] + 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() + print( + f" all {count} discs boot {first.get('boot_exe')} " + f"({first.get('serial')}) — one program covers the set." + ) + print( + " Scaffolding that program now. Mounting the later discs at " + "runtime still needs docs/MULTI_DISC.md P1 + P3; the runtime " + "mounts the boot disc only today." + ) + + 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)