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.
231 lines
7.9 KiB
Python
Executable File
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()) |