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:
+108
-22
@@ -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 <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)
|
||||
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("<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)
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -397,13 +447,25 @@ def read_pbp_section(path: Path, header: List[int], index: int) -> 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("<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:
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user