#!/usr/bin/env python3 """ Identify a Ratchet & Clank: Going Commando disc image. Reads only the ISO 9660 structures, SYSTEM.CNF and the boot ELF, so it needs a few megabytes of seeks rather than a full read. Reports the serial, version and video mode, and warns when the disc is not the build our references target. python3 tools/identify-disc.py python3 tools/identify-disc.py --verbose Only your own legally obtained disc is read. Nothing is written anywhere. """ from __future__ import annotations import argparse import hashlib import struct import sys import zlib from dataclasses import dataclass, field from pathlib import Path SECTOR = 2048 # The build OpenRAC/rac2-gc-decomp pins, and the one our reference material # describes. Boot-ELF SHA-1 below is measured from a retail v1.01 image. TARGET_SERIAL = "SCUS_972.68" TARGET_VERSION = "1.01" TARGET_ELF_SHA1 = "bacda860cbd6723142e86db56f197e47e728970f" # Serial -> (game, edition) for the R&C discs we can recognise. KNOWN_TITLES = { "SCUS_972.68": ("Going Commando (USA)", "Black Label v1.01"), "SCUS_972.68GH": ("Going Commando (USA)", "Greatest Hits v2.00 - DIFFERENT TARGET"), "SCES_516.07": ("Going Commando (PAL)", "Locked and Loaded v1.00 - 50 Hz"), "SCPS_150.56": ("Going Commando (JP)", "Gagaga! Ginga no Commando-ssu"), "SCKA_200.11": ("Going Commando (KR)", ""), "SCUS_971.99": ("Ratchet & Clank (USA)", "the first game, not Going Commando"), "SCES_509.16": ("Ratchet & Clank (PAL)", "the first game, not Going Commando"), } class NotAnIso(Exception): pass @dataclass class Iso: """Minimal read-only ISO 9660 view: enough to find the root directory.""" path: Path volume_id: str = "" root_lba: int = 0 root_size: int = 0 files: dict[str, tuple[int, int]] = field(default_factory=dict) def read(self, lba: int, size: int) -> bytes: with open(self.path, "rb") as fh: fh.seek(lba * SECTOR) data = fh.read(size) if len(data) < size: raise NotAnIso( f"image is truncated: wanted {size} bytes at LBA {lba}, got {len(data)}" ) return data def open_iso(path: Path) -> Iso: size = path.stat().st_size with open(path, "rb") as fh: magic = fh.read(SECTOR * 16 + 6) # The CD001 signature sits at offset 0x8001 in the Primary Volume Descriptor. if magic[0x8001:0x8006] != b"CD001": # A 2352-byte raw dump has a different layout; we only support .iso. head = magic[:5] raise NotAnIso( "not an ISO 9660 image (no CD001 signature at 0x8001); " "only plain 2048-byte-sector .iso dumps are supported" ) iso = Iso(path=path) with open(path, "rb") as fh: fh.seek(16 * SECTOR) pvd = fh.read(SECTOR) iso.volume_id = pvd[40:72].decode("latin1").strip() root = pvd[156:190] iso.root_lba = struct.unpack_from(" str: for line in cnf.splitlines(): if "=" not in line: continue k, v = line.split("=", 1) if k.strip() == key: return v.split(";")[0].strip() return "" def elf_summary(blob: bytes) -> str: if len(blob) < 52 or blob[:4] != b"\x7fELF": return "not an ELF" bits = {1: "ELF32", 2: "ELF64"}.get(blob[4], "?") endian = {1: "little", 2: "big"}.get(blob[5], "?") machine = struct.unpack_from(" int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("iso", type=Path, help="path to your own disc image (.iso)") ap.add_argument("--verbose", "-v", action="store_true", help="list root directory") args = ap.parse_args() if not args.iso.is_file(): print(f"error: {args.iso} is not a file", file=sys.stderr) return 2 try: iso = open_iso(args.iso) except NotAnIso as exc: print(f"error: {exc}", file=sys.stderr) return 2 print("Disc identification") print("=" * 62) print(f" file {args.iso.name}") print(f" size {args.iso.stat().st_size:,} bytes") print(f" volume id {iso.volume_id or '(none)'}") if "SYSTEM.CNF" not in iso.files: # A truncated dump still has a valid PVD, so distinguish "damaged" # from "genuinely not a PS2 disc". if iso.volume_id: print( f"\n SYSTEM.CNF is missing but the image declares volume id " f"{iso.volume_id!r}, which means the dump is truncated or damaged.", file=sys.stderr, ) return 4 print("\n no SYSTEM.CNF: not a PlayStation 2 game disc", file=sys.stderr) return 3 lba, size = iso.files["SYSTEM.CNF"] cnf = iso.read(lba, size).decode("latin1") boot = cnf_value(cnf, "BOOT2").replace("cdrom0:\\", "").replace("\\", "/") serial = boot.rsplit("/", 1)[-1] version = cnf_value(cnf, "VER") vmode = cnf_value(cnf, "VMODE") print(f" BOOT2 {boot}") print(f" serial {serial}") print(f" version {version or '(absent)'}") print(f" video mode {vmode or '(absent)'}") if serial in KNOWN_TITLES: title, edition = KNOWN_TITLES[serial] print(f" title {title}") if edition: print(f" edition {edition}") if boot in iso.files: blba, bsize = iso.files[boot] elf = iso.read(blba, bsize) sha1 = hashlib.sha1(elf).hexdigest() print() print("Boot executable") print("-" * 62) print(f" size {bsize:,} bytes") print(f" SHA-1 {sha1}") print(f" CRC-32 {zlib.crc32(elf)}") print(f" format {elf_summary(elf)}") else: print("\n the boot executable named by SYSTEM.CNF is not in the root directory") sha1 = None if args.verbose: print() print("Root directory") print("-" * 62) for name in sorted(iso.files): l, s = iso.files[name] print(f" {name:24s} LBA {l:>8,} {s:>12,} bytes") print() print("=" * 62) is_target = serial == TARGET_SERIAL and version == TARGET_VERSION if is_target and sha1 == TARGET_ELF_SHA1: print(" This is the build we target: USA Black Label v1.01, NTSC 60 Hz.") print(" Expected SHA-1 of the boot executable matches.") elif serial == TARGET_SERIAL: print(f" Serial matches, but version is {version or 'unknown'}" f" and we target {TARGET_VERSION}.") print(" Greatest Hits v2.00 is a different build and will not match") print(" the reference material. Everything else should still work.") else: print(f" Not the target build. We target {TARGET_SERIAL} v{TARGET_VERSION}.") if vmode == "PAL": print(" PAL disc: the game runs at 50 Hz, so movement runs at 5/6") print(" speed. Plan timing constants accordingly.") return 0 if __name__ == "__main__": raise SystemExit(main())