Generate PS1 TOC in EBOOT converter

This commit is contained in:
2026-09-07 16:20:39 -04:00
parent da3429d918
commit 128b62e5b8
+116 -22
View File
@@ -215,6 +215,12 @@ def _to_bcd(value: int) -> int:
return ((value // 10) << 4) | (value % 10) 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: def _expand_cooked_sector(data: bytes, lba: int) -> bytes:
"""Expand a 2048-byte cooked sector to a 2352-byte raw Mode2 Form1 sector. """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) out.write(b"\x00" * delta)
def _build_gameid_block(game_id: str) -> bytes: # Track type values used in the PS1 TOC (PSAR +0x800).
"""Best-effort PSAR 0x400 block (game id + reserved header fields). _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 def build_toc(disc: "DiscImage") -> bytes:
minimal zero-filled block with the game id in place; boot on real hardware """Generate the PS1 table-of-contents stored at PSAR +0x800.
must be verified against PSXPackager output.
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] tracks = disc.tracks_info()
block = bytearray(PSAR_TOC_OFFSET - PSAR_GAMEID_OFFSET) # 0x400 -> 0x800 total = disc.total_sectors()
block[: len(game_id)] = game_id entries = [bytearray(_TOC_ENTRY) for _ in range(len(tracks) + 3)]
return bytes(block)
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: def _build_psar_header(disc: "DiscImage", game_id: str, title: str) -> bytes:
"""Best-effort PSAR TOC block at +0x800. """Build the fixed PSAR header region 0x400..0x4000.
TODO(validate): the reference writer uses a fixed 11740-byte template with Layout mirrors the reference writer's data1/data2 templates:
the title overlaid at offset 8. We emit the title + zero padding; the TOC * game id at +0x000 (11 bytes, "_SLUS_00594" form)
format must be verified against PSXPackager output. * 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 block = bytearray(PSAR_INDEX_OFFSET - PSAR_GAMEID_OFFSET) # 0x400..0x4000
title = title.encode("ascii", "replace")[:0x80 - 8]
block[8: 8 + len(title)] = title gid = _format_psar_game_id(game_id).encode("ascii")[:11]
# First two u32 of the TOC are zero in the reference template. block[: len(gid)] = gid
struct.pack_into("<II", block, 0, 0, total_sectors)
toc = build_toc(disc)
toc_off = PSAR_TOC_OFFSET - PSAR_GAMEID_OFFSET # 0x400
block[toc_off: toc_off + len(toc)] = toc
title_bytes = title.encode("ascii", "replace")[:0x80]
title_off = 0x1220 - PSAR_GAMEID_OFFSET + 8 # data2 template offset 8
block[title_off: title_off + len(title_bytes)] = title_bytes
return bytes(block) return bytes(block)
@@ -495,8 +550,7 @@ def _write_psar(
out.write(struct.pack("<I", iso_size + PSAR_ISO_OFFSET)) out.write(struct.pack("<I", iso_size + PSAR_ISO_OFFSET))
_pad_to(out, base + PSAR_GAMEID_OFFSET) _pad_to(out, base + PSAR_GAMEID_OFFSET)
out.write(_build_gameid_block(game_id)) out.write(_build_psar_header(disc, game_id, title))
out.write(_build_toc_block(title, disc.total_sectors()))
_pad_to(out, base + PSAR_INDEX_OFFSET) _pad_to(out, base + PSAR_INDEX_OFFSET)
# ISO block index: (offset, length) per 0x9300 block. Uncompressed => # ISO block index: (offset, length) per 0x9300 block. Uncompressed =>
@@ -548,6 +602,27 @@ class DiscImage:
size += BLOCK_SIZE - (size % BLOCK_SIZE) size += BLOCK_SIZE - (size % BLOCK_SIZE)
return 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]: def iter_raw(self, progress_cb=None, cancel_event=None) -> Iterator[bytes]:
if self.cue is None: if self.cue is None:
yield from iter_raw_sectors( 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.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]
# 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. # SFO round-trip through the project's own parser.
from . import iso_reader # local import to keep the module importable standalone from . import iso_reader # local import to keep the module importable standalone