Add pure-Python PS1 to EBOOT.PBP converter (phase 1)
This commit is contained in:
@@ -6,3 +6,7 @@ data/*.sqlite
|
|||||||
data/*.sqlite-*
|
data/*.sqlite-*
|
||||||
data/credentials.json
|
data/credentials.json
|
||||||
session-transcript-2026-09-01.md
|
session-transcript-2026-09-01.md
|
||||||
|
|
||||||
|
# Copyrighted PSN EBOOTs (base.pbp) used only for local PS1 conversion testing
|
||||||
|
data/*.PBP
|
||||||
|
data/*.pbp
|
||||||
|
|||||||
@@ -0,0 +1,765 @@
|
|||||||
|
"""PS1 -> PSP EBOOT.PBP conversion (pure Python).
|
||||||
|
|
||||||
|
Converts a PlayStation 1 disc image (``.iso``, ``.img``, or a ``.cue`` +
|
||||||
|
``.bin`` set) into the ``EBOOT.PBP`` "PS1 classic" container that PSP POPS
|
||||||
|
firmware can launch.
|
||||||
|
|
||||||
|
This is an independent, MIT-clean reimplementation of the popstation-md
|
||||||
|
algorithm (same format that PSXPackager/RupertAvery writes), implemented from
|
||||||
|
the published container format rather than by copying any GPL source.
|
||||||
|
|
||||||
|
Format reference (offsets are byte-exact):
|
||||||
|
* PBP header - 40 bytes: b"\\x00PBP" magic + version + 8 uint32 section offsets
|
||||||
|
(SFO, ICON0, ICON1, PIC0, PIC1, SND0, DATA.PSP, DATA.PSAR).
|
||||||
|
* PSAR - the DATA.PSAR section holding the disc image. Starts with
|
||||||
|
b"PSISOIMG0000", game id at +0x400, TOC at +0x800,
|
||||||
|
ISO block index at +0x4000, disc data at +0x100000.
|
||||||
|
|
||||||
|
A ``base.pbp`` (any PSN PS1 classic ``EBOOT.PBP``) is REQUIRED to supply two
|
||||||
|
blobs that we cannot synthesise:
|
||||||
|
|
||||||
|
* DATA.PSP - the encrypted POPS loader (header section 8).
|
||||||
|
* STARTDAT - the trailing encrypted block (boot.png + PGD).
|
||||||
|
|
||||||
|
Without a base.pbp the resulting EBOOT will not boot on real hardware; the
|
||||||
|
converter refuses to run without one.
|
||||||
|
|
||||||
|
Compression: only level 0 (uncompressed) is implemented. LZRC (levels 1-9) is a
|
||||||
|
planned fast-follow.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterator, List, Optional, Tuple
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Constants
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PBP_MAGIC = b"\x00PBP"
|
||||||
|
PBP_HEADER_SIZE = 0x28
|
||||||
|
PBP_HEADER_U32 = 10 # magic + version + 8 section offsets
|
||||||
|
|
||||||
|
# Section index -> header offset (in bytes) within the 40-byte header.
|
||||||
|
SECTION_OFFSET = {
|
||||||
|
"sfo": 0x08,
|
||||||
|
"icon0": 0x0C,
|
||||||
|
"icon1": 0x10,
|
||||||
|
"pic0": 0x14,
|
||||||
|
"pic1": 0x18,
|
||||||
|
"snd0": 0x1C,
|
||||||
|
"data_psp": 0x20,
|
||||||
|
"psar": 0x24,
|
||||||
|
}
|
||||||
|
SECTION_ORDER = ["sfo", "icon0", "icon1", "pic0", "pic1", "snd0", "data_psp", "psar"]
|
||||||
|
|
||||||
|
# PSAR (DATA.PSAR) layout.
|
||||||
|
PSAR_MAGIC = b"PSISOIMG0000"
|
||||||
|
PSAR_GAMEID_OFFSET = 0x400
|
||||||
|
PSAR_TOC_OFFSET = 0x800
|
||||||
|
PSAR_INDEX_OFFSET = 0x4000
|
||||||
|
PSAR_ISO_OFFSET = 0x100000
|
||||||
|
PSAR_ALIGNMENT = 0x10000
|
||||||
|
|
||||||
|
# One disc "block" in the ISO index = 16 raw 2352-byte sectors.
|
||||||
|
RAW_SECTOR = 0x930 # 2352
|
||||||
|
COOKED_SECTOR = 0x800 # 2048
|
||||||
|
BLOCK_SIZE = RAW_SECTOR * 16 # 0x9300
|
||||||
|
INDEX_ENTRY_SIZE = 32 # offset u32 + length u32 + 6 unused u32
|
||||||
|
|
||||||
|
SYNC_PATTERN = b"\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00"
|
||||||
|
|
||||||
|
# SFO field formats (PSP PARAM.SFO).
|
||||||
|
FMT_U32 = 0x0004
|
||||||
|
FMT_STRING = 0x0204
|
||||||
|
|
||||||
|
SFO_DEFAULTS = {
|
||||||
|
"BOOTABLE": (FMT_U32, 1),
|
||||||
|
"CATEGORY": (FMT_STRING, "ME"),
|
||||||
|
"DISC_VERSION": (FMT_STRING, "1.00"),
|
||||||
|
"LICENSE": (FMT_STRING, "Library programs Copyright(C) Sony ComputerEntertainment Inc."),
|
||||||
|
"PARENTAL_LEVEL": (FMT_U32, 1),
|
||||||
|
"PSP_SYSTEM_VER": (FMT_STRING, "3.71"),
|
||||||
|
"REGION": (FMT_U32, 0x8000),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ConversionError(Exception):
|
||||||
|
"""Raised when a disc cannot be converted."""
|
||||||
|
|
||||||
|
|
||||||
|
class Cancelled(Exception):
|
||||||
|
"""Raised internally when the caller requests cancellation."""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# CUE sheet parsing
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CueTrack:
|
||||||
|
number: int
|
||||||
|
mode: str # "MODE1/2048", "MODE1/2352", "MODE2/2352", "AUDIO"
|
||||||
|
file: str
|
||||||
|
file_type: str # "BINARY" | "WAVE" | ...
|
||||||
|
indexes: List[Tuple[int, int]] = field(default_factory=list) # (index, msf)
|
||||||
|
sector_size: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def pregap_sectors(self) -> int:
|
||||||
|
"""Sectors between INDEX 00 and INDEX 01 (0 if no pregap)."""
|
||||||
|
idx = dict(self.indexes)
|
||||||
|
if 0 in idx and 1 in idx:
|
||||||
|
return max(0, idx[1] - idx[0])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CueDisc:
|
||||||
|
tracks: List[CueTrack]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_track(self) -> Optional[CueTrack]:
|
||||||
|
for t in self.tracks:
|
||||||
|
if t.mode != "AUDIO":
|
||||||
|
return t
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _msf_to_sectors(msf: str) -> int:
|
||||||
|
m, s, f = (int(x) for x in msf.split(":"))
|
||||||
|
return (m * 60 + s) * 75 + f
|
||||||
|
|
||||||
|
|
||||||
|
def _sector_size_for_mode(mode: str) -> int:
|
||||||
|
if mode in ("MODE1/2048",):
|
||||||
|
return COOKED_SECTOR
|
||||||
|
if mode in ("MODE1/2352", "MODE2/2352", "AUDIO"):
|
||||||
|
return RAW_SECTOR
|
||||||
|
# CDG / MODE2/2336 etc. fall back to raw.
|
||||||
|
return RAW_SECTOR
|
||||||
|
|
||||||
|
|
||||||
|
def parse_cue(text: str) -> CueDisc:
|
||||||
|
"""Parse a CUE sheet into a :class:`CueDisc`.
|
||||||
|
|
||||||
|
Handles the common PS1 layouts: one or more ``FILE`` lines, ``TRACK``
|
||||||
|
lines (``MODE1/2048``, ``MODE1/2352``, ``MODE2/2352``, ``AUDIO``) and
|
||||||
|
``INDEX`` lines (``INDEX 00`` pregap + ``INDEX 01`` start).
|
||||||
|
"""
|
||||||
|
tracks: List[CueTrack] = []
|
||||||
|
current_file: Optional[str] = None
|
||||||
|
current_type = "BINARY"
|
||||||
|
current_track: Optional[CueTrack] = None
|
||||||
|
|
||||||
|
file_re = re.compile(r'^FILE\s+"([^"]+)"\s+(\w+)', re.I)
|
||||||
|
track_re = re.compile(r'^\s*TRACK\s+(\d{1,2})\s+(\S+)', re.I)
|
||||||
|
index_re = re.compile(r'^\s*INDEX\s+(\d{1,2})\s+(\d{1,2}:\d{2}:\d{2})', re.I)
|
||||||
|
|
||||||
|
for raw in text.splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.startswith(("REM", "CATALOG", "CDTEXTFILE", "PERFORMER", "TITLE", "ISRC", "FLAGS", "POSTGAP", "PREGAP")):
|
||||||
|
if line.startswith("PREGAP"):
|
||||||
|
# PREGAP <mm:ss:ff> is equivalent to an INDEX 00 offset.
|
||||||
|
m = re.search(r'PREGAP\s+(\d{1,2}:\d{2}:\d{2})', line, re.I)
|
||||||
|
if m and current_track is not None:
|
||||||
|
current_track.indexes.append((0, _msf_to_sectors(m.group(1))))
|
||||||
|
continue
|
||||||
|
|
||||||
|
m = file_re.match(line)
|
||||||
|
if m:
|
||||||
|
current_file = m.group(1)
|
||||||
|
current_type = m.group(2).upper()
|
||||||
|
current_track = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
m = track_re.match(line)
|
||||||
|
if m:
|
||||||
|
number = int(m.group(1))
|
||||||
|
mode = m.group(2).upper()
|
||||||
|
current_track = CueTrack(
|
||||||
|
number=number,
|
||||||
|
mode=mode,
|
||||||
|
file=current_file or "",
|
||||||
|
file_type=current_type,
|
||||||
|
sector_size=_sector_size_for_mode(mode),
|
||||||
|
)
|
||||||
|
tracks.append(current_track)
|
||||||
|
continue
|
||||||
|
|
||||||
|
m = index_re.match(line)
|
||||||
|
if m and current_track is not None:
|
||||||
|
current_track.indexes.append((int(m.group(1)), _msf_to_sectors(m.group(2))))
|
||||||
|
|
||||||
|
if not tracks:
|
||||||
|
raise ConversionError("CUE sheet contains no tracks")
|
||||||
|
for t in tracks:
|
||||||
|
if not t.file:
|
||||||
|
raise ConversionError(f"track {t.number} has no FILE")
|
||||||
|
return CueDisc(tracks)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Sector normalisation (cooked 2048 -> raw 2352 Mode 2 Form 1)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _to_bcd(value: int) -> int:
|
||||||
|
return ((value // 10) << 4) | (value % 10)
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_cooked_sector(data: bytes, lba: int) -> bytes:
|
||||||
|
"""Expand a 2048-byte cooked sector to a 2352-byte raw Mode2 Form1 sector.
|
||||||
|
|
||||||
|
Rebuilds the 12-byte sync pattern, the 4-byte MSF header (BCD), the
|
||||||
|
8-byte subheader, and appends 280 bytes of EDC/ECC (zero-filled; the PSP
|
||||||
|
POPS loader does not verify EDC/ECC for converted games).
|
||||||
|
"""
|
||||||
|
assert len(data) == COOKED_SECTOR
|
||||||
|
frame = lba + 150 # 150-sector lead-in offset
|
||||||
|
msf = bytes([
|
||||||
|
_to_bcd((frame // 75 // 60) % 100),
|
||||||
|
_to_bcd((frame // 75) % 60),
|
||||||
|
_to_bcd(frame % 75),
|
||||||
|
0x02, # mode 2
|
||||||
|
])
|
||||||
|
# Subheader: file=2, channel=0, submode=0, coding=0; repeated once.
|
||||||
|
subheader = b"\x00\x00\x08\x00" * 2
|
||||||
|
return SYNC_PATTERN + msf + subheader + data + (b"\x00" * 280)
|
||||||
|
|
||||||
|
|
||||||
|
def iter_raw_sectors(
|
||||||
|
path: Path,
|
||||||
|
sector_size: int,
|
||||||
|
pregap_sectors: int = 0,
|
||||||
|
progress_cb=None,
|
||||||
|
cancel_event=None,
|
||||||
|
) -> Iterator[bytes]:
|
||||||
|
"""Yield 2352-byte raw sectors for one track file.
|
||||||
|
|
||||||
|
``sector_size`` is 2048 (cooked; expanded on the fly) or 2352 (raw).
|
||||||
|
``pregap_sectors`` leading zero sectors are emitted first.
|
||||||
|
"""
|
||||||
|
def _cancelled():
|
||||||
|
return cancel_event is not None and cancel_event.is_set()
|
||||||
|
|
||||||
|
if pregap_sectors:
|
||||||
|
for _ in range(pregap_sectors):
|
||||||
|
yield b"\x00" * RAW_SECTOR
|
||||||
|
|
||||||
|
size = path.stat().st_size
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
lba = 0
|
||||||
|
done = 0
|
||||||
|
while True:
|
||||||
|
if _cancelled():
|
||||||
|
raise Cancelled()
|
||||||
|
chunk = fh.read(sector_size)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
if len(chunk) != sector_size:
|
||||||
|
chunk = chunk.ljust(sector_size, b"\x00")
|
||||||
|
if sector_size == COOKED_SECTOR:
|
||||||
|
yield _expand_cooked_sector(chunk, lba)
|
||||||
|
else:
|
||||||
|
yield chunk
|
||||||
|
lba += 1
|
||||||
|
done += sector_size
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb(done, size)
|
||||||
|
if progress_cb:
|
||||||
|
progress_cb(size, size)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# PARAM.SFO writer
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_param_sfo(entries: List[Tuple[str, int, object]]) -> bytes:
|
||||||
|
"""Build a PSP PARAM.SFO from ``(key, fmt, value)`` entries.
|
||||||
|
|
||||||
|
``fmt`` is ``FMT_U32`` (uint32) or ``FMT_STRING`` (utf-8 string).
|
||||||
|
Layout matches the PSP format: header, index table, key table, data table.
|
||||||
|
"""
|
||||||
|
header_size = 20
|
||||||
|
index_size = len(entries) * 16
|
||||||
|
key_table_start = header_size + index_size
|
||||||
|
|
||||||
|
key_table = b"".join(key.encode("utf-8") + b"\x00" for key, _, _ in entries)
|
||||||
|
key_pad = (4 - (len(key_table) % 4)) % 4
|
||||||
|
data_table_start = key_table_start + len(key_table) + key_pad
|
||||||
|
|
||||||
|
data_table = b""
|
||||||
|
index_table = b""
|
||||||
|
key_offset = 0
|
||||||
|
data_offset = 0
|
||||||
|
for key, fmt, value in entries:
|
||||||
|
if fmt == FMT_U32:
|
||||||
|
raw = struct.pack("<I", int(value))
|
||||||
|
length = 4
|
||||||
|
max_len = 4
|
||||||
|
elif fmt == FMT_STRING:
|
||||||
|
s = str(value)
|
||||||
|
raw = s.encode("utf-8") + b"\x00"
|
||||||
|
length = len(s)
|
||||||
|
max_len = len(s) + 1
|
||||||
|
if len(raw) < max_len:
|
||||||
|
raw = raw.ljust(max_len, b"\x00")
|
||||||
|
else:
|
||||||
|
raise ConversionError(f"unsupported SFO format 0x{fmt:04x}")
|
||||||
|
|
||||||
|
index_table += struct.pack(
|
||||||
|
"<HHIII", key_offset, fmt, length, max_len, data_offset
|
||||||
|
)
|
||||||
|
data_table += raw
|
||||||
|
key_offset += len(key) + 1
|
||||||
|
data_offset += len(raw)
|
||||||
|
|
||||||
|
return (
|
||||||
|
b"\x00PSF"
|
||||||
|
+ struct.pack("<IIII", 0x00000101, key_table_start, data_table_start, len(entries))
|
||||||
|
+ index_table
|
||||||
|
+ key_table
|
||||||
|
+ (b"\x00" * key_pad)
|
||||||
|
+ data_table
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_ps1_sfo(title: str, game_id: str) -> bytes:
|
||||||
|
"""A complete PARAM.SFO for a converted PS1 game."""
|
||||||
|
entries = []
|
||||||
|
for key, (fmt, value) in SFO_DEFAULTS.items():
|
||||||
|
entries.append((key, fmt, value))
|
||||||
|
entries.append(("DISC_ID", FMT_STRING, game_id))
|
||||||
|
entries.append(("TITLE", FMT_STRING, title))
|
||||||
|
return build_param_sfo(entries)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Minimal PNG generator (no external image deps)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _png(width: int, height: int, rgb: Tuple[int, int, int]) -> bytes:
|
||||||
|
def chunk(typ: bytes, data: bytes) -> bytes:
|
||||||
|
payload = typ + data
|
||||||
|
return struct.pack(">I", len(data)) + payload + struct.pack(">I", zlib.crc32(payload) & 0xFFFFFFFF)
|
||||||
|
|
||||||
|
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0) # 8-bit RGB
|
||||||
|
raw = b"".join(b"\x00" + bytes(rgb) * width for _ in range(height))
|
||||||
|
return (
|
||||||
|
b"\x89PNG\r\n\x1a\n"
|
||||||
|
+ chunk(b"IHDR", ihdr)
|
||||||
|
+ chunk(b"IDAT", zlib.compress(raw, 9))
|
||||||
|
+ chunk(b"IEND", b"")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_icon0_png() -> bytes:
|
||||||
|
return _png(144, 80, (24, 24, 24))
|
||||||
|
|
||||||
|
|
||||||
|
def default_pic1_png() -> bytes:
|
||||||
|
return _png(480, 272, (10, 10, 16))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# PBP reading (used both for base.pbp extraction and self-test validation)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def read_pbp_header(path: Path) -> List[int]:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
data = fh.read(PBP_HEADER_SIZE)
|
||||||
|
if len(data) < PBP_HEADER_SIZE or data[:4] != PBP_MAGIC:
|
||||||
|
raise ConversionError(f"{path} is not a PBP file (bad magic)")
|
||||||
|
return list(struct.unpack("<10I", data))
|
||||||
|
|
||||||
|
|
||||||
|
def read_pbp_section(path: Path, header: List[int], index: int) -> bytes:
|
||||||
|
start = header[index]
|
||||||
|
end = header[index + 1] if index + 1 < len(header) else None
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
fh.seek(start)
|
||||||
|
data = fh.read() if end is None else fh.read(end - start)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def extract_data_psp(base_pbp: Path) -> bytes:
|
||||||
|
"""Extract the encrypted POPS loader (DATA.PSP section) from a base PBP."""
|
||||||
|
header = read_pbp_header(base_pbp)
|
||||||
|
idx = SECTION_OFFSET["data_psp"] // 4 # header array index (2 + section index)
|
||||||
|
data = read_pbp_section(base_pbp, header, idx)
|
||||||
|
if not data:
|
||||||
|
raise ConversionError(f"{base_pbp} has no DATA.PSP section")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def extract_startdat(base_pbp: Path) -> bytes:
|
||||||
|
"""Extract the trailing STARTDAT block (boot.png + PGD) from a base PBP.
|
||||||
|
|
||||||
|
The STARTDAT offset is discovered the same way the reference writer does:
|
||||||
|
read the u32 at ``psar + 0x0C``, add ``0x50000``, and expect b"STARTDAT"
|
||||||
|
there. Everything from that marker to EOF is copied verbatim.
|
||||||
|
"""
|
||||||
|
header = read_pbp_header(base_pbp)
|
||||||
|
psar_offset = header[SECTION_OFFSET["psar"] // 4] # header array index
|
||||||
|
with open(base_pbp, "rb") as fh:
|
||||||
|
fh.seek(psar_offset + 0x0C)
|
||||||
|
(x,) = struct.unpack("<I", fh.read(4))
|
||||||
|
marker_off = x + 0x50000
|
||||||
|
fh.seek(marker_off)
|
||||||
|
if fh.read(8) != b"STARTDAT":
|
||||||
|
raise ConversionError(f"cannot find STARTDAT in {base_pbp} (not a valid PS1 base PBP?)")
|
||||||
|
fh.seek(marker_off)
|
||||||
|
return fh.read()
|
||||||
|
raise ConversionError("unreachable")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# PBP writer
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _format_psar_game_id(game_id: str) -> str:
|
||||||
|
"""Format a PS1 serial for the PSAR header field.
|
||||||
|
|
||||||
|
The PBP's PARAM.SFO uses the plain 9-char id (``SLPS01762``), while the
|
||||||
|
PSAR header stores the region-delineated form (``_SLPS_01762``).
|
||||||
|
"""
|
||||||
|
m = re.match(r"^([A-Za-z]{4})[_-]?(\d{5})$", game_id)
|
||||||
|
if m:
|
||||||
|
return f"_{m.group(1).upper()}_{m.group(2)}"
|
||||||
|
return game_id
|
||||||
|
|
||||||
|
|
||||||
|
def _pad_to(out, target: int) -> None:
|
||||||
|
"""Write zero bytes until the stream reaches ``target`` (absolute offset)."""
|
||||||
|
delta = target - out.tell()
|
||||||
|
if delta < 0:
|
||||||
|
raise ConversionError(f"internal error: overshot offset by {-delta} bytes")
|
||||||
|
if delta:
|
||||||
|
out.write(b"\x00" * delta)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_gameid_block(game_id: str) -> bytes:
|
||||||
|
"""Best-effort PSAR 0x400 block (game id + reserved header fields).
|
||||||
|
|
||||||
|
TODO(validate): the reference writer overlays the game id onto a fixed
|
||||||
|
3616-byte template that also carries the PS1 TOC at +0x800. We generate a
|
||||||
|
minimal zero-filled block with the game id in place; boot on real hardware
|
||||||
|
must be verified against PSXPackager output.
|
||||||
|
"""
|
||||||
|
game_id = _format_psar_game_id(game_id).encode("ascii")[:11]
|
||||||
|
block = bytearray(PSAR_TOC_OFFSET - PSAR_GAMEID_OFFSET) # 0x400 -> 0x800
|
||||||
|
block[: len(game_id)] = game_id
|
||||||
|
return bytes(block)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_toc_block(title: str, total_sectors: int) -> bytes:
|
||||||
|
"""Best-effort PSAR TOC block at +0x800.
|
||||||
|
|
||||||
|
TODO(validate): the reference writer uses a fixed 11740-byte template with
|
||||||
|
the title overlaid at offset 8. We emit the title + zero padding; the TOC
|
||||||
|
format must be verified against PSXPackager output.
|
||||||
|
"""
|
||||||
|
block = bytearray(PSAR_INDEX_OFFSET - PSAR_TOC_OFFSET) # 0x800 -> 0x4000
|
||||||
|
title = title.encode("ascii", "replace")[:0x80 - 8]
|
||||||
|
block[8: 8 + len(title)] = title
|
||||||
|
# First two u32 of the TOC are zero in the reference template.
|
||||||
|
struct.pack_into("<II", block, 0, 0, total_sectors)
|
||||||
|
return bytes(block)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_psar(
|
||||||
|
out: io.BufferedWriter,
|
||||||
|
disc: "DiscImage",
|
||||||
|
game_id: str,
|
||||||
|
title: str,
|
||||||
|
progress_cb=None,
|
||||||
|
cancel_event=None,
|
||||||
|
) -> None:
|
||||||
|
"""Write the DATA.PSAR section (uncompressed, single-disc).
|
||||||
|
|
||||||
|
All offsets are relative to the start of the PSAR section.
|
||||||
|
"""
|
||||||
|
base = out.tell()
|
||||||
|
iso_size = disc.padded_size()
|
||||||
|
|
||||||
|
# 0x000: PSAR signature; 0x00C: disc size hint.
|
||||||
|
out.write(PSAR_MAGIC)
|
||||||
|
out.write(struct.pack("<I", iso_size + PSAR_ISO_OFFSET))
|
||||||
|
_pad_to(out, base + PSAR_GAMEID_OFFSET)
|
||||||
|
|
||||||
|
out.write(_build_gameid_block(game_id))
|
||||||
|
out.write(_build_toc_block(title, disc.total_sectors()))
|
||||||
|
_pad_to(out, base + PSAR_INDEX_OFFSET)
|
||||||
|
|
||||||
|
# ISO block index: (offset, length) per 0x9300 block. Uncompressed =>
|
||||||
|
# length is always BLOCK_SIZE and offsets increase linearly.
|
||||||
|
block_count = iso_size // BLOCK_SIZE
|
||||||
|
offset = 0
|
||||||
|
for _ in range(block_count):
|
||||||
|
out.write(struct.pack("<II", offset, BLOCK_SIZE))
|
||||||
|
out.write(b"\x00" * (INDEX_ENTRY_SIZE - 8))
|
||||||
|
offset += BLOCK_SIZE
|
||||||
|
|
||||||
|
_pad_to(out, base + PSAR_ISO_OFFSET)
|
||||||
|
|
||||||
|
# Disc data, block by block.
|
||||||
|
buf = bytearray()
|
||||||
|
for sector in disc.iter_raw(progress_cb=progress_cb, cancel_event=cancel_event):
|
||||||
|
buf.extend(sector)
|
||||||
|
if len(buf) == BLOCK_SIZE:
|
||||||
|
out.write(buf)
|
||||||
|
buf = bytearray()
|
||||||
|
if buf:
|
||||||
|
out.write(buf.ljust(BLOCK_SIZE, b"\x00"))
|
||||||
|
|
||||||
|
|
||||||
|
class DiscImage:
|
||||||
|
"""A normalised view of a PS1 disc as a stream of raw 2352-byte sectors."""
|
||||||
|
|
||||||
|
def __init__(self, cue: Optional[CueDisc], path: Path):
|
||||||
|
self.cue = cue
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def total_sectors(self) -> int:
|
||||||
|
if self.cue is None:
|
||||||
|
size = self.path.stat().st_size
|
||||||
|
if size % RAW_SECTOR == 0:
|
||||||
|
return size // RAW_SECTOR
|
||||||
|
return (size + COOKED_SECTOR - 1) // COOKED_SECTOR
|
||||||
|
total = 0
|
||||||
|
base_dir = self.path.parent
|
||||||
|
for track in self.cue.tracks:
|
||||||
|
fpath = base_dir / track.file
|
||||||
|
if fpath.is_file():
|
||||||
|
total += track.pregap_sectors + (fpath.stat().st_size // track.sector_size)
|
||||||
|
return total
|
||||||
|
|
||||||
|
def padded_size(self) -> int:
|
||||||
|
size = self.total_sectors() * RAW_SECTOR
|
||||||
|
if size % BLOCK_SIZE:
|
||||||
|
size += BLOCK_SIZE - (size % BLOCK_SIZE)
|
||||||
|
return size
|
||||||
|
|
||||||
|
def iter_raw(self, progress_cb=None, cancel_event=None) -> Iterator[bytes]:
|
||||||
|
if self.cue is None:
|
||||||
|
yield from iter_raw_sectors(
|
||||||
|
self.path,
|
||||||
|
RAW_SECTOR if self.path.stat().st_size % RAW_SECTOR == 0 else COOKED_SECTOR,
|
||||||
|
progress_cb=progress_cb,
|
||||||
|
cancel_event=cancel_event,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
base_dir = self.path.parent
|
||||||
|
for track in self.cue.tracks:
|
||||||
|
fpath = base_dir / track.file
|
||||||
|
if not fpath.is_file():
|
||||||
|
raise ConversionError(f"track file missing: {fpath}")
|
||||||
|
yield from iter_raw_sectors(
|
||||||
|
fpath,
|
||||||
|
track.sector_size,
|
||||||
|
pregap_sectors=track.pregap_sectors,
|
||||||
|
progress_cb=progress_cb,
|
||||||
|
cancel_event=cancel_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _aligned_up(value: int, alignment: int) -> int:
|
||||||
|
return value + ((alignment - (value % alignment)) % alignment)
|
||||||
|
|
||||||
|
|
||||||
|
def convert(
|
||||||
|
source: Path,
|
||||||
|
title: str,
|
||||||
|
game_id: str,
|
||||||
|
base_pbp: Path,
|
||||||
|
out_path: Path,
|
||||||
|
progress_cb=None,
|
||||||
|
cancel_event=None,
|
||||||
|
) -> Path:
|
||||||
|
"""Convert a PS1 disc image to ``out_path`` (an ``EBOOT.PBP``).
|
||||||
|
|
||||||
|
``source`` may be a ``.cue`` (multi-track supported) or a single
|
||||||
|
``.iso``/``.img``/``.bin``. ``base_pbp`` must be a PSN PS1 classic EBOOT.
|
||||||
|
"""
|
||||||
|
if not base_pbp.is_file():
|
||||||
|
raise ConversionError(f"base.pbp not found: {base_pbp}")
|
||||||
|
if not source.is_file():
|
||||||
|
raise ConversionError(f"disc image not found: {source}")
|
||||||
|
|
||||||
|
game_id = game_id.strip()
|
||||||
|
if not game_id:
|
||||||
|
raise ConversionError("a game id (e.g. SLUS00594) is required")
|
||||||
|
|
||||||
|
if source.suffix.lower() == ".cue":
|
||||||
|
disc: DiscImage = DiscImage(parse_cue(source.read_text(encoding="utf-8", errors="replace")), source)
|
||||||
|
else:
|
||||||
|
disc = DiscImage(None, source)
|
||||||
|
|
||||||
|
# Resources required from the base PBP.
|
||||||
|
data_psp = extract_data_psp(base_pbp)
|
||||||
|
startdat = extract_startdat(base_pbp)
|
||||||
|
|
||||||
|
sfo = default_ps1_sfo(title, game_id)
|
||||||
|
icon0 = default_icon0_png()
|
||||||
|
pic1 = default_pic1_png()
|
||||||
|
|
||||||
|
# Sections in header order; empty sections keep the previous offset.
|
||||||
|
sections = {
|
||||||
|
"sfo": sfo,
|
||||||
|
"icon0": icon0,
|
||||||
|
"icon1": b"",
|
||||||
|
"pic0": b"",
|
||||||
|
"pic1": pic1,
|
||||||
|
"snd0": b"",
|
||||||
|
"data_psp": data_psp,
|
||||||
|
# psar written separately, sized after everything else.
|
||||||
|
"psar": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
header = [0] * PBP_HEADER_U32
|
||||||
|
header[0] = struct.unpack("<I", PBP_MAGIC)[0]
|
||||||
|
header[1] = 0x00010000
|
||||||
|
|
||||||
|
current = PBP_HEADER_SIZE
|
||||||
|
for i, key in enumerate(SECTION_ORDER):
|
||||||
|
header[2 + i] = current
|
||||||
|
if key != "psar":
|
||||||
|
current += len(sections[key])
|
||||||
|
|
||||||
|
# DATA.PSAR must start on a 0x10000 boundary. It is the final section, so
|
||||||
|
# its length is implicit (everything up to STARTDAT/EOF).
|
||||||
|
psar_offset = _aligned_up(header[2 + SECTION_ORDER.index("psar")], PSAR_ALIGNMENT)
|
||||||
|
header[2 + SECTION_ORDER.index("psar")] = psar_offset
|
||||||
|
|
||||||
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
tmp = out_path.with_suffix(out_path.suffix + ".tmp")
|
||||||
|
try:
|
||||||
|
with open(tmp, "wb") as fh:
|
||||||
|
fh.write(struct.pack("<10I", *header))
|
||||||
|
for key in SECTION_ORDER:
|
||||||
|
if key == "psar":
|
||||||
|
break
|
||||||
|
if sections[key]:
|
||||||
|
fh.write(sections[key])
|
||||||
|
|
||||||
|
# Pad up to the aligned PSAR offset.
|
||||||
|
while fh.tell() < psar_offset:
|
||||||
|
fh.write(b"\x00")
|
||||||
|
|
||||||
|
_write_psar(fh, disc, game_id, title, progress_cb=progress_cb, cancel_event=cancel_event)
|
||||||
|
|
||||||
|
# STARTDAT goes at the very end.
|
||||||
|
fh.write(startdat)
|
||||||
|
os.replace(tmp, out_path)
|
||||||
|
except BaseException:
|
||||||
|
try:
|
||||||
|
tmp.unlink(missing_ok=True)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
raise
|
||||||
|
return out_path
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Self-test / CLI
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _self_test() -> None:
|
||||||
|
"""Validate the parts that do not need hardware: SFO round-trip, PNG
|
||||||
|
generation, PBP header round-trip, and cue parsing."""
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# CUE parsing.
|
||||||
|
cue = parse_cue(
|
||||||
|
'FILE "Game (Track 1).bin" BINARY\n'
|
||||||
|
" TRACK 01 MODE2/2352\n"
|
||||||
|
" INDEX 01 00:00:00\n"
|
||||||
|
" TRACK 02 AUDIO\n"
|
||||||
|
" INDEX 00 00:02:00\n"
|
||||||
|
" INDEX 01 00:04:00\n"
|
||||||
|
'FILE "Game (Track 2).bin" BINARY\n'
|
||||||
|
" TRACK 03 AUDIO\n"
|
||||||
|
" INDEX 01 00:00:00\n"
|
||||||
|
)
|
||||||
|
assert len(cue.tracks) == 3, "expected 3 tracks"
|
||||||
|
assert cue.tracks[0].mode == "MODE2/2352"
|
||||||
|
assert cue.tracks[1].pregap_sectors == 150, "expected 2s (150 sector) pregap"
|
||||||
|
assert cue.data_track is cue.tracks[0]
|
||||||
|
|
||||||
|
# SFO round-trip through the project's own parser.
|
||||||
|
from . import iso_reader # local import to keep the module importable standalone
|
||||||
|
|
||||||
|
sfo = default_ps1_sfo("Final Fantasy VII", "SCUS94163")
|
||||||
|
parsed = iso_reader.parse_sfo(sfo)
|
||||||
|
assert parsed.get("TITLE") == "Final Fantasy VII", parsed
|
||||||
|
assert parsed.get("DISC_ID") == "SCUS94163", parsed
|
||||||
|
assert parsed.get("CATEGORY") == "ME", parsed
|
||||||
|
assert parsed.get("BOOTABLE") == 1, parsed
|
||||||
|
|
||||||
|
# PNG generation produces valid PNG signatures + IHDR dimensions.
|
||||||
|
icon = default_icon0_png()
|
||||||
|
assert icon[:8] == b"\x89PNG\r\n\x1a\n"
|
||||||
|
assert struct.unpack(">II", icon[16:24]) == (144, 80)
|
||||||
|
pic = default_pic1_png()
|
||||||
|
assert struct.unpack(">II", pic[16:24]) == (480, 272)
|
||||||
|
|
||||||
|
# PBP header round-trip: write a synthetic PBP and read it back.
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
p = Path(td) / "test.pbp"
|
||||||
|
header = [0] * 10
|
||||||
|
header[0] = struct.unpack("<I", PBP_MAGIC)[0]
|
||||||
|
header[1] = 0x00010000
|
||||||
|
header[2] = 0x28
|
||||||
|
header[3] = 0x28 + len(sfo)
|
||||||
|
with open(p, "wb") as fh:
|
||||||
|
fh.write(struct.pack("<10I", *header))
|
||||||
|
fh.write(sfo)
|
||||||
|
assert read_pbp_header(p)[0] == header[0]
|
||||||
|
assert read_pbp_section(p, header, 2) == sfo
|
||||||
|
|
||||||
|
print("ps1_convert self-test OK")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Convert a PS1 disc image to a PSP EBOOT.PBP")
|
||||||
|
ap.add_argument("--source", help="path to .cue or a single .iso/.img/.bin")
|
||||||
|
ap.add_argument("--title", help="game title shown on the PSP")
|
||||||
|
ap.add_argument("--game-id", help="PS1 game id, e.g. SLUS00594")
|
||||||
|
ap.add_argument("--base-pbp", help="path to a PSN PS1 classic EBOOT.PBP (required)")
|
||||||
|
ap.add_argument("--out", help="output EBOOT.PBP path")
|
||||||
|
ap.add_argument("--test", action="store_true", help="run self-test and exit")
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
if args.test:
|
||||||
|
_self_test()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not all([args.source, args.title, args.game_id, args.base_pbp, args.out]):
|
||||||
|
ap.error("--source --title --game-id --base-pbp and --out are all required (or use --test)")
|
||||||
|
|
||||||
|
try:
|
||||||
|
convert(
|
||||||
|
Path(args.source),
|
||||||
|
args.title,
|
||||||
|
args.game_id,
|
||||||
|
Path(args.base_pbp),
|
||||||
|
Path(args.out),
|
||||||
|
)
|
||||||
|
print(f"wrote {args.out}")
|
||||||
|
return 0
|
||||||
|
except ConversionError as exc:
|
||||||
|
print(f"error: {exc}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user