Files
ReaperOfVeriod a02f79a6d1 Initialise native Rust port of Ratchet & Clank: Going Commando
Sets up the foundation for reimplementing the PS2 game in Rust rather than
emulating it. Nothing is implemented yet; this is the scaffold, the verified
target and the working agreements that keep a long project coherent.

Target disc, verified against a real image:

  SCUS_972.68 v1.01, USA Black Label, NTSC 60 Hz
  boot ELF 2,618,684 bytes, SHA-1 bacda860...d098a77, entry 0x00131AE8

The entry point independently matches the value recorded by the rac2-gc-decomp
community reference, which cross-confirms the identification. This is the exact
build the upstream research pins; Greatest Hits v2.00 is a different binary and
is not the target. NTSC matters beyond label compatibility: PAL discs run 50 Hz,
which would make chargeboots and other speed-sensitive movement run at 5/6
speed. Verifying the disc before writing movement code avoids discovering that
in Phase 5.

Contents:

- Five-crate workspace (formats, extract, game, video, engine) on Bevy 0.19.1,
  matching the version ReRAC uses so its Graphics Synthesizer and VU work stays
  portable. `cargo check --workspace` is clean.

- tools/identify-disc.py, which reproduces the identification against any
  disc image and distinguishes a truncated dump from a non-PS2 image.

- .githooks/pre-commit, refusing game data from entering git history: disc
  image extensions, asset directories, container signatures at offset 0, and
  files over 50 MiB. Verified against seven cases including a disc image
  renamed to look like source. Now that this pushes to a remote, this matters
  more than it would have locally.

- AGENTS.md, the operating agreements: no game data ever, reproduce the PS2
  float model rather than approximating it, bound every read, verify against
  the real disc.

- PROVENANCE.md, recording that this follows ReRAC (ISC) and learns from
  rac2-gc-decomp (MIT). Wrench is used as a written specification only and no
  GPLv3 code is copied, since that would relicense everything derived from it.
  RAC2Decomp is explicitly not used: its provenance is disputed in the upstream
  community reference, and it targets the wrong region.

- docs/roadmap.md, phased so the riskiest unknowns are retired early. The two
  that matter: overlay relocation transfers from ReRAC and makes 27 level
  overlays tractable, while VU1 microcode is binary and is the hardest single
  item. Phase 5 is a deliberately scoped Veldin vertical slice as the go/no-go.
2026-10-09 15:42:10 +02:00

231 lines
7.9 KiB
Python
Executable File

#!/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 <disc.iso>
python3 tools/identify-disc.py --verbose <disc.iso>
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("<I", root, 2)[0]
iso.root_size = struct.unpack_from("<I", root, 10)[0]
fh.seek(iso.root_lba * SECTOR)
data = fh.read(iso.root_size)
off = 0
while off < len(data):
rec_len = data[off]
if rec_len == 0:
# Records never span a logical block; skip to the next one.
off = (off // SECTOR + 1) * SECTOR
continue
name_len = data[off + 32]
lba = struct.unpack_from("<I", data, off + 2)[0]
fsize = struct.unpack_from("<I", data, off + 10)[0]
name = data[off + 33:off + 33 + name_len].split(b";")[0].split(b"\x00")[0]
iso.files[name.decode("latin1")] = (lba, fsize)
off += rec_len
return iso
def cnf_value(cnf: str, key: str) -> 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("<H", blob, 18)[0]
entry = struct.unpack_from("<I", blob, 24)[0]
machine_name = {8: "MIPS (EM_MIPS)", 40: "PowerPC", 20: "PPC"}.get(machine, str(machine))
return f"{bits} {endian}-endian, {machine_name}, entry 0x{entry:08X}"
def main() -> 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())