#!/usr/bin/env python3 """xdftool - read/write Human68k FAT12 floppy images (.XDF) from Linux. Why this exists: mtools cannot handle these images. Human68k 2HD floppies use 1024-byte logical sectors, and mformat mis-sizes the volume while mcopy fails with rc=1 regardless of configuration. imgtool has no Human68k module. So the JoeyLib X68000 gate needs its own reader/writer to get a test binary onto a disk and its log back off again. The filesystem itself is ordinary FAT12; only the sector size is unusual, and every offset in the BPB is already expressed in sectors, so nothing needs special-casing beyond reading bytesPerSector rather than assuming 512. This does NOT create bootable images. Mint one once by running Human68k's own FORMAT.X/SYS.X inside an emulator (they drive the FDD through IOCS), keep it as a template, and copy it per run -- the same pattern the IIgs gate uses with gsos-system.po. Usage: xdftool.py list xdftool.py add [name] xdftool.py delete xdftool.py extract xdftool.py free """ import os import struct import sys DIR_ENTRY_SIZE = 32 ATTR_VOLUME = 0x08 ATTR_DIR = 0x10 ATTR_ARCHIVE = 0x20 FREE_MARKER = 0x00 DELETED_MARKER = 0xE5 EOC_MIN = 0xFF8 # >= this in a FAT12 entry means end-of-chain class Xdf: def __init__(self, path, writable=False): self.path = path self.writable = writable with open(path, "rb") as fp: self.data = bytearray(fp.read()) self._parse_bpb() def _parse_bpb(self): (self.bps, self.spc, self.reserved, self.nfats, self.root_entries, self.total_sectors, self.media, self.spf, self.spt, self.heads, self.hidden) = struct.unpack("> 4) if (cluster & 1) else (pair & 0x0FFF) def fat_set(self, cluster, value): # Mirror into every FAT copy so the image stays self-consistent. for fat in range(self.nfats): base = self.fat_start + fat * self.spf * self.bps off = base + (cluster * 3) // 2 pair = self.data[off] | (self.data[off + 1] << 8) if cluster & 1: pair = (pair & 0x000F) | ((value & 0x0FFF) << 4) else: pair = (pair & 0xF000) | (value & 0x0FFF) self.data[off] = pair & 0xFF self.data[off + 1] = (pair >> 8) & 0xFF def free_clusters(self): return [c for c in range(2, self.max_cluster + 1) if self.fat_get(c) == 0] def cluster_offset(self, cluster): return self.data_start + (cluster - 2) * self.cluster_bytes # ----- Directory ----- def _entries(self): for i in range(self.root_entries): off = self.root_start + i * DIR_ENTRY_SIZE yield i, off, self.data[off:off + DIR_ENTRY_SIZE] def listdir(self): out = [] for _, _, e in self._entries(): if e[0] == FREE_MARKER: break if e[0] == DELETED_MARKER or (e[11] & (ATTR_VOLUME | ATTR_DIR)): continue name = e[0:8].decode("ascii", "replace").rstrip() ext = e[8:11].decode("ascii", "replace").rstrip() out.append({ "name": f"{name}.{ext}" if ext else name, "cluster": struct.unpack(" 8 or len(ext) > 3: raise ValueError(f"'{name}' does not fit 8.3") return stem.ljust(8).encode("ascii") + ext.ljust(3).encode("ascii") def find(self, name): # Human68k PRESERVES filename case in the directory entry (unlike # MS-DOS, which upcases), so a program writing "joeylog.txt" leaves it # lowercase on disk. Match case-insensitively or extracting a # guest-written file fails with a confusing "not in image". want = self.encode_name(name).upper() for i, off, e in self._entries(): if e[0] == FREE_MARKER: break if e[0] == DELETED_MARKER: continue if bytes(e[0:11]).upper() == want: return i, off, e return None, None, None # ----- File operations ----- def read_file(self, name): _, _, e = self.find(name) if e is None: raise FileNotFoundError(f"{name} not in {self.path}") size = struct.unpack(" self.max_cluster: raise ValueError(f"{name}: cluster chain loops") return bytes(out[:size]) def delete(self, name): _, off, e = self.find(name) if e is None: return False cluster = struct.unpack("9} clus={f['cluster']}") return 0 if cmd == "free": img = Xdf(image) n = len(img.free_clusters()) print(f"{n * img.cluster_bytes} bytes free ({n} clusters)") return 0 if cmd == "add": if len(argv) < 4: print("usage: xdftool.py add [name]", file=sys.stderr) return 2 host = argv[3] name = argv[4] if len(argv) > 4 else os.path.basename(host) img = Xdf(image, writable=True) with open(host, "rb") as fp: img.add_file(name, fp.read()) img.flush() print(f"added {name} ({os.path.getsize(host)} bytes) to {image}") return 0 if cmd == "delete": if len(argv) < 4: print("usage: xdftool.py delete ", file=sys.stderr) return 2 img = Xdf(image, writable=True) if not img.delete(argv[3]): print(f"{argv[3]} not in {image}", file=sys.stderr) return 1 img.flush() print(f"deleted {argv[3]} from {image}") return 0 if cmd == "extract": if len(argv) < 5: print("usage: xdftool.py extract ", file=sys.stderr) return 2 img = Xdf(image) with open(argv[4], "wb") as fp: fp.write(img.read_file(argv[3])) print(f"extracted {argv[3]} -> {argv[4]}") return 0 print(f"unknown command '{cmd}'", file=sys.stderr) return 2 if __name__ == "__main__": sys.exit(main(sys.argv))