From 2f1fc760a1b0173811262c39d7ba51bd60a0cac7 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 7 Sep 2026 16:50:52 -0400 Subject: [PATCH] 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. --- app/ps1_convert.py | 130 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 108 insertions(+), 22 deletions(-) diff --git a/app/ps1_convert.py b/app/ps1_convert.py index 334cbf6..5e9c4a7 100644 --- a/app/ps1_convert.py +++ b/app/ps1_convert.py @@ -67,7 +67,9 @@ PSAR_GAMEID_OFFSET = 0x400 PSAR_TOC_OFFSET = 0x800 PSAR_INDEX_OFFSET = 0x4000 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. RAW_SECTOR = 0x930 # 2352 @@ -112,10 +114,17 @@ class CueTrack: file_type: str # "BINARY" | "WAVE" | ... indexes: List[Tuple[int, int]] = field(default_factory=list) # (index, msf) sector_size: int = 0 + external_pregap: int = 0 # sectors from a PREGAP command (not in the file) @property 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) if 0 in idx and 1 in idx: return max(0, idx[1] - idx[0]) @@ -168,10 +177,11 @@ def parse_cue(text: str) -> CueDisc: line = raw.strip() if not line or line.startswith(("REM", "CATALOG", "CDTEXTFILE", "PERFORMER", "TITLE", "ISRC", "FLAGS", "POSTGAP", "PREGAP")): if line.startswith("PREGAP"): - # PREGAP is equivalent to an INDEX 00 offset. + # PREGAP 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) 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 m = file_re.match(line) @@ -348,6 +358,46 @@ def default_ps1_sfo(title: str, game_id: str) -> bytes: 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 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(" 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) idx = SECTION_OFFSET["data_psp"] // 4 # header array index (2 + section index) - data = read_pbp_section(base_pbp, header, idx) - if not data: + section = read_pbp_section(base_pbp, header, idx) + if len(section) < 0x30: 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(" len(section): + prx_size = len(section) + return section[:prx_size] def extract_startdat(base_pbp: Path) -> bytes: @@ -474,22 +536,25 @@ def build_toc(disc: "DiscImage") -> bytes: total = disc.total_sectors() entries = [bytearray(_TOC_ENTRY) for _ in range(len(tracks) + 3)] - first_num, first_data, _ = tracks[0] - last_num, last_data, _ = tracks[-1] + first_num, _, _ = tracks[0] + 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[0] = _TOC_DATA if first_data else _TOC_AUDIO + e[0] = _TOC_DATA e[2] = _TOC_A0 e[7] = _to_bcd(first_num) - e[8] = _to_bcd(0x20) + e[8] = 0x20 e = entries[1] - e[0] = _TOC_DATA if last_data else _TOC_AUDIO + e[0] = _TOC_DATA e[2] = _TOC_A1 e[7] = _to_bcd(last_num) e = entries[2] - e[0] = _TOC_AUDIO + e[0] = _TOC_DATA e[2] = _TOC_A2 mm, ss, ff = _sectors_to_msf(total) e[7], e[8], e[9] = _to_bcd(mm), _to_bcd(ss), _to_bcd(ff) @@ -603,7 +668,7 @@ class DiscImage: 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) + total += track.external_pregap + (fpath.stat().st_size // track.sector_size) return total def padded_size(self) -> int: @@ -627,10 +692,10 @@ class DiscImage: cursor = 0 for track in self.cue.tracks: 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 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 def iter_raw(self, progress_cb=None, cancel_event=None) -> Iterator[bytes]: @@ -651,7 +716,7 @@ class DiscImage: yield from iter_raw_sectors( fpath, track.sector_size, - pregap_sectors=track.pregap_sectors, + pregap_sectors=track.external_pregap, progress_cb=progress_cb, cancel_event=cancel_event, ) @@ -693,7 +758,9 @@ def convert( data_psp = extract_data_psp(base_pbp) startdat = extract_startdat(base_pbp) - sfo = default_ps1_sfo(title, game_id) + # 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() @@ -805,6 +872,13 @@ def _self_test() -> None: assert cue.tracks[1].pregap_sectors == 150, "expected 2s (150 sector) pregap" 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). with tempfile.TemporaryDirectory() as td: d = Path(td) @@ -814,11 +888,16 @@ def _self_test() -> None: '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' ) - 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 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[22] == 0xA2 # A2: lead-out + assert toc[8] == 0x20 # A0: raw Red Book disc-type byte, not BCD + 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[40] == 0x01 and toc[42] == 0x02 # track 2: audio, number 2 # track 2 relative address = start(160) + 150 lead-in = 310 = 00:04:10 @@ -834,6 +913,13 @@ def _self_test() -> None: assert parsed.get("CATEGORY") == "ME", 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. icon = default_icon0_png() assert icon[:8] == b"\x89PNG\r\n\x1a\n"