From 128b62e5b88796056db3555272d1f4edb31049a6 Mon Sep 17 00:00:00 2001 From: Mathieu Moran Date: Mon, 7 Sep 2026 12:04:09 -0400 Subject: [PATCH] Generate PS1 TOC in EBOOT converter --- app/ps1_convert.py | 138 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 116 insertions(+), 22 deletions(-) diff --git a/app/ps1_convert.py b/app/ps1_convert.py index 0c4fff6..fbc5f81 100644 --- a/app/ps1_convert.py +++ b/app/ps1_convert.py @@ -215,6 +215,12 @@ def _to_bcd(value: int) -> int: return ((value // 10) << 4) | (value % 10) +def _sectors_to_msf(sectors: int) -> Tuple[int, int, int]: + """Convert a sector count to (minutes, seconds, frames) at 75 frames/s.""" + total_seconds = sectors // 75 + return total_seconds // 60, total_seconds % 60, sectors % 75 + + def _expand_cooked_sector(data: bytes, lba: int) -> bytes: """Expand a 2048-byte cooked sector to a 2352-byte raw Mode2 Form1 sector. @@ -446,32 +452,81 @@ def _pad_to(out, target: int) -> None: out.write(b"\x00" * delta) -def _build_gameid_block(game_id: str) -> bytes: - """Best-effort PSAR 0x400 block (game id + reserved header fields). +# Track type values used in the PS1 TOC (PSAR +0x800). +_TOC_DATA = 0x41 +_TOC_AUDIO = 0x01 +_TOC_A0 = 0xA0 +_TOC_A1 = 0xA1 +_TOC_A2 = 0xA2 +_TOC_ENTRY = 10 # bytes per TOC entry +_TOC_LEAD_IN = 150 # 2-second lead-in, in sectors - TODO(validate): the reference writer overlays the game id onto a fixed - 3616-byte template that also carries the PS1 TOC at +0x800. We generate a - minimal zero-filled block with the game id in place; boot on real hardware - must be verified against PSXPackager output. + +def build_toc(disc: "DiscImage") -> bytes: + """Generate the PS1 table-of-contents stored at PSAR +0x800. + + Emits the standard CD-style TOC: an A0 entry (first track), an A1 entry + (last track), an A2 entry (lead-out), then one 10-byte entry per track. + Track addresses are BCD-encoded MSF; a 150-sector (2 s) lead-in is added + to the relative address field, matching the reference implementation. """ - game_id = _format_psar_game_id(game_id).encode("ascii")[:11] - block = bytearray(PSAR_TOC_OFFSET - PSAR_GAMEID_OFFSET) # 0x400 -> 0x800 - block[: len(game_id)] = game_id - return bytes(block) + tracks = disc.tracks_info() + 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] + + e = entries[0] + e[0] = _TOC_DATA if first_data else _TOC_AUDIO + e[2] = _TOC_A0 + e[7] = _to_bcd(first_num) + e[8] = _to_bcd(0x20) + + e = entries[1] + e[0] = _TOC_DATA if last_data else _TOC_AUDIO + e[2] = _TOC_A1 + e[7] = _to_bcd(last_num) + + e = entries[2] + e[0] = _TOC_AUDIO + 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) + + for i, (num, is_data, start) in enumerate(tracks): + e = entries[3 + i] + e[0] = _TOC_DATA if is_data else _TOC_AUDIO + e[2] = _to_bcd(num) + mm, ss, ff = _sectors_to_msf(start) + e[3], e[4], e[5] = _to_bcd(mm), _to_bcd(ss), _to_bcd(ff) + mm, ss, ff = _sectors_to_msf(start + _TOC_LEAD_IN) + e[7], e[8], e[9] = _to_bcd(mm), _to_bcd(ss), _to_bcd(ff) + + return b"".join(bytes(e) for e in entries) -def _build_toc_block(title: str, total_sectors: int) -> bytes: - """Best-effort PSAR TOC block at +0x800. +def _build_psar_header(disc: "DiscImage", game_id: str, title: str) -> bytes: + """Build the fixed PSAR header region 0x400..0x4000. - TODO(validate): the reference writer uses a fixed 11740-byte template with - the title overlaid at offset 8. We emit the title + zero padding; the TOC - format must be verified against PSXPackager output. + Layout mirrors the reference writer's data1/data2 templates: + * game id at +0x000 (11 bytes, "_SLUS_00594" form) + * TOC at +0x400 (i.e. absolute PSAR +0x800) + * title at data2 offset 8 (absolute PSAR +0x1228), 128 bytes max """ - block = bytearray(PSAR_INDEX_OFFSET - PSAR_TOC_OFFSET) # 0x800 -> 0x4000 - title = title.encode("ascii", "replace")[:0x80 - 8] - block[8: 8 + len(title)] = title - # First two u32 of the TOC are zero in the reference template. - struct.pack_into(" @@ -548,6 +602,27 @@ class DiscImage: size += BLOCK_SIZE - (size % BLOCK_SIZE) return size + def tracks_info(self) -> List[Tuple[int, bool, int]]: + """Return ``(track_number, is_data, absolute_start_sector)`` per track. + + ``absolute_start_sector`` is the disc-wide sector of each track's + INDEX 01 (i.e. accounting for every preceding track's pregap + data). + A cue-less single image is treated as one data track starting at 0. + """ + if self.cue is None: + return [(1, True, 0)] + + base_dir = self.path.parent + out: List[Tuple[int, bool, int]] = [] + cursor = 0 + for track in self.cue.tracks: + idx01 = dict(track.indexes).get(1, 0) + out.append((track.number, track.mode != "AUDIO", cursor + 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 + return out + def iter_raw(self, progress_cb=None, cancel_event=None) -> Iterator[bytes]: if self.cue is None: yield from iter_raw_sectors( @@ -695,6 +770,25 @@ def _self_test() -> None: assert cue.tracks[1].pregap_sectors == 150, "expected 2s (150 sector) pregap" assert cue.data_track is cue.tracks[0] + # TOC generation: 1 data (10 sectors) + 1 audio track (20 sectors + 2s pregap). + with tempfile.TemporaryDirectory() as td: + d = Path(td) + (d / "t1.bin").write_bytes(b"\x00" * (10 * RAW_SECTOR)) + (d / "t2.bin").write_bytes(b"\x00" * (20 * RAW_SECTOR)) + cue2 = parse_cue( + '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")) + 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[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 + assert toc[47] == 0x00 and toc[48] == 0x04 and toc[49] == 0x10 + # SFO round-trip through the project's own parser. from . import iso_reader # local import to keep the module importable standalone