joeylib2/tools/adfTool.py
2026-10-13 14:01:46 -05:00

142 lines
5.2 KiB
Python
Executable file

#!/usr/bin/env python3
# Part of JoeyLib, Copyright (c) 2026 Scott Duensing, under the MIT License.
# See LICENSE.md for license information.
# SPDX-License-Identifier: MIT
"""adfTool.py - read files out of an Amiga OFS/FFS disk image (.adf).
adfTool.py list IMAGE every file, one path per line
adfTool.py extract IMAGE PATH OUT one file (PATH like Devs/serial.device)
adfTool.py extractall IMAGE OUTDIR every file, keeping the drawer tree
Read-only. Used by the installer's `import` (a Workbench disk image is how
most people own AmigaOS files such as Devs/serial.device) and by the Amiga
gates. Handles DD (880 KB) and HD (1.76 MB) images; names match case-
insensitively, as AmigaDOS does.
"""
import os
import struct
import sys
BLOCK_SIZE = 512
HASH_SLOTS = 72 # hash-table entries in a 512-byte header block
TYPE_HEADER = 2 # primary type of a root / drawer / file header
SEC_TYPE_ROOT = 1
SEC_TYPE_USERDIR = 2
SEC_TYPE_FILE = -3
OFS_DATA_HEADER = 24 # OFS data blocks start with a 24-byte header
class AdfImage:
def __init__(self, path):
with open(path, "rb") as f:
self.image = f.read()
if len(self.image) % BLOCK_SIZE != 0 or self.image[0:3] != b"DOS":
raise ValueError("%s is not an AmigaDOS disk image" % path)
self.isFfs = (self.image[3] & 1) != 0
self.rootBlock = (len(self.image) // BLOCK_SIZE) // 2
def block(self, number):
return self.image[number * BLOCK_SIZE:(number + 1) * BLOCK_SIZE]
@staticmethod
def long(data, offset):
# Negative offsets count from the end of the block, as the
# AmigaDOS structure definitions do.
if offset < 0:
offset += BLOCK_SIZE
return struct.unpack(">l", data[offset:offset + 4])[0]
@staticmethod
def name(data):
length = data[BLOCK_SIZE - 80]
return data[BLOCK_SIZE - 79:BLOCK_SIZE - 79 + length].decode("latin-1")
def entries(self, dirBlock):
# (name, header block, secondary type) for everything in a drawer.
data = self.block(dirBlock)
found = []
for slot in range(HASH_SLOTS):
number = self.long(data, 24 + 4 * slot)
while number:
header = self.block(number)
if self.long(header, 0) == TYPE_HEADER:
found.append((self.name(header), number, self.long(header, -4)))
number = self.long(header, -16)
return sorted(found, key=lambda e: e[0].upper())
def walk(self, dirBlock=None, prefix=""):
# Yield (path, header block) for every file under a drawer.
if dirBlock is None:
dirBlock = self.rootBlock
for entryName, number, secType in self.entries(dirBlock):
path = prefix + entryName
if secType == SEC_TYPE_USERDIR:
yield from self.walk(number, path + "/")
elif secType == SEC_TYPE_FILE:
yield path, number
def find(self, path):
current = self.rootBlock
for part in path.split("/"):
match = [n for entryName, n, _ in self.entries(current) if entryName.upper() == part.upper()]
if not match:
raise KeyError("not found in image: %s" % path)
current = match[0]
return current
def read(self, headerBlock):
header = self.block(headerBlock)
size = self.long(header, -188)
data = bytearray()
chunk = header
while True:
count = self.long(chunk, 8)
# Data-block pointers fill the table from the END (slot 71 first).
pointers = [self.long(chunk, 24 + 4 * i) for i in range(HASH_SLOTS)][::-1][:count]
for pointer in pointers:
blockData = self.block(pointer)
if self.isFfs:
data += blockData
else:
data += blockData[OFS_DATA_HEADER:OFS_DATA_HEADER + self.long(blockData, 12)]
extension = self.long(chunk, -8)
if not extension:
break
chunk = self.block(extension)
return bytes(data[:size])
def main(argv):
if len(argv) < 3 or argv[1] not in ("list", "extract", "extractall"):
sys.stderr.write(__doc__)
return 2
try:
image = AdfImage(argv[2])
if argv[1] == "list":
for path, _ in image.walk():
print(path)
elif argv[1] == "extract":
if len(argv) != 5:
sys.stderr.write(__doc__)
return 2
with open(argv[4], "wb") as f:
f.write(image.read(image.find(argv[3])))
else:
if len(argv) != 4:
sys.stderr.write(__doc__)
return 2
for path, number in image.walk():
out = os.path.join(argv[3], *path.split("/"))
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "wb") as f:
f.write(image.read(number))
except (OSError, ValueError, KeyError) as error:
sys.stderr.write("adfTool.py: %s\n" % error)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))