tools: verify a multi-disc set at scaffold time

setup_project.sh takes --disc once per disc now (in disc order, boot disc
first); the .ps1 takes the array form -Disc a.cue,b.cue because PowerShell
parameters cannot be repeated. This is the verification half of MULTI_DISC.md
P7 only — no per-disc [[game.disc]], no per-program seeds, no N-program build.

New verify_disc_set.py consumes probe_disc.py JSON dumps and answers one
question: can the framework build this set today? A set whose discs all boot
the same program scaffolds that one program and records each disc's identity;
a set whose discs each boot their own program is refused by name, because one
binary linking N recompiled programs is P2 and the game emitter still emits
unprefixed symbols that would collide at link. It also refuses the same image
passed twice and discs from different releases. The check runs before $ROOT
exists, so a refusal creates nothing rather than leaving a half-scaffold.

game.toml keeps the singular disc key: psxrecomp_cli.py generate reads only
that, and emitting `discs` would produce config the runtime honours by mounting
discs.front() and silently dropping the rest.

probe_disc.py gains --identity-only, which skips the data-track md5/sha1 pass
so verifying N discs does not hash every track twice. It is rejected alongside
--write-game-toml / --write-catalog, which need those digests.

Two things testing changed. The TOC fingerprint is derived from the track
layout, so two genuinely different discs of one release can share it; keying
duplicate detection on the fingerprint alone rejected a legitimate data-only
set, and the identity now also requires the volume id and data-track size to
match. A shared fingerprint across a set is instead reported as a warning,
since [netplay] required_disc_fp cannot tell those discs apart. Separately,
the template block files installed a second EXIT trap that would have replaced
the disc-set one and leaked its temp dir; both now share one cleanup function.

Verified end to end against synthesised cooked-2048 discs: three-disc
data-only scaffolds in order, N-programs / duplicate / mixed-release each
refuse with nothing created, and a single-disc run is unchanged and leaves no
disc_set.json. The .ps1 changes are unrun — no PowerShell on this machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017AwqKPQFnK3x7xQbK4PQKj
This commit is contained in:
Alex Vanderveen
2026-08-28 10:43:04 -04:00
parent 0c7f5475e1
commit 5d1f7be37f
7 changed files with 562 additions and 12 deletions
+35 -2
View File
@@ -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)`).
+11
View File
@@ -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
+25 -3
View File
@@ -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
+86 -4
View File
@@ -1,7 +1,11 @@
# New Project Layout scaffolding (Windows).
#
# Path params (pass on the CLI -- tab-complete in your shell):
# -Disc <cue> REQUIRED legal Redump .cue (kept on your dump drive)
# -Disc <cue>[,<cue>] 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> Parent directory (default: .)
# -Bios <SCPH1001.BIN> 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
}
+123 -3
View File
@@ -2,7 +2,9 @@
# New Project Layout scaffolding (Linux / macOS).
#
# Path flags (pass on the CLI — use your shell's path completion):
# --disc <cue> REQUIRED legal Redump .cue (kept on your dump drive)
# --disc <cue> 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> Parent directory for the new repo (default: .)
# --bios <SCPH1001.BIN> Optional retail BIOS for --generate / generate prompt
# --boot-exe <name> 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 <path-to-game.cue> 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
@@ -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
+278
View File
@@ -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 '<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]
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)