Created
April 8, 2025 13:58
-
-
Save spicyjpeg/f48d9330cf74a6f9744f5952e1b7acb9 to your computer and use it in GitHub Desktop.
2336/2340/2352/2448-byte to 2048-byte sector (".bin to .iso") CD-ROM image converter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# -*- coding: utf-8 -*- | |
"""2336/2340/2352/2448-byte to 2048-byte (".bin to .iso") CD-ROM image converter | |
A simple command-line script to convert CD-ROM disc or track images with "full" | |
sectors (2336, 2340, 2352 or 2448 bytes per sector) - as commonly found in .bin | |
files distributed alongside cuesheets - to ones with standard 2048-byte sectors, | |
in order to allow them to be used with tools that only support the | |
2048-byte-sector ".iso" format. Requires no external dependencies. | |
Note that this conversion is only possible if the input image follows the | |
regular mode 1 or 2 CD-ROM format and contains no sectors that make use of the | |
extra sector capacity for purposes other than storing error correction | |
information (for instance Video CD files or interleaved XA-ADPCM). | |
""" | |
__version__ = "0.1.0" | |
__author__ = "spicyjpeg" | |
from argparse import ArgumentParser, FileType, Namespace | |
from collections.abc import ByteString | |
from enum import IntEnum, IntFlag | |
from io import SEEK_SET | |
from typing import Callable | |
## Sector validation and data extraction | |
SYNC_PATTERN: bytes = bytes.fromhex("00 ff ff ff ff ff ff ff ff ff ff 00") | |
class CDROMMode(IntEnum): | |
MODE1 = 0x01 | |
MODE2 = 0x02 | |
class XASubmodeFlag(IntFlag): | |
END_OF_RECORD = 1 << 0 | |
TYPE_VIDEO = 1 << 1 | |
TYPE_AUDIO = 1 << 2 | |
TYPE_DATA = 1 << 3 | |
TRIGGER = 1 << 4 | |
FORM2 = 1 << 5 | |
REAL_TIME = 1 << 6 | |
END_OF_FILE = 1 << 7 | |
def parse2336ByteSector(sector: ByteString) -> ByteString: | |
if not sector: | |
return b"" | |
if len(sector) < 2336: | |
raise ValueError("sector data is incomplete") | |
if sector[0:4] != sector[4:8]: | |
raise ValueError("invalid or corrupted mode 2 XA subheader") | |
if XASubmodeFlag(sector[0]) & XASubmodeFlag.FORM2: | |
raise RuntimeError( | |
"mode 2 form 2 sectors cannot be converted to 2048-byte format" | |
) | |
return sector[8:8 + 2048] | |
def parse2340ByteSector(sector: ByteString) -> ByteString: | |
if not sector: | |
return b"" | |
if len(sector) < 2340: | |
raise ValueError("sector data is incomplete") | |
match CDROMMode(sector[3]): | |
case CDROMMode.MODE1: | |
return sector[4:4 + 2048] | |
case CDROMMode.MODE2: | |
return parse2336ByteSector(sector[4:]) | |
def parse2352ByteSector(sector: ByteString) -> ByteString: | |
if not sector: | |
return b"" | |
if len(sector) < 2352: | |
raise ValueError("sector data is incomplete") | |
if sector[0:12] != SYNC_PATTERN: | |
raise ValueError("invalid sync pattern at beginning of sector") | |
return parse2340ByteSector(sector[12:]) | |
def parse2448ByteSector(sector: ByteString) -> ByteString: | |
if not sector: | |
return b"" | |
if len(sector) < 2448: | |
raise ValueError("sector data is incomplete") | |
return parse2352ByteSector(sector[0:2352]) | |
## Sector type detection | |
_SECTOR_PARSERS: dict[int, Callable] = { | |
2448: parse2448ByteSector, | |
2352: parse2352ByteSector, | |
2340: parse2340ByteSector, | |
2336: parse2336ByteSector | |
} | |
def getSectorParser(data: ByteString) -> tuple[int, Callable]: | |
for length, parser in _SECTOR_PARSERS.items(): | |
try: | |
for offset in range(0, len(data) - length, length): | |
parser(data[offset:offset + length]) | |
return length, parser | |
except ValueError: | |
continue | |
raise RuntimeError( | |
"unable to determine the input image's format (image already has " | |
"2048-byte sectors?)" | |
) | |
## Main | |
def createParser() -> ArgumentParser: | |
parser = ArgumentParser( | |
description = \ | |
"Converts a CD-ROM image with 2336-, 2340-, 2352- or 2448-byte " | |
"sectors (typically found with the .bin extension alongside a " | |
"cuesheet) to one with 2048-byte sectors (as used by .iso files) " | |
"if possible.", | |
add_help = False | |
) | |
group = parser.add_argument_group("Tool options") | |
group.add_argument( | |
"-h", "--help", | |
action = "help", | |
help = "Show this help message and exit" | |
) | |
group = parser.add_argument_group("File paths") | |
group.add_argument( | |
"input", | |
type = FileType("rb"), | |
help = "Path to input image file" | |
) | |
group.add_argument( | |
"output", | |
type = FileType("wb"), | |
help = "Path to image file to generate" | |
) | |
return parser | |
def main(): | |
parser: ArgumentParser = createParser() | |
args: Namespace = parser.parse_args() | |
with args.input: | |
data: bytes = args.input.read(2448 * 16) | |
length, parser = getSectorParser(data) | |
args.input.seek(0, SEEK_SET) | |
with args.output: | |
while (data := parser(args.input.read(length))): | |
args.output.write(data) | |
if __name__ == "__main__": | |
main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment