Skip to content

Instantly share code, notes, and snippets.

@Holzhaus
Last active November 18, 2020 01:01
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Holzhaus/ae3dacf6a2e83dd00421 to your computer and use it in GitHub Desktop.
Save Holzhaus/ae3dacf6a2e83dd00421 to your computer and use it in GitHub Desktop.
Simple python Dreamcast image parser
#!/usr/bin/env python
# (c) 2015 Jan Holthuis
# A simple python script to get some info about Dreamcast images
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import os
import sys
import csv
import struct
CDI_V2 = 0x80000004
CDI_V3 = 0x80000005
CDI_V35 = 0x80000006
CDI_V4 = CDI_V35
TRACK_START_MARK = (0, 0, 0x01, 0, 0, 0, 0xFF, 0xFF, 0xFF, 0xFF)
TRACK_SECTOR_SIZES = {0: 2048, 1: 2336, 2: 2352}
TRACK_MODES = {0: 'audio', 1: 'mode1', 2: 'mode2'}
class Image(object):
def __init__(self, filename, sessions):
self._filename = filename
self._sessions = tuple(sessions)
@staticmethod
def parse(filename):
ext = os.path.splitext(filename)[1].lower()
if ext == '.cdi':
return CdiImage.parse(filename)
elif ext == '.gdi':
return GdiImage.parse(filename)
elif ext == '.chd':
print("CHD images are not supported right now!")
else:
print("Unknown image format")
@staticmethod
def parse_ip_info(ip_bin_data):
return (s.decode('ascii') for s in
struct.unpack("<16s16s16s8s8s10s6s16s16s16s128s",
ip_bin_data))
@property
def filename(self):
return self._filename
@property
def sessions(self):
return self._sessions
@property
def ip_info_raw(self):
raise NotImplementedError()
@property
def ip_info(self):
return self.parse_ip_info(self.ip_info_raw)
@property
def disk_type(self):
modes = (False, False, False)
for session in self.sessions:
for track in session.tracks:
modes[track.mode[0]] = True
if modes != (True, False, False):
if modes[2] is True:
return 'cdrom_xa'
elif modes[0] is True and modes[1] is True:
return 'cdrom_extra'
return 'cdrom'
def print_info(self):
print("IP.BIN info:")
(hardware_id, maker_id, disc_id, areas, peripherals, product_id,
product_version, release_date, bootfile, publisher,
product_name) = self.ip_info
print(" hardware_id: %s" % hardware_id)
print(" maker_id: %s" % maker_id)
print(" disc_id: %s" % disc_id)
print(" areas: %s" % areas)
print(" peripherals: %s" % peripherals)
print(" product_id: %s" % product_id)
print(" product_version: %s" % product_version)
print(" release_date: %s" % release_date)
print(" bootfile: %s" % bootfile)
print(" publisher: %s" % publisher)
print(" product_name: %s" % product_name)
print("Sessions: %d" % len(self.sessions))
for sid, session in enumerate(self.sessions, start=1):
print("Session #%s (%d tracks):" % (sid, len(session.tracks)))
for tid, track in enumerate(session.tracks, start=1):
print(" Track #%d: %s" % (tid, track.mode[1]))
print(" position: %d" % track.position)
print(" filename: %s" % track.filename)
print(" pregap_length: %d" % track.pregap_length)
print(" length: %d" % track.length)
print(" total_length: %d" % track.total_length)
print(" start_lba: %d" % track.start_lba)
print(" sector_size: %d" % track.sector_size)
print(" start_fad: %d" % track.start_fad)
print(" end_fad: %d" % track.end_fad)
print(" offset: %d" % track.offset)
class CdiImage(Image):
def __init__(self, version, header_offset, *args):
self._version = version
self._header_offset = header_offset
super(CdiImage, self).__init__(*args)
@property
def version(self):
return self._version
@property
def header_offset(self):
return self._header_offset
@property
def ip_info_raw(self):
# Extract IP.BIN data
last_data_track = self.sessions[-1].tracks[-1]
ip_bin_position = last_data_track.position
if last_data_track.sector_size == 2336:
ip_bin_position += 8
with open(self.filename, mode="rb") as f:
f.seek(ip_bin_position)
return f.read(256)
@classmethod
def parse(cls, filename):
file_size = os.path.getsize(filename)
if file_size < 8:
print("Image size too short")
return
with open(filename, mode="rb") as f:
f.seek(file_size-8)
image_version = struct.unpack("<I", f.read(4))[0]
image_header_offset = struct.unpack("<I", f.read(4))[0]
if image_header_offset == 0:
print("Bad image format")
return
if image_version not in (CDI_V2, CDI_V3, CDI_V35, CDI_V4):
print("Unsupported CDI version!")
sys.exit(1)
f.seek(image_header_offset)
num_sessions = struct.unpack("<H", f.read(2))[0]
sessions = []
track_offset = 0
for s in range(num_sessions):
num_tracks = struct.unpack("<H", f.read(2))[0]
tracks = []
for t in range(num_tracks):
track = cls._parse_track(f, track_offset, filename)
if track is not None:
tracks.append(track)
track_offset += track.total_length*track.sector_size
f.seek(29, 1)
if image_version != CDI_V2:
f.seek(5, 1)
temp_value = struct.unpack("<I", f.read(4))[0]
if temp_value == 0xffffffff:
# extra data (DJ 3.00.780 and up)
f.seek(78, 1)
sessions.append(Session(tracks))
# Skip to next session
f.seek(4, 1)
f.seek(8, 1)
if image_version != CDI_V2:
f.seek(1, 1)
return cls(image_version, image_header_offset, filename, sessions)
@staticmethod
def _parse_track(f, position, image_filename):
temp_value = struct.unpack("<I", f.read(4))[0]
if temp_value != 0:
# extra data (DJ 3.00.780 and up)
f.seek(8, 1)
current_start_mark = struct.unpack("<10B", f.read(10))
if current_start_mark != TRACK_START_MARK:
print("Unsupported format: Missing track start mark")
current_start_mark = struct.unpack("<10B", f.read(10))
if current_start_mark != TRACK_START_MARK:
print("Unsupported format: Missing track start mark")
f.seek(4, 1)
filename_length = struct.unpack("<B", f.read(1))[0]
filename = f.read(filename_length).decode('utf-8')
f.seek(11, 1)
f.seek(4, 1)
f.seek(4, 1)
temp_value = struct.unpack("<I", f.read(4))[0]
if temp_value == 0x80000000:
# DiscJuggler 4
f.seek(8, 1)
f.seek(2, 1)
pregap_length = struct.unpack("<I", f.read(4))[0]
length = struct.unpack("<I", f.read(4))[0]
f.seek(6, 1)
mode = struct.unpack("<I", f.read(4))[0]
f.seek(12, 1)
start_lba = struct.unpack("<I", f.read(4))[0]
total_length = struct.unpack("<I", f.read(4))[0]
if total_length != (pregap_length + length):
print("total_length %d != pregap_length %d + length %d" %
(total_length, pregap_length, length))
f.seek(16, 1)
sector_size_id = struct.unpack("<I", f.read(4))[0]
if sector_size_id not in TRACK_SECTOR_SIZES:
print("Unsupported sector size")
return None
sector_size = TRACK_SECTOR_SIZES[sector_size_id]
if mode not in TRACK_MODES:
print("Unsupported format: Track mode not supported")
return None
ctrl = 0 if mode == 0 else 4
return Track(filename, pregap_length, length, mode, ctrl,
start_lba, sector_size, position, image_filename)
def print_info(self):
if self.version == CDI_V2:
version_string = "CDI_V2"
elif self.version == CDI_V3:
version_string = "CDI_V3"
elif self.version == CDI_V35:
version_string = "CDI_V35"
else:
version_string = "CDI_V4"
print("Type: DiscJuggler (CDI)")
print("CDI version: %s (0x%x)" % (version_string, self.version))
print("Header offset: %d" % self.header_offset)
super(CdiImage, self).print_info()
class GdiImage(Image):
@classmethod
def parse(cls, filename):
sessions = []
tracks = []
with open(filename, mode="r") as f:
num_tracks = int(f.readline().strip())
gdi_reader = csv.reader(f, delimiter=' ', quotechar='"')
for row in gdi_reader:
track_index = int(row[0])
if track_index == 3:
# The high density disc area always starts with track 03,
# so we just start a new session here.
# (see GD-ROM Format Basic Specifications Ver. 2.14, p. 9
# by Sega Enterprises, Ltd. for details)
sessions.append(Session(tracks))
tracks = []
start_lba = int(row[1])
ctrl = int(row[2])
mode = 0 if ctrl == 0 else 1
sector_size = int(row[3])
track_filename = os.path.abspath(os.path.join(
os.path.dirname(filename), row[4]))
pregap_length = 150
length = os.path.getsize(track_filename)/sector_size
offset = int(row[5])
track = Track(track_filename, pregap_length, length, mode,
ctrl, start_lba, sector_size, offset, filename)
tracks.append(track)
if num_tracks != track_index:
print("Incorrect track number in gdi file!")
sessions.append(Session(tracks))
return cls(filename, sessions)
@property
def ip_info_raw(self):
# Extract IP.BIN data
last_data_track = self.sessions[-1].tracks[-1]
with open(last_data_track.filename, mode="rb") as f:
ip_bin_position = 0x10
f.seek(ip_bin_position)
return f.read(256)
def print_info(self):
print("Type: GD-ROM disc dump (GDI)")
super(GdiImage, self).print_info()
class Session(object):
def __init__(self, tracks):
self._tracks = tuple(tracks)
@property
def tracks(self):
return self._tracks
@property
def start_fad(self):
return self._tracks[0].start_fad
class Track(object):
def __init__(self, filename, pregap_length, length, mode, ctrl,
start_lba, sector_size, offset, image_filename):
self._filename = filename
self._pregap_length = pregap_length
self._length = length
self._mode = mode
self._ctrl = ctrl
self._start_lba = start_lba
self._sector_size = sector_size
self._offset = offset
self._image_filename = image_filename
@property
def filename(self):
return self._filename
@property
def pregap_length(self):
return self._pregap_length
@property
def length(self):
return self._length
@property
def total_length(self):
return self.pregap_length + self.length
@property
def mode(self):
return (self._mode, TRACK_MODES[self._mode])
@property
def ctrl(self):
return self._ctrl
@property
def start_lba(self):
return self._start_lba
@property
def sector_size(self):
return self._sector_size
@property
def position(self):
return self.offset + self.pregap_length * self.sector_size
@property
def offset(self):
return self._offset
@property
def start_fad(self):
return self.start_lba + self.pregap_length
@property
def end_fad(self):
return self.start_fad + self.length - 1
def read_sectors(self, fad, sectors=1):
with open(self._image_filename, mode="rb") as f:
f.seek(self.position+fad*self.sector_size)
print(f.tell())
return f.read(sectors*self.sector_size)
if __name__ == "__main__":
filename = sys.argv[1]
img = Image.parse(filename)
img.print_info()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment