270 lines
10 KiB
Python
Executable file
270 lines
10 KiB
Python
Executable file
#!/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 <image>
|
|
xdftool.py add <image> <hostfile> [name]
|
|
xdftool.py delete <image> <name>
|
|
xdftool.py extract <image> <name> <hostfile>
|
|
xdftool.py free <image>
|
|
"""
|
|
|
|
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("<HBHBHHBHHHH", self.data[11:30])
|
|
if self.bps == 0 or self.spc == 0:
|
|
raise ValueError(f"{self.path}: implausible BPB (bytesPerSector={self.bps})")
|
|
self.fat_start = self.reserved * self.bps
|
|
self.root_start = (self.reserved + self.nfats * self.spf) * self.bps
|
|
self.data_start = self.root_start + self.root_entries * DIR_ENTRY_SIZE
|
|
self.cluster_bytes = self.spc * self.bps
|
|
# Clusters are numbered from 2, and the last valid one is bounded by
|
|
# how much space is actually left after the root directory.
|
|
self.max_cluster = 1 + (len(self.data) - self.data_start) // self.cluster_bytes
|
|
|
|
# ----- FAT12 entry access -----
|
|
|
|
def fat_get(self, cluster):
|
|
off = self.fat_start + (cluster * 3) // 2
|
|
pair = self.data[off] | (self.data[off + 1] << 8)
|
|
return (pair >> 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("<H", e[26:28])[0],
|
|
"size": struct.unpack("<I", e[28:32])[0],
|
|
"attr": e[11],
|
|
})
|
|
return out
|
|
|
|
@staticmethod
|
|
def encode_name(name):
|
|
name = name.upper()
|
|
stem, _, ext = name.partition(".")
|
|
if len(stem) > 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("<I", e[28:32])[0]
|
|
cluster = struct.unpack("<H", e[26:28])[0]
|
|
out = bytearray()
|
|
guard = 0
|
|
while 2 <= cluster < EOC_MIN and len(out) < size:
|
|
off = self.cluster_offset(cluster)
|
|
out += self.data[off:off + self.cluster_bytes]
|
|
cluster = self.fat_get(cluster)
|
|
guard += 1
|
|
if guard > 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("<H", e[26:28])[0]
|
|
while 2 <= cluster < EOC_MIN:
|
|
nxt = self.fat_get(cluster)
|
|
self.fat_set(cluster, 0)
|
|
cluster = nxt
|
|
self.data[off] = DELETED_MARKER
|
|
return True
|
|
|
|
def add_file(self, name, payload):
|
|
self.delete(name) # overwrite semantics
|
|
need = (len(payload) + self.cluster_bytes - 1) // self.cluster_bytes
|
|
free = self.free_clusters()
|
|
if len(free) < need:
|
|
raise OSError(f"{self.path}: need {need} clusters, {len(free)} free "
|
|
f"({len(free) * self.cluster_bytes} bytes)")
|
|
chain = free[:need]
|
|
for idx, cluster in enumerate(chain):
|
|
off = self.cluster_offset(cluster)
|
|
chunk = payload[idx * self.cluster_bytes:(idx + 1) * self.cluster_bytes]
|
|
self.data[off:off + len(chunk)] = chunk
|
|
# Zero the tail of the final cluster so stale bytes never leak.
|
|
if len(chunk) < self.cluster_bytes:
|
|
self.data[off + len(chunk):off + self.cluster_bytes] = \
|
|
bytes(self.cluster_bytes - len(chunk))
|
|
self.fat_set(cluster, 0xFFF if idx == need - 1 else chain[idx + 1])
|
|
|
|
slot = None
|
|
for i, off, e in self._entries():
|
|
if e[0] in (FREE_MARKER, DELETED_MARKER):
|
|
slot = off
|
|
break
|
|
if slot is None:
|
|
raise OSError(f"{self.path}: root directory full ({self.root_entries} entries)")
|
|
|
|
entry = bytearray(DIR_ENTRY_SIZE)
|
|
entry[0:11] = self.encode_name(name)
|
|
entry[11] = ATTR_ARCHIVE
|
|
# Fixed timestamp: reproducible images matter more than real mtimes,
|
|
# since these get byte-compared across gate runs.
|
|
struct.pack_into("<H", entry, 22, (12 << 11)) # 12:00:00
|
|
struct.pack_into("<H", entry, 24, ((2026 - 1980) << 9) | (1 << 5) | 1)
|
|
struct.pack_into("<H", entry, 26, chain[0] if need else 0)
|
|
struct.pack_into("<I", entry, 28, len(payload))
|
|
self.data[slot:slot + DIR_ENTRY_SIZE] = entry
|
|
|
|
def flush(self):
|
|
if not self.writable:
|
|
raise PermissionError("opened read-only")
|
|
with open(self.path, "wb") as fp:
|
|
fp.write(self.data)
|
|
|
|
|
|
def main(argv):
|
|
if len(argv) < 3:
|
|
print(__doc__.strip())
|
|
return 2
|
|
cmd, image = argv[1], argv[2]
|
|
|
|
if cmd == "list":
|
|
img = Xdf(image)
|
|
print(f"{image}: {len(img.data)} bytes, {img.bps}-byte sectors, "
|
|
f"{img.total_sectors} sectors, {img.spc} sec/cluster")
|
|
for f in img.listdir():
|
|
print(f" {f['name']:<14} {f['size']:>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 <image> <hostfile> [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 <image> <name>", 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 <image> <name> <hostfile>", 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))
|