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.
This commit is contained in:
2026-09-07 16:50:52 -04:00
parent 02b4adb1c1
commit 2f1fc760a1
+108 -22
View File
@@ -67,7 +67,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
@@ -112,10 +114,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 +177,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 +358,46 @@ 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 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,13 +447,25 @@ 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 extract_startdat(base_pbp: Path) -> bytes: def extract_startdat(base_pbp: Path) -> bytes:
@@ -474,22 +536,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)
@@ -603,7 +668,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 +692,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 +716,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,
) )
@@ -693,7 +758,9 @@ def convert(
data_psp = extract_data_psp(base_pbp) data_psp = extract_data_psp(base_pbp)
startdat = extract_startdat(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() icon0 = default_icon0_png()
pic1 = default_pic1_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.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 +888,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 +913,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"