Files
psp-preservation-tool/app/ps1.py
T

159 lines
5.0 KiB
Python

"""PS1 library helpers: discover source discs and converted EBOOT.PBPs.
This is the read/scan side of PS1 support (the conversion side lives in
``app.ps1_convert``). It finds raw PS1 disc images (``.cue`` sets and
standalone ``.iso``/``.img``), reads each disc's boot serial, and lists
converted ``EBOOT.PBP`` files in the output folder.
"""
from __future__ import annotations
import re
from pathlib import Path
from typing import List, Optional
from . import config
RAW_SECTOR = 2352
COOKED_SECTOR = 2048
EBOOT_NAME = "EBOOT.PBP"
# SYSTEM.CNF boot line: cdrom:\SLPS_017.62;1 (backslash optional in some rips)
_SERIAL_RE = re.compile(rb"cdrom:\\?([A-Za-z]{4})[_-]?(\d{3})\.(\d{2})")
def _sector_size(path: Path) -> int:
size = path.stat().st_size
if size and size % RAW_SECTOR == 0:
return RAW_SECTOR
return COOKED_SECTOR
def read_serial(image_path: Path) -> Optional[str]:
"""Read the boot serial (e.g. ``SLPS01762``) from a PS1 disc image.
Works on both raw 2352-byte/sector images (BIN/IMG) and cooked 2048-byte
images (ISO). Returns ``None`` if the boot line can't be found.
"""
image_path = Path(image_path)
if not image_path.is_file():
return None
sector_size = _sector_size(image_path)
data_off = 24 if sector_size == RAW_SECTOR else 0
buf = bytearray()
with open(image_path, "rb") as fh:
for _ in range(128): # SYSTEM.CNF is always near the start of the disc
sector = fh.read(sector_size)
if len(sector) < sector_size:
break
buf += sector[data_off:data_off + COOKED_SECTOR]
m = _SERIAL_RE.search(bytes(buf))
if m:
return (m.group(1) + m.group(2) + m.group(3)).decode().upper()
return None
def cue_data_track(cue_path: Path) -> Optional[Path]:
"""Return the first data-track image referenced by a cue sheet."""
from . import ps1_convert
try:
disc = ps1_convert.parse_cue(cue_path.read_text(encoding="utf-8", errors="replace"))
except Exception:
return None
track = disc.data_track
if track is None:
return None
return (cue_path.parent / track.file) if track.file else None
def iter_source_discs(root: Path):
"""Yield PS1 source discs under ``root`` (cue sheets + standalone images)."""
root = Path(root)
if not root.is_dir():
return
for path in sorted(root.rglob("*")):
if not path.is_file():
continue
suffix = path.suffix.lower()
if suffix == ".cue":
yield path
elif suffix in (".iso", ".img"):
# A standalone image: no cue sheet next to it (same stem).
if not (path.parent / f"{path.stem}.cue").exists():
yield path
def find_eboots(root: Path) -> List[Path]:
"""List converted/PSN ``EBOOT.PBP`` files under ``root``."""
root = Path(root)
if not root.is_dir():
return []
return [p for p in sorted(root.rglob("*")) if p.is_file() and p.name.upper() == EBOOT_NAME]
def eboot_title(pbp_path: Path) -> str:
"""Read the game title embedded in an EBOOT.PBP (falls back to folder name)."""
from . import iso_reader, ps1_convert
try:
header = ps1_convert.read_pbp_header(pbp_path)
idx = ps1_convert.SECTION_OFFSET["sfo"] // 4
sfo = ps1_convert.read_pbp_section(pbp_path, header, idx)
parsed = iso_reader.parse_sfo(sfo)
title = parsed.get("TITLE") or parsed.get("TITLE_0") or parsed.get("TITLE_00")
if title:
return str(title)
except Exception:
pass
return pbp_path.parent.name
def scan_library(ps1_dir, output_dir) -> List[dict]:
"""Return a combined view of PS1 source discs and converted EBOOTs.
Each entry is a dict with ``kind`` ("disc" or "eboot"), ``path``, ``name``,
``serial`` and ``status`` ("pending" / "converted").
"""
ps1_dir = Path(ps1_dir)
output_dir = Path(output_dir)
eboots = find_eboots(output_dir)
eboot_names = {e.name.upper() for e in eboots}
eboot_titles = {eboot_title(e).lower() for e in eboots}
entries: List[dict] = []
for src in iter_source_discs(ps1_dir):
if src.suffix.lower() == ".cue":
image = cue_data_track(src) or src
else:
image = src
serial = read_serial(image)
title = src.stem
# A disc is "converted" when an EBOOT's embedded title or filename
# matches the source disc's title/serial.
status = "pending"
if title.lower() in eboot_titles or (serial and any(serial.lower() in t for t in eboot_titles)):
status = "converted"
entries.append({
"kind": "disc",
"path": str(src),
"name": title,
"serial": serial or "",
"status": status,
})
for e in eboots:
entries.append({
"kind": "eboot",
"path": str(e),
"name": eboot_title(e),
"serial": "",
"status": "converted",
})
return entries