diff --git a/app/ps1_convert.py b/app/ps1_convert.py index 5e9c4a7..e001cd2 100644 --- a/app/ps1_convert.py +++ b/app/ps1_convert.py @@ -24,13 +24,16 @@ blobs that we cannot synthesise: 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. +Compression: the disc data is split into 0x9300-byte blocks and deflated +(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 import argparse +import hashlib import io import os import re @@ -365,6 +368,13 @@ def extract_sfo(base_pbp: Path) -> bytes: 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 patch_sfo(base_sfo: bytes, title: str, game_id: str) -> bytes: """Patch ``TITLE`` and ``DISC_ID`` in a base PARAM.SFO in place. @@ -605,49 +615,104 @@ def _build_psar_header(disc: "DiscImage", game_id: str, title: str) -> bytes: 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("= BLOCK_SIZE: + struct.pack_into(" 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. """ base = out.tell() 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(struct.pack(" - # length is always BLOCK_SIZE and offsets increase linearly. - block_count = iso_size // BLOCK_SIZE - offset = 0 + # Reserve the index table; filled in once the disc data is compressed. for _ in range(block_count): - out.write(struct.pack(" 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. + ``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(): raise ConversionError(f"base.pbp not found: {base_pbp}") @@ -761,8 +829,11 @@ def convert( # Reuse the base PBP's SFO, patched with our title + game id (the # popstation approach), rather than rebuilding it from scratch. sfo = patch_sfo(extract_sfo(base_pbp), title, game_id) - icon0 = default_icon0_png() - pic1 = default_pic1_png() + # 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 = { @@ -770,7 +841,7 @@ def convert( "icon0": icon0, "icon1": b"", "pic0": b"", - "pic1": pic1, + "pic1": b"", "snd0": b"", "data_psp": data_psp, # psar written separately, sized after everything else. @@ -807,7 +878,8 @@ def convert( while fh.tell() < psar_offset: 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. fh.write(startdat) @@ -829,20 +901,20 @@ def convert_to_folder( out_dir: Path, progress_cb=None, cancel_event=None, + complevel: int = 1, ) -> Path: """Convert a PS1 disc into a game folder ready for /PSP/GAME. Writes ``//EBOOT.PBP`` — the same single-file output the - popstation tool produces (uncompressed/unencrypted level-0 PSAR). No - KEYS.BIN or DOCUMENT.DAT is written: popstation doesn't emit them and CFW - (ARK-4 POPS) doesn't need them. + popstation tool produces. No KEYS.BIN or DOCUMENT.DAT is written: + popstation doesn't emit them and CFW (ARK-4 POPS) doesn't need them. """ folder_name = re.sub(r"[^A-Za-z0-9]", "", game_id).upper() or "PS1GAME" folder = Path(out_dir) / folder_name folder.mkdir(parents=True, exist_ok=True) 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 @@ -941,6 +1013,35 @@ def _self_test() -> None: assert read_pbp_header(p)[0] == header[0] 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(" int: 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-dir", help="output folder: writes //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") args = ap.parse_args(argv) @@ -966,13 +1069,13 @@ def main(argv: Optional[List[str]] = None) -> int: if args.out_dir: folder = convert_to_folder( 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}") else: convert( 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}") return 0