Skip to content

Instantly share code, notes, and snippets.

@spicyjpeg
Created April 8, 2025 13:58
Show Gist options
  • Save spicyjpeg/f48d9330cf74a6f9744f5952e1b7acb9 to your computer and use it in GitHub Desktop.
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
#!/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