mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-28 02:56:36 -04:00
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:
@@ -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)`).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user