159 lines
5.0 KiB
Python
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
|