Created
January 9, 2026 00:45
-
-
Save spicyjpeg/bd0c535cda94b1e5dd68f3b080664ff3 to your computer and use it in GitHub Desktop.
PlayStation 1 game ID dumping utility
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 -*- | |
| """PlayStation 1 game ID dumping utility | |
| A simple command-line script that attempts to extract the game ID (in XXXX-nnnnn | |
| format) from a Sony PlayStation CD-ROM image or first track thereof. For | |
| simplicity's sake, only images with 2352-byte sectors (commonly found as .bin | |
| files alongside cuesheets) are supported. Requires no external dependencies. | |
| Note that certain discs, such as a handful of obscure retail games, Lightspan | |
| games, most homebrew games/apps and all discs meant for PS1-based arcade | |
| systems, do not follow the convention of naming their boot executable after the | |
| game ID - sometimes going as far as to omit the SYSTEM.CNF file that specifies | |
| the boot executable in favor of using the default name (PSX.EXE). This script | |
| will not be able to determine the game ID in such cases. | |
| """ | |
| __version__ = "0.1.0" | |
| __author__ = "spicyjpeg" | |
| import re, sys | |
| from argparse import ArgumentParser, FileType, Namespace | |
| from collections.abc import Generator | |
| from dataclasses import dataclass | |
| from enum import IntEnum, IntFlag | |
| from typing import BinaryIO, Self | |
| ## CD-ROM image parser | |
| OUTER_SECTOR_SIZE: int = 2352 | |
| INNER_SECTOR_SIZE: int = 2048 | |
| 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 readSector(image: BinaryIO, lba: int) -> bytes: | |
| image.seek(lba * OUTER_SECTOR_SIZE) | |
| data: bytes = image.read(OUTER_SECTOR_SIZE) | |
| if len(data) < OUTER_SECTOR_SIZE: | |
| raise IOError("sector data is incomplete") | |
| if data[0x00:0x0c] != SYNC_PATTERN: | |
| raise IOError("invalid sync pattern at beginning of sector") | |
| match CDROMMode(data[0x0f]): | |
| case CDROMMode.MODE1: | |
| return data[0x10:0x10 + INNER_SECTOR_SIZE] | |
| case CDROMMode.MODE2: | |
| if data[0x10:0x14] != data[0x14:0x18]: | |
| raise IOError("invalid or corrupted mode 2 XA subheader") | |
| if XASubmodeFlag(data[0x12]) & XASubmodeFlag.FORM2: | |
| raise IOError("mode 2 form 2 sectors cannot be parsed") | |
| return data[0x18:0x18 + INNER_SECTOR_SIZE] | |
| def readSpan(image: BinaryIO, lba: int, length: int) -> bytearray: | |
| data: bytearray = bytearray() | |
| while len(data) < length: | |
| data += readSector(image, lba) | |
| lba += 1 | |
| return data[0:length] | |
| ## ISO9660 directory parser | |
| ISO_PVD_LBA: int = 0x10 | |
| ISO_PVD_MAGIC: bytes = b"\x01CD001\x01" | |
| class ISORecordFlag(IntFlag): | |
| EXISTENCE = 1 << 0 | |
| DIRECTORY = 1 << 1 | |
| ASSOCIATED = 1 << 2 | |
| EXT_ATTR = 1 << 3 | |
| PROTECTION = 1 << 4 | |
| MULTI_EXTENT = 1 << 7 | |
| @dataclass | |
| class ISORecord: | |
| name: str | |
| lba: int | |
| length: int | |
| flags: ISORecordFlag | |
| extendedAttr: bytes = b"" | |
| @classmethod | |
| def parse(cls, data: bytes, offset: int = 0) -> tuple[Self, int]: | |
| recordLength: int = data[offset + 0x00] | |
| lengthWithName: int = data[offset + 0x20] + 0x21 | |
| extendedAttrOffset: int = lengthWithName + (lengthWithName % 2) | |
| record: Self = cls( | |
| data[offset + 0x21:offset + lengthWithName].decode("ascii").upper(), | |
| int.from_bytes(data[offset + 0x02:offset + 0x06], "little"), | |
| int.from_bytes(data[offset + 0x0a:offset + 0x0e], "little"), | |
| ISORecordFlag(data[offset + 0x19]), | |
| data[offset + extendedAttrOffset:offset + recordLength] | |
| ) | |
| return record, recordLength | |
| def getRootDirectory(image: BinaryIO) -> ISORecord: | |
| pvd: bytes = readSector(image, ISO_PVD_LBA) | |
| if pvd[0x00:0x07] != ISO_PVD_MAGIC: | |
| raise IOError("invalid ISO9660 primary volume descriptor") | |
| root, _ = ISORecord.parse(pvd, 0x9c) | |
| return root | |
| def listDirectory( | |
| image: BinaryIO, | |
| directory: ISORecord | |
| ) -> Generator[ISORecord, None, None]: | |
| if not directory.flags & ISORecordFlag.DIRECTORY: | |
| raise NotADirectoryError("can only list entries of directories") | |
| data: bytearray = readSpan(image, directory.lba, directory.length) | |
| offset: int = 0 | |
| while offset < len(data): | |
| if not data[offset]: # Skip padding between entries | |
| offset += 1 | |
| continue | |
| record, recordLength = ISORecord.parse(data, offset) | |
| offset += recordLength | |
| yield record | |
| ## Main | |
| CONFIG_FILE_NAME: str = "SYSTEM.CNF;1" | |
| DEFAULT_EXE_NAME: str = "PSX.EXE;1" | |
| BOOT_ENTRY_REGEX: re.Pattern = \ | |
| re.compile(r"^[ \t]*BOOT[ \t]*=[ \t]*([0-9A-Za-z_.:;\\]+)", re.MULTILINE) | |
| GAME_ID_REGEX: re.Pattern = \ | |
| re.compile(r"([A-Z]{4})_([0-9]{3})\.([0-9]{2})(?:;1)?$") | |
| def createParser() -> ArgumentParser: | |
| parser = ArgumentParser( | |
| description = \ | |
| "Determines and outputs the game ID (XXXX-nnnnn) of a PlayStation " | |
| "1 disc image. Note that not all disc images contain a valid game " | |
| "ID.", | |
| 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.add_argument( | |
| "-p", "--path", | |
| action = "store_true", | |
| help = \ | |
| "Dump the full path to the boot executable (even if it does not " | |
| "follow the format of a valid ID) rather than just the game ID" | |
| ) | |
| group.add_argument( | |
| "-c", "--config", | |
| action = "store_true", | |
| help = \ | |
| f"Dump the entire contents of {CONFIG_FILE_NAME} (even if the " | |
| f"executable name does not follow the format of a valid ID) rather " | |
| f"than just the game ID" | |
| ) | |
| group = parser.add_argument_group("File paths") | |
| group.add_argument( | |
| "input", | |
| type = FileType("rb"), | |
| help = \ | |
| f"Path to input CD-ROM image or first track thereof (must have " | |
| f"{OUTER_SECTOR_SIZE}-byte sectors)" | |
| ) | |
| return parser | |
| def main(): | |
| parser: ArgumentParser = createParser() | |
| args: Namespace = parser.parse_args() | |
| hasDefaultEXE: bool = False | |
| with args.input as image: | |
| root: ISORecord = getRootDirectory(image) | |
| for record in listDirectory(image, root): | |
| if record.name == DEFAULT_EXE_NAME: | |
| hasDefaultEXE = True | |
| if record.name != CONFIG_FILE_NAME: | |
| continue | |
| config: str = \ | |
| readSpan(image, record.lba, record.length).decode("shift-jis") | |
| if args.config: | |
| sys.stdout.write(config) | |
| return | |
| bootEntry: re.Match | None = BOOT_ENTRY_REGEX.search(config) | |
| if bootEntry is None: | |
| raise RuntimeError(f"{CONFIG_FILE_NAME} has no boot entry") | |
| exePath: str = bootEntry.group(1) | |
| if args.path: | |
| sys.stdout.write(f"{exePath}\n") | |
| return | |
| gameID: re.Match | None = GAME_ID_REGEX.search(exePath.upper()) | |
| if gameID is None: | |
| raise RuntimeError( | |
| f"executable path ({exePath}) does not follow game ID " | |
| f"format" | |
| ) | |
| prefix, code1, code2 = gameID.groups() | |
| sys.stdout.write(f"{prefix}-{code1}{code2}\n") | |
| return | |
| if hasDefaultEXE and args.path: | |
| sys.stdout.write(f"cdrom:\\{DEFAULT_EXE_NAME}\n") | |
| else: | |
| raise FileNotFoundError(f"{CONFIG_FILE_NAME} not found in disc image") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment