A dependency-free Apple II disk-image toolkit for Python — read and
write ProDOS, DOS 3.3, Apple Pascal, HFS and
NuFX/ShrinkIt, in .po, .hdv, .2mg, .dsk/.do, .shk, .bxy and
.sdk files.
| format | read | write | notes |
|---|---|---|---|
| ProDOS | ✅ | ✅ full | seedling/sapling/tree, extended (forked) files, sparse files, GS/OS mixed-case names, 2IMG wrappers, DOS 3.3 sector order (auto-detected and deinterleaved) |
| DOS 3.3 | ✅ | ✅ full | flat catalog: no directories, no rename |
| Apple Pascal | ✅ | ✅ full | flat catalog, strictly contiguous files |
| NuFX / ShrinkIt | ✅ | ✅ full | .shk/.bxy archives as a namespace of ProDOS-attributed forked files; a .sdk opens as the volume inside it. Reads every ShrinkIt codec (Squeeze, LZW/1, LZW/2, LZC-12/16, Deflate, BZip2); writes LZW/2 or uncompressed |
| HFS (standard) | ✅ | ◐ never allocates | overlay a fork in place, set a record's metadata, resize a fork within what it already owns. No create, delete, rename or growth — see notes/hfs-write.md |
- Write (ProDOS/DOS 3.3/Pascal/NuFX): create files (sparse-aware), delete,
mkdir, overwrite, rename, edit metadata, grow directories, format fresh volumes — every mutation transactional and rolled back atomically on error. - Interfaces: an
os-style module API, an interactive FTP-style shell, and a one-shot CLI. - Translate:
get/put/cattranslate Apple text and tokenized Applesoft/Integer/Business BASIC by default;a2til mirrorpoll-syncs a ProDOS directory with a host directory, translating both ways. - Pure standard library. Cross-checked byte-for-byte against three independent
implementations — cadius for
ProDOS, NuLib2 for NuFX,
hfsutils for HFS — and against
real disks (it recreates Total Replay v5.2 — 32 MB, 2480 files — with a
clean
CHECKVOLUME).
uv tool install a2til # or: pipx install a2tila2til # interactive FTP-style shell (no image)
a2til disk.2mg # shell, with disk.2mg mounted
a2til info disk.2mg
a2til ls disk.2mg /SUBDIR
a2til cat disk.2mg /HELLO
a2til check disk.2mg # integrity check (exit 1 if problems)
a2til repair disk.2mg # fix the one field older a2til builds got wrong
a2til convert notes.txt notes.raw # host <-> Apple text, no image involved
# heredoc / pipe friendly:
printf 'cd GAMES\nget LODE.RUNNER\nbye\n' | a2til disk.2mgThe shell and the info/ls/cat/check one-shots work on ProDOS, DOS 3.3,
and Apple Pascal images alike (auto-detected). DOS 3.3 and Pascal are flat
catalogs — no subdirectories — so cd/mkdir/rmdir only work on ProDOS,
mv is ProDOS/Pascal only (DOS 3.3 has no rename), -r/-a (resource
forks/AppleSingle) are ProDOS-only, and check is ProDOS/DOS 3.3 only
(Pascal has no checker) — the prompt shows the format for the flat engines
(a2til:dos33>), and any unsupported operation prints a one-line error
instead of a traceback. a2til mirror (below) stays ProDOS-only.
On DOS 3.3, get/get -b/cat return the file's on-disk bytes, headers and
sector padding included (read_file/read_raw are the same call); the
BASIC/text converters strip what they need. put of a .bas/.txt file
stores a tokenized A/high-ASCII T file — the engine re-adds the on-disk
length header itself, so the converter's own header is stripped first. DOS
3.3 has no truly raw file type (every type carries a header), so put -b,
and any unmatched extension, store a type-B file at load address 0. Pascal
has no converters at all yet (its TEXT format is future work): get/put
always move raw bytes.
get/put/cat/mget/mput translate known text and BASIC files by
default — like FTP's ascii mode: ProDOS/DOS 3.3 TXT bytes (CR line
endings, DOS 3.3's high-ASCII) become plain host text on the way out and back
again on the way in; tokenized Applesoft (BAS), Integer (INT), and Apple
/// Business BASIC (BA3) programs become readable .bas/.int/.ba3
listings and — for Applesoft and Business BASIC — re-tokenize on the way
back in. Integer BASIC is read-only translation: its tokenizer was
syntax-directed (the same = or , crunches to different bytes by
statement context), so put of a .int listing is refused rather than
risk storing a program that misbehaves; use put -b, or a syntax-aware
tool like a2kit, to write one. get
appends the matching extension to the local name. Pass -b/--raw for
byte-exact transfers — the old, untranslated behavior. Files with no
registered converter always move raw, silently, whether or not -b is
given.
In the shell: ls, cd, pwd, cat [-b], get [-b] [-r|-a], mget [-b] [-r],
put [-b], mput [-b], rm, mkdir, rmdir, mv, info, check, open,
plus lcd/lls/lpwd and ! for the local shell — with tab completion of
in-image and local paths.
.shk and .bxy archives open like any other image, because a NuFX archive
is a namespace of ProDOS-attributed forked files — the type and aux bytes
are real ProDOS ones, so get HELLO detokenises Applesoft out of an archive
exactly as it does out of a disk:
a2til ls game.shk
a2til cat game.shk /GAME/README
printf 'cd SRC\nget HELLO.BAS\nbye\n' | a2til game.shkA .sdk is a disk image inside an archive; a2til unwraps it and shows you
the volume, so a2til disk.sdk behaves like a2til disk.po — including
writes, which are recompressed back into the archive on close.
from a2til import nufx
with nufx.NufxArchive('game.shk') as arc:
for entry in arc.scandir('/'):
print(entry.name, entry.size, entry.rsrc_size, entry.type)
data = arc.read_file('/SRC/HELLO', fork='data')
arc.write_file('/SRC/NOTES', b'added\r', file_type=0x04)
arc.remove('/SRC/OLD')An archive is rebuilt rather than patched in place — threads are compressed
and variable-length, so there is no unit to write through — and the rebuild is
atomic: a record a2til did not touch is re-emitted from its own bytes, so
adding one file leaves every other byte-identical. check and free space are
absent on purpose: an archive has no blocks, and a fabricated block count
would be a lie with a number on it.
HFS (standard) volumes — System-6/7-era Mac disks, reachable from the Apple II side through GS/OS's HFS FST — read completely: both forks of every file, extents-overflow continuations, MacRoman names, allocation blocks of any size.
from a2til import hfs
vol = hfs.HfsVolume('disk7.2mg', readonly=True)
for entry in vol.scandir('/System Folder'):
print(entry.type, entry.creator, entry.size, entry.rsrc_size, entry.name)
data = vol.read_file('/System Folder/p8')
rsrc = vol.read_file('/System Folder/p8', fork='rsrc')Paths come in two spellings, because / is an ordinary character in an HFS
name (Apple's own disks carry Apple //e Boot Blocks). /-separated is the
convenient one; HFS's real separator is :, and a path containing one is read
as HFS syntax — Mac HD:System Folder:p8 names the volume first,
:System Folder:p8 starts at the root.
Writing is narrow, and narrow in one specific way: nothing allocates.
with hfs.HfsVolume('disk7.2mg') as vol:
entry = vol.entry_for('/System Folder/p8')
entry.overlay(rebuild(entry.read())) # same length, or refused
entry.set_file_info(type='PS16', creator='pdos')
entry.resize(len(shorter)); entry.overlay(shorter)overlay rewrites a fork's content into the blocks it already occupies;
set_file_info writes one catalog record's metadata; resize sets a fork's
logical length within what its extents already cover. The allocation bitmap
and every block placement survive all three, which is what makes a byte-exact
disk reconstruction possible — overlay every file with the content just read
from it and the image is unchanged, byte for byte.
There is no create, delete, rename, or unbounded growth: those need bitmap maintenance and B*-tree mutation, whose failure mode is a subtly wrong node split that reads back fine and loses files three writes later. notes/hfs-write.md measures what each tier would cost and why the refusal is the better product.
a2til mirror IMAGE:/VOL/DIR LOCALDIR polls a ProDOS directory and a host
directory and keeps them in sync (ProDOS only — DOS 3.3/Pascal mirroring is
a separate, later effort) — edit files with any editor while an
emulator has the disk mounted, no NuFX/ShrinkIt shuffle required. It's a
three-way sync against a small JSON sidecar (LOCALDIR/.a2til-mirror.json)
recording the last agreed state of each pair: changed on one side pushes or
pulls, changed on both sides since the last sync is a conflict (skipped and
logged, or resolved with --prefer image/--prefer host), and a delete
propagates only when the other side didn't also change. Every image mutation
in a pass is one transaction — a failure rolls the image back and leaves the
sidecar untouched, so a pass is all-or-nothing. Before each pass the image's
mtime must have been stable for a full --interval (default 2s), so mirror
never opens an image an emulator might still be writing.
a2til mirror games.po:/GAMES/SRC ./src # poll every 2s
a2til mirror games.po:/GAMES/SRC ./src --once # one pass, then exit
a2til mirror games.po:/GAMES/SRC ./src --raw # byte-exact, no translation
a2til mirror games.po:/GAMES/SRC ./src --prefer host # host wins conflictsTranslation defaults on, same dispatch as get/put: edit hello.bas as
plain text on the host —
$ echo '10 PRINT "HI THERE"' > ./src/hello.bas
$ a2til mirror games.po:/GAMES/SRC ./src --once
push hello.bas -> HELLO (11 bytes)
$ a2til cat games.po /GAMES/SRC/HELLO
10 PRINT "HI THERE"— and the next time the emulator lists HELLO, it's a real tokenized
Applesoft program. Unconverted types raw-copy with a cadius-style
NAME#TTAAAA host suffix (parsed back on the way in); Integer BASIC's put
is refused for the same syntax-directed-tokenizer reason put/convert are
(see above) — that shows up as a refused: ... log line for just that file,
not a failed pass.
from a2til import prodos
prodos.mount('disk.2mg') # -> 'TESTVOL'
for entry in prodos.scandir('/TESTVOL'):
print(entry.name, entry.size, entry.is_dir())
data = prodos.read('/TESTVOL/HELLO') # data fork
rsrc = prodos.read('/TESTVOL/FORKED', fork='rsrc')
prodos.write('/MYVOL/F', data, resource=rsrc) # create an extended (forked) file
prodos.rename('/MYVOL/F', '/MYVOL/DOCS/F') # rename or move (data never moves)
prodos.set_file_info('/MYVOL/DOCS/F', access=0x21) # lock (0xE3 unlocks)
# writing needs a writable backing (file opened r+b, or a bytearray)
prodos.create('new.po', 'MYVOL', blocks=1600)
# dir_blocks sizes the volume directory: 13 entries a block, less the
# header, and ProDOS never grows it later. The default 4 gives 51.
prodos.create('wide.po', 'WIDE', blocks=1600, dir_blocks=16) # 207 entries
prodos.mount('new.po')
prodos.mkdir('/MYVOL/DOCS')
prodos.write('/MYVOL/DOCS/README', b'hello\n', file_type=0x04)
prodos.unmount('MYVOL') # flushes to diskTemporary (until 2026-11-01): older a2til builds wrote one subdirectory header field —
parent_entry— one entry early, which real ProDOS follows off the volume and hangs onDESTROYtwo levels deep. Opening such an image reports it (volume.repairs_needed, and a line on stderr from the CLI) and changes nothing;a2til repair IMAGE,Volume(path, repair=True)orvolume.repair()fixes it. Seenotes/kludges.md.
Both prodos.scandir and Volume.scandir yield the same DirEntry, so code
moves between the two APIs unchanged. Predicates are methods (is_dir(),
is_file(), is_extended()) and data is plain attributes (name, path,
size, rsrc_size, type, aux_type, created, modified); entry.stat()
hands back the raw on-disk directory entry for anyone who wants its fields.
Every engine's entries answer is_dir()/is_file() the same way.
Writing to a damaged volume is refused. The shell's write commands and
a2til mirror run check() when they open an image and stop if it fails,
because a2til trusts a volume's metadata the way every filesystem trusts its
own — the allocator believes the bitmap, so a bitmap that calls a live file's
block free will hand that block to the next write. Reading is never gated:
the files are still there to rescue. force on in the shell, or --force for
mirror, says you meant it.
What to catch. ValueError means the argument was wrong — a name the
format cannot hold, a size that does not fit, an unknown fork. OSError means
the volume could not do it, with the usual subclasses where they apply
(FileNotFoundError, FileExistsError, IsADirectoryError,
NotADirectoryError, PermissionError on a read-only image) and
errno.ENOSPC when the answer is "no room" — a full volume and a full
directory alike, on every engine.
Volume is the engine and can be used directly, constructed from a path or a
bytes/bytearray/memoryview buffer. Pass readonly=True (to Volume or
mount) to open without write intent: the image is mapped read-only — so
files on read-only media open fine — and any mutation raises
PermissionError up front.
The prodos.mount/open/read module functions are convenience wrappers over
a single process-global Session — handy for scripts, the shell, and the
examples above. Session itself is the composable API: it is a format-neutral
mount table and fd registry, so construct your own when you want isolation (no
shared global state), multiple independent mount tables, or to mix engines.
from a2til import Session, prodos
sess = Session()
sess.mount('MYVOL', prodos.Volume('disk.2mg'))
vol = sess.get('MYVOL')
for entry in vol.scandir('/'):
print(entry.name)
sess.unmount('MYVOL') # severs its fds and flushes to diskThe version number is a promise about the API: from 1.0, a call site that works keeps working until a major release. It is not a claim that every engine has seen equal use, which is a different thing and the thing you probably want to know:
| engine | maturity | what that rests on |
|---|---|---|
| ProDOS | stable | exercised many times a day by the author's other projects; oracled against cadius; recreates a 32 MB real disk (Total Replay v5.2, 2480 files) byte-for-byte |
| DOS 3.3 | read: used · write: unexercised | reading is how old files get pulled off period disks and gets real traffic; the writer is fixture- and shell-tested but has no real-world mileage |
| Apple Pascal | provisional | full read/write, fixture- and shell-tested, essentially no real-world traffic |
| NuFX/ShrinkIt | provisional | oracled against NuLib2 across every codec, against a real 111-record archive that must come back byte-identical, and — for a 32 MB disk image — producing the same compressed bytes NufxLib produces. Provisional because it is unused, not because it is unchecked |
| HFS | read: proven · write: provisional | reading is oracled against hfsutils and validated against Apple's System 6.0.1 disks in their redistributed 2IMG form; the write surface is deliberately tiny and will grow (see notes/hfs-write.md), so it will change |
"Provisional" means what it says: those APIs may change in a minor release, and the changelog will say so. ProDOS will not.
One asymmetry worth stating rather than leaving to be discovered:
open_image/detect_format hand back ProDOS, DOS 3.3, Pascal and NuFX
volumes, but not HFS — the factory promises a volume you can name a path
in and write to, and HFS offers three verbs that never allocate. Reach for
hfs.HfsVolume directly.
uv pip install -e '.[dev]' # or: pip install -e '.[dev]'
pytest # ~1300 unit, integration and differential testsThe interesting half of the suite is differential: a2til is checked against independent implementations rather than against itself, because a fixture we generate cannot disagree with the reader we wrote it for.
brew install cadius hfsutils # ProDOS and HFS oracles
cargo install a2kit # Applesoft/Integer tokeniser oracle
test/oracle/build-nulib2.sh # NuFX oracle (no formula; source build)Each module skips cleanly when its tool is absent, so pytest stays
dependency-free — see notes/oracles.md for what each one
proves and how it is driven. Set A2TIL_REQUIRE_ORACLES=1 to turn those skips
into failures, which is what CI should do: an oracle that quietly stops
installing otherwise looks exactly like an oracle that passes.
test/test_fuzz.py is Hypothesis-based robustness testing over malformed
images. notes/ carries the design record — why the engines are split, why
archives rebuild rather than undo-log, what HFS writes would cost, and what
is deliberately not built.
MIT