Compare commits

...
6 Commits
Author SHA1 Message Date
quoije 02ed99c9c7 library: only show converted PS1 EBOOTs
Pending PS1 source discs stay in the PS1 tab; the Library now lists only
converted EBOOT entries.
2026-09-07 18:07:08 -04:00
quoije bcc2f4f73c gui: switch to Queue tab on PS1 convert; PS1 Category Lite folder
- _on_ps1_convert now opens the Queue tab so the user sees the conversion
  job start when converting from the Library or PS1 tab.
- Add a 'PS1 category (Category Lite)' setting (ps1_category) alongside the
  existing ISO category; PS1 EBOOTs copy to /PSP/GAME/<category>/<ID>/
  when set, matching the ISO category behaviour.
2026-09-07 18:05:53 -04:00
quoije f61a77a31f ps1: bundle plain-ELF POPS loader + KEYS.BIN (iPoPS, MIT)
Fix 80020148 'game could not be started' on ARK-5: the base PBP's encrypted
~PSP loader fails to link on modern CFW. Use the bundled plain-ELF loader
(data/loader/DATA.PSP) as the DATA.PSP section instead, and write the 16-byte
NPDRM version key (data/loader/KEYS.BIN) alongside the EBOOT so CFW can
decrypt the STARTDAT PGD. Both assets are from iPoPS (MIT, attributed).

- load_data_psp: prefer the bundled ELF, fall back to the base ~PSP loader.
- load_keys_bin: return the bundled 16-byte key when present.
- convert: write KEYS.BIN next to the output EBOOT.
- README + data/loader/ATTRIBUTION.md: document the loader and its license.
2026-09-07 17:44:54 -04:00
quoije ea08f52d44 ps1: general STARTDAT lookup + warn on old base.pbp loader
- extract_startdat: locate STARTDAT at PSAR + p1 (the u32 at PSAR+0x0C)
  instead of popstation's p1 + 0x50000 absolute offset, which only worked
  for bases whose PSAR was aligned to 0x50000. Any PS1 classic can now be
  used as a base.
- _warn_old_base: print a warning when the base PBP is a 3.xx-era classic,
  since its POPS loader often fails to link on 6.60/6.61 CFW (error
  80020148 'game could not be started').
- README: document the 6.60/6.61 base preference and DEFLATE compression.
2026-09-07 17:36:16 -04:00
quoije e50cd02f2a ps1: compress disc data (raw DEFLATE) for real-hardware boot
POPS on real PSP hardware refuses to boot uncompressed (level-0) PSARs
(noted in pop-fe: 'Uncompressed EBOOTs don't work'), which caused 'the game
cannot be started'. Compress each 0x9300 disc block with raw DEFLATE and
switch the index table to the pop-fe layout (offset u32, length u16, flags
u8, SHA-1[16]); patch p1/p2 after the compressed size is known.

- _write_disc_block: deflate each block; store raw bytes when a block does
  not shrink.
- _write_psar: reserve + patch the index table and p1/p2.
- convert/convert_to_folder/CLI: complevel parameter (default 1).
- Reuse the base PBP's ICON0 and drop generated PIC1 to match popstation.
- Self-test: add a DEFLATE round-trip with index/SHA-1 checks.
2026-09-07 17:05:31 -04:00
quoije 2f1fc760a1 ps1: byte-exact popstation output; fix TOC, DATA.PSP, SFO, pregap
Fix the EBOOT.PBP 'data is corrupted' boot failure on CFW by making the
converter match popstation byte-for-byte where it counts:

- build_toc: A0 entry byte 8 is the raw Red Book disc-type bitfield 0x20
  (CD-ROM XA), not BCD(0x20)=0x32; A-point entries use control 0x41.
- extract_data_psp: copy only prx_size (u32 at DATA.PSP+0x2C), not the
  whole zero-padded section.
- PSAR_ALIGNMENT: 0x50000 (matches popstation and the base PBP).
- SFO: patch the base PBP's PARAM.SFO in place (title + DISC_ID) instead
  of rebuilding, preserving integer field types (0x0404) and values.
- Cue pregap: INDEX 00 pregap is already inside the bin file, so do not
  re-emit zero sectors; only a PREGAP command adds external pregap.
  Track starts/lead-out now match PSXPackager's merged-cue output.
2026-09-07 16:50:52 -04:00
6 changed files with 393 additions and 72 deletions
+17 -9
View File
@@ -93,8 +93,10 @@ Discs are matched by hash first (most precise), then serial, then filename.
- `data/covers/` — checked-in assets: the UMD video/game cover templates, - `data/covers/` — checked-in assets: the UMD video/game cover templates,
`libretro-index.json` + `openvgdb-index.json` catalogs, `libretro-covers.zip` + `libretro-index.json` + `openvgdb-index.json` catalogs, `libretro-covers.zip` +
`openvgdb-covers.zip` offline cover bundles, and the legacy cover cache. `openvgdb-covers.zip` offline cover bundles, and the legacy cover cache.
- `data/base.PBP` — a PSN PS1 classic EBOOT (any game) used as the encrypted - `data/base.PBP` — a PSN PS1 classic EBOOT (any game) supplying `STARTDAT`
loader when converting PS1 discs to `EBOOT.PBP` (git-ignored, copyrighted). + a base `PARAM.SFO`/icon for PS1 conversion (git-ignored, copyrighted).
- `data/loader/DATA.PSP` + `data/loader/KEYS.BIN` — bundled plain-ELF POPS
loader and NPDRM version key (from iPoPS, MIT).
- State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`): - State dir (`~/.local/state/psp-preserve/` by default, or `$XDG_STATE_HOME/psp-preserve`):
- `library.sqlite` — the catalog + library database. - `library.sqlite` — the catalog + library database.
- `covers/` — downloaded cover cache. - `covers/` — downloaded cover cache.
@@ -188,16 +190,22 @@ PS1 discs (pending) and converted EBOOTs also show up in the **Library** tab
- **Serial detection** — reads `SYSTEM.CNF` to derive the game ID (e.g. - **Serial detection** — reads `SYSTEM.CNF` to derive the game ID (e.g.
`SLUS-00001`) and writes it into `PARAM.SFO` + the PSAR header. `SLUS-00001`) and writes it into `PARAM.SFO` + the PSAR header.
- **base.pbp** — conversion needs a PSN PS1 classic `EBOOT.PBP` (any game) to - **base.pbp** — conversion needs a PSN PS1 classic `EBOOT.PBP` (any game) to
supply Sony's encrypted `DATA.PSP` loader. Copy one to `data/base.PBP` and supply the `STARTDAT` block (boot logo + PGD) and a base `PARAM.SFO`/icon.
point **Settings → General → PS1 base.pbp** at it. Copy one to `data/base.PBP` and point **Settings → General → PS1 base.pbp**
- **Output** — a folder `<out>/<Game ID>/` containing a single `EBOOT.PBP` at it. Any PS1 classic works structurally — the converter finds `STARTDAT`
(the popstation way: uncompressed level-0 PSAR, no `KEYS.BIN`/`DOCUMENT.DAT`), at `PSAR + p1` regardless of PSAR alignment.
ready to copy to `<PSP>/PSP/GAME/<Game ID>/`. Compatible with CFW POPS - **POPS loader** — the `DATA.PSP` section uses a bundled plain-ELF loader
(ARK-4). (`data/loader/DATA.PSP`, from iPoPS, MIT) so the EBOOT boots on modern CFW
without the base PBP's encrypted loader failing to link (`80020148`). The
base PBP's own loader is only used as a fallback when the bundle is missing.
- **Output** — a folder `<out>/<Game ID>/` containing `EBOOT.PBP` (raw-DEFLATE
compressed PSAR) plus a `KEYS.BIN` (NPDRM version key used to decrypt the
`STARTDAT` PGD), ready to copy to `<PSP>/PSP/GAME/<Game ID>/`. Compatible
with CFW POPS (ARK-4/ARK-5).
Configure the folders in **Settings → General** (or `PSP_PS1_ROOTS` / Configure the folders in **Settings → General** (or `PSP_PS1_ROOTS` /
`PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc `PSP_PS1_OUTPUT_DIR`). Current limits: single-disc games only (no multi-disc
PBP), and images are stored uncompressed (LZRC compression is on the roadmap). PBP); disc data is DEFLATE-compressed (level 1 by default, `--complevel 0-9`).
## Store — archive.org and local folder ## Store — archive.org and local folder
+20 -6
View File
@@ -1055,7 +1055,7 @@ class SettingsPage(QWidget):
def __init__(self, games_dir, videos_dir, store_dir, ppsspp_dir, cloud_dir, ppsspp_exe, def __init__(self, games_dir, videos_dir, store_dir, ppsspp_dir, cloud_dir, ppsspp_exe,
store_server_url, store_token, store_archive_id, store_archive_sig, store_archive_user, store_server_url, store_token, store_archive_id, store_archive_sig, store_archive_user,
iso_category, ps1_dir, ps1_output_dir, ps1_base_pbp, parent=None): iso_category, ps1_category, ps1_dir, ps1_output_dir, ps1_base_pbp, parent=None):
super().__init__(parent) super().__init__(parent)
outer = QVBoxLayout(self) outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0) outer.setContentsMargins(0, 0, 0, 0)
@@ -1107,6 +1107,9 @@ class SettingsPage(QWidget):
self.iso_category_edit = QLineEdit(iso_category) self.iso_category_edit = QLineEdit(iso_category)
self.iso_category_edit.setPlaceholderText("e.g. Homebrew (blank = ISO root)") self.iso_category_edit.setPlaceholderText("e.g. Homebrew (blank = ISO root)")
form.addRow("ISO category (Category Lite):", self.iso_category_edit) form.addRow("ISO category (Category Lite):", self.iso_category_edit)
self.ps1_category_edit = QLineEdit(ps1_category)
self.ps1_category_edit.setPlaceholderText("e.g. PS1 (blank = PSP/GAME root)")
form.addRow("PS1 category (Category Lite):", self.ps1_category_edit)
self.tabs.addTab(page, "PSP") self.tabs.addTab(page, "PSP")
# -- Cover sources ------------------------------------------------ # # -- Cover sources ------------------------------------------------ #
@@ -1259,6 +1262,9 @@ class SettingsPage(QWidget):
def iso_category(self): def iso_category(self):
return self.iso_category_edit.text().strip() return self.iso_category_edit.text().strip()
def ps1_category(self):
return self.ps1_category_edit.text().strip()
def ps1_dir(self): def ps1_dir(self):
return self.ps1_edit.text().strip() return self.ps1_edit.text().strip()
@@ -3121,7 +3127,7 @@ def build_library_items(ps1_dir="", output_dir=""):
def _ps1_library_items(ps1_dir, output_dir, overrides, dates): def _ps1_library_items(ps1_dir, output_dir, overrides, dates):
"""PS1 discs (pending) + converted EBOOTs, merged into the library list.""" """Converted PS1 EBOOTs, merged into the library list."""
if not ps1_dir: if not ps1_dir:
return [] return []
try: try:
@@ -3130,8 +3136,9 @@ def _ps1_library_items(ps1_dir, output_dir, overrides, dates):
return [] return []
out = [] out = []
for e in entries: for e in entries:
# A converted disc is represented by its EBOOT entry (one row per game). # Only converted EBOOTs are shown in the Library; pending source discs
if e["kind"] == "disc" and e.get("status") == "converted": # live in the PS1 tab, where they can be converted.
if e["kind"] != "eboot":
continue continue
name = e["name"] name = e["name"]
rid = covers.stable_key(name) rid = covers.stable_key(name)
@@ -3467,6 +3474,7 @@ class MainWindow(QMainWindow):
self._auto_sync_saves = str(self.settings.value("auto_sync_saves", "false")).lower() == "true" self._auto_sync_saves = str(self.settings.value("auto_sync_saves", "false")).lower() == "true"
self._ppsspp_exe = str(self.settings.value("ppsspp_exe", "") or "") self._ppsspp_exe = str(self.settings.value("ppsspp_exe", "") or "")
self._iso_category = str(self.settings.value("iso_category", "") or "") self._iso_category = str(self.settings.value("iso_category", "") or "")
self._ps1_category = str(self.settings.value("ps1_category", "") or "")
self._ps1_dir = self._first_existing(config.PS1_ROOTS) or str(self.settings.value("ps1_dir", "") or "") self._ps1_dir = self._first_existing(config.PS1_ROOTS) or str(self.settings.value("ps1_dir", "") or "")
self._ps1_output_dir = str(self.settings.value("ps1_output_dir", str(config.PS1_OUTPUT_DIR)) or "") self._ps1_output_dir = str(self.settings.value("ps1_output_dir", str(config.PS1_OUTPUT_DIR)) or "")
self._ps1_base_pbp = str(self.settings.value("ps1_base_pbp", "") or "") self._ps1_base_pbp = str(self.settings.value("ps1_base_pbp", "") or "")
@@ -3678,6 +3686,7 @@ class MainWindow(QMainWindow):
self._ppsspp_saves_dir, self._cloud_saves_dir, self._ppsspp_exe, self._ppsspp_saves_dir, self._cloud_saves_dir, self._ppsspp_exe,
self._store_server_url, self._store_token, self._store_archive_id, self._store_server_url, self._store_token, self._store_archive_id,
self._store_archive_sig, self._store_archive_user, self._iso_category, self._store_archive_sig, self._store_archive_user, self._iso_category,
self._ps1_category,
self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp) self._ps1_dir, self._ps1_output_dir, self._ps1_base_pbp)
self.settings_page.saved.connect(self._on_settings_saved) self.settings_page.saved.connect(self._on_settings_saved)
return self.settings_page return self.settings_page
@@ -3707,6 +3716,7 @@ class MainWindow(QMainWindow):
self._auto_sync_saves = self.settings_page.auto_sync_saves() self._auto_sync_saves = self.settings_page.auto_sync_saves()
self._ppsspp_exe = self.settings_page.ppsspp_exe() self._ppsspp_exe = self.settings_page.ppsspp_exe()
self._iso_category = self.settings_page.iso_category() self._iso_category = self.settings_page.iso_category()
self._ps1_category = self.settings_page.ps1_category()
self._ps1_dir = self.settings_page.ps1_dir() self._ps1_dir = self.settings_page.ps1_dir()
self._ps1_output_dir = self.settings_page.ps1_output_dir() self._ps1_output_dir = self.settings_page.ps1_output_dir()
self._ps1_base_pbp = self.settings_page.ps1_base_pbp() self._ps1_base_pbp = self.settings_page.ps1_base_pbp()
@@ -3719,6 +3729,7 @@ class MainWindow(QMainWindow):
self.settings.setValue("auto_sync_saves", self._auto_sync_saves) self.settings.setValue("auto_sync_saves", self._auto_sync_saves)
self.settings.setValue("ppsspp_exe", self._ppsspp_exe) self.settings.setValue("ppsspp_exe", self._ppsspp_exe)
self.settings.setValue("iso_category", self._iso_category) self.settings.setValue("iso_category", self._iso_category)
self.settings.setValue("ps1_category", self._ps1_category)
self._update_saves_locations() self._update_saves_locations()
self._update_dir_status() self._update_dir_status()
@@ -4124,9 +4135,11 @@ class MainWindow(QMainWindow):
if system == "videos": if system == "videos":
dest_dir = psp_root / "ISO" / "VIDEO" dest_dir = psp_root / "ISO" / "VIDEO"
elif system == "ps1": elif system == "ps1":
# PS1 EBOOTs install as a whole game folder under /PSP/GAME. # PS1 EBOOTs install as a whole game folder under /PSP/GAME
# (optionally nested in a Category Lite folder).
src_folder = Path(item["path"]).parent src_folder = Path(item["path"]).parent
dest_dir = psp_root / "PSP" / "GAME" ps1_cat = "".join(c for c in (self._ps1_category or "").strip() if c not in "/\\")
dest_dir = (psp_root / "PSP" / "GAME" / ps1_cat) if ps1_cat else (psp_root / "PSP" / "GAME")
log.info("copying ps1 game to psp: %s -> %s", src_folder, dest_dir / src_folder.name) log.info("copying ps1 game to psp: %s -> %s", src_folder, dest_dir / src_folder.name)
self.queue.enqueue(transfers.TreeCopySource(str(src_folder)), dest_dir, self.queue.enqueue(transfers.TreeCopySource(str(src_folder)), dest_dir,
{"dest_name": src_folder.name}, item.get("name") or src_folder.name, {"dest_name": src_folder.name}, item.get("name") or src_folder.name,
@@ -4249,6 +4262,7 @@ class MainWindow(QMainWindow):
out_dir = Path(self._ps1_output_dir) if self._ps1_output_dir else Path.cwd() / "ps1_converted" out_dir = Path(self._ps1_output_dir) if self._ps1_output_dir else Path.cwd() / "ps1_converted"
source = transfers.Ps1ConvertSource(self._ps1_base_pbp) source = transfers.Ps1ConvertSource(self._ps1_base_pbp)
self.queue.enqueue(source, out_dir, item, item.get("title") or item["source"], kind="ps1-convert") self.queue.enqueue(source, out_dir, item, item.get("title") or item["source"], kind="ps1-convert")
self.tabs.setCurrentWidget(self.queue_page)
def _on_queue_job_done(self, job): def _on_queue_job_done(self, job):
if self._closing: if self._closing:
+325 -57
View File
@@ -15,22 +15,27 @@ Format reference (offsets are byte-exact):
b"PSISOIMG0000", game id at +0x400, TOC at +0x800, b"PSISOIMG0000", game id at +0x400, TOC at +0x800,
ISO block index at +0x4000, disc data at +0x100000. ISO block index at +0x4000, disc data at +0x100000.
A ``base.pbp`` (any PSN PS1 classic ``EBOOT.PBP``) is REQUIRED to supply two A ``base.pbp`` (any PSN PS1 classic ``EBOOT.PBP``) supplies the blobs that we
blobs that we cannot synthesise: cannot synthesise:
* DATA.PSP - the encrypted POPS loader (header section 8).
* STARTDAT - the trailing encrypted block (boot.png + PGD). * STARTDAT - the trailing encrypted block (boot.png + PGD).
* DATA.PSP - fallback only: the encrypted POPS loader, used when the
bundled plain-ELF loader (``data/loader/DATA.PSP``, from
iPoPS/MIT) is unavailable.
Without a base.pbp the resulting EBOOT will not boot on real hardware; the Without a base.pbp the resulting EBOOT will not boot on real hardware; the
converter refuses to run without one. converter refuses to run without one.
Compression: only level 0 (uncompressed) is implemented. LZRC (levels 1-9) is a Compression: the disc data is split into 0x9300-byte blocks and deflated
planned fast-follow. (raw DEFLATE, no zlib wrapper) at the configured level (default 1). POPS on
real PSP hardware refuses to boot uncompressed (level-0) PSARs, so level 0
is kept only for diagnostics; a valid conversion always uses level >= 1.
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import hashlib
import io import io
import os import os
import re import re
@@ -67,7 +72,9 @@ PSAR_GAMEID_OFFSET = 0x400
PSAR_TOC_OFFSET = 0x800 PSAR_TOC_OFFSET = 0x800
PSAR_INDEX_OFFSET = 0x4000 PSAR_INDEX_OFFSET = 0x4000
PSAR_ISO_OFFSET = 0x100000 PSAR_ISO_OFFSET = 0x100000
PSAR_ALIGNMENT = 0x10000 # popstation aligns the DATA.PSAR section to a 0x50000 boundary (the base PBP
# also places it there). Some POPS loaders rely on this alignment.
PSAR_ALIGNMENT = 0x50000
# One disc "block" in the ISO index = 16 raw 2352-byte sectors. # One disc "block" in the ISO index = 16 raw 2352-byte sectors.
RAW_SECTOR = 0x930 # 2352 RAW_SECTOR = 0x930 # 2352
@@ -77,6 +84,14 @@ 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" SYNC_PATTERN = b"\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\x00"
# Bundled plain-ELF POPS loader + NPDRM key (from iPoPS, MIT). Modern CFW
# (ARK/PRO/ME) loads this ELF directly as the DATA.PSP section, sidestepping
# the base PBP's encrypted ~PSP loader which frequently fails to link on
# 6.60/6.61 (error 80020148 "game could not be started").
_LOADER_DIR = Path(__file__).resolve().parent.parent / "data" / "loader"
BUNDLED_DATA_PSP = _LOADER_DIR / "DATA.PSP"
BUNDLED_KEYS_BIN = _LOADER_DIR / "KEYS.BIN"
# SFO field formats (PSP PARAM.SFO). # SFO field formats (PSP PARAM.SFO).
FMT_U32 = 0x0004 FMT_U32 = 0x0004
FMT_STRING = 0x0204 FMT_STRING = 0x0204
@@ -112,10 +127,17 @@ class CueTrack:
file_type: str # "BINARY" | "WAVE" | ... file_type: str # "BINARY" | "WAVE" | ...
indexes: List[Tuple[int, int]] = field(default_factory=list) # (index, msf) indexes: List[Tuple[int, int]] = field(default_factory=list) # (index, msf)
sector_size: int = 0 sector_size: int = 0
external_pregap: int = 0 # sectors from a PREGAP command (not in the file)
@property @property
def pregap_sectors(self) -> int: def pregap_sectors(self) -> int:
"""Sectors between INDEX 00 and INDEX 01 (0 if no pregap).""" """Sectors between INDEX 00 and INDEX 01 (the in-file pregap).
This is informational only: when both INDEX 00 and INDEX 01 are
present in the same FILE, the pregap data is already inside the file
and must not be re-emitted. A ``PREGAP`` command, by contrast, is a
pregap that is *not* present in the file (see ``external_pregap``).
"""
idx = dict(self.indexes) idx = dict(self.indexes)
if 0 in idx and 1 in idx: if 0 in idx and 1 in idx:
return max(0, idx[1] - idx[0]) return max(0, idx[1] - idx[0])
@@ -168,10 +190,11 @@ def parse_cue(text: str) -> CueDisc:
line = raw.strip() line = raw.strip()
if not line or line.startswith(("REM", "CATALOG", "CDTEXTFILE", "PERFORMER", "TITLE", "ISRC", "FLAGS", "POSTGAP", "PREGAP")): if not line or line.startswith(("REM", "CATALOG", "CDTEXTFILE", "PERFORMER", "TITLE", "ISRC", "FLAGS", "POSTGAP", "PREGAP")):
if line.startswith("PREGAP"): if line.startswith("PREGAP"):
# PREGAP <mm:ss:ff> is equivalent to an INDEX 00 offset. # PREGAP <mm:ss:ff> is a pregap NOT stored in the track file;
# the writer must emit that many leading zero sectors.
m = re.search(r'PREGAP\s+(\d{1,2}:\d{2}:\d{2})', line, re.I) m = re.search(r'PREGAP\s+(\d{1,2}:\d{2}:\d{2})', line, re.I)
if m and current_track is not None: if m and current_track is not None:
current_track.indexes.append((0, _msf_to_sectors(m.group(1)))) current_track.external_pregap = _msf_to_sectors(m.group(1))
continue continue
m = file_re.match(line) m = file_re.match(line)
@@ -348,6 +371,93 @@ def default_ps1_sfo(title: str, game_id: str) -> bytes:
return build_param_sfo(entries) return build_param_sfo(entries)
def extract_sfo(base_pbp: Path) -> bytes:
"""Return the PARAM.SFO section of a base PBP."""
header = read_pbp_header(base_pbp)
idx = SECTION_OFFSET["sfo"] // 4
return read_pbp_section(base_pbp, header, idx)
def extract_icon0(base_pbp: Path) -> bytes:
"""Return the ICON0.PNG section of a base PBP."""
header = read_pbp_header(base_pbp)
idx = SECTION_OFFSET["icon0"] // 4
return read_pbp_section(base_pbp, header, idx)
def _sfo_entry(base_sfo: bytes, key: str) -> Optional[bytes]:
"""Return the raw data of an SFO entry, or ``None`` if absent."""
if len(base_sfo) < 20 or base_sfo[:4] != b"\x00PSF":
return None
_, _, key_table_off, data_table_off, nitems = struct.unpack("<IIIII", base_sfo[:20])
for i in range(nitems):
entry_off = 20 + i * 16
koff, _, _, max_len, doff = struct.unpack("<HHIII", base_sfo[entry_off:entry_off + 16])
kend = base_sfo.find(b"\x00", key_table_off + koff)
k = bytes(base_sfo[key_table_off + koff:kend]).decode("ascii", "replace")
if k == key:
return base_sfo[data_table_off + doff: data_table_off + doff + max_len]
return None
def _warn_old_base(base_pbp: Path) -> None:
"""Warn when the base PBP carries a very old POPS loader.
PS1 classics from the 3.xx firmware era ship a POPS loader that often
fails to link on 6.60/6.61 CFW (the PSP reports error 80020148), so
newer base PBPs are strongly preferred.
"""
try:
raw = _sfo_entry(extract_sfo(base_pbp), "PSP_SYSTEM_VER")
if not raw:
return
version = raw.rstrip(b"\x00").decode("ascii", "replace")
major = version.split(".")[0]
if major.isdigit() and int(major) < 6:
import sys
print(
f"warning: base PBP is a {version}-era PS1 classic; its POPS loader "
"may fail to load on 6.60/6.61 CFW (error 80020148). "
"Prefer a newer base.pbp.",
file=sys.stderr,
)
except Exception:
pass
def patch_sfo(base_sfo: bytes, title: str, game_id: str) -> bytes:
"""Patch ``TITLE`` and ``DISC_ID`` in a base PARAM.SFO in place.
The reference writer reuses the base PBP's SFO verbatim (preserving the
original field types, sizes and values) and only rewrites the title and
game id. This keeps the output byte-compatible with official PSN classics
(e.g. integer fields stay ``0x0404`` rather than ``0x0004``).
"""
if len(base_sfo) < 20 or base_sfo[:4] != b"\x00PSF":
raise ConversionError("base PBP has no valid PARAM.SFO")
_, _, key_table_off, data_table_off, nitems = struct.unpack("<IIIII", base_sfo[:20])
sfo = bytearray(base_sfo)
patches = {"TITLE": title.encode("utf-8"), "DISC_ID": game_id.encode("ascii")}
for i in range(nitems):
entry_off = 20 + i * 16
koff, _, _, max_len, doff = struct.unpack("<HHIII", sfo[entry_off:entry_off + 16])
key_end = sfo.find(b"\x00", key_table_off + koff)
key = bytes(sfo[key_table_off + koff:key_end]).decode("ascii", "replace")
if key not in patches:
continue
value = patches[key]
# strncpy semantics: value + zero padding, capped at the entry's size.
raw = value[:max_len].ljust(max_len, b"\x00")
sfo[data_table_off + doff: data_table_off + doff + max_len] = raw
# SFO string length includes the NUL terminator (matches the base SFO
# and the reference SetSFOTitle implementation).
new_len = min(len(value) + 1, max_len)
struct.pack_into("<I", sfo, entry_off + 4, new_len)
return bytes(sfo)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Minimal PNG generator (no external image deps) # Minimal PNG generator (no external image deps)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -397,28 +507,61 @@ def read_pbp_section(path: Path, header: List[int], index: int) -> bytes:
def extract_data_psp(base_pbp: Path) -> bytes: def extract_data_psp(base_pbp: Path) -> bytes:
"""Extract the encrypted POPS loader (DATA.PSP section) from a base PBP.""" """Extract the encrypted POPS loader (DATA.PSP) from a base PBP.
The reference writer reads the ``~PSP`` header's total-size field
(u32 at DATA.PSP + 0x2C, the 12th u32) and copies exactly that many
bytes. The section in the source PBP is padded with zeros up to the PSAR
offset; copying that padding would bloat the DATA.PSP section for no
reason, so we copy only ``prx_size`` bytes, matching popstation.
"""
header = read_pbp_header(base_pbp) header = read_pbp_header(base_pbp)
idx = SECTION_OFFSET["data_psp"] // 4 # header array index (2 + section index) idx = SECTION_OFFSET["data_psp"] // 4 # header array index (2 + section index)
data = read_pbp_section(base_pbp, header, idx) section = read_pbp_section(base_pbp, header, idx)
if not data: if len(section) < 0x30:
raise ConversionError(f"{base_pbp} has no DATA.PSP section") raise ConversionError(f"{base_pbp} has no DATA.PSP section")
return data if section[:4] != b"~PSP":
raise ConversionError(f"{base_pbp} DATA.PSP has no ~PSP header")
(prx_size,) = struct.unpack("<I", section[0x2C:0x30])
if prx_size == 0 or prx_size > len(section):
prx_size = len(section)
return section[:prx_size]
def load_data_psp(base_pbp: Path) -> bytes:
"""Return the POPS loader (DATA.PSP) to embed.
Prefers the bundled plain-ELF loader (MIT, from iPoPS), which modern CFW
can load without ~PSP decryption. Falls back to the base PBP's encrypted
~PSP loader when the bundle is absent.
"""
if BUNDLED_DATA_PSP.is_file():
return BUNDLED_DATA_PSP.read_bytes()
return extract_data_psp(base_pbp)
def load_keys_bin() -> Optional[bytes]:
"""Return the bundled NPDRM version key (KEYS.BIN), if available."""
if BUNDLED_KEYS_BIN.is_file():
return BUNDLED_KEYS_BIN.read_bytes()
return None
def extract_startdat(base_pbp: Path) -> bytes: def extract_startdat(base_pbp: Path) -> bytes:
"""Extract the trailing STARTDAT block (boot.png + PGD) from a base PBP. """Extract the trailing STARTDAT block (boot.png + PGD) from a base PBP.
The STARTDAT offset is discovered the same way the reference writer does: STARTDAT sits immediately after the disc data: at ``PSAR + p1`` where
read the u32 at ``psar + 0x0C``, add ``0x50000``, and expect b"STARTDAT" ``p1`` is the u32 at ``PSAR + 0x0C``. (The original popstation computes
there. Everything from that marker to EOF is copied verbatim. ``p1 + 0x50000`` as an absolute offset, which only works for bases whose
PSAR happens to be aligned to 0x50000; using ``PSAR + p1`` works for any
PS1 classic.)
""" """
header = read_pbp_header(base_pbp) header = read_pbp_header(base_pbp)
psar_offset = header[SECTION_OFFSET["psar"] // 4] # header array index psar_offset = header[SECTION_OFFSET["psar"] // 4] # header array index
with open(base_pbp, "rb") as fh: with open(base_pbp, "rb") as fh:
fh.seek(psar_offset + 0x0C) fh.seek(psar_offset + 0x0C)
(x,) = struct.unpack("<I", fh.read(4)) (x,) = struct.unpack("<I", fh.read(4))
marker_off = x + 0x50000 marker_off = psar_offset + x
fh.seek(marker_off) fh.seek(marker_off)
if fh.read(8) != b"STARTDAT": if fh.read(8) != b"STARTDAT":
raise ConversionError(f"cannot find STARTDAT in {base_pbp} (not a valid PS1 base PBP?)") raise ConversionError(f"cannot find STARTDAT in {base_pbp} (not a valid PS1 base PBP?)")
@@ -474,22 +617,25 @@ def build_toc(disc: "DiscImage") -> bytes:
total = disc.total_sectors() total = disc.total_sectors()
entries = [bytearray(_TOC_ENTRY) for _ in range(len(tracks) + 3)] entries = [bytearray(_TOC_ENTRY) for _ in range(len(tracks) + 3)]
first_num, first_data, _ = tracks[0] first_num, _, _ = tracks[0]
last_num, last_data, _ = tracks[-1] last_num, _, _ = tracks[-1]
# A-point entries use control byte 0x41 (data) regardless of track type,
# matching popstation and official PSN classics. A0's byte 8 is the raw
# Red Book disc-type bitfield 0x20 (CD-ROM XA), not a BCD value.
e = entries[0] e = entries[0]
e[0] = _TOC_DATA if first_data else _TOC_AUDIO e[0] = _TOC_DATA
e[2] = _TOC_A0 e[2] = _TOC_A0
e[7] = _to_bcd(first_num) e[7] = _to_bcd(first_num)
e[8] = _to_bcd(0x20) e[8] = 0x20
e = entries[1] e = entries[1]
e[0] = _TOC_DATA if last_data else _TOC_AUDIO e[0] = _TOC_DATA
e[2] = _TOC_A1 e[2] = _TOC_A1
e[7] = _to_bcd(last_num) e[7] = _to_bcd(last_num)
e = entries[2] e = entries[2]
e[0] = _TOC_AUDIO e[0] = _TOC_DATA
e[2] = _TOC_A2 e[2] = _TOC_A2
mm, ss, ff = _sectors_to_msf(total) mm, ss, ff = _sectors_to_msf(total)
e[7], e[8], e[9] = _to_bcd(mm), _to_bcd(ss), _to_bcd(ff) e[7], e[8], e[9] = _to_bcd(mm), _to_bcd(ss), _to_bcd(ff)
@@ -540,49 +686,104 @@ def _build_psar_header(disc: "DiscImage", game_id: str, title: str) -> bytes:
return bytes(block) return bytes(block)
def _write_disc_block(
out: io.BufferedWriter,
block: bytes,
complevel: int,
data_offset: int,
indexes: bytearray,
) -> int:
"""Compress and write one 0x9300 disc block, appending its index entry.
Blocks are deflated with raw DEFLATE (the zlib header and Adler-32
trailer stripped). If a block does not shrink, the raw bytes are stored
instead. The index entry follows pop-fe: offset (u32), length (u16),
flags (u8), then the first 16 bytes of the block's SHA-1.
"""
assert len(block) == BLOCK_SIZE
compressed = zlib.compress(block, complevel)[2:-4] if complevel > 0 else block
entry = bytearray(INDEX_ENTRY_SIZE)
struct.pack_into("<I", entry, 0, data_offset)
if complevel == 0:
entry[6] = 0x01 # uncompressed block
entry[8:24] = hashlib.sha1(block).digest()[:16]
if len(compressed) >= BLOCK_SIZE:
struct.pack_into("<H", entry, 4, BLOCK_SIZE)
out.write(block)
data_offset += BLOCK_SIZE
else:
struct.pack_into("<H", entry, 4, len(compressed))
out.write(compressed)
data_offset += len(compressed)
indexes.extend(entry)
return data_offset
def _write_psar( def _write_psar(
out: io.BufferedWriter, out: io.BufferedWriter,
disc: "DiscImage", disc: "DiscImage",
game_id: str, game_id: str,
title: str, title: str,
complevel: int = 1,
progress_cb=None, progress_cb=None,
cancel_event=None, cancel_event=None,
) -> None: ) -> None:
"""Write the DATA.PSAR section (uncompressed, single-disc). """Write the DATA.PSAR section (single-disc).
Disc data is split into 0x9300-byte blocks and deflated (``complevel`` >
0). The PSAR header's p1/p2 fields and the index table are patched after
the compressed data is written, since the compressed size is not known
up front.
All offsets are relative to the start of the PSAR section. All offsets are relative to the start of the PSAR section.
""" """
base = out.tell() base = out.tell()
iso_size = disc.padded_size() iso_size = disc.padded_size()
block_count = iso_size // BLOCK_SIZE
# 0x000: PSAR signature; 0x00C: disc size hint. # 0x000: PSAR signature; 0x00C: p1 (disc data end, patched below).
out.write(PSAR_MAGIC) out.write(PSAR_MAGIC)
out.write(struct.pack("<I", iso_size + PSAR_ISO_OFFSET)) out.write(struct.pack("<I", iso_size + PSAR_ISO_OFFSET))
_pad_to(out, base + PSAR_GAMEID_OFFSET) _pad_to(out, base + PSAR_GAMEID_OFFSET)
# 0x400..0x4000: game id / TOC / title (p2 at 0x1220 is patched below).
out.write(_build_psar_header(disc, game_id, title)) out.write(_build_psar_header(disc, game_id, title))
_pad_to(out, base + PSAR_INDEX_OFFSET) _pad_to(out, base + PSAR_INDEX_OFFSET)
# ISO block index: (offset, length) per 0x9300 block. Uncompressed => # Reserve the index table; filled in once the disc data is compressed.
# length is always BLOCK_SIZE and offsets increase linearly.
block_count = iso_size // BLOCK_SIZE
offset = 0
for _ in range(block_count): for _ in range(block_count):
out.write(struct.pack("<II", offset, BLOCK_SIZE)) out.write(b"\x00" * INDEX_ENTRY_SIZE)
out.write(b"\x00" * (INDEX_ENTRY_SIZE - 8))
offset += BLOCK_SIZE
_pad_to(out, base + PSAR_ISO_OFFSET) _pad_to(out, base + PSAR_ISO_OFFSET)
# Disc data, block by block. # Compress + write disc data, one 0x9300 block at a time.
indexes = bytearray()
data_offset = 0 # relative to PSAR_ISO_OFFSET
buf = bytearray() buf = bytearray()
for sector in disc.iter_raw(progress_cb=progress_cb, cancel_event=cancel_event): for sector in disc.iter_raw(progress_cb=progress_cb, cancel_event=cancel_event):
buf.extend(sector) buf.extend(sector)
if len(buf) == BLOCK_SIZE: if len(buf) < BLOCK_SIZE:
out.write(buf) continue
buf = bytearray() data_offset = _write_disc_block(out, buf, complevel, data_offset, indexes)
buf = bytearray()
if buf: if buf:
out.write(buf.ljust(BLOCK_SIZE, b"\x00")) data_offset = _write_disc_block(out, buf.ljust(BLOCK_SIZE, b"\x00"), complevel, data_offset, indexes)
# Align the end of the disc data to 0x10 before STARTDAT (which the
# caller appends), then patch p1/p2 and the index table.
_pad_to(out, (out.tell() + 0xF) & ~0xF)
end_offset = out.tell() - base
end = out.tell()
out.seek(base + 0x0C)
out.write(struct.pack("<I", end_offset))
out.seek(base + 0x1220)
out.write(struct.pack("<I", end_offset + 0x2D31))
out.seek(base + PSAR_INDEX_OFFSET)
out.write(bytes(indexes))
out.seek(end)
class DiscImage: class DiscImage:
@@ -603,7 +804,7 @@ class DiscImage:
for track in self.cue.tracks: for track in self.cue.tracks:
fpath = base_dir / track.file fpath = base_dir / track.file
if fpath.is_file(): if fpath.is_file():
total += track.pregap_sectors + (fpath.stat().st_size // track.sector_size) total += track.external_pregap + (fpath.stat().st_size // track.sector_size)
return total return total
def padded_size(self) -> int: def padded_size(self) -> int:
@@ -627,10 +828,10 @@ class DiscImage:
cursor = 0 cursor = 0
for track in self.cue.tracks: for track in self.cue.tracks:
idx01 = dict(track.indexes).get(1, 0) idx01 = dict(track.indexes).get(1, 0)
out.append((track.number, track.mode != "AUDIO", cursor + idx01)) out.append((track.number, track.mode != "AUDIO", cursor + track.external_pregap + idx01))
fpath = base_dir / track.file fpath = base_dir / track.file
data_sectors = fpath.stat().st_size // track.sector_size if fpath.is_file() else 0 data_sectors = fpath.stat().st_size // track.sector_size if fpath.is_file() else 0
cursor += track.pregap_sectors + data_sectors cursor += track.external_pregap + data_sectors
return out return out
def iter_raw(self, progress_cb=None, cancel_event=None) -> Iterator[bytes]: def iter_raw(self, progress_cb=None, cancel_event=None) -> Iterator[bytes]:
@@ -651,7 +852,7 @@ class DiscImage:
yield from iter_raw_sectors( yield from iter_raw_sectors(
fpath, fpath,
track.sector_size, track.sector_size,
pregap_sectors=track.pregap_sectors, pregap_sectors=track.external_pregap,
progress_cb=progress_cb, progress_cb=progress_cb,
cancel_event=cancel_event, cancel_event=cancel_event,
) )
@@ -669,11 +870,14 @@ def convert(
out_path: Path, out_path: Path,
progress_cb=None, progress_cb=None,
cancel_event=None, cancel_event=None,
complevel: int = 1,
) -> Path: ) -> Path:
"""Convert a PS1 disc image to ``out_path`` (an ``EBOOT.PBP``). """Convert a PS1 disc image to ``out_path`` (an ``EBOOT.PBP``).
``source`` may be a ``.cue`` (multi-track supported) or a single ``source`` may be a ``.cue`` (multi-track supported) or a single
``.iso``/``.img``/``.bin``. ``base_pbp`` must be a PSN PS1 classic EBOOT. ``.iso``/``.img``/``.bin``. ``base_pbp`` must be a PSN PS1 classic EBOOT.
``complevel`` is the DEFLATE level for the disc data (1-9; 0 = raw, which
does not boot on real hardware and is only useful for diagnostics).
""" """
if not base_pbp.is_file(): if not base_pbp.is_file():
raise ConversionError(f"base.pbp not found: {base_pbp}") raise ConversionError(f"base.pbp not found: {base_pbp}")
@@ -690,12 +894,18 @@ def convert(
disc = DiscImage(None, source) disc = DiscImage(None, source)
# Resources required from the base PBP. # Resources required from the base PBP.
data_psp = extract_data_psp(base_pbp) data_psp = load_data_psp(base_pbp)
startdat = extract_startdat(base_pbp) startdat = extract_startdat(base_pbp)
_warn_old_base(base_pbp)
sfo = default_ps1_sfo(title, game_id) # Reuse the base PBP's SFO, patched with our title + game id (the
icon0 = default_icon0_png() # popstation approach), rather than rebuilding it from scratch.
pic1 = default_pic1_png() sfo = patch_sfo(extract_sfo(base_pbp), title, game_id)
# Reuse the base PBP's ICON0 too. The XMB icon parser is picky about PNG
# variants, and the base icon is guaranteed to decode; the other image
# sections are left empty exactly like popstation when no assets are
# supplied alongside the conversion.
icon0 = extract_icon0(base_pbp)
# Sections in header order; empty sections keep the previous offset. # Sections in header order; empty sections keep the previous offset.
sections = { sections = {
@@ -703,7 +913,7 @@ def convert(
"icon0": icon0, "icon0": icon0,
"icon1": b"", "icon1": b"",
"pic0": b"", "pic0": b"",
"pic1": pic1, "pic1": b"",
"snd0": b"", "snd0": b"",
"data_psp": data_psp, "data_psp": data_psp,
# psar written separately, sized after everything else. # psar written separately, sized after everything else.
@@ -740,7 +950,8 @@ def convert(
while fh.tell() < psar_offset: while fh.tell() < psar_offset:
fh.write(b"\x00") fh.write(b"\x00")
_write_psar(fh, disc, game_id, title, progress_cb=progress_cb, cancel_event=cancel_event) _write_psar(fh, disc, game_id, title, complevel=complevel,
progress_cb=progress_cb, cancel_event=cancel_event)
# STARTDAT goes at the very end. # STARTDAT goes at the very end.
fh.write(startdat) fh.write(startdat)
@@ -751,6 +962,13 @@ def convert(
except OSError: except OSError:
pass pass
raise raise
# Write the NPDRM version key next to the EBOOT (the popstation/iPoPS
# convention; ARK and other CFW read it to decrypt the STARTDAT PGD).
keys = load_keys_bin()
if keys is not None:
out_path.with_name("KEYS.BIN").write_bytes(keys)
return out_path return out_path
@@ -762,20 +980,20 @@ def convert_to_folder(
out_dir: Path, out_dir: Path,
progress_cb=None, progress_cb=None,
cancel_event=None, cancel_event=None,
complevel: int = 1,
) -> Path: ) -> Path:
"""Convert a PS1 disc into a game folder ready for /PSP/GAME. """Convert a PS1 disc into a game folder ready for /PSP/GAME.
Writes ``<out_dir>/<GAME_ID>/EBOOT.PBP`` — the same single-file output the Writes ``<out_dir>/<GAME_ID>/EBOOT.PBP`` plus a ``KEYS.BIN`` (the NPDRM
popstation tool produces (uncompressed/unencrypted level-0 PSAR). No version key modern CFW uses to decrypt the STARTDAT PGD). No
KEYS.BIN or DOCUMENT.DAT is written: popstation doesn't emit them and CFW DOCUMENT.DAT is written; CFW (ARK POPS) doesn't need one.
(ARK-4 POPS) doesn't need them.
""" """
folder_name = re.sub(r"[^A-Za-z0-9]", "", game_id).upper() or "PS1GAME" folder_name = re.sub(r"[^A-Za-z0-9]", "", game_id).upper() or "PS1GAME"
folder = Path(out_dir) / folder_name folder = Path(out_dir) / folder_name
folder.mkdir(parents=True, exist_ok=True) folder.mkdir(parents=True, exist_ok=True)
convert(source, title, game_id, base_pbp, folder / "EBOOT.PBP", convert(source, title, game_id, base_pbp, folder / "EBOOT.PBP",
progress_cb=progress_cb, cancel_event=cancel_event) progress_cb=progress_cb, cancel_event=cancel_event, complevel=complevel)
return folder return folder
@@ -805,6 +1023,13 @@ def _self_test() -> None:
assert cue.tracks[1].pregap_sectors == 150, "expected 2s (150 sector) pregap" assert cue.tracks[1].pregap_sectors == 150, "expected 2s (150 sector) pregap"
assert cue.data_track is cue.tracks[0] assert cue.data_track is cue.tracks[0]
# PREGAP command is a pregap NOT stored in the file.
cue_pg = parse_cue(
'FILE "t.bin" BINARY\n TRACK 01 AUDIO\n PREGAP 00:02:00\n INDEX 01 00:00:00\n'
)
assert cue_pg.tracks[0].external_pregap == 150, cue_pg.tracks[0].external_pregap
assert cue_pg.tracks[0].pregap_sectors == 0
# TOC generation: 1 data (10 sectors) + 1 audio track (20 sectors + 2s pregap). # TOC generation: 1 data (10 sectors) + 1 audio track (20 sectors + 2s pregap).
with tempfile.TemporaryDirectory() as td: with tempfile.TemporaryDirectory() as td:
d = Path(td) d = Path(td)
@@ -814,11 +1039,16 @@ def _self_test() -> None:
'FILE "t1.bin" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n' 'FILE "t1.bin" BINARY\n TRACK 01 MODE2/2352\n INDEX 01 00:00:00\n'
'FILE "t2.bin" BINARY\n TRACK 02 AUDIO\n INDEX 00 00:00:00\n INDEX 01 00:02:00\n' 'FILE "t2.bin" BINARY\n TRACK 02 AUDIO\n INDEX 00 00:00:00\n INDEX 01 00:02:00\n'
) )
toc = build_toc(DiscImage(cue2, d / "t.cue")) disc2 = DiscImage(cue2, d / "t.cue")
# INDEX 00 pregap is inside the file, so the disc is just the files
# concatenated: 10 + 20 sectors (not 10 + 150 + 20).
assert disc2.total_sectors() == 30, disc2.total_sectors()
toc = build_toc(disc2)
assert len(toc) == (2 + 3) * 10, len(toc) assert len(toc) == (2 + 3) * 10, len(toc)
assert toc[0] == 0x41 and toc[2] == 0xA0 and toc[7] == 0x01 # A0: first track assert toc[0] == 0x41 and toc[2] == 0xA0 and toc[7] == 0x01 # A0: first track
assert toc[10] == 0x01 and toc[12] == 0xA1 and toc[17] == 0x02 # A1: last track assert toc[8] == 0x20 # A0: raw Red Book disc-type byte, not BCD
assert toc[22] == 0xA2 # A2: lead-out assert toc[10] == 0x41 and toc[12] == 0xA1 and toc[17] == 0x02 # A1: last track
assert toc[20] == 0x41 and toc[22] == 0xA2 # A2: lead-out (control 0x41)
assert toc[30] == 0x41 and toc[32] == 0x01 # track 1: data, number 1 assert toc[30] == 0x41 and toc[32] == 0x01 # track 1: data, number 1
assert toc[40] == 0x01 and toc[42] == 0x02 # track 2: audio, number 2 assert toc[40] == 0x01 and toc[42] == 0x02 # track 2: audio, number 2
# track 2 relative address = start(160) + 150 lead-in = 310 = 00:04:10 # track 2 relative address = start(160) + 150 lead-in = 310 = 00:04:10
@@ -834,6 +1064,13 @@ def _self_test() -> None:
assert parsed.get("CATEGORY") == "ME", parsed assert parsed.get("CATEGORY") == "ME", parsed
assert parsed.get("BOOTABLE") == 1, parsed assert parsed.get("BOOTABLE") == 1, parsed
# Base-SFO patching preserves everything but TITLE + DISC_ID.
patched = patch_sfo(default_ps1_sfo("Hot Shots Golf", "SCUS94476"), "Pepsiman", "SLPS01762")
parsed2 = iso_reader.parse_sfo(patched)
assert parsed2.get("TITLE") == "Pepsiman", parsed2
assert parsed2.get("DISC_ID") == "SLPS01762", parsed2
assert parsed2.get("BOOTABLE") == 1, parsed2
# PNG generation produces valid PNG signatures + IHDR dimensions. # PNG generation produces valid PNG signatures + IHDR dimensions.
icon = default_icon0_png() icon = default_icon0_png()
assert icon[:8] == b"\x89PNG\r\n\x1a\n" assert icon[:8] == b"\x89PNG\r\n\x1a\n"
@@ -855,6 +1092,35 @@ def _self_test() -> None:
assert read_pbp_header(p)[0] == header[0] assert read_pbp_header(p)[0] == header[0]
assert read_pbp_section(p, header, 2) == sfo assert read_pbp_section(p, header, 2) == sfo
# PSAR compression round-trip (level 1): the disc data must survive
# raw-DEFLATE and the index table must be readable with the pop-fe
# layout (offset u32, length u16, flags u8, SHA-1[16]).
with tempfile.TemporaryDirectory() as td:
d = Path(td)
src = d / "cd.bin"
src.write_bytes(bytes(range(256)) * (BLOCK_SIZE // 256 * 3))
disc = DiscImage(None, src)
buf = io.BytesIO()
_write_psar(buf, disc, "SLUS00594", "Round Trip", complevel=1)
raw = buf.getvalue()
assert raw[:12] == PSAR_MAGIC
rebuilt = bytearray()
i = 0
while True:
e = raw[PSAR_INDEX_OFFSET + i * 32: PSAR_INDEX_OFFSET + (i + 1) * 32]
off, = struct.unpack("<I", e[0:4])
ln, = struct.unpack("<H", e[4:6])
if ln == 0:
break
data = raw[PSAR_ISO_OFFSET + off: PSAR_ISO_OFFSET + off + ln]
rebuilt += zlib.decompress(data, wbits=-15) if ln < BLOCK_SIZE else data
i += 1
assert i == 3, i
assert len(rebuilt) == disc.padded_size(), len(rebuilt)
assert bytes(rebuilt[: src.stat().st_size]) == src.read_bytes()
# first index entry carries the SHA-1 of the first raw block
assert raw[PSAR_INDEX_OFFSET + 8: PSAR_INDEX_OFFSET + 24] == hashlib.sha1(bytes(rebuilt[:BLOCK_SIZE])).digest()[:16]
print("ps1_convert self-test OK") print("ps1_convert self-test OK")
@@ -866,6 +1132,8 @@ def main(argv: Optional[List[str]] = None) -> int:
ap.add_argument("--base-pbp", help="path to a PSN PS1 classic EBOOT.PBP (required)") ap.add_argument("--base-pbp", help="path to a PSN PS1 classic EBOOT.PBP (required)")
ap.add_argument("--out", help="output EBOOT.PBP path (single file)") ap.add_argument("--out", help="output EBOOT.PBP path (single file)")
ap.add_argument("--out-dir", help="output folder: writes <dir>/<GAME_ID>/EBOOT.PBP") ap.add_argument("--out-dir", help="output folder: writes <dir>/<GAME_ID>/EBOOT.PBP")
ap.add_argument("--complevel", type=int, default=1, choices=range(0, 10),
help="DEFLATE compression level 0-9 for the disc data (default 1)")
ap.add_argument("--test", action="store_true", help="run self-test and exit") ap.add_argument("--test", action="store_true", help="run self-test and exit")
args = ap.parse_args(argv) args = ap.parse_args(argv)
@@ -880,13 +1148,13 @@ def main(argv: Optional[List[str]] = None) -> int:
if args.out_dir: if args.out_dir:
folder = convert_to_folder( folder = convert_to_folder(
Path(args.source), args.title, args.game_id, Path(args.source), args.title, args.game_id,
Path(args.base_pbp), Path(args.out_dir), Path(args.base_pbp), Path(args.out_dir), complevel=args.complevel,
) )
print(f"wrote {folder}") print(f"wrote {folder}")
else: else:
convert( convert(
Path(args.source), args.title, args.game_id, Path(args.source), args.title, args.game_id,
Path(args.base_pbp), Path(args.out), Path(args.base_pbp), Path(args.out), complevel=args.complevel,
) )
print(f"wrote {args.out}") print(f"wrote {args.out}")
return 0 return 0
+30
View File
@@ -0,0 +1,30 @@
# Loader assets
The files in this directory are bundled from [iPoPS](https://github.com/julianxhokaxhiu/iPoPS)
by Julian Xhokaxhiu, licensed under the MIT License:
- `DATA.PSP` — a plain-ELF PSP POPS loader (relocatable PRX, 19002 bytes).
- `KEYS.BIN` — a 16-byte NPDRM version key used by CFW to decrypt the
`STARTDAT` PGD.
MIT License
Copyright (c) 2020 Julian Xhokaxhiu
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
MB\Ω:�� �� �"