Generate PS1 TOC in EBOOT converter
This commit is contained in:
+116
-22
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user