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_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"
|
||||||
|
|||||||
Reference in New Issue
Block a user