├── .gitignore ├── README.md ├── appledouble.py ├── applicationutil.py ├── disk.py ├── diskcopyimage.py ├── driver.bin ├── driver.ini ├── macftp.py ├── preparevolume.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | *.scsi 2 | .idea 3 | __pycache__ 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pimp My Plus 2 | This repo contains a bunch of scripts for downloading all of the classic mac software you could ever want and creating HD images with that software for use with SCSI2SD. No floppy disks required! 3 | 4 | *NOTE:* The `driver.bin` file contains the SCSI driver that is installed to the disk by Apple HD SC Setup 7.3.5. The licensing of this driver is not clear. Please consider this before using this repo for commercial purposes. 5 | -------------------------------------------------------------------------------- /appledouble.py: -------------------------------------------------------------------------------- 1 | """ 2 | Reference: 3 | http://formats.kaitai.io/apple_single_double/index.html 4 | http://kaiser-edv.de/documents/AppleSingle_AppleDouble.pdf 5 | above linke is dead, archive: 6 | https://web.archive.org/web/20180311140826/http://kaiser-edv.de/documents/AppleSingle_AppleDouble.pdf 7 | """ 8 | 9 | import enum 10 | from ctypes import BigEndianStructure, c_uint32, c_uint16, c_char 11 | from io import RawIOBase 12 | from typing import List, Optional 13 | 14 | 15 | class AppleDoubleHeader(BigEndianStructure): 16 | _pack_ = 1 17 | _fields_ = [ 18 | ('signature', c_uint32), 19 | ('version', c_uint32), 20 | ('reserved', c_uint32 * 4), # Must all be zero 21 | ('num_entries', c_uint16) 22 | ] 23 | 24 | 25 | class AppleDoubleEntry(BigEndianStructure): 26 | _pack_ = 1 27 | _fields_ = [ 28 | ('type', c_uint32), 29 | ('body_offset', c_uint32), 30 | ('body_length', c_uint32) 31 | ] 32 | 33 | 34 | class EntryType(enum.Enum): 35 | data_fork = 1 36 | resource_fork = 2 37 | real_name = 3 # File name on a file system that supports all the attributes. 38 | comment = 4 39 | icon_bw = 5 40 | icon_color = 6 41 | file_dates_info = 8 # File creation, modification, access date/timestamps. 42 | finder_info = 9 43 | macintosh_file_info = 10 44 | prodos_file_info = 11 45 | msdos_file_info = 12 46 | afp_short_name = 13 47 | afp_file_info = 14 48 | afp_directory_id = 15 49 | 50 | 51 | class Point(BigEndianStructure): 52 | """Specifies 2D coordinate in QuickDraw grid.""" 53 | _pack_ = 1 54 | _fields_ = [ 55 | ('x', c_uint16), 56 | ('y', c_uint16), 57 | ] 58 | 59 | 60 | class FinderInfo(BigEndianStructure): 61 | """From the older Inside Macintosh publication, Volume II page 84 or Volume IV page 104.""" 62 | _pack_ = 1 63 | _fields_ = [ 64 | ('fdType', c_char * 4), 65 | ('fdCreator', c_char * 4), 66 | ('fdFlags', c_uint16), 67 | ('fdLocation', Point), # File icon's coordinates when displaying this folder. 68 | ('fdFolder', c_uint16) # File folder ID (=window). 69 | 70 | ] 71 | 72 | 73 | class Entry(object): 74 | def __init__(self): 75 | self.info = None 76 | self.data = None 77 | 78 | 79 | class AppleDouble(object): 80 | def __init__(self): 81 | self.header = AppleDoubleHeader() 82 | self.entries: List[Entry] = [] 83 | 84 | def get_entry(self, type: EntryType) -> Optional[Entry]: 85 | for entry in self.entries: 86 | if entry.info.type == type.value: 87 | return entry 88 | return None 89 | 90 | 91 | SIGNATURE_APPLE_SINGLE = 0x00051600 92 | SIGNATURE_APPLE_DOUBLE = 0x00051607 93 | 94 | 95 | def parse(f: RawIOBase) -> AppleDouble: 96 | header = AppleDoubleHeader() 97 | f.readinto(header) 98 | 99 | # Validate signature 100 | if header.signature not in (SIGNATURE_APPLE_SINGLE, SIGNATURE_APPLE_DOUBLE): 101 | raise ValueError('Invalid signature') 102 | 103 | entries = [] 104 | for i in range(header.num_entries): 105 | entry = Entry() 106 | info = AppleDoubleEntry() 107 | f.readinto(info) 108 | entry.info = info 109 | entries.append(entry) 110 | 111 | for entry in entries: 112 | info = entry.info 113 | f.seek(info.body_offset) 114 | data = f.read(info.body_length) 115 | 116 | if info.type == EntryType.finder_info.value: 117 | entry.data = FinderInfo.from_buffer_copy(data) 118 | else: 119 | entry.data = data 120 | 121 | result = AppleDouble() 122 | result.header = header 123 | result.entries = entries 124 | return result 125 | -------------------------------------------------------------------------------- /applicationutil.py: -------------------------------------------------------------------------------- 1 | # https://vintageapple.org/inside_r/pdf/PPC_System_Software_1994.pdf 2 | 3 | from io import BytesIO 4 | from typing import List 5 | 6 | import rsrcfork 7 | 8 | 9 | ARCH_68K = '68k' 10 | ARCH_PPC = 'PPC' 11 | 12 | 13 | def get_supported_archs(rsrc: bytes) -> List[str]: 14 | with BytesIO(rsrc) as f: 15 | resource_file = rsrcfork.ResourceFile(f) 16 | 17 | archs = [] 18 | 19 | cfrg = resource_file.get(b'cfrg') 20 | if cfrg: 21 | # Assume it supports PPC if there's a cfrg lump. 22 | # TODO: Check the actuall processor field in this cfrg lump? 23 | archs.append(ARCH_PPC) 24 | 25 | code = resource_file.get(b'code') 26 | if code: 27 | archs.append(ARCH_68K) 28 | 29 | return archs 30 | 31 | -------------------------------------------------------------------------------- /disk.py: -------------------------------------------------------------------------------- 1 | # https://developer.apple.com/library/archive/documentation/mac/pdf/Devices/SCSI_Manager.pdf 2 | 3 | import configparser 4 | from ctypes import BigEndianStructure, c_uint16, c_uint32, c_char 5 | from typing import BinaryIO, List 6 | 7 | import machfs 8 | 9 | 10 | class DriverIni(object): 11 | def __init__(self): 12 | self.partition_type = b'Apple_Driver43' 13 | self.partition_flags = 0 14 | self.booter = 0 15 | self.bytes = 0 16 | self.load_address_0 = 0 17 | self.load_address_1 = 0 18 | self.goto_address_0 = 0 19 | self.goto_address_1 = 0 20 | self.checksum = 0 21 | self.processor = b'68000' 22 | self.boot_args: List[int] = [] 23 | 24 | 25 | def driver_from_ini(section) -> DriverIni: 26 | ini = DriverIni() 27 | ini.partition_type = bytes(section['partition_type'], encoding='ascii') 28 | ini.partition_flags = int(section['partition_flags']) 29 | ini.booter = int(section['booter']) 30 | ini.bytes = int(section['bytes']) 31 | ini.load_address_0 = int(section['load_address_0'], 16) 32 | ini.load_address_1 = int(section['load_address_1'], 16) 33 | ini.goto_address_0 = int(section['goto_address_0'], 16) 34 | ini.goto_address_1 = int(section['goto_address_1'], 16) 35 | ini.checksum = int(section['checksum'], 16) 36 | ini.processor = bytes(section['processor'], encoding='ascii') 37 | ini.boot_args = [int(x, 0) for x in section['boot_args'].split(',')] 38 | return ini 39 | 40 | 41 | class DriverDescriptor(BigEndianStructure): 42 | _pack_ = 1 43 | _fields_ = [ 44 | ('ddBlock', c_uint32), 45 | ('ddSize', c_uint16), 46 | ('ddType', c_uint16), 47 | ] 48 | 49 | 50 | class Block0(BigEndianStructure): 51 | _pack_ = 1 52 | _fields_ = [ 53 | ('sbSig', c_uint16), 54 | ('sbBlkSize', c_uint16), 55 | ('sbBlkCount', c_uint32), 56 | 57 | # Dev Type and Dev Id both have no information from apple. 58 | # Apple's hfdisk utility assigns zero for both, but System 6's disk utility sets these both to 1. 59 | # I have no idea if these fields are used at all. 60 | ('sbDevType', c_uint16), 61 | ('sbDevId', c_uint16), 62 | 63 | # Reserved. Seems to be unused by anything. 64 | ('sbData', c_uint32), 65 | 66 | ('sbDrvrCount', c_uint16), 67 | ('ddDrivers', DriverDescriptor * 61), 68 | 69 | ('_pad1', c_uint32), 70 | ('_pad2', c_uint16) 71 | ] 72 | 73 | def __init__(self): 74 | super().__init__() 75 | self.sbSig = 0x4552 # sbSIGWord magic number. 76 | 77 | 78 | class PartitionMapBlock(BigEndianStructure): 79 | _pack_ = 1 80 | _fields_ = [ 81 | ('dpme_signature', c_uint16), 82 | ('dpme_sigPad', c_uint16), 83 | ('dpme_map_entries', c_uint32), 84 | ('dpme_pblock_start', c_uint32), 85 | ('dpme_pblocks', c_uint32), 86 | ('dpme_name', c_char * 32), 87 | ('dpme_type', c_char * 32), 88 | ('dpme_lblock_start', c_uint32), 89 | ('dpme_lblocks', c_uint32), 90 | 91 | # Apple Docs say this is only used by A/UX. That is not 100% true. 92 | ('dpme_flags', c_uint32), 93 | 94 | # Note: Below data appears to only be used for SCSI Driver partitions 95 | ('dpme_boot_block', c_uint32), 96 | ('dpme_boot_bytes', c_uint32), 97 | ('dpme_load_addr', c_uint32), 98 | ('dpme_load_addr_2', c_uint32), 99 | ('dpme_goto_addr', c_uint32), 100 | ('dpme_goto_addr_2', c_uint32), 101 | ('dpme_checksum', c_uint32), 102 | ('dpme_process_id', c_char * 16), 103 | ('dpme_boot_args', c_uint32 * 32), 104 | ('dpme_reserved_3', c_uint32 * 62) 105 | ] 106 | 107 | def __init__(self): 108 | super().__init__() 109 | self.dpme_signature = 0x504d # "PM" 110 | 111 | 112 | def create_basic_partition(name, type, start_block, block_count, flags) -> PartitionMapBlock: 113 | block = PartitionMapBlock() 114 | block.dpme_pblock_start = start_block 115 | block.dpme_pblocks = block_count 116 | block.dpme_name = name 117 | block.dpme_type = type 118 | block.dpme_lblocks = block_count 119 | block.dpme_flags = flags 120 | return block 121 | 122 | 123 | def create_partition_map_partition() -> PartitionMapBlock: 124 | block = PartitionMapBlock() 125 | block.dpme_pblock_start = 1 126 | block.dpme_pblocks = 63 127 | block.dpme_name = b'Apple' 128 | block.dpme_type = b'Apple_partition_map' 129 | block.dpme_lblocks = 63 130 | return block 131 | 132 | 133 | def create_driver_partition_block(info: DriverIni, start_block: int, block_count: int) -> PartitionMapBlock: 134 | block = PartitionMapBlock() 135 | block.dpme_pblock_start = start_block 136 | block.dpme_pblocks = block_count 137 | block.dpme_name = b'Macintosh' 138 | block.dpme_type = info.partition_type 139 | block.dpme_lblocks = block_count 140 | block.dpme_flags = info.partition_flags 141 | block.dpme_boot_block = info.booter 142 | block.dpme_boot_bytes = info.bytes 143 | block.dpme_load_addr = info.load_address_0 144 | block.dpme_load_addr_2 = info.load_address_1 145 | block.dpme_goto_addr = info.goto_address_0 146 | block.dpme_goto_addr_2 = info.goto_address_1 147 | block.dpme_checksum = info.checksum 148 | block.dpme_process_id = info.processor 149 | for i, value in enumerate(info.boot_args): 150 | block.dpme_boot_args[i] = value 151 | return block 152 | 153 | 154 | def create_bootable_disk(of: BinaryIO, volume: machfs.Volume, block_count: int): 155 | """ 156 | 157 | :param of: 158 | :param volume: 159 | :param block_count: Total blocks in the disk, including blocks used by block0, partition map, and all partitions. 160 | :return: 161 | """ 162 | 163 | driver_ini = configparser.ConfigParser() 164 | driver_ini.read('driver.ini') 165 | driver_info = driver_from_ini(driver_ini['Driver']) 166 | 167 | block0 = Block0() 168 | block0.sbBlkSize = 512 169 | block0.sbBlkCount = block_count 170 | 171 | block0.sbDrvrCount = 1 172 | descriptor = DriverDescriptor() 173 | descriptor.ddBlock = 64 174 | descriptor.ddSize = int(driver_info.bytes / int(512) + (1 if driver_info.bytes % int(512) != 0 else 0)) 175 | descriptor.ddType = 1 # Always 1 176 | block0.ddDrivers[0] = descriptor 177 | 178 | # Set these both to 1, just in case. See comment in Block0 class. 179 | # TODO: Once we get a booting disk on a real MacPlus, try removing and see if it still works. 180 | block0.sbDevType = 1 181 | block0.sbDevId = 1 182 | 183 | block0_bytes = bytes(block0) 184 | if len(block0_bytes) != 512: 185 | raise ValueError('ASSERTION FAILED! sizeof(Block0) != 512') 186 | of.write(block0_bytes) 187 | 188 | def write_partition_map_block(block: PartitionMapBlock): 189 | block_bytes = bytes(block) 190 | if len(block_bytes) != 512: 191 | raise ValueError('ASSERTION FAILED! sizeof(PartitionMapBlock) != 512') 192 | of.write(block_bytes) 193 | 194 | volume_offset = 64 + 32 # Block0 + Partition Map + Driver 195 | volume_block_count = block_count - volume_offset 196 | 197 | partition_map_0 = create_basic_partition( 198 | name=b'MacOS', 199 | type=b'Apple_HFS', 200 | start_block=volume_offset, 201 | block_count=volume_block_count, 202 | flags=0) 203 | partition_map_0.dpme_map_entries = 3 204 | write_partition_map_block(partition_map_0) 205 | 206 | partition_map_1 = create_partition_map_partition() 207 | partition_map_1.dpme_map_entries = 3 208 | write_partition_map_block(partition_map_1) 209 | 210 | partition_map_2 = create_driver_partition_block(driver_info, 64, 32) 211 | partition_map_2.dpme_map_entries = 3 212 | write_partition_map_block(partition_map_2) 213 | 214 | # Write empty partition map entries 215 | empty_block = b'\0' * 512 216 | for i in range(1 + 3, 64): # 3 is partition map block count TODO: Kill all magic numbers 217 | of.write(empty_block) 218 | 219 | # Write Driver 220 | with open('driver.bin', 'rb') as f: 221 | for i in range(32): 222 | 223 | of.write(f.read(512)) 224 | 225 | # Write HFS Volume 226 | volume_data = volume.write( 227 | size=volume_block_count * 512, 228 | # desktopdb=False, 229 | bootable=False 230 | ) 231 | 232 | if len(volume_data) != volume_block_count * 512: 233 | raise ValueError('ASSERTION FAILED! len(volume_data) != volume_block_count * 512') 234 | of.write(volume_data) 235 | 236 | if of.tell() != block_count * 512: 237 | raise ValueError('Error! Output file is not expected size!') 238 | 239 | 240 | def mb_block_count(mb: int) -> int: 241 | kb = mb * 1024 242 | return kb * 2 # 2 512-byte blocks = 1 kb. 243 | -------------------------------------------------------------------------------- /diskcopyimage.py: -------------------------------------------------------------------------------- 1 | # https://www.discferret.com/wiki/Apple_DiskCopy_4.2 2 | 3 | from ctypes import BigEndianStructure, c_byte, c_char, c_uint32, c_uint16 4 | from typing import BinaryIO 5 | 6 | 7 | NAME_SIZE = 63 8 | 9 | 10 | class DiskCopyImageHeader(BigEndianStructure): 11 | _pack_ = 1 12 | _fields_ = [ 13 | ('name_length', c_byte), 14 | ('name', c_char * NAME_SIZE), 15 | ('data_size', c_uint32), 16 | ('tag_size', c_uint32), 17 | ('data_checksum', c_uint32), 18 | ('tag_checksum', c_uint32), 19 | ('disk_type', c_byte), 20 | ('format', c_byte), 21 | ('magic_number', c_uint16), 22 | ] 23 | 24 | def read_data(self, f: BinaryIO) -> bytes: 25 | if self.magic_number != 0x0100: 26 | raise ValueError('Invalid Magic Number') 27 | 28 | data = f.read(self.data_size) 29 | if len(data) != self.data_size: 30 | raise ValueError('Unexpected EOF') 31 | 32 | # TODO: Checksum verification? 33 | return data 34 | 35 | @property 36 | def image_name(self) -> bytes: 37 | return self.name[:min(self.name_length, NAME_SIZE)] 38 | -------------------------------------------------------------------------------- /driver.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sfuller/pimpmyplus/de17ef8df8c502f69153628e671febee48fa41b7/driver.bin -------------------------------------------------------------------------------- /driver.ini: -------------------------------------------------------------------------------- 1 | [Driver] 2 | partition_type=Apple_Driver43 3 | partition_flags=0 4 | booter=0 5 | bytes=9392 6 | load_address_0=0 7 | load_address_1=0 8 | goto_address_0=0 9 | goto_address_1=0 10 | checksum=f624 11 | processor=68000 12 | boot_args=0x00010600,0,1,0x00070000 13 | 14 | -------------------------------------------------------------------------------- /macftp.py: -------------------------------------------------------------------------------- 1 | import os 2 | import ftplib 3 | import json 4 | import argparse 5 | 6 | # https://macintoshgarden.org/forum/public-access-file-repository 7 | FTP_URL = 'repo1.macintoshgarden.org' 8 | FTP_USER = 'macgarden' 9 | FTP_PASS = 'publicdl' 10 | 11 | DIR = 'Garden/apps' 12 | 13 | OUTPUT_DIR = os.path.join('./macdl', DIR) 14 | 15 | ALLOWED_EXTENSIONS = set(('.sit', '.dsk')) 16 | MAX_SIZE = 1024 * 1024 * 10 # 10 MB 17 | 18 | JSON_LIST_FILENAME = 'items.json' 19 | 20 | 21 | argparser = argparse.ArgumentParser() 22 | argparser.add_argument('--cached-list', help='Use cached listing of files', action='store_true') 23 | 24 | 25 | def parse_item(line): 26 | parts = [x for x in line.split(' ') if len(x) > 0] 27 | size = parts[4] 28 | name = parts[8] 29 | return int(size), name 30 | 31 | 32 | def should_include(size, name): 33 | _, ext = os.path.splitext(name) 34 | return size <= MAX_SIZE and ext in ALLOWED_EXTENSIONS 35 | 36 | 37 | def exists(local_path, size, name): 38 | local_path = os.path.join(OUTPUT_DIR, name) 39 | if not os.path.isfile(local_path): 40 | return False 41 | return os.path.getsize(local_path) == size 42 | 43 | def download(ftp, size, name): 44 | local_path = os.path.join(OUTPUT_DIR, name) 45 | 46 | if exists(local_path, size, name): 47 | print(f'File already downloaded: {name} ({size})') 48 | return 49 | 50 | print(f'Downloading {name} ({size})') 51 | with open(local_path, 'wb') as f: 52 | result = ftp.retrbinary('RETR ' + name, f.write) 53 | print(f' result: {result}') 54 | 55 | 56 | args = argparser.parse_args() 57 | 58 | try: 59 | print('Connecting to FTP...') 60 | ftp = ftplib.FTP(FTP_URL, user=FTP_USER, passwd=FTP_PASS) 61 | print(f'Setting FTP directory to {DIR}') 62 | ftp.cwd(DIR) 63 | 64 | items = [] 65 | all_items = [] 66 | 67 | def add_item(item): 68 | size, name = item 69 | all_items.append(item) 70 | 71 | will_include = should_include(size, name) 72 | print(f'{"+" if will_include else "-"} {name} ({size})') 73 | 74 | if will_include: 75 | items.append(item) 76 | 77 | def item_callback(line): 78 | item = parse_item(line) 79 | add_item(item) 80 | 81 | if not args.cached_list: 82 | ftp.retrlines('LIST', callback=item_callback) 83 | 84 | print('Saving all_items as JSON...') 85 | with open(JSON_LIST_FILENAME, 'w') as f: 86 | json.dump(all_items, f) 87 | else: 88 | print('Loading items from JSON...') 89 | with open(JSON_LIST_FILENAME) as f: 90 | cached_items = json.load(f) 91 | 92 | for item in cached_items: 93 | add_item(item) 94 | 95 | print('Downloading items') 96 | 97 | os.makedirs(OUTPUT_DIR, exist_ok=True) 98 | 99 | for size, name in items: 100 | download(ftp, size, name) 101 | 102 | print('done!') 103 | 104 | finally: 105 | print('Calling ftp.quit()') 106 | ftp.quit() 107 | -------------------------------------------------------------------------------- /preparevolume.py: -------------------------------------------------------------------------------- 1 | 2 | # How to get HFS file type and creator data from the rsrc: 3 | # http://bitsavers.org/pdf/apple/mac/Inside_Macintosh_Promotional_Edition_1985.pdf 4 | # http://mirror.informatimago.com/next/developer.apple.com/documentation/mac/MoreToolbox/MoreToolbox-9.html 5 | # https://developer.apple.com/library/archive/documentation/mac/pdf/MacintoshToolboxEssentials.pdf 6 | # ^ See FInfo and FXInfo 7 | 8 | import argparse 9 | import os 10 | import subprocess 11 | import traceback 12 | from typing import Dict, Tuple, Union, Optional 13 | 14 | import machfs 15 | import rsrcfork 16 | from machfs.directory import AbstractFolder 17 | from progress.bar import Bar 18 | 19 | import appledouble 20 | import applicationutil 21 | import diskcopyimage 22 | import disk 23 | 24 | DEFAULT_BLOCK_TARGET = int((1024 * 1024 * 1024 * 1) / 512) 25 | 26 | argparser = argparse.ArgumentParser() 27 | argparser.add_argument('dl_folder') 28 | argparser.add_argument('sit_dir') 29 | argparser.add_argument('--target-blocks', type=int, default=DEFAULT_BLOCK_TARGET, help=f'Target size in 512 byte blocks. Default is {DEFAULT_BLOCK_TARGET} (1GiB)') 30 | argparser.add_argument('--volume-start-index', type=int, default=0) 31 | argparser.add_argument('--hfs-internals-ratio', type=float, default=0.85) 32 | argparser.add_argument('--verbose', '-v', action='store_true') 33 | 34 | args = argparser.parse_args() 35 | 36 | 37 | class FilterException(Exception): 38 | pass 39 | 40 | 41 | class PreparationIssue(Exception): 42 | pass 43 | 44 | 45 | def sanitize_hfs_name(name: bytes, is_folder: bool) -> bytes: 46 | if len(name) < 1: 47 | raise ValueError('Invalid empty hfs name') 48 | # name = b' ' 49 | 50 | val = name.replace(b':', b'?') 51 | if is_folder: 52 | return val[:17] 53 | else: 54 | return val[:31] 55 | 56 | 57 | def sanitize_hfs_name_str(name: str, is_folder: bool) -> bytes: 58 | return sanitize_hfs_name(name.encode('mac_roman', errors='replace'), is_folder=is_folder) 59 | 60 | 61 | def get_hfs_file_size(file: machfs.File) -> int: 62 | def block_align(size: int) -> int: 63 | return (int(size / 512) + (1 if size % 512 != 0 else 0)) * 512 64 | 65 | # Guessing 1K of Filesystem Junk for each file 66 | return block_align(len(file.data)) + block_align(len(file.rsrc)) # + 1024 67 | 68 | 69 | def add_disk_data(containing_folder: AbstractFolder, path: str, data: bytes) -> int: 70 | dsk_volume = machfs.Volume() 71 | 72 | try: 73 | dsk_volume.read(data) 74 | except Exception: 75 | traceback.print_exc() 76 | print(f'Issue reading file system from disk image at path: {path}. Skipping.') 77 | return 0 78 | 79 | for name, child in dsk_volume.items(): 80 | containing_folder[name] = child 81 | 82 | total_bytes = 0 83 | 84 | for path_tuple, dirnames, filenames in containing_folder.walk(): 85 | current_folder = containing_folder[path_tuple] if len(path_tuple) > 0 else containing_folder 86 | for file in filenames: 87 | current_file = current_folder[file] 88 | total_bytes += get_hfs_file_size(current_file) 89 | 90 | return total_bytes 91 | 92 | 93 | def add_dsk(path: str) -> Tuple[machfs.Folder, bytes, int]: 94 | if args.verbose: 95 | print(f'* Adding DSK image at {path}') 96 | 97 | base_path, dsk_filname = os.path.split(path) 98 | folder_name, _ = os.path.splitext(dsk_filname) 99 | dsk_folder = machfs.Folder() 100 | sanitized_folder_name = sanitize_hfs_name_str(folder_name, is_folder=True) 101 | 102 | with open(path, 'rb') as f: 103 | flat = f.read() 104 | 105 | return dsk_folder, sanitized_folder_name, add_disk_data(dsk_folder, path, flat) 106 | 107 | 108 | def add_img(path: str) -> Tuple[machfs.Folder, bytes, int]: 109 | if args.verbose: 110 | print(f'* Adding DiskCopy image at {path}') 111 | 112 | header = diskcopyimage.DiskCopyImageHeader() 113 | with open(path, 'rb') as f: 114 | f.readinto(header) 115 | try: 116 | data = header.read_data(f) 117 | except ValueError as e: 118 | raise PreparationIssue(f'Error reading DiskCopy file at {path}: {e}') 119 | 120 | disk_folder = machfs.Folder() 121 | folder_name = header.image_name 122 | 123 | if len(folder_name) < 1: 124 | _, filename = os.path.split(path) 125 | folder_name = sanitize_hfs_name_str(os.path.splitext(filename)[0], is_folder=True) 126 | else: 127 | folder_name = sanitize_hfs_name(folder_name, is_folder=True) 128 | 129 | return disk_folder, folder_name, add_disk_data(disk_folder, path, data) 130 | 131 | 132 | def add_file(path: str) -> Optional[Tuple[Union[machfs.Folder, machfs.File], bytes, int]]: 133 | base_path, filename = os.path.split(path) 134 | base_filename, ext = os.path.splitext(filename) 135 | 136 | has_data_file = True 137 | 138 | if filename == '.DS_Store': 139 | return None 140 | 141 | if ext == '.dmg': 142 | raise FilterException('Contains an OSX DMG') 143 | 144 | if ext == '.rsrc': 145 | if not os.path.isfile(os.path.join(base_path, base_filename)): 146 | has_data_file = False 147 | else: 148 | # Skip .rsrc files, we handle resource forks while adding each normal file. 149 | return None 150 | 151 | # Try to Mount DiskCopy images 152 | if ext == '.img' or ext == '.image': 153 | return add_img(path) 154 | 155 | # Expand sit files 156 | if ext == '.sit': 157 | return add_sit(path) 158 | 159 | # Expand dsk files 160 | if ext == '.dsk': 161 | return add_dsk(path) 162 | 163 | file = machfs.File() 164 | 165 | if has_data_file: 166 | with open(path, 'rb') as f: 167 | size = f.seek(0, 2) 168 | if size > 1024 * 1024 * 5: # >5 MiB, TODO: MAKE THIS TUNABLE 169 | raise FilterException('Contains a file that is greater than 5 MiB') 170 | f.seek(0) 171 | file.data = f.read() 172 | rsrc_path = path + '.rsrc' 173 | else: 174 | rsrc_path = path 175 | 176 | if os.path.isfile(rsrc_path): 177 | with open(rsrc_path, 'rb') as f: 178 | try: 179 | double = appledouble.parse(f) 180 | except ValueError: 181 | double = None 182 | 183 | if double: 184 | rsrc_entry = double.get_entry(appledouble.EntryType.resource_fork) 185 | if rsrc_entry: 186 | file.rsrc = rsrc_entry.data 187 | 188 | finder_entry = double.get_entry(appledouble.EntryType.finder_info) 189 | if finder_entry: 190 | file.type = bytes(finder_entry.data.fdType) 191 | file.creator = bytes(finder_entry.data.fdCreator) 192 | file.flags = finder_entry.data.fdFlags 193 | file.x = finder_entry.data.fdLocation.x 194 | file.y = finder_entry.data.fdLocation.y 195 | 196 | try: 197 | supported_archs = applicationutil.get_supported_archs(file.rsrc) 198 | except rsrcfork.api.InvalidResourceFileError: 199 | print(f'Warning: Unable to parse resource fork from AppleDouble file at {rsrc_path}') 200 | supported_archs = [] 201 | 202 | if len(supported_archs) > 0 and applicationutil.ARCH_68K not in supported_archs: 203 | raise FilterException('Found a non-68k executable.') 204 | 205 | if args.verbose: 206 | print(f'* Adding file at path {path}') 207 | 208 | hfs_filename = filename if has_data_file else base_filename 209 | sanitized_name = sanitize_hfs_name_str(hfs_filename, is_folder=False) 210 | return file, sanitized_name, get_hfs_file_size(file) 211 | 212 | 213 | def add_files(root: AbstractFolder, path: str) -> int: 214 | path_map: Dict[str, AbstractFolder] = {path: root} 215 | 216 | total_bytes = 0 217 | 218 | for dirpath, dirnames, filenames in os.walk(path): 219 | containing_folder: AbstractFolder = path_map[dirpath] 220 | 221 | for dirname in dirnames: 222 | _, dirname_ext = os.path.splitext(dirname) 223 | if dirname_ext == '.app': 224 | raise FilterException(".app directory detected") 225 | 226 | dirname_path = os.path.join(dirpath, dirname) 227 | hfs_dir = machfs.Folder() 228 | clean_dirname = sanitize_hfs_name_str(dirname, is_folder=True) 229 | containing_folder[clean_dirname] = hfs_dir 230 | path_map[dirname_path] = hfs_dir 231 | 232 | for filename in filenames: 233 | filepath = os.path.join(dirpath, filename) 234 | result = add_file(filepath) 235 | if not result: 236 | continue 237 | file, hfs_filename, file_bytes = result 238 | containing_folder[hfs_filename] = file 239 | total_bytes += file_bytes 240 | 241 | return total_bytes 242 | 243 | 244 | def add_sit(path: str) -> Tuple[machfs.Folder, bytes, int]: 245 | _, filename = os.path.split(path) 246 | folder_name, _ = os.path.splitext(filename) 247 | output_dir = os.path.join(args.sit_dir, folder_name) 248 | result = subprocess.run([ 249 | 'unar', 250 | '-o', args.sit_dir, 251 | '-s', # Skip files which exist 252 | '-d', # Force directory, 253 | '-p', '', # Always use blank password 254 | '-q', # Quiet 255 | '-forks', 'visible', 256 | path 257 | ]) 258 | if result.returncode != 0: 259 | raise PreparationIssue(f'There was an error extracting {path}') 260 | 261 | folder = machfs.Folder() 262 | folder_name = sanitize_hfs_name_str(folder_name, is_folder=True) 263 | return folder, folder_name, add_files(folder, output_dir) 264 | 265 | 266 | def sizeof_fmt(num, suffix='B'): 267 | """https://stackoverflow.com/a/1094933/594760""" 268 | for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']: 269 | if abs(num) < 1024.0: 270 | return "%3.1f%s%s" % (num, unit, suffix) 271 | num /= 1024.0 272 | return "%.1f%s%s" % (num, 'Yi', suffix) 273 | 274 | 275 | class CoolBar(Bar): 276 | def __init__(self, *args, **kwargs): 277 | super().__init__(*args, **kwargs) 278 | self.bytes_taken = 0 279 | 280 | @property 281 | def human_readable_bytes(self): 282 | return sizeof_fmt(self.bytes_taken) 283 | 284 | 285 | def to_blocks(byte_count: int) -> int: 286 | return int(byte_count / 512) + (1 if byte_count % 512 != 0 else 0) 287 | 288 | 289 | class VolumeManager: 290 | def __init__(self, start_index: int): 291 | self.volume_index = start_index 292 | self.bytes_taken = 0 293 | self.volume = machfs.Volume() 294 | 295 | def write_volume(self): 296 | self.volume.name = f'Pimp My Plus #{self.volume_index}' 297 | 298 | volume_output_path = f'collection.{self.volume_index}.scsi' 299 | print(f'Writing Volume to {volume_output_path} with {args.target_blocks} blocks') 300 | with open(volume_output_path, 'wb') as f: 301 | disk.create_bootable_disk(f, self.volume, args.target_blocks) 302 | 303 | self.volume_index += 1 304 | self.volume = machfs.Volume() 305 | self.bytes_taken = 0 306 | 307 | 308 | files = os.listdir(args.dl_folder) 309 | files.sort(key=str.casefold) 310 | volume_manager = VolumeManager(args.volume_start_index) 311 | 312 | # Extra blocks are taken by the filesystem when writing the volume. 313 | # In the future, we could be more smart about this (Do bookkeeping when adding files to the volume, might require modifying machfs library) 314 | # For now, just cheese it and calculate usable space using a ratio of file data to filesystem data. 315 | target_blocks_per_volume = int(args.target_blocks * args.hfs_internals_ratio) - 96 316 | target_size = target_blocks_per_volume * 512 317 | 318 | 319 | with CoolBar(max=len(files), suffix='%(percent)d%% -- %(index)d / %(max)d -- ~%(human_readable_bytes)s') as progress: 320 | progress.start() 321 | 322 | for file in files: 323 | path = os.path.join(args.dl_folder, file) 324 | result = None 325 | try: 326 | result = add_file(path) 327 | except (PreparationIssue, FilterException) as e: 328 | print(e) 329 | 330 | if result: 331 | result_file, result_filename, bytes_taken = result 332 | 333 | # If this new entry would cause us to go over, write the current volume out and start a new volume. 334 | if volume_manager.bytes_taken + bytes_taken > target_size: 335 | print('Reached target size, writing a volume.') 336 | volume_manager.write_volume() 337 | progress.bytes_taken = 0 338 | 339 | volume_manager.volume[result_filename] = result_file 340 | print(f'\n* Added {file} (~{sizeof_fmt(bytes_taken)})') 341 | progress.bytes_taken += bytes_taken 342 | volume_manager.bytes_taken += bytes_taken 343 | 344 | progress.next() 345 | 346 | volume_manager.write_volume() 347 | 348 | print('Done!') 349 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | machfs==1.2.4 2 | rsrcfork==1.8.0 3 | progress==1.5 --------------------------------------------------------------------------------