Files
psxrecomp/tools/prepare_disc.py
T
Alex Vanderveen 922e8c5a07 Rename the product from RetComM to Retro
The launcher and studio ship as Retro Launcher and Retro Studio now,
from the RetroPortingToolKit org, so the old product name is gone from
display text, documentation and release asset filenames.

The split is by case, and it is not cosmetic. CamelCase RetComM was
always the product; lowercase retcomm and RETCOMM_ are identifiers --
the C++ namespace, the binaries, the data directories, the CMake and
environment variables, the Cloudflare service names, the readme markers.
Renaming those would orphan existing installs for no benefit, so they
are untouched: 1101 lowercase retcomm and 205 RETCOMM_ still stand in
the launcher alone.

Four things are deliberately still spelled RetComM, and three of them
would have been real breakage:

- RetComM-Data is CamelCase but it is a directory on disk --
  <exe_dir>\RetComM-Data\{runtime,config,data} for a portable install.
  Renaming it strands every portable user's config, toolchains and
  catalog cache.

- TechnicallyComputers/RetComM-Launcher in readme_has_launcher() is the
  historical half of a both-slugs test. Renaming it makes it a duplicate
  of the line above and sends every port built before the transfer back
  to reporting its launcher section missing.

- _LEGACY_LAUNCHER_RE matched "^## RetComM Launcher". A sweep renamed it,
  which would have silently stopped it matching every README already out
  there; it now accepts both headings.

- The portable-stub lookup gained the pre-rename filenames back as
  fallbacks, because old zips stay on the releases page and a rollback
  downloads one.

Release assets become Retro-Launcher-* / Retro-Studio-*. That is safe
for installed clients: pick_launcher_asset() gates on *linux*/*windows*/
*macos* plus extension and arch, not on the product name, and the
Windows portable stub is identified by its RCM1 trailer rather than its
filename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013DpaQqVguHFpBuCmdoFYWY
2026-09-09 17:32:19 -04:00

699 lines
24 KiB
Python

#!/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=<abs path>``
for the first-run wizard / Retro.
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 json
import re
import shutil
import struct
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
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("<I", buf, end, x & 0xFFFFFFFF)
def encode_mode2_form1(user: bytes, lba: int, submode: int = 0x08) -> 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
)
# NB: Path.write_text(newline=) is Python 3.10+. Use open() so the
# tools keep working on 3.9, which RHEL/Rocky 9, Debian 11 and Ubuntu
# 20.04 still ship as the system python3.
with open(cue_dest, "w", encoding="utf-8", newline="\n") as fh:
fh.write(text)
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("<I", root, i + 2)[0]
size = struct.unpack_from("<I", root, i + 10)[0]
namelen = root[i + 32]
name = root[i + 33 : i + 33 + namelen]
if b";" in name:
name = name.split(b";")[0]
if name not in (b"\x00", b"\x01"):
entries[name.decode("ascii", "replace")] = (extent, size)
i += reclen
return entries
def extract_via(
read_user, data: bytes, boot_exe: str
) -> 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("<I", pvd, 158)[0]
root_size = struct.unpack_from("<I", pvd, 166)[0]
root = bytearray()
for i in range((root_size + USER - 1) // USER):
root += read_user(data, root_extent + i)
root = bytes(root[:root_size])
entries = parse_root_entries(root)
files: dict[str, bytes] = {}
# Very early titles (e.g. King's Field, Dec 1994) ship no SYSTEM.CNF; the
# BIOS falls back to booting PSX.EXE from the root directory. probe_disc.py
# already accepts these discs (5ab7a053); staging has to accept the same
# ones or a clean worktree can never prepare them.
needed = ["SYSTEM.CNF", boot_exe]
if "SYSTEM.CNF" not in entries:
if boot_exe not in entries:
raise SystemExit(
f"SYSTEM.CNF missing on disc and no {boot_exe} fallback "
f"(found {sorted(entries)[:20]})"
)
print(
" SYSTEM.CNF missing; using the BIOS "
f"{boot_exe} fallback boot path"
)
needed = [boot_exe]
for need in needed:
if need not in entries:
raise SystemExit(f"missing {need} on disc (found {sorted(entries)[:20]})")
extent, size = entries[need]
out = bytearray()
rem, lba = size, extent
while rem > 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
selected_image = src
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)
# 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:
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")
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)")
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("<I", files[cfg.boot_exe], 0x10)[0]
print(f" {cfg.boot_exe} PC0={pc0:#010x} ({len(files[cfg.boot_exe])} bytes)")
for name, blob in files.items():
out_path = cfg.out_dir / name
out_path.write_bytes(blob)
print(f"wrote {out_path} ({len(blob)} bytes)")
cue_path = stage_multitrack_cue(
cue_src, cue_bins, cfg.out_dir, cfg.cue_name
)
out_md5, out_sha1, out_size = file_hashes(cfg.out_dir / src.name)
print("working data track:")
print(f" size {out_size}")
print(f" md5 {out_md5}")
print(f" sha1 {out_sha1}")
finish(cue_path)
return 0
if kind == "bin2352":
bin_data = src.read_bytes()
entries, files = extract_via(read_user_bin, bin_data, cfg.boot_exe)
elif kind == "iso":
iso_data = src.read_bytes()
entries, files = extract_via(read_user_iso, iso_data, cfg.boot_exe)
print(f" converting {len(iso_data) // USER} sectors ISO -> 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("<I", files[cfg.boot_exe], 0x10)[0]
print(f" {cfg.boot_exe} PC0={pc0:#010x} ({len(files[cfg.boot_exe])} bytes)")
for name, blob in files.items():
out_path = cfg.out_dir / name
out_path.write_bytes(blob)
print(f"wrote {out_path} ({len(blob)} bytes)")
bin_path = cfg.out_dir / cfg.bin_name
if src.resolve() == bin_path.resolve() and kind == "bin2352":
print(f"source already at {bin_path}")
else:
print(f"writing {bin_path}")
bin_path.write_bytes(bin_data)
cue_path = cfg.out_dir / cfg.cue_name
with open(cue_path, "w", encoding="ascii", newline="\n") as fh:
fh.write(
f'FILE "{cfg.bin_name}" BINARY\n'
f" TRACK 01 MODE2/2352\n"
f" INDEX 01 00:00:00\n"
)
print(f"wrote {cue_path}")
out_md5, out_sha1, out_size = file_hashes(bin_path)
print("working bin:")
print(f" size {out_size}")
print(f" md5 {out_md5}")
print(f" sha1 {out_sha1}")
if kind == "iso":
print(" rebuilt from ISO (EDC set; ECC zeroed - fine for software readers)")
elif kind == "raw2448":
print(" trimmed from 2448-byte/sector dump")
finish(cue_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())