diff --git a/docs/DISC_COMPANIONS.md b/docs/DISC_COMPANIONS.md new file mode 100644 index 00000000..a89789db --- /dev/null +++ b/docs/DISC_COMPANIONS.md @@ -0,0 +1,92 @@ +# Disc subchannel companions + +An SBI companion contains disc subchannel metadata that a main-track image can omit. +`tools/prepare_disc.py` retains a user-supplied companion when it copies or converts a disc. +`psxrecomp_cli.py verify-disc` and `generate` check it before generation. +These tools do not supply, download, or license SBI files. + +## Supply a companion + +1. Obtain the matching SBI file through a lawful source. +2. Put it beside the input image with the same basename and the `.sbi` extension. +3. For CUE input, use the CUE basename, even when its first BIN has a different name. +4. Run preparation or verification again. + +For example, `Album.cue` uses `Album.sbi`. +Preparation accepts `.SBI` too, then writes the output CUE basename with a lowercase `.sbi` extension. +It retains the companion bytes unchanged, including when the output CUE has a new name. +The runtime loads that companion beside the mounted CUE or image. +An SBI beside the first track does not substitute for one beside the selected CUE. + +Preparation refuses conflicting output companions before it writes disc files. +Use an empty output directory or move the conflicting companion before retrying. +An identical existing companion remains untouched. +Concurrent preparation into the same directory is unsupported. + +## Exact revision coverage + +The registry in `tools/disc_companion.py` requires both measured main-track size and SHA-1. +It does not infer protection from a region, filename, or configured serial. +The initial supported identity is: + +| Field | Measured identity | +|---|---| +| Title / serial | Resident Evil 3: Nemesis / SLES-02529 | +| Revision key | Europe; exact data track below, no claim for other pressings | +| Main-track size | `712491360` bytes | +| Main-track SHA-1 | `b3ec6631e1c9ed3b0f554b3a5f61166bac8ebb9d` | +| Qualified SBI SHA-256 | `ada8877a2a964eff2743d53cc043be0b7b148469b60de82fd1069f32700670eb` | + +For that identity, a missing or different SBI stops setup with an actionable error. +`--skip-hash-check` does not bypass the companion gate. +The matching companion must also pass the supported nonempty SBI type-1 record checks. +A different valid SBI representation needs maintainer qualification before this gate accepts it. + +Unknown identities remain `unknown` when no SBI exists. +A supplied, structurally valid SBI for an unknown identity is `format_valid_unqualified`. +Neither result proves complete subchannels or compatibility with that revision. +Cooked ISO and 2448-byte conversions preserve supplied SBI files but do not inherit the raw-track registry identity. +The existing 2448-byte conversion discards embedded subchannels; this change does not extract them into SBI. +CHD preparation and automatic protection detection for other revisions are outside this tool's coverage. + +## Receipts and callers + +Preparation writes `.disc-receipt.json` beside the output. +It records the source image, measured data-track hashes, output CUE, and separate `subchannel` identity. +The companion record contains its source path, byte size, SHA-256, record count, status, and output path. +For a qualified revision, it also contains the registry identity and evidence reference. +Keep this receipt private with the user's inputs because it includes local paths. + +The existing `RESULT_CUE=` output remains unchanged. +CLI disc results add the same `subchannel` record. +The legacy `verified` field describes only the existing main-track check; it never proves complete subchannels. +Missing required companions return preparation exit code 1 or CLI verification exit code 3. +No configuration keys or graphical file selectors changed. +Existing setup hosts need the updated Python tools, including `tools/disc_companion.py`, to receive this gate. +Direct runtime launches and older installed setup kits retain their existing behavior. + +## Provenance and validation + +The registry metadata comes from the private 2026-09-05 SLES-02529 same-executable control and operator acceptance. +The unchanged Wave 3 source was `63446a28111a21a0cb7e8b5106c3614c898fc4b5`. +The accepted executable SHA-256 was `131e8ecbd18a425ba2b5b49a30133fc0afed888af13666b653b3b5e927f3b0fe`. +It closed normally at frame 14841 with `sdl_window_close` after the operator reported correct behavior. +The same main track without SBI stalled at the warning screen. +The private portfolio report and operator receipt retain the detailed evidence; no retail or SBI payload accompanies this source. +That technical acceptance does not grant redistribution rights for the companion. + +Primary format reference: [PSX-SPX CDROM format](https://psx-spx.consoledev.net/cdromformat/#cdrom-protection-libcrypt). +The existing runtime reader supplies the type-1 parsing contract in `ISOReader::LoadSBICompanion`. +Runtime support came from `cebe738fd17cc4c37a76729462e093ff16ee457a`. +Upstream `17f49ad3b20dc30917a881a02baaa25374c13d18` already contains that commit, but lacks the setup gate and copy step. +This change starts from that upstream identity and changes no runtime code. + +Run the source-owned setup tests from the framework root: + +```sh +python tools/tests/test_disc_companion.py -v +``` + +The fixtures create synthetic ISO directory records, an inert executable header, audio bytes, and SBI records. +Tests bind their own synthetic revision through an in-memory registry entry. +No fixture copies retail game data, BIOS data, or a third-party SBI. diff --git a/docs/LOCAL_CODEGEN_SDK.md b/docs/LOCAL_CODEGEN_SDK.md index cfeff466..e51c8363 100644 --- a/docs/LOCAL_CODEGEN_SDK.md +++ b/docs/LOCAL_CODEGEN_SDK.md @@ -83,6 +83,10 @@ python psxrecomp/psxrecomp_cli.py pgo-train \ [--disc …] [--train-secs 120] [--train-runs 3] [--json-progress] ``` +Disc verification and preparation also check and retain user-supplied +[SBI companions](DISC_COMPANIONS.md). Main-track hashes alone do not prove +complete subchannel input. + `generate` normalizes the dump via `tools/prepare_disc.py` when needed, then runs `psxrecomp-game --config game.toml` into `[recompiler] out_dir`. diff --git a/docs/config_schema.md b/docs/config_schema.md index 88747aca..0454d4e1 100644 --- a/docs/config_schema.md +++ b/docs/config_schema.md @@ -60,6 +60,11 @@ How the two configs relate: [audit] ``` +`[prepare_disc]` digests identify the main track only. See +[disc companions](DISC_COMPANIONS.md) for the separate SBI input gate, +exact revision coverage, and preparation receipts. No SBI configuration key +is required. + ## Netplay disc mount (`[netplay]`) Optional. Online play needs the same CD geometry on every peer — data-track diff --git a/psxrecomp_cli.py b/psxrecomp_cli.py index bc828055..2339242f 100755 --- a/psxrecomp_cli.py +++ b/psxrecomp_cli.py @@ -28,6 +28,7 @@ from typing import Any, Optional ROOT = Path(__file__).resolve().parent sys.path.insert(0, str(ROOT / "tools")) from sdk_progress import ProgressReporter # noqa: E402 +from disc_companion import CompanionError, inspect_companion # noqa: E402 from toolchain_pack import ( # noqa: E402 ensure_toolchain as _ensure_toolchain_pack, resolve_toolchain_bin, @@ -845,12 +846,17 @@ def verify_disc_path( if path.suffix.lower() == ".cue": path = resolve_cue_bin(path) md5, sha1, size = file_hashes(path) + try: + subchannel, _ = inspect_companion(disc, size, sha1) + except CompanionError as exc: + raise DiscVerifyError(str(exc)) from exc identity = { "path": str(path), "md5": md5, "sha1": sha1, "size": size, "verified": False, + "subchannel": subchannel, } progress.event("disc", **identity) sizes = [int(s) for s in (prep.get("known_sizes") or [])] @@ -906,7 +912,8 @@ def cmd_verify_disc(args: argparse.Namespace, progress: ProgressReporter) -> int except DiscVerifyError as exc: progress.error(str(exc), code=EXIT_VERIFY, verify_failed=True) return EXIT_VERIFY - progress.phase("done", pct=1.0, message="Disc OK") + progress.phase("done", pct=1.0, + message=f"Main track accepted; subchannel status: {identity['subchannel']['status']}") progress.result(ok=True, **identity) return EXIT_OK diff --git a/tools/disc_companion.py b/tools/disc_companion.py new file mode 100644 index 00000000..93d5f1fd --- /dev/null +++ b/tools/disc_companion.py @@ -0,0 +1,119 @@ +"""Exact-revision SBI intake. No media payloads or network access. + +The registry contains measured identities, not a region-based protection list. +See docs/DISC_COMPANIONS.md for provenance and coverage limits. +""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + + +class CompanionError(ValueError): + pass + + +# Key: (main-track byte size, SHA-1). Values are metadata, never SBI records. +REQUIRED_SBI = { + (712491360, "b3ec6631e1c9ed3b0f554b3a5f61166bac8ebb9d"): { + "title": "Resident Evil 3: Nemesis", + "serial": "SLES-02529", + "revision": "Europe, data-track SHA-1 b3ec6631e1c9ed3b0f554b3a5f61166bac8ebb9d", + "sbi_sha256": "ada8877a2a964eff2743d53cc043be0b7b148469b60de82fd1069f32700670eb", + "evidence": "RE3 same-executable operator acceptance, 2026-09-05; docs/DISC_COMPANIONS.md", + }, +} + + +def validate_sbi(data: bytes) -> int: + """Check the nonempty type-1 format accepted by ISOReader::LoadSBICompanion.""" + if data[:4] != b"SBI\0" or len(data) < 18 or (len(data) - 4) % 14: + raise CompanionError("Invalid SBI: expected a header and complete type-1 records.") + frames: set[int] = set() + for offset in range(4, len(data), 14): + record = data[offset:offset + 14] + if record[3] != 1 or any((b & 15) > 9 or (b >> 4) > 9 for b in record[:3]): + raise CompanionError("Invalid SBI: unsupported record type or invalid BCD position.") + mm, ss, ff = [(b >> 4) * 10 + (b & 15) for b in record[:3]] + frame = mm * 4500 + ss * 75 + ff + if ss >= 60 or ff >= 75 or frame < 150 or frame in frames: + raise CompanionError("Invalid SBI: out-of-range or duplicate disc position.") + frames.add(frame) + return len(frames) + + +def inspect_companion(image: Path, size: int, sha1: str) -> tuple[dict, bytes | None]: + """Inspect only the selected image's companion; never guess from another file. + + CUE input uses the CUE basename, matching the runtime mount contract. + Case-insensitive discovery permits .SBI on case-sensitive source volumes. + """ + image = image.resolve() + expected = image.with_suffix(".sbi") + requirement = REQUIRED_SBI.get((size, sha1.lower())) + matches = [p for p in image.parent.iterdir() if p.name.casefold() == expected.name.casefold()] + if len(matches) > 1: + raise CompanionError(f"Ambiguous SBI companions for {image}: {matches}. Keep one matching file.") + report = { + "status": "missing" if requirement else "unknown", + "required": bool(requirement), + "revision": requirement, + "expected_path": str(expected), + } + if not matches: + if requirement: + raise CompanionError( + f"Missing SBI for {requirement['title']} ({requirement['serial']}, " + f"{requirement['revision']}). Supply your lawfully obtained matching file at " + f"{expected}, then retry. A matching main-track hash does not include subchannel data. " + "Setup does not supply or download SBI files." + ) + return report, None + companion = matches[0] + try: + # A type-1 SBI cannot exceed one record per addressable BCD disc frame. + if companion.stat().st_size > 63000004: + raise CompanionError(f"SBI exceeds the supported size: {companion}") + data = companion.read_bytes() + except OSError as exc: + raise CompanionError(f"Cannot read SBI companion {companion}: {exc}") from exc + count = validate_sbi(data) + digest = hashlib.sha256(data).hexdigest() + if requirement and digest != requirement["sbi_sha256"]: + raise CompanionError( + f"SBI identity is not qualified for {requirement['serial']} ({requirement['revision']}): " + f"{companion}. Expected SHA-256 {requirement['sbi_sha256']}; got {digest}. " + "Supply the matching lawful companion or ask the port maintainer to qualify this variant." + ) + report.update(status="verified" if requirement else "format_valid_unqualified", + path=str(companion), sha256=digest, size=len(data), records=count) + return report, data + + +def check_destination(cue: Path, data: bytes | None) -> None: + """Refuse stale or conflicting companions before preparation writes media.""" + expected = cue.with_suffix(".sbi") + if not expected.parent.exists(): + return + matches = [p for p in expected.parent.iterdir() if p.name.casefold() == expected.name.casefold()] + if len(matches) > 1: + raise CompanionError(f"Ambiguous output SBI companions for {cue}. Keep only {expected}.") + for path in matches: + if data is None or not path.is_file() or path.read_bytes() != data: + raise CompanionError(f"Conflicting output SBI: {path}. Use an empty output directory or move this file, then retry.") + if path.name != expected.name and not expected.exists(): + raise CompanionError(f"Output SBI uses a different filename case: {path}. Rename it to {expected}, then retry.") + + +def stage_companion(cue: Path, report: dict, data: bytes | None) -> dict: + check_destination(cue, data) + result = dict(report) + if data is not None: + destination = cue.with_suffix(".sbi") + if not destination.exists(): + # Exclusive creation protects a companion installed since preflight. + with destination.open("xb") as stream: + stream.write(data) + result["output_path"] = str(destination.resolve()) + return result diff --git a/tools/prepare_disc.py b/tools/prepare_disc.py index f3e39f28..ea1e62fe 100644 --- a/tools/prepare_disc.py +++ b/tools/prepare_disc.py @@ -23,6 +23,7 @@ from __future__ import annotations import argparse import hashlib +import json import re import shutil import struct @@ -30,6 +31,8 @@ import sys from dataclasses import dataclass, field from pathlib import Path +from disc_companion import CompanionError, check_destination, inspect_companion, stage_companion + DST_SEC = 2352 SRC_2448 = 2448 USER = 2048 @@ -549,8 +552,7 @@ def main() -> int: print(f"source not found: {src}", file=sys.stderr) return 1 - cfg.out_dir.mkdir(parents=True, exist_ok=True) - + selected_image = src cue_src: Path | None = None cue_bins: list[Path] = [] if src.suffix.lower() == ".cue": @@ -570,6 +572,14 @@ def main() -> int: kind = detect_kind(src, src_size) + # Bind the selected CUE basename, not its first track's basename. + try: + companion, companion_data = inspect_companion(selected_image, src_size, src_sha1) + check_destination(cfg.out_dir / cfg.cue_name, companion_data) + except CompanionError as exc: + print(str(exc), file=sys.stderr) + return 1 + known_hit = matches_known(cfg, src_size, src_md5, src_sha1) if cfg.known and not cfg.skip_hash_check: if known_hit: @@ -589,6 +599,22 @@ def main() -> int: elif not cfg.known and not cfg.skip_hash_check and kind == "bin2352": print(" no prepare_disc.known_* configured - verifying boot EXE only") + cfg.out_dir.mkdir(parents=True, exist_ok=True) + + def finish(cue_path: Path) -> None: + report = stage_companion(cue_path, companion, companion_data) + receipt = { + "schema": "psxrecomp-disc-preparation-v1", + "source_image": str(selected_image), + "source_data_track": {"path": str(src), "size": src_size, "sha1": src_sha1, "md5": src_md5}, + "output_cue": str(cue_path.resolve()), + "subchannel": report, + } + cue_path.with_suffix(".disc-receipt.json").write_text( + json.dumps(receipt, indent=2) + "\n", encoding="utf-8") + print(f"subchannel: {report['status']}") + print(f"RESULT_CUE={cue_path.resolve()}") + # Multi-track Redump: keep the cue + every track bin so CDDA works. if cue_src is not None and len(cue_bins) > 1 and kind == "bin2352": print(f" preserving multi-track Redump set ({len(cue_bins)} files)") @@ -609,7 +635,7 @@ def main() -> int: print(f" size {out_size}") print(f" md5 {out_md5}") print(f" sha1 {out_sha1}") - print(f"RESULT_CUE={cue_path}") + finish(cue_path) return 0 if kind == "bin2352": @@ -664,7 +690,7 @@ def main() -> int: elif kind == "raw2448": print(" trimmed from 2448-byte/sector dump") - print(f"RESULT_CUE={cue_path.resolve()}") + finish(cue_path) return 0 diff --git a/tools/tests/test_disc_companion.py b/tools/tests/test_disc_companion.py new file mode 100644 index 00000000..227af399 --- /dev/null +++ b/tools/tests/test_disc_companion.py @@ -0,0 +1,206 @@ +"""Source-owned disc/SBI fixtures. No retail sectors, executables or SBI records.""" +import contextlib +import argparse +import hashlib +import io +import json +from pathlib import Path +import struct +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[2] +sys.path[:0] = [str(ROOT), str(ROOT / "tools")] +import disc_companion as dc +import prepare_disc as prepare +import psxrecomp_cli as cli +from sdk_progress import ProgressReporter + +SBI = b"SBI\0" + bytes([0, 2, 0, 1]) + bytes(range(10)) +BOOT = "SLES_000.00" + + +def cooked_disc(): + sectors = [bytearray(2048) for _ in range(24)] + sectors[16][1:6] = b"CD001" + struct.pack_into("