mirror of
https://github.com/RetroPortingToolKit/psxrecomp
synced 2026-10-27 10:38:46 -04:00
d17676a5c7
The runtime mounts a .chd natively and the launcher lists it in the disc picker, so a player could select one and see a valid disc identity, then have first-run setup fail: verify-disc, prepare_disc.py and probe_disc.py hash and parse raw 2352-byte track data, which a CHD stores compressed. Nothing on the Python side could read it, and telling players to install MAME for chdman is not an answer. libchdr is already vendored and compiled for the runtime and the native media CLI. Build it once more as a shared library in recompiler/ (CMake target chdr, output next to the emitters, static CRT on Windows so a plain python.exe can load it) and read the CHD from Python through ctypes. tools/psx_chd.py mirrors the layout the runtime's iso_reader.cpp uses -- 2448-byte frames, 4-frame track padding, stored versus virtual pregaps, big-endian audio -- and reproduces the .bin files a Redump dump of the same disc contains, in either of Redump's two layouts (one bin per track, or one bin for the disc). verify-disc hashes those reconstructed track bytes against [prepare_disc] instead of the container. prepare_disc.py writes the tracks into out_dir in whichever layout the recorded digests match and then continues down its normal cue path, so the data track is written once, SBI companions are looked up next to the CHD, and the receipt names the CHD the player chose. probe_disc.py extracts to a scratch directory and probes the cue. The CLI builds target chdr on demand for a kit whose emitters predate it, and falls back to the extract-it-first message when it cannot. Verified against two discs from a Redump library converted with chdman: a single-track MODE2 disc and a four-track disc with stored audio pregaps both reproduce their catalogued track-1 size, MD5 and SHA-1 exactly; verify-disc and prepare_disc.py accept the four-track one end to end. Tests build a synthetic uncompressed v5 CHD around a cooked ISO image, so no retail data is needed; the decode tests skip with a reason when no libchdr is built.
853 lines
29 KiB
Python
Executable File
853 lines
29 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Probe a Redump-style PS1 .cue (+ bins) and emit identity for game.toml / catalog.
|
|
|
|
Used by tools/new_project_layout/setup_project.{sh,ps1}. Prefer probing the
|
|
source cue in place (external dumps); optionally extract only the boot EXE
|
|
with ``--write-boot-exe``. Full cue+bin staging into ``disc/`` is optional
|
|
(``setup_project --stage-disc``).
|
|
|
|
Writes:
|
|
- JSON to stdout (or --json-out)
|
|
- Optional game.toml (--write-game-toml)
|
|
- Optional catalog_identity.json (--write-catalog)
|
|
- Optional seeds/ghidra_funcs.txt (--write-seeds): entry + in-image JAL targets
|
|
- Optional boot EXE file (--write-boot-exe DIR)
|
|
|
|
Does not require a pre-filled game.toml. Derives serial / boot EXE from
|
|
SYSTEM.CNF, EXE header fields from the PS-X EXE, digests from data Track 01,
|
|
track count + TOC fingerprint from the cue sheet, and a first-pass seed list
|
|
from the boot EXE text.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import struct
|
|
import sys
|
|
from dataclasses import asdict, dataclass, field
|
|
from pathlib import Path
|
|
|
|
DST_SEC = 2352
|
|
USER = 2048
|
|
USER_OFF = 24
|
|
EXE_HDR = 0x800
|
|
SYNC = bytes([0x00] + [0xFF] * 10 + [0x00])
|
|
|
|
|
|
@dataclass
|
|
class CueTrack:
|
|
number: int
|
|
kind: str # "AUDIO" or MODE…
|
|
file: str
|
|
index01_frames: int | None = None
|
|
index00_frames: int | None = None
|
|
|
|
|
|
@dataclass
|
|
class DiscProbe:
|
|
cue_path: str
|
|
cue_name: str
|
|
bin_name: str
|
|
track_count: int
|
|
data_track_size: int
|
|
data_track_md5: str
|
|
data_track_sha1: str
|
|
data_track_crc32: str = "" # 8-digit lowercase hex (Redump-compatible)
|
|
boot_exe: str = ""
|
|
serial: str = "" # SLUS-00562
|
|
serial_exe: str = "" # SLUS_005.62
|
|
volume_id: str = ""
|
|
display_name: str = ""
|
|
load_address: str = ""
|
|
entry_pc: str = ""
|
|
text_size: str = ""
|
|
stack_base: str = ""
|
|
require_cue: bool = True
|
|
required_disc_fp: str = ""
|
|
# Content hash of the boot executable itself. `serial` and `boot_exe` name
|
|
# the DISC; this names the PROGRAM, and the two are not the same thing --
|
|
# Final Fantasy VII ships one byte-identical executable on three discs
|
|
# under three serials (SCUS-94163/64/65).
|
|
boot_exe_sha256: str = ""
|
|
seed_count: int = 0
|
|
seed_addrs: list[str] = field(default_factory=list)
|
|
warnings: list[str] = field(default_factory=list)
|
|
notes: list[str] = field(default_factory=list)
|
|
# Not serialized — used by --write-boot-exe
|
|
boot_exe_bytes: bytes = field(default=b"", repr=False, compare=False)
|
|
|
|
|
|
def file_hashes(path: Path) -> tuple[str, str, str, int]:
|
|
"""Return (md5_hex, sha1_hex, crc32_hex8, size)."""
|
|
import zlib
|
|
|
|
h_md5, h_sha1 = hashlib.md5(), hashlib.sha1()
|
|
crc = 0
|
|
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)
|
|
crc = zlib.crc32(chunk, crc)
|
|
return (
|
|
h_md5.hexdigest(),
|
|
h_sha1.hexdigest(),
|
|
f"{crc & 0xFFFFFFFF:08x}",
|
|
size,
|
|
)
|
|
|
|
|
|
def msf_to_frames(msf: str) -> int:
|
|
parts = msf.strip().split(":")
|
|
if len(parts) != 3:
|
|
raise ValueError(f"bad MSF: {msf}")
|
|
mm, ss, ff = (int(p) for p in parts)
|
|
return ((mm * 60) + ss) * 75 + ff
|
|
|
|
|
|
def resolve_cue_file(cue_path: Path, name: str) -> Path:
|
|
cand = Path(name.replace("\\", "/"))
|
|
if not cand.is_absolute():
|
|
cand = cue_path.parent / cand
|
|
if cand.is_file():
|
|
return cand.resolve()
|
|
# Case-insensitive fallback
|
|
parent = cand.parent
|
|
want = cand.name.lower()
|
|
if parent.is_dir():
|
|
for child in parent.iterdir():
|
|
if child.is_file() and child.name.lower() == want:
|
|
return child.resolve()
|
|
raise SystemExit(f"cue references missing bin: {cand}")
|
|
|
|
|
|
def parse_cue(cue_path: Path) -> tuple[list[CueTrack], list[str]]:
|
|
text = cue_path.read_text(encoding="utf-8", errors="replace")
|
|
tracks: list[CueTrack] = []
|
|
files_order: list[str] = []
|
|
current_file = ""
|
|
current: CueTrack | None = None
|
|
for raw in text.splitlines():
|
|
line = raw.strip()
|
|
if not line or line.startswith("REM"):
|
|
continue
|
|
m = re.match(r'FILE\s+"([^"]+)"\s+(\S+)', line, re.I)
|
|
if not m:
|
|
m = re.match(r"FILE\s+(\S+)\s+(\S+)", line, re.I)
|
|
if m:
|
|
current_file = m.group(1)
|
|
if current_file not in files_order:
|
|
files_order.append(current_file)
|
|
continue
|
|
m = re.match(r"TRACK\s+(\d+)\s+(\S+)", line, re.I)
|
|
if m:
|
|
if current:
|
|
tracks.append(current)
|
|
current = CueTrack(
|
|
number=int(m.group(1)),
|
|
kind=m.group(2).upper(),
|
|
file=current_file,
|
|
)
|
|
continue
|
|
m = re.match(r"INDEX\s+(\d+)\s+(\d+:\d+:\d+)", line, re.I)
|
|
if m and current:
|
|
idx = int(m.group(1))
|
|
frames = msf_to_frames(m.group(2))
|
|
if idx == 0:
|
|
current.index00_frames = frames
|
|
elif idx == 1:
|
|
current.index01_frames = frames
|
|
if current:
|
|
tracks.append(current)
|
|
if not tracks:
|
|
raise SystemExit(f"no TRACK lines in cue: {cue_path}")
|
|
return tracks, files_order
|
|
|
|
|
|
def first_binary_bin(cue_path: Path, files_order: list[str], tracks: list[CueTrack]) -> Path:
|
|
t1 = next((t for t in tracks if t.number == 1), tracks[0])
|
|
name = t1.file or (files_order[0] if files_order else "")
|
|
if not name:
|
|
raise SystemExit(f"cue has no FILE for track 1: {cue_path}")
|
|
return resolve_cue_file(cue_path, name)
|
|
|
|
|
|
def compute_disc_fp(cue_path: Path, tracks: list[CueTrack], files_order: list[str]) -> str:
|
|
"""Match runtime disc_identity.cpp psxrecomp-toc-v1 SHA-256."""
|
|
next_lba = 0
|
|
file_start: dict[str, int] = {}
|
|
for name in files_order:
|
|
path = resolve_cue_file(cue_path, name)
|
|
size = path.stat().st_size
|
|
raw = size % DST_SEC == 0 and size > 0
|
|
sec = DST_SEC if raw else USER
|
|
if size % sec != 0:
|
|
raise SystemExit(f"track file size {size} not a multiple of {sec}: {path}")
|
|
file_start[name] = next_lba
|
|
next_lba += size // sec
|
|
leadout = next_lba
|
|
|
|
lines = [
|
|
"psxrecomp-toc-v1\n",
|
|
f"tracks={len(tracks)}\n",
|
|
f"leadout={leadout}\n",
|
|
]
|
|
for t in sorted(tracks, key=lambda x: x.number):
|
|
if not t.file or t.file not in file_start:
|
|
raise SystemExit(f"track {t.number} has no FILE mapping")
|
|
base = file_start[t.file]
|
|
index01 = t.index01_frames if t.index01_frames is not None else 0
|
|
index00 = t.index00_frames if t.index00_frames is not None else index01
|
|
start = base + index01
|
|
pregap = base + index00
|
|
audio = 1 if "AUDIO" in t.kind.upper() else 0
|
|
lines.append(
|
|
f"t={t.number},a={audio},start={start},pregap={pregap}\n"
|
|
)
|
|
canonical = "".join(lines)
|
|
return hashlib.sha256(canonical.encode("ascii")).hexdigest()
|
|
|
|
|
|
def scan_jal_seeds(exe: bytes) -> list[int]:
|
|
"""Entry PC + direct JAL targets inside the boot EXE text (MotK-style)."""
|
|
if exe[:8] != b"PS-X EXE":
|
|
raise SystemExit("not a PS-X EXE")
|
|
pc0 = struct.unpack_from("<I", exe, 0x10)[0]
|
|
t_addr = struct.unpack_from("<I", exe, 0x18)[0]
|
|
t_size = struct.unpack_from("<I", exe, 0x1C)[0]
|
|
text = exe[EXE_HDR : EXE_HDR + t_size]
|
|
if len(text) < 4:
|
|
return sorted({pc0 & ~3})
|
|
lo, hi = t_addr, t_addr + len(text)
|
|
seeds = {pc0 & ~3}
|
|
for off in range(0, len(text) - 3, 4):
|
|
w = struct.unpack_from("<I", text, off)[0]
|
|
if (w >> 26) != 3: # JAL
|
|
continue
|
|
pc = t_addr + off
|
|
target = (pc & 0xF0000000) | ((w & 0x03FFFFFF) << 2)
|
|
if lo <= target < hi and (target & 3) == 0:
|
|
seeds.add(target)
|
|
return sorted(seeds)
|
|
|
|
|
|
def render_seeds_file(seeds: list[int], *, boot_exe: str, entry: int, load: int, text: int) -> str:
|
|
lines = [
|
|
f"# Auto-scanned JAL targets (+ entry) from {boot_exe}",
|
|
f"# entry=0x{entry:08x} load=0x{load:08x} text_size=0x{text:x}",
|
|
"# First-pass only — add overlay / runtime discoveries as you decomp.",
|
|
]
|
|
for addr in seeds:
|
|
lines.append(f"0x{addr:08X}")
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
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:
|
|
off2 = lba * USER
|
|
if off2 + USER <= len(data) and data[off2 + 1 : off2 + 6] == b"CD001":
|
|
return data[off2 : off2 + USER]
|
|
if lba == 16:
|
|
raise SystemExit("bad sync at LBA 16 (expected MODE2/2352 or cooked ISO)")
|
|
raise KeyError(lba)
|
|
return data[off + USER_OFF : off + USER_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 read_file(read_user, data: bytes, extent: int, size: int) -> bytes:
|
|
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
|
|
return bytes(out)
|
|
|
|
|
|
def parse_system_cnf(cnf: bytes) -> str:
|
|
text = cnf.decode("ascii", "replace")
|
|
m = re.search(r"BOOT\s*=\s*cdrom:\\?([^;\s]+)", text, re.I)
|
|
if not m:
|
|
m = re.search(r"BOOT\s*=\s*([^;\s]+)", text, re.I)
|
|
if not m:
|
|
raise SystemExit("SYSTEM.CNF has no BOOT= line")
|
|
token = m.group(1).strip()
|
|
token = token.split("\\")[-1].split("/")[-1]
|
|
return token
|
|
|
|
|
|
def normalize_serial(boot_exe: str) -> tuple[str, str]:
|
|
"""Return (game_id SLUS-00562, exe_name SLUS_005.62)."""
|
|
raw = boot_exe.strip()
|
|
if "_" in raw and "." in raw:
|
|
s = "".join(c for c in raw.upper() if c.isalnum())
|
|
if len(s) >= 9:
|
|
return f"{s[:4]}-{s[4:9]}", raw.upper() if raw.count(".") == 1 else (
|
|
f"{s[:4]}_{s[4:7]}.{s[7:9]}"
|
|
)
|
|
s = "".join(c for c in raw.upper() if c.isalnum())
|
|
if len(s) < 9:
|
|
return "", raw
|
|
game_id = f"{s[:4]}-{s[4:9]}"
|
|
exe_name = f"{s[:4]}_{s[4:7]}.{s[7:9]}"
|
|
return game_id, exe_name
|
|
|
|
|
|
def display_name_from_cue(cue_path: Path, volume_id: str) -> str:
|
|
stem = cue_path.stem
|
|
name = re.sub(
|
|
r"\s*\((USA|Europe|Japan|World|En,Fr,De,Es,It)\)\s*$",
|
|
"",
|
|
stem,
|
|
flags=re.I,
|
|
)
|
|
return name.strip() or stem
|
|
|
|
|
|
def probe(cue_path: Path, *, identity_only: bool = False) -> DiscProbe:
|
|
cue_path = cue_path.resolve()
|
|
if cue_path.suffix.lower() != ".cue":
|
|
raise SystemExit("probe_disc expects a .cue (Redump multi-track layout)")
|
|
|
|
tracks, files_order = parse_cue(cue_path)
|
|
bin_path = first_binary_bin(cue_path, files_order, tracks)
|
|
if identity_only:
|
|
md5, sha1, crc32, size = "", "", "", bin_path.stat().st_size
|
|
else:
|
|
md5, sha1, crc32, size = file_hashes(bin_path)
|
|
|
|
warnings: list[str] = []
|
|
notes: list[str] = []
|
|
if identity_only:
|
|
notes.append(
|
|
"identity-only probe: data-track md5/sha1/crc32 skipped. Not usable "
|
|
"for [prepare_disc] digests or the catalog."
|
|
)
|
|
if len(tracks) == 1:
|
|
warnings.append(
|
|
"cue has only 1 TRACK — Track-01-only dumps fail multi-track "
|
|
"netplay/catalog gates; prefer a full Redump cue."
|
|
)
|
|
|
|
disc_fp = ""
|
|
try:
|
|
disc_fp = compute_disc_fp(cue_path, tracks, files_order)
|
|
except SystemExit as e:
|
|
warnings.append(f"TOC fingerprint skipped: {e}")
|
|
except OSError as e:
|
|
warnings.append(f"TOC fingerprint skipped: {e}")
|
|
|
|
print(f" reading data track {bin_path.name} ({size} bytes)…", file=sys.stderr)
|
|
data = bin_path.read_bytes()
|
|
|
|
def read_user_iso(d: bytes, lba: int) -> bytes:
|
|
off = lba * USER
|
|
if off + USER > len(d):
|
|
raise KeyError(lba)
|
|
return d[off : off + USER]
|
|
|
|
cooked = (
|
|
len(data) >= 17 * USER
|
|
and data[16 * USER + 1 : 16 * USER + 6] == b"CD001"
|
|
and not (
|
|
len(data) >= 17 * DST_SEC
|
|
and data[16 * DST_SEC : 16 * DST_SEC + 12] == SYNC
|
|
)
|
|
)
|
|
if cooked:
|
|
read_user = read_user_iso
|
|
warnings.append(
|
|
"data track looks like cooked 2048-byte sectors; "
|
|
"Generate/prepare_disc will normalize to MODE2/2352."
|
|
)
|
|
else:
|
|
read_user = read_user_bin
|
|
|
|
pvd = read_user(data, 16)
|
|
if pvd[1:6] != b"CD001":
|
|
raise SystemExit("PVD not found at LBA 16 (expected CD001)")
|
|
|
|
vol = pvd[40:72].decode("ascii", "replace").strip()
|
|
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)
|
|
entries = parse_root_entries(bytes(root[:root_size]))
|
|
|
|
if "SYSTEM.CNF" not in entries:
|
|
# 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.
|
|
psx_exe = next((k for k in entries if k.upper() == "PSX.EXE"), None)
|
|
if psx_exe is None:
|
|
raise SystemExit(
|
|
f"SYSTEM.CNF missing on disc and no PSX.EXE fallback "
|
|
f"(found {sorted(entries)[:24]})"
|
|
)
|
|
boot_token = psx_exe
|
|
warnings.append(
|
|
"SYSTEM.CNF missing; using the BIOS PSX.EXE fallback boot path. "
|
|
"No serial is recoverable from the filesystem — set game_id "
|
|
"manually in catalog_identity.json / game.toml."
|
|
)
|
|
else:
|
|
extent, fsize = entries["SYSTEM.CNF"]
|
|
cnf = read_file(read_user, data, extent, fsize)
|
|
boot_token = parse_system_cnf(cnf)
|
|
serial, boot_exe = normalize_serial(boot_token)
|
|
|
|
disc_boot = boot_token
|
|
if disc_boot not in entries:
|
|
if boot_exe in entries:
|
|
disc_boot = boot_exe
|
|
else:
|
|
upper = {k.upper(): k for k in entries}
|
|
if disc_boot.upper() in upper:
|
|
disc_boot = upper[disc_boot.upper()]
|
|
elif boot_exe.upper() in upper:
|
|
disc_boot = upper[boot_exe.upper()]
|
|
else:
|
|
raise SystemExit(
|
|
f"boot EXE {boot_token!r} not on disc "
|
|
f"(found {sorted(entries)[:24]})"
|
|
)
|
|
|
|
bext, bsize = entries[disc_boot]
|
|
exe = read_file(read_user, data, bext, bsize)
|
|
if exe[:8] != b"PS-X EXE":
|
|
raise SystemExit(f"{disc_boot} is not a PS-X EXE")
|
|
|
|
pc0 = struct.unpack_from("<I", exe, 0x10)[0]
|
|
t_addr = struct.unpack_from("<I", exe, 0x18)[0]
|
|
t_size = struct.unpack_from("<I", exe, 0x1C)[0]
|
|
s_addr = struct.unpack_from("<I", exe, 0x30)[0]
|
|
seeds = scan_jal_seeds(exe)
|
|
|
|
boot_name = disc_boot
|
|
if not serial:
|
|
serial, _ = normalize_serial(boot_name)
|
|
|
|
display = display_name_from_cue(cue_path, vol)
|
|
notes.append(
|
|
"entry_pc/load_address/text_size/stack_base taken from PS-X EXE header."
|
|
)
|
|
notes.append(
|
|
"seeds are first-pass boot-EXE JAL targets (+ entry); expect to grow "
|
|
"as overlays / runtime paths are discovered."
|
|
)
|
|
if disc_fp:
|
|
notes.append(
|
|
"required_disc_fp is psxrecomp-toc-v1 (matches runtime DiscIdentity)."
|
|
)
|
|
else:
|
|
notes.append("required_disc_fp unavailable; required_tracks still set.")
|
|
|
|
return DiscProbe(
|
|
cue_path=str(cue_path),
|
|
cue_name=cue_path.name,
|
|
bin_name=bin_path.name,
|
|
track_count=len(tracks),
|
|
data_track_size=size,
|
|
data_track_md5=md5,
|
|
data_track_sha1=sha1,
|
|
data_track_crc32=crc32,
|
|
boot_exe=boot_name,
|
|
serial=serial,
|
|
serial_exe=boot_name if "_" in boot_name else (
|
|
f"{serial[:4]}_{serial[5:8]}.{serial[8:10]}" if serial else boot_name
|
|
),
|
|
volume_id=vol,
|
|
display_name=display,
|
|
load_address=f"0x{t_addr:08X}",
|
|
entry_pc=f"0x{pc0:08X}",
|
|
text_size=f"0x{t_size:08X}",
|
|
stack_base=f"0x{s_addr:08X}",
|
|
require_cue=True,
|
|
required_disc_fp=disc_fp,
|
|
seed_count=len(seeds),
|
|
seed_addrs=[f"0x{a:08X}" for a in seeds],
|
|
warnings=warnings,
|
|
notes=notes,
|
|
boot_exe_bytes=exe,
|
|
boot_exe_sha256=hashlib.sha256(exe).hexdigest() if exe else "",
|
|
)
|
|
|
|
|
|
def toml_escape(s: str) -> str:
|
|
return s.replace("\\", "\\\\").replace('"', '\\"')
|
|
|
|
|
|
def render_game_toml(p: DiscProbe, *, disc_rel: str, out_dir: str, players: int,
|
|
extra_discs: list[str] | None = None) -> str:
|
|
exe_rel = f"{out_dir.rstrip('/')}/{p.boot_exe}"
|
|
lines = [
|
|
"# Autofilled by tools/new_project_layout/probe_disc.py from your legal dump.",
|
|
"# Review before shipping. Runtime quirks / overlay seeds still need hand work.",
|
|
"",
|
|
"[game]",
|
|
f'name = "{toml_escape(p.display_name)}"',
|
|
f'id = "{toml_escape(p.serial)}"' if p.serial else '# id = "SLUS-XXXXX"',
|
|
f"players = {players}",
|
|
f'exe = "{toml_escape(exe_rel)}"',
|
|
# One program, N images: the loader already accepts `discs`, and a set
|
|
# verified data-only is exactly that case. `disc` stays for a single
|
|
# image so a one-disc title is byte-identical to before.
|
|
*(
|
|
[f'disc = "{toml_escape(disc_rel)}"']
|
|
if not extra_discs
|
|
else [
|
|
"# Verified as one program on N images (verify_disc_set.py).",
|
|
"# Disc 1 boots; the rest are data. The runtime builds its disc",
|
|
"# roster from this list and mounts the SELECTED one (remembered",
|
|
"# in disc_index), so the launcher can switch discs between runs.",
|
|
"# Swapping mid-session is MULTI_DISC.md P3 and is not implemented.",
|
|
"discs = [",
|
|
*[f' "{toml_escape(d)}",' for d in [disc_rel, *extra_discs]],
|
|
"]",
|
|
]
|
|
),
|
|
f'load_address = "{p.load_address}"',
|
|
f'entry_pc = "{p.entry_pc}"',
|
|
f'text_size = "{p.text_size}"',
|
|
f'stack_base = "{p.stack_base}"',
|
|
"",
|
|
"# Digests are for data Track 01 (first BINARY FILE / TRACK 01).",
|
|
"[prepare_disc]",
|
|
f'out_dir = "{toml_escape(out_dir)}"',
|
|
f'bin_name = "{toml_escape(p.bin_name)}"',
|
|
f'cue_name = "{toml_escape(p.cue_name)}"',
|
|
f'boot_exe = "{toml_escape(p.boot_exe)}"',
|
|
f"known_sizes = [{p.data_track_size}]",
|
|
"known_md5 = [",
|
|
f' "{p.data_track_md5}",',
|
|
"]",
|
|
"known_sha1 = [",
|
|
f' "{p.data_track_sha1}",',
|
|
"]",
|
|
"known_crc32 = [",
|
|
f' "{p.data_track_crc32}",',
|
|
"]",
|
|
"",
|
|
"[recompiler]",
|
|
'seeds = "seeds/ghidra_funcs.txt"',
|
|
'out_dir = "generated"',
|
|
"strict = true",
|
|
"",
|
|
"[runtime]",
|
|
'window_title = "' + toml_escape(p.display_name + " Recompiled") + '"',
|
|
'memcard_dir = "saves"',
|
|
# Enable captured dirty-RAM overlays by default. A freshly probed
|
|
# project otherwise never initializes the cache pipeline and silently
|
|
# interprets every runtime-loaded overlay until a maintainer discovers
|
|
# and adds this setting by hand.
|
|
"overlay_cache = true",
|
|
"",
|
|
"[video]",
|
|
'renderer = "opengl"',
|
|
'aspect_ratio = "4:3"',
|
|
"",
|
|
"[controller]",
|
|
'default_mode = "digital"',
|
|
"",
|
|
"# Online mount gate: digests + track count + optional TOC fingerprint",
|
|
"# (see docs/NETPLAY.md).",
|
|
"[netplay]",
|
|
f"require_cue = {str(p.require_cue).lower()}",
|
|
f"required_tracks = {p.track_count}",
|
|
]
|
|
if p.required_disc_fp:
|
|
lines.append(f'required_disc_fp = "{p.required_disc_fp}"')
|
|
lines.append("")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def catalog_identity(
|
|
p: DiscProbe,
|
|
*,
|
|
players: int = 2,
|
|
description: str = "",
|
|
publisher: str = "",
|
|
year: str = "",
|
|
region: str = "",
|
|
) -> dict:
|
|
rom = {
|
|
"cue_name": p.cue_name,
|
|
"bin_name": p.bin_name,
|
|
"track_counts": [p.track_count],
|
|
"require_cue": p.require_cue,
|
|
"data_track": {
|
|
"size": p.data_track_size,
|
|
"md5": p.data_track_md5,
|
|
"sha1": p.data_track_sha1,
|
|
"crc32": p.data_track_crc32,
|
|
},
|
|
}
|
|
if p.required_disc_fp:
|
|
rom["disc_fp"] = p.required_disc_fp
|
|
marketing = {
|
|
"description": description or "",
|
|
"publisher": publisher or "",
|
|
"year": year or "",
|
|
"region": region or "",
|
|
"players": int(players),
|
|
}
|
|
return {
|
|
"format_version": 1,
|
|
"game": {
|
|
"name": p.display_name,
|
|
"id": p.serial,
|
|
"boot_exe": p.boot_exe,
|
|
},
|
|
"marketing": marketing,
|
|
"rom_identity": rom,
|
|
"exe": {
|
|
"load_address": p.load_address,
|
|
"entry_pc": p.entry_pc,
|
|
"text_size": p.text_size,
|
|
"stack_base": p.stack_base,
|
|
},
|
|
"seeds": {"count": p.seed_count, "source": "boot_exe_jal"},
|
|
"warnings": p.warnings,
|
|
"notes": p.notes,
|
|
}
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("cue", help="path to .cue (with sibling .bin tracks)")
|
|
ap.add_argument("--json-out", default="", help="write probe JSON here")
|
|
ap.add_argument("--write-game-toml", default="", help="write autofilled game.toml")
|
|
ap.add_argument(
|
|
"--write-catalog",
|
|
default="",
|
|
help="write catalog_identity.json for Retro / submission autofill",
|
|
)
|
|
ap.add_argument(
|
|
"--write-seeds",
|
|
default="",
|
|
help="write seeds/ghidra_funcs.txt (entry + JAL targets)",
|
|
)
|
|
ap.add_argument(
|
|
"--disc-rel",
|
|
default="",
|
|
help="game.disc path in game.toml (relative or absolute; default: disc/<cue>)",
|
|
)
|
|
ap.add_argument(
|
|
"--extra-disc",
|
|
action="append",
|
|
default=[],
|
|
help="another image for game.toml `discs`, repeatable, in disc order "
|
|
"after the boot disc. Only for a set verify_disc_set.py reported "
|
|
"as data-only — one program, N images.",
|
|
)
|
|
ap.add_argument(
|
|
"--extra-disc-list",
|
|
default="",
|
|
help="file of additional disc paths, one per line, in disc order. "
|
|
"Preferred over repeated --extra-disc from a shell: PS1 dump "
|
|
"filenames contain spaces and parentheses.",
|
|
)
|
|
ap.add_argument(
|
|
"--out-dir",
|
|
default="disc",
|
|
help="prepare_disc.out_dir / exe parent (default: disc)",
|
|
)
|
|
ap.add_argument(
|
|
"--write-boot-exe",
|
|
default="",
|
|
help="Write extracted boot EXE into this directory (small; not full dump)",
|
|
)
|
|
ap.add_argument("--players", type=int, default=2, help="game.players default")
|
|
ap.add_argument(
|
|
"--display-name",
|
|
default="",
|
|
help="override display name (default: cleaned cue stem)",
|
|
)
|
|
ap.add_argument("--description", default="", help="marketing blurb for catalog/README")
|
|
ap.add_argument("--publisher", default="", help="marketing publisher")
|
|
ap.add_argument("--year", default="", help="marketing year (string ok)")
|
|
ap.add_argument("--region", default="", help="marketing region (e.g. USA)")
|
|
ap.add_argument(
|
|
"--identity-only",
|
|
action="store_true",
|
|
help="skip the data-track md5/sha1/crc32 pass (fast identity check; "
|
|
"not usable with --write-game-toml / --write-catalog)",
|
|
)
|
|
args = ap.parse_args()
|
|
|
|
if args.identity_only and (args.write_game_toml or args.write_catalog):
|
|
print(
|
|
"--identity-only cannot be combined with --write-game-toml / "
|
|
"--write-catalog: both need the data-track digests it skips.",
|
|
file=sys.stderr,
|
|
)
|
|
return 2
|
|
|
|
cue = Path(args.cue).expanduser()
|
|
if not cue.is_file():
|
|
print(f"cue not found: {cue}", file=sys.stderr)
|
|
return 1
|
|
|
|
# A .chd is read back into Redump-shaped tracks (one bin per track) in a
|
|
# scratch directory and probed as that cue, so identity, digests and the
|
|
# boot EXE come out exactly as they would from the dump it was made from.
|
|
scratch = None
|
|
if cue.suffix.lower() == ".chd":
|
|
import tempfile
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
import psx_chd
|
|
|
|
fw_root = Path(__file__).resolve().parents[2]
|
|
lib_path = psx_chd.find_libchdr(None, fw_root)
|
|
if lib_path is None:
|
|
print(psx_chd.unsupported_message(cue), file=sys.stderr)
|
|
return 1
|
|
scratch = tempfile.TemporaryDirectory(prefix="probe-chd-")
|
|
try:
|
|
with psx_chd.ChdDisc(cue, psx_chd.LibChdr(lib_path)) as disc:
|
|
print(f" chd: {len(disc.tracks)} track(s) via {lib_path.name}", file=sys.stderr)
|
|
cue = psx_chd.extract(disc, Path(scratch.name), f"{cue.stem}.cue", layout="multi")
|
|
except psx_chd.ChdError as exc:
|
|
print(str(exc), file=sys.stderr)
|
|
scratch.cleanup()
|
|
return 1
|
|
|
|
try:
|
|
p = probe(cue, identity_only=args.identity_only)
|
|
finally:
|
|
if scratch is not None:
|
|
scratch.cleanup()
|
|
if args.display_name:
|
|
p.display_name = args.display_name
|
|
|
|
for w in p.warnings:
|
|
print(f" warning: {w}", file=sys.stderr)
|
|
|
|
disc_rel = args.disc_rel or f"{args.out_dir.rstrip('/')}/{p.cue_name}"
|
|
extra_discs = [d for d in (args.extra_disc or []) if d.strip()]
|
|
if args.extra_disc_list:
|
|
lp = Path(args.extra_disc_list)
|
|
if lp.is_file():
|
|
extra_discs += [ln.strip()
|
|
for ln in lp.read_text(encoding="utf-8").splitlines()
|
|
if ln.strip()]
|
|
|
|
# Drop bulky / binary fields from JSON dumps
|
|
payload = asdict(p)
|
|
payload.pop("seed_addrs", None)
|
|
payload.pop("boot_exe_bytes", None)
|
|
|
|
if args.json_out:
|
|
Path(args.json_out).write_text(
|
|
json.dumps(payload, indent=2) + "\n", encoding="utf-8"
|
|
)
|
|
else:
|
|
print(json.dumps({k: v for k, v in payload.items() if k != "seed_addrs"}, indent=2))
|
|
|
|
if args.write_game_toml:
|
|
text = render_game_toml(
|
|
p, disc_rel=disc_rel, out_dir=args.out_dir, players=args.players,
|
|
extra_discs=extra_discs
|
|
)
|
|
Path(args.write_game_toml).write_text(text, encoding="utf-8")
|
|
print(f" wrote {args.write_game_toml}", file=sys.stderr)
|
|
|
|
if args.write_boot_exe:
|
|
if not p.boot_exe_bytes:
|
|
print("error: no boot EXE bytes to write", file=sys.stderr)
|
|
return 1
|
|
dest_dir = Path(args.write_boot_exe).expanduser()
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
dest = dest_dir / p.boot_exe
|
|
dest.write_bytes(p.boot_exe_bytes)
|
|
print(f" wrote boot EXE {dest} ({len(p.boot_exe_bytes)} bytes)", file=sys.stderr)
|
|
|
|
if args.write_catalog:
|
|
Path(args.write_catalog).write_text(
|
|
json.dumps(
|
|
catalog_identity(
|
|
p,
|
|
players=args.players,
|
|
description=args.description,
|
|
publisher=args.publisher,
|
|
year=args.year,
|
|
region=args.region,
|
|
),
|
|
indent=2,
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
print(f" wrote {args.write_catalog}", file=sys.stderr)
|
|
|
|
if args.write_seeds:
|
|
seeds = [int(a, 16) for a in p.seed_addrs]
|
|
entry = int(p.entry_pc, 16)
|
|
load = int(p.load_address, 16)
|
|
text_sz = int(p.text_size, 16)
|
|
out = Path(args.write_seeds)
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_text(
|
|
render_seeds_file(
|
|
seeds,
|
|
boot_exe=p.boot_exe,
|
|
entry=entry,
|
|
load=load,
|
|
text=text_sz,
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
print(f" wrote {out} ({len(seeds)} seeds)", file=sys.stderr)
|
|
|
|
fp_note = f" disc_fp={p.required_disc_fp[:12]}…" if p.required_disc_fp else ""
|
|
print(
|
|
f" probed {p.serial or '?'} boot={p.boot_exe} "
|
|
f"tracks={p.track_count} entry={p.entry_pc} seeds={p.seed_count}{fp_note}",
|
|
file=sys.stderr,
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
raise SystemExit(main())
|
|
except BrokenPipeError:
|
|
raise SystemExit(0)
|