#!/usr/bin/env python3 """Normalize a PS1 disc dump to MODE2/2352 .bin/.cue for psxrecomp games. Framework tool — game identity comes from ``game.toml`` (``[game]`` + optional ``[prepare_disc]``) or CLI flags. Accepted inputs: * Redump / raw MODE2/2352 ``.bin`` (+ ``.cue``), including multi-track Redump sets (data Track 01 + AUDIO tracks) — the full cue/bin set is preserved so CDDA survives into ``out_dir`` * Cooked 2048-byte/sector ``.iso`` (RomM-style libraries) * 2448-byte/sector raw dumps (legacy MotK ISO conversion): trim to 2352 Writes working ``.bin`` + ``.cue`` (or copies a multi-track Redump set), extracts ``SYSTEM.CNF`` + boot EXE, and prints ``RESULT_CUE=`` for the first-run wizard / RetComM. ISO→2352 sets Mode2 Form1 sync/header/subheader/EDC (ECC zeroed — fine for software readers). Rebuilt images are not bit-identical to Redump. """ from __future__ import annotations import argparse import hashlib import re import shutil import struct import sys from dataclasses import dataclass, field from pathlib import Path DST_SEC = 2352 SRC_2448 = 2448 USER = 2048 USER_OFF = 24 SYNC = bytes([0x00] + [0xFF] * 10 + [0x00]) def _configure_stdio() -> None: """Avoid UnicodeEncodeError on Windows consoles (cp1252 / Store Python).""" for stream in (sys.stdout, sys.stderr): reconf = getattr(stream, "reconfigure", None) if callable(reconf): try: reconf(errors="replace") except Exception: # noqa: BLE001 — best-effort stdio harden pass def _build_edc_table() -> list[int]: table = [] for i in range(256): x = i for _ in range(8): x = (x >> 1) ^ 0xD8018001 if (x & 1) else (x >> 1) table.append(x & 0xFFFFFFFF) return table _EDC_TABLE = _build_edc_table() def _adjust_edc(buf: bytearray, start: int, length: int) -> None: x = 0 end = start + length for i in range(start, end): x = _EDC_TABLE[(x ^ buf[i]) & 0xFF] ^ (x >> 8) struct.pack_into(" bytes: if len(user) != USER: raise ValueError("user data must be 2048 bytes") sector = bytearray(DST_SEC) sector[0:12] = SYNC t = lba + 150 mm, ss, ff = t // (75 * 60), (t // 75) % 60, t % 75 sector[12] = ((mm // 10) << 4) | (mm % 10) sector[13] = ((ss // 10) << 4) | (ss % 10) sector[14] = ((ff // 10) << 4) | (ff % 10) sector[15] = 0x02 sub = bytes([0x00, 0x00, submode & 0xFF, 0x00]) sector[16:20] = sub sector[20:24] = sub sector[24 : 24 + USER] = user _adjust_edc(sector, 0x10, 0x800 + 8) return bytes(sector) @dataclass class KnownImage: size: int = 0 md5: str = "" sha1: str = "" @dataclass class PrepareConfig: project_root: Path out_dir: Path bin_name: str cue_name: str boot_exe: str serial: str = "" known: list[KnownImage] = field(default_factory=list) skip_hash_check: bool = False def file_hashes(path: Path) -> tuple[str, str, int]: h_md5 = hashlib.md5() h_sha1 = hashlib.sha1() size = 0 with open(path, "rb") as f: while True: chunk = f.read(1024 * 1024) if not chunk: break size += len(chunk) h_md5.update(chunk) h_sha1.update(chunk) return h_md5.hexdigest(), h_sha1.hexdigest(), size def _parse_array_items(inner: str) -> list[object]: items: list[object] = [] if not inner.strip(): return items for part in re.split(r",(?=(?:[^\"]*\"[^\"]*\")*[^\"]*$)", inner): part = part.strip().rstrip(",").strip() if not part: continue if part.startswith('"') and part.endswith('"'): items.append(part[1:-1]) else: try: items.append(int(part, 0)) except ValueError: items.append(part) return items def _parse_toml_simple(text: str) -> dict[str, dict[str, str]]: """Minimal TOML reader for flat string/int/bool + string/int arrays. Supports multi-line arrays (``key = [ … ]`` spanning lines). """ sections: dict[str, dict[str, object]] = {"": {}} cur = "" array_key: str | None = None array_buf: list[str] = [] for raw in text.splitlines(): line = raw.split("#", 1)[0].strip() if array_key is not None: array_buf.append(line) joined = " ".join(array_buf) if "]" in joined: inner = joined.split("]", 1)[0] # Drop leading "[" already consumed when array opened if inner.startswith("["): inner = inner[1:] sections[cur][array_key] = _parse_array_items(inner) array_key = None array_buf = [] continue if not line: continue if line.startswith("[") and line.endswith("]"): cur = line[1:-1].strip() sections.setdefault(cur, {}) continue if "=" not in line: continue key, val = line.split("=", 1) key = key.strip() val = val.strip() if val.startswith("["): if val.endswith("]") and val.count("[") == val.count("]"): sections[cur][key] = _parse_array_items(val[1:-1]) else: array_key = key array_buf = [val[1:]] # after opening [ continue if val.startswith('"') and val.endswith('"'): sections[cur][key] = val[1:-1] elif val.lower() in ("true", "false"): sections[cur][key] = val.lower() == "true" else: try: sections[cur][key] = int(val, 0) except ValueError: sections[cur][key] = val return sections # type: ignore[return-value] def load_config(project_root: Path, config_path: Path | None, out_dir_cli: str | None, skip_hash: bool) -> PrepareConfig: project_root = project_root.resolve() game: dict = {} prep: dict = {} if config_path and config_path.is_file(): sections = _parse_toml_simple(config_path.read_text(encoding="utf-8")) game = sections.get("game", {}) or {} prep = sections.get("prepare_disc", {}) or {} boot = str(prep.get("boot_exe") or "") if not boot: exe = str(game.get("exe") or "") boot = Path(exe).name if exe else "" if not boot: boot = "SLUS_000.00" out_rel = str(prep.get("out_dir") or "prepared_disc") out_dir = Path(out_dir_cli).expanduser() if out_dir_cli else (project_root / out_rel) disc = str(game.get("disc") or prep.get("bin_name") or "game.bin") bin_name = str(prep.get("bin_name") or Path(disc).name) cue_name = str(prep.get("cue_name") or (Path(bin_name).stem + ".cue")) known: list[KnownImage] = [] sizes = prep.get("known_sizes") or [] md5s = prep.get("known_md5") or [] sha1s = prep.get("known_sha1") or [] if isinstance(sizes, list) or isinstance(md5s, list) or isinstance(sha1s, list): n = max( len(sizes) if isinstance(sizes, list) else 0, len(md5s) if isinstance(md5s, list) else 0, len(sha1s) if isinstance(sha1s, list) else 0, ) for i in range(n): known.append( KnownImage( size=int(sizes[i]) if isinstance(sizes, list) and i < len(sizes) else 0, md5=str(md5s[i]).lower() if isinstance(md5s, list) and i < len(md5s) else "", sha1=str(sha1s[i]).lower() if isinstance(sha1s, list) and i < len(sha1s) else "", ) ) return PrepareConfig( project_root=project_root, out_dir=out_dir.resolve(), bin_name=bin_name, cue_name=cue_name, boot_exe=boot, serial=str(game.get("id") or ""), known=known, skip_hash_check=skip_hash, ) def list_cue_bins(cue_path: Path) -> list[Path]: """Return every BINARY FILE referenced by a cue, in order.""" text = cue_path.read_text(encoding="utf-8", errors="replace") names = re.findall(r'FILE\s+"([^"]+)"\s+BINARY', text, flags=re.I) if not names: names = re.findall(r"FILE\s+(\S+)\s+BINARY", text, flags=re.I) if not names: raise SystemExit(f"no BINARY FILE in cue: {cue_path}") out: list[Path] = [] for name in names: cand = Path(name) if not cand.is_absolute(): cand = cue_path.parent / cand if not cand.is_file(): raise SystemExit(f"cue references missing bin: {cand}") out.append(cand.resolve()) return out def resolve_cue_bin(cue_path: Path) -> Path: """First BINARY file in the cue (data track for Redump multi-track sets).""" return list_cue_bins(cue_path)[0] def stage_multitrack_cue( cue_src: Path, bins: list[Path], out_dir: Path, cue_name: str ) -> Path: """Copy a multi-track Redump cue + all bin files into out_dir unchanged.""" out_dir.mkdir(parents=True, exist_ok=True) for bin_src in bins: dest = out_dir / bin_src.name if dest.resolve() == bin_src.resolve(): print(f"source already at {dest}") continue print(f"copying {bin_src.name} -> {dest}") shutil.copy2(bin_src, dest) cue_dest = out_dir / cue_name if cue_dest.resolve() != cue_src.resolve(): # Rewrite FILE lines to basenames so the cue stays portable if the # source used absolute/relative paths with directories. text = cue_src.read_text(encoding="utf-8", errors="replace") def _basename_file(m: re.Match[str]) -> str: return f'FILE "{Path(m.group(1)).name}" BINARY' text = re.sub( r'FILE\s+"([^"]+)"\s+BINARY', _basename_file, text, flags=re.I ) cue_dest.write_text(text, encoding="utf-8", newline="\n") print(f"wrote {cue_dest}") else: print(f"source cue already at {cue_dest}") return cue_dest.resolve() def detect_kind(path: Path, size: int) -> str: suf = path.suffix.lower() if suf == ".cue": return "cue" if suf == ".iso": return "iso" # MotK-style 2448 before generic 2352 (some sizes divisible by both). if size % SRC_2448 == 0 and size > SRC_2448 and size % DST_SEC != 0: return "raw2448" if size % SRC_2448 == 0 and size > SRC_2448: # Peek: if sync looks like CD at +0, treat as 2448 when user data path works. with open(path, "rb") as f: head = f.read(16) if head[:12] == SYNC: # Could be 2352 or 2448; check sector stride via second sync. fpos_2352_ok = False fpos_2448_ok = False with open(path, "rb") as f: f.seek(DST_SEC) fpos_2352_ok = f.read(12) == SYNC f.seek(SRC_2448) fpos_2448_ok = f.read(12) == SYNC if fpos_2448_ok and not fpos_2352_ok: return "raw2448" if size % DST_SEC == 0 and size > DST_SEC: with open(path, "rb") as f: sec0 = f.read(12) f.seek(16 * DST_SEC) sync16 = f.read(12) if sec0 == SYNC or sync16 == SYNC: return "bin2352" if size % USER == 0 and size > USER: return "iso" if size % SRC_2448 == 0 and size > SRC_2448: return "raw2448" raise SystemExit( f"unrecognized disc image {path} (size={size}). " "Need MODE2/2352 .bin/.cue, cooked 2048 .iso, or 2448-byte/sector dump." ) def read_user_bin(data: bytes, lba: int) -> bytes: off = lba * DST_SEC if off + DST_SEC > len(data): raise KeyError(lba) if data[off : off + 12] != SYNC: raise SystemExit(f"bad sync at LBA {lba}") return data[off + USER_OFF : off + USER_OFF + USER] def read_user_iso(data: bytes, lba: int) -> bytes: off = lba * USER if off + USER > len(data): raise KeyError(lba) return data[off : off + USER] def parse_root_entries(root: bytes) -> dict[str, tuple[int, int]]: entries: dict[str, tuple[int, int]] = {} i = 0 while i < len(root): reclen = root[i] if reclen == 0: i = ((i // USER) + 1) * USER if i >= len(root): break continue if i + reclen > len(root): break extent = struct.unpack_from(" tuple[dict[str, tuple[int, int]], dict[str, bytes]]: pvd = read_user(data, 16) if pvd[1:6] != b"CD001": raise SystemExit("PVD not found at LBA 16 (expected CD001)") root_extent = struct.unpack_from(" 0: sector = read_user(data, lba) take = min(USER, rem) out += sector[:take] rem -= take lba += 1 files[need] = bytes(out) if files[boot_exe][:8] != b"PS-X EXE": raise SystemExit(f"{boot_exe} is not a PS-X EXE") return entries, files def iso_to_bin(iso_data: bytes) -> bytes: if len(iso_data) % USER != 0: raise SystemExit(f"ISO size {len(iso_data)} is not a multiple of 2048") n = len(iso_data) // USER out = bytearray() for lba in range(n): user = iso_data[lba * USER : (lba + 1) * USER] out += encode_mode2_form1(user, lba, submode=0x08) return bytes(out) def raw2448_to_bin(src: Path) -> bytes: out = bytearray() n = 0 with open(src, "rb") as inf: while True: sec = inf.read(SRC_2448) if not sec: break if len(sec) != SRC_2448: raise SystemExit(f"truncated 2448 sector {n}: got {len(sec)} bytes") out += sec[:DST_SEC] n += 1 print(f" trimmed {n} sectors 2448 -> 2352") return bytes(out) def matches_known(cfg: PrepareConfig, size: int, md5: str, sha1: str) -> bool: if not cfg.known: return False md5 = md5.lower() sha1 = sha1.lower() for k in cfg.known: if k.md5 and k.md5.lower() == md5: if not k.size or k.size == size: return True if k.sha1 and k.sha1.lower() == sha1: if not k.size or k.size == size: return True return False def main() -> int: _configure_stdio() here = Path(__file__).resolve().parent # tools/ -> psxrecomp/ -> often game root is parent of psxrecomp fw_root = here.parent ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("source", nargs="?", default="", help="path to .iso / .cue / .bin") ap.add_argument( "--config", default="", help="game.toml (reads [game] + [prepare_disc])", ) ap.add_argument( "--project-root", default="", help="game project root (default: parent of config, else cwd)", ) ap.add_argument("--out-dir", default="", help="override prepare_disc.out_dir") ap.add_argument("--bin-name", default="", help="override output .bin basename") ap.add_argument("--cue-name", default="", help="override output .cue basename") ap.add_argument("--boot-exe", default="", help="override boot EXE name on disc") ap.add_argument( "--skip-hash-check", action="store_true", help="do not require prepare_disc.known_* digests", ) args = ap.parse_args() config_path = Path(args.config).expanduser() if args.config else None if config_path and not config_path.is_file(): print(f"config not found: {config_path}", file=sys.stderr) return 1 if args.project_root: project_root = Path(args.project_root).expanduser().resolve() elif config_path: project_root = config_path.parent.resolve() else: # Invoked from a game tree: prefer cwd if it has game.toml cwd = Path.cwd() project_root = cwd if (cwd / "game.toml").is_file() else fw_root.parent if not config_path: cand = project_root / "game.toml" if cand.is_file(): config_path = cand cfg = load_config(project_root, config_path, args.out_dir or None, args.skip_hash_check) if args.bin_name: cfg.bin_name = args.bin_name if args.cue_name: cfg.cue_name = args.cue_name if args.boot_exe: cfg.boot_exe = args.boot_exe if not args.source: # Convenience: existing working bin under out_dir cand = cfg.out_dir / cfg.bin_name if cand.is_file(): args.source = str(cand) else: print("source required (.iso / .cue / .bin)", file=sys.stderr) return 1 src = Path(args.source).expanduser().resolve() if not src.is_file(): print(f"source not found: {src}", file=sys.stderr) return 1 cfg.out_dir.mkdir(parents=True, exist_ok=True) cue_src: Path | None = None cue_bins: list[Path] = [] if src.suffix.lower() == ".cue": cue_src = src.resolve() cue_bins = list_cue_bins(cue_src) print(f"source cue: {cue_src} ({len(cue_bins)} BINARY file(s))") src = cue_bins[0] print(f"data track: {src.name}") src_md5, src_sha1, src_size = file_hashes(src) print(f"source: {src}") print(f" size {src_size}") print(f" md5 {src_md5}") print(f" sha1 {src_sha1}") if cfg.serial: print(f" game {cfg.serial} boot={cfg.boot_exe}") kind = detect_kind(src, src_size) known_hit = matches_known(cfg, src_size, src_md5, src_sha1) if cfg.known and not cfg.skip_hash_check: if known_hit: print(" known digest OK") elif kind in ("bin2352", "raw2448"): print( "source digests are not in prepare_disc.known_* " "(pass --skip-hash-check to force)", file=sys.stderr, ) return 1 else: print( "ISO digests are not in prepare_disc.known_*; " "verifying boot EXE only." ) elif not cfg.known and not cfg.skip_hash_check and kind == "bin2352": print(" no prepare_disc.known_* configured - verifying boot EXE only") # 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)") bin_data = src.read_bytes() entries, files = extract_via(read_user_bin, bin_data, cfg.boot_exe) print(f" root entries: {sorted(entries)[:24]}") pc0 = struct.unpack_from(" MODE2/2352...") bin_data = iso_to_bin(iso_data) extract_via(read_user_bin, bin_data, cfg.boot_exe) elif kind == "raw2448": print(" converting 2448 -> 2352...") bin_data = raw2448_to_bin(src) entries, files = extract_via(read_user_bin, bin_data, cfg.boot_exe) else: print(f"unsupported kind {kind}", file=sys.stderr) return 1 print(f" root entries: {sorted(entries)[:24]}") pc0 = struct.unpack_from("