Repository navigation
Expand file tree
/
Copy pathiso_bin.py
More file actions
202 lines (179 loc) · 7.46 KB
/
Copy pathiso_bin.py
File metadata and controls
202 lines (179 loc) · 7.46 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
#!/usr/bin/env python3
"""Read the ISO9660 filesystem out of a raw Mode1/2352 CD image, on the fly.
These "CD dumps" ship under many extensions — GOG's `.GOG`, plain `.BIN`, `.DOT`,
`.IMG` — but the format is the same: a raw image whose data track is MODE1/2352
(2352 bytes/sector = 12 sync + 4 header + 2048 user data + 288 EDC/ECC), paired with
a CUE sheet (often a `.DAT`/`.CUE`) that lists the data track plus any CD-DA audio
tracks. This reader cares only about the single MODE1/2352 *data* track (track 1,
starting at image offset 0); audio tracks live elsewhere in the image and are the
CUE's business, not ours.
We map an ISO logical block (LBA) to a byte offset of `lba*2352 + 16` and read 2048
bytes, so the ISO9660 filesystem is walked in place — no need to materialize a 600 MB
plain `.iso`. Verified against the Adeline CD dumps for LBA1 (`LBA.DOT`), Time
Commando (`GAME.GOG`), and LBA2 (`LBA2.GOG`).
A cooked image (2048 bytes/sector, data track only, what a `.iso` usually is) reads
the same way with the stride and header offset changed, so `open_image` accepts
either. Only a raw one carries the audio tracks.
This also doubles as the working spec for an in-engine ISO9660-from-image reader that
could let the engine load media directly from a GOG package without an extraction step
(see issue #119). For bulk extraction of LBA2's missing media into an install dir, use
`extract_lba2_gog_media.py`.
Usage:
iso_bin.py <image> [--ls PATH] [--tree] [--extract ISOPATH OUTFILE]
Examples:
iso_bin.py LBA2.GOG --tree | grep -i '\\.HQR$'
iso_bin.py GAME.GOG --extract /STAGE00/RUN0/SCENE.HQR out.hqr
"""
import struct, argparse
SECTOR = 2352
USER = 2048
HDR = 16 # 12 sync + 4 header before the 2048 user bytes (Mode1/2352)
# Raw first: a cooked image has no sync pattern, so it can only be read one way,
# while a raw one read as cooked lands mid-sector and matches nothing.
#
# 2352/16 MODE1 raw: 12 sync + 4 header, then the user data.
# 2352/24 MODE2 Form 1 raw (CD-ROM XA / CD-Bridge): the same 16 bytes plus an
# 8-byte subheader. The European EA pressing of LBA2 is one of these.
# 2048/0 cooked, data track only.
MODE2_HDR = 24
LAYOUTS = ((SECTOR, HDR), (SECTOR, MODE2_HDR), (USER, 0))
class Image:
def __init__(self, path, stride=SECTOR, hdr=HDR):
self.f = open(path, "rb")
self.stride = stride
self.hdr = hdr
def read_lba(self, lba, count=1):
out = bytearray()
for i in range(count):
self.f.seek((lba + i) * self.stride + self.hdr)
out += self.f.read(USER)
return bytes(out)
@property
def is_raw(self):
return self.stride == SECTOR
@property
def mode(self):
"""MODE1, MODE2 or cooked: the stride alone cannot tell the first two apart."""
if self.stride != SECTOR:
return "cooked"
return "MODE2" if self.hdr == MODE2_HDR else "MODE1"
def open_image(path):
"""Open an image whichever layout it uses, or return None if it is not ISO9660.
Raw (2352) carries the CD-DA audio tracks alongside the filesystem; cooked
(2048) is the data track alone. Both are readable here, but only a raw image
has audio to extract."""
for stride, hdr in LAYOUTS:
img = Image(path, stride, hdr)
try:
if img.read_lba(16)[1:6] == b"CD001":
return img
except OSError:
pass
img.f.close()
return None
def parse_dir_records(data):
"""Yield (name, lba, size, is_dir) from a directory extent's bytes."""
i = 0
while i < len(data):
rec_len = data[i]
if rec_len == 0:
# advance to next logical sector boundary
nxt = (i // USER + 1) * USER
if nxt <= i:
break
i = nxt
continue
_ext_attr = data[i + 1] # extended attribute length, unused by this reader
lba = struct.unpack_from("<I", data, i + 2)[0]
size = struct.unpack_from("<I", data, i + 10)[0]
flags = data[i + 25]
len_fi = data[i + 32]
fi = data[i + 33 : i + 33 + len_fi]
is_dir = bool(flags & 0x02)
if len_fi == 1 and fi in (b"\x00", b"\x01"):
name = "." if fi == b"\x00" else ".."
else:
name = fi.decode("latin-1").split(";")[0]
yield name, lba, size, is_dir
i += rec_len
def get_pvd(img):
# volume descriptors start at LBA 16
for lba in range(16, 32):
d = img.read_lba(lba)
if d[1:6] != b"CD001":
raise SystemExit("not an ISO9660 image (no CD001 at LBA %d)" % lba)
if d[0] == 1: # Primary Volume Descriptor
return d
if d[0] == 255:
break
raise SystemExit("no Primary Volume Descriptor found")
def root_record(pvd):
rec = pvd[156 : 156 + 34]
lba = struct.unpack_from("<I", rec, 2)[0]
size = struct.unpack_from("<I", rec, 10)[0]
return lba, size
def list_dir(img, lba, size):
nsec = (size + USER - 1) // USER
data = img.read_lba(lba, nsec)
return [r for r in parse_dir_records(data) if r[0] not in (".", "..")]
def walk(img, lba, size, prefix=""):
for name, clba, csize, is_dir in list_dir(img, lba, size):
path = prefix + "/" + name
yield path, clba, csize, is_dir
if is_dir:
yield from walk(img, clba, csize, path)
def find(img, root_lba, root_size, target):
target = target.strip("/").upper()
lba, size = root_lba, root_size
if not target:
return lba, size, True
parts = target.split("/")
for j, part in enumerate(parts):
match = None
for name, clba, csize, is_dir in list_dir(img, lba, size):
if name.upper() == part:
match = (clba, csize, is_dir); break
if match is None:
raise SystemExit("path not found: %s" % target)
lba, size, is_dir = match
if j < len(parts) - 1 and not is_dir:
raise SystemExit("not a directory: %s" % part)
return lba, size, is_dir
def main():
ap = argparse.ArgumentParser()
ap.add_argument("image")
ap.add_argument("--ls", default="/", help="list a directory")
ap.add_argument("--tree", action="store_true", help="recursive listing from root")
ap.add_argument("--extract", nargs=2, metavar=("ISOPATH", "OUT"))
a = ap.parse_args()
img = open_image(a.image)
if img is None:
raise SystemExit("not an ISO9660 image (no CD001 at LBA 16, either layout)")
pvd = get_pvd(img)
rlba, rsize = root_record(pvd)
volid = pvd[40:72].decode("latin-1").strip()
print(f"# volume: {volid!r} root LBA={rlba} size={rsize}")
if a.extract:
lba, size, is_dir = find(img, rlba, rsize, a.extract[0])
if is_dir:
raise SystemExit("is a directory")
nsec = (size + USER - 1) // USER
data = img.read_lba(lba, nsec)[:size]
with open(a.extract[1], "wb") as fh:
fh.write(data)
print(f"wrote {a.extract[1]} ({size} bytes, LBA {lba})")
return
if a.tree:
for path, lba, size, is_dir in walk(img, rlba, rsize):
tag = "DIR " if is_dir else " "
print(f"{tag}{size:>10} LBA{lba:>8} {path}")
return
lba, size, is_dir = find(img, rlba, rsize, a.ls)
if not is_dir:
print(f"(file) {a.ls} size={size} LBA={lba}")
return
for name, clba, csize, d in sorted(list_dir(img, lba, size)):
tag = "DIR " if d else " "
print(f"{tag}{csize:>10} LBA{clba:>8} {name}")
if __name__ == "__main__":
main()