Skip to content

Instantly share code, notes, and snippets.

@spicyjpeg
Created January 9, 2026 00:45
Show Gist options
  • Select an option

  • Save spicyjpeg/bd0c535cda94b1e5dd68f3b080664ff3 to your computer and use it in GitHub Desktop.

Select an option

Save spicyjpeg/bd0c535cda94b1e5dd68f3b080664ff3 to your computer and use it in GitHub Desktop.
PlayStation 1 game ID dumping utility
#!/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