#!/usr/bin/env python3 """ mpc60decompile - take apart MPC60 drive images, programs and samples. ./mpc60decompile.py map drive.hda partition overview ./mpc60decompile.py ls drive.hda C files in one partition ./mpc60decompile.py pgm drive.hda C BD decode a program ./mpc60decompile.py pgm PROGRAM_.pgm ...or a loose .PGM ./mpc60decompile.py snd drive.hda C KICK_1 decode a sample header ./mpc60decompile.py export drive.hda C -o out pull every file out ./mpc60decompile.py diff a.PGM b.PGM compare two programs `diff` is the important one. Anything this tool labels UNKNOWN is a field nobody has decoded; diffing a program the MPC wrote against one built by mpc60build.py is how those get worked out. FORMAT, as recovered from a real MPC60 disk ------------------------------------------- DRIVE N partitions of 59304 sectors laid end to end. No partition table. The MPC letters them A..Z, so 26 maximum. PART FAT12. Boot sector OEM 'MPC-60 ', 512-byte sectors, 16 per cluster (8 KB), 1 reserved, 2 FATs of 11 sectors, 224 root entries. That cluster size is why a partition is ~29 MB: FAT12 tops out at 4084 clusters. Directory entries carry attr 0x00 and a zero date, not the 0x20 a normal FAT writer would use. SND 39-byte header, then 12-bit LINEAR audio packed 2 samples per 3 bytes at 40 kHz: A = (b0 << 4) | (b1 & 0x0F) B = (b1 >> 4) | (b2 << 4) 0x00 u16 magic 0x0101 0x02 16s name, ASCII, space padded 0x13 u24 sample count 0x16 u24 UNKNOWN - 1280 in every file seen 0x19 u24 UNKNOWN - 3199 0x1C u24 UNKNOWN - 5120 0x1F u8 UNKNOWN - 100 0x26 u8 UNKNOWN - 100 PGM 4608 bytes. 0x0000 u16 UNKNOWN - 7 in the reference program 0x0002 name table, 16-byte names, stride 17, 19 slots 0x0882 u32 sample lengths, one per slot, positionally locked 0x0A82 program name and parameters - PARTLY UNKNOWN 0x0AC0 64 pad records, stride 24. Byte 0 is the slot index, 0xFF for an unassigned pad. Bytes 1-23 UNKNOWN (level, tuning, decay, mute group live somewhere in here). 0x11C0 64-byte note table, a permutation of MIDI notes 35-98. """ import argparse, os, struct, sys SECT, SPC = 512, 16 RESERVED, NFATS, SPF, ROOT_ENTS = 1, 2, 11, 224 DATA_START = RESERVED + NFATS * SPF + ROOT_ENTS * 32 // SECT NAME_OFF, STRIDE, NSLOTS, LEN_OFF = 0x02, 17, 19, 0x0882 PAD_OFF, PAD_STRIDE, NPADS, NOTE_OFF = 0x0AC0, 24, 64, 0x11C0 SND_HDR = 39 def part_size(blob): return struct.unpack("> 4) | (b2 << 4)) return out def read_file(part, start, size): fat = read_fat(part) out, c, hops = b"", start, 0 while 2 <= c < len(fat) and hops < 8000: o = (DATA_START + (c - 2) * SPC) * SECT out += part[o:o + SPC * SECT] if len(out) >= size or fat[c] >= 0xFF8: break c = fat[c] hops += 1 return out[:size] def entries(part): root = part[(RESERVED + NFATS * SPF) * SECT:][:ROOT_ENTS * 32] for j in range(ROOT_ENTS): e = root[j * 32:j * 32 + 32] if e[0] in (0x00, 0xE5) or (e[11] & 0x18): continue yield (e[0:8].decode("ascii", "replace").rstrip(), e[8:11].decode("ascii", "replace").rstrip(), int.from_bytes(e[26:28], "little"), int.from_bytes(e[28:32], "little"), e) def load_partition(path, letter=None): data = open(path, "rb").read() if letter is None: return data psize = part_size(data) i = ord(letter.upper()) - 65 if (i + 1) * psize > len(data): sys.exit(f"partition {letter.upper()} is past the end of {path}") return data[i * psize:(i + 1) * psize] def grab(args, ext): """Return raw bytes of a .PGM/.SND, from a drive or a loose file.""" if args.letter is None: return open(args.image, "rb").read() part = load_partition(args.image, args.letter) for base, e, start, size, _ in entries(part): if e == ext and base.upper() == args.name.upper(): return read_file(part, start, size) sys.exit(f"{args.name}.{ext} not found in partition {args.letter.upper()}") # ---------------------------------------------------------------- commands def cmd_map(a): d = open(a.image, "rb").read() psize = part_size(d) n = len(d) // psize print(f"{a.image}: {len(d)} bytes, {n} partitions of {psize} " f"({psize // SECT} sectors)\n") print(f" {'':3} {'files':>6} {'pgms':>5} {'sound KB':>9} programs") for i in range(n): part = d[i * psize:(i + 1) * psize] snd = pgm = kb = 0 names = [] for base, e, start, size, _ in entries(part): if e == "PGM": pgm += 1; names.append(base) else: snd += 1; kb += max(0, size - SND_HDR) print(f" {chr(65+i)} {snd:>6} {pgm:>5} {kb/1024:>9.0f} " + (", ".join(names[:5]) + (" ..." if len(names) > 5 else "") or "-")) def cmd_ls(a): part = load_partition(a.image, a.letter) print(f"partition {a.letter.upper()}\n") print(f" {'name':<13} {'bytes':>9} {'clus':>5} detail") for base, e, start, size, _ in entries(part): det = "" if e == "SND": h = read_file(part, start, SND_HDR) if len(h) >= 22: cnt = h[19] | (h[20] << 8) | (h[21] << 16) det = f"{cnt} samples, {cnt/40000:.3f}s" print(f" {base+'.'+e:<13} {size:>9} {start:>5} {det}") def cmd_snd(a): d = grab(a, "SND") cnt = d[19] | (d[20] << 8) | (d[21] << 16) body = len(d) - SND_HDR print(f" header {d[:SND_HDR].hex()}") print(f" magic {d[0]:#04x} {d[1]:#04x}" + ("" if d[:2] == b"\x01\x01" else " <- expected 01 01")) print(f" name {d[2:18].decode('ascii','replace')!r}") print(f" count {cnt} ({cnt/40000:.4f}s at 40 kHz)") print(f" body {body} bytes; expected {(cnt+1)//2*3}" + (" OK" if body == (cnt + 1) // 2 * 3 else " MISMATCH")) for off in (22, 25, 28): print(f" UNKNOWN u24 @{off} {d[off] | (d[off+1]<<8) | (d[off+2]<<16)}") print(f" UNKNOWN u8 @31 {d[31]}") print(f" UNKNOWN u8 @38 {d[38]}") def decode_pgm(d): slots = [] for i in range(NSLOTS): o = NAME_OFF + STRIDE * i nm = d[o:o + 16].decode("ascii", "replace").rstrip("\x00 ") ln = struct.unpack("8} samples " f"{ln*1.5/1024:>7.0f} KB{flag}") print(f" {'':2} {'TOTAL':<17} {total:>8} samples {total*1.5/1024:>7.0f} KB" f" (MPC60 has 750 KB, 1500 KB expanded)") filled = {i for i, nm, ln, _ in slots if nm} assigned = [(k, v) for k, v, _ in pads if v != 0xFF] orphan = [(k, v) for k, v in assigned if v not in filled] print(f"\n PADS {len(assigned)} assigned, {NPADS-len(assigned)} unassigned (0xFF)") if orphan: print(f" BROKEN: {len(orphan)} pad(s) point at an empty slot: " + ", ".join(f"pad{k}->slot{v}" for k, v in orphan[:8])) else: print(" every assigned pad points at a filled slot") byslot = {} for k, v in assigned: byslot.setdefault(v, []).append(k) for i, nm, ln, _ in slots: if i in byslot: print(f" slot {i:2} {nm:<17} <- pads {byslot[i]}") print(f"\n NOTE TABLE @0x11C0 (64 bytes)") print(f" range {min(notes)}..{max(notes)}, " f"{'a permutation of 35..98' if sorted(notes)==list(range(35,99)) else 'NOT a clean permutation'}") print(f"\n UNKNOWN region @0x0A82: {d[0x0A82:0x0A92].hex()}") print(f" UNKNOWN pad bytes 1-23, e.g. pad0: {pads[0][2][1:].hex()}") def cmd_export(a): part = load_partition(a.image, a.letter) out = a.out or f"partition_{a.letter.upper()}" os.makedirs(out, exist_ok=True) n = 0 for base, e, start, size, _ in entries(part): open(os.path.join(out, f"{base}.{e}"), "wb").write(read_file(part, start, size)) n += 1 print(f" {n} files -> {out}/") def cmd_diff(a): x, y = open(a.a, "rb").read(), open(a.b, "rb").read() print(f" A = {a.a} ({len(x)} bytes)") print(f" B = {a.b} ({len(y)} bytes)\n") if len(x) != len(y): print(" sizes differ\n") sx, px, nx = decode_pgm(x) sy, py, ny = decode_pgm(y) print(" SLOTS") for (i, n1, l1, _), (_, n2, l2, _) in zip(sx, sy): if (n1, l1) != (n2, l2): print(f" {i:2} A={n1 or '(empty)'!r:<18}{l1:>8} " f"B={n2 or '(empty)'!r:<18}{l2:>8}") print(" PADS (byte 0 only)") for (k, v1, r1), (_, v2, r2) in zip(px, py): if v1 != v2: print(f" pad{k:2} A={v1:#04x} B={v2:#04x}") print(" NOTE TABLE:", "identical" if nx == ny else "DIFFERS") n = min(len(x), len(y)) runs, i = [], 0 while i < n: if x[i] != y[i]: s = i while i < n and x[i] != y[i]: i += 1 runs.append((s, i)) else: i += 1 print(f"\n {len(runs)} differing byte run(s), {sum(e-s for s,e in runs)} bytes total") for s, e in runs[:20]: print(f" 0x{s:04x}-0x{e:04x} A={x[s:min(e,s+12)].hex()} B={y[s:min(e,s+12)].hex()}") if len(runs) > 20: print(f" ... and {len(runs)-20} more") def main(): p = argparse.ArgumentParser(description="Decompile MPC60 drives, programs and samples.") s = p.add_subparsers(dest="cmd", required=True) c = s.add_parser("map", help="partition overview"); c.add_argument("image") c.set_defaults(func=cmd_map) c = s.add_parser("ls", help="files in a partition") c.add_argument("image"); c.add_argument("letter"); c.set_defaults(func=cmd_ls) c = s.add_parser("pgm", help="decode a program") c.add_argument("image"); c.add_argument("letter", nargs="?") c.add_argument("name", nargs="?"); c.set_defaults(func=cmd_pgm) c = s.add_parser("snd", help="decode a sample header") c.add_argument("image"); c.add_argument("letter", nargs="?") c.add_argument("name", nargs="?"); c.set_defaults(func=cmd_snd) c = s.add_parser("export", help="pull every file out of a partition") c.add_argument("image"); c.add_argument("letter"); c.add_argument("-o", "--out") c.set_defaults(func=cmd_export) c = s.add_parser("diff", help="compare two .PGM files") c.add_argument("a"); c.add_argument("b"); c.set_defaults(func=cmd_diff) a = p.parse_args() sys.exit(a.func(a) or 0) if __name__ == "__main__": main()