/> READING DOS 3.3 </

═══════════════════════════════════════
Reading DOS 3.3 the Way DOS Does

Michael Heilemann, The Norseman / The Viking — Field notes from the disk revival project: Apple II archiving

A DOS 3.3 disk image is 143,360 bytes sitting in a row: 35 tracks of 16 sectors of 256 bytes each. Nothing about that raw layout tells you whether it's a working disk. To know that, you have to read it the way DOS 3.3 itself would: start at a fixed location, follow the same pointers DOS follows, and ask the same questions DOS asks before it hands you a file. This post is about building exactly that reader.

-=[ THE BOOT RECORD ]=-

Before diving into the filesystem, we check track 0, sector 0: the boot record. The first byte tells you immediately whether this disk is meant to boot. If it starts with 0x01, you're looking at a bootable disk. If it's anything else, it's a data-only disk and won't boot on its own.

For bootable disks, the boot sector contains signatures that identify what kind of DOS (or custom boot code) is running. Standard DOS 3.3 and ProDOS boot sectors have recognizable patterns in their code that you can search for. Custom or copy-protected boot sectors won't match any standard signature—and that's useful information in itself. A disk with a non-standard boot sector is either intentionally protected, heavily modified by the original developer, or corrupted. Knowing which one matters for understanding how to handle the rest of the disk.

-=[ THE VOLUME TABLE OF CONTENTS (VTOC) ]=-

Track 17, sector 0 is always the Volume Table of Contents (VTOC) and serves as the root of the DOS 3.3 filesystem. Track 17 was chosen because it sits at the center of a 35-track disk. Placing the VTOC there minimizes the average seek distance, since the disk head never has to move more than halfway across the disk to reach any data track.

Before trusting anything else about the disk, this pipeline checks the VTOC's own header fields: does it declare 256 bytes per sector, and 122 as the maximum track/sector pairs per T/S list sector? Every offset calculation for the rest of the disk implicitly assumes them. A VTOC that disagrees with those standard values means either real corruption in the VTOC itself, or a non-standard DOS variant this pipeline can't safely interpret using fixed-offset math. Either way, that's good to know before parsing the data.

The first thing we verify are two foundational assumptions. The VTOC header must declare 256 bytes per sector and a maximum of 122 track/sector pairs per list sector. Every offset calculation for every file on the disk depends on these exact values being correct. If the VTOC disagrees, nothing that follows can be trusted.

def dos33_vtoc_header_problems(vtoc):
    problems = []
    bytes_per_sector = vtoc[0x36] | (vtoc[0x37] << 8)
    if bytes_per_sector != 256:
        problems.append(f"VTOC declares {bytes_per_sector} bytes/sector "
                         "instead of the standard 256")
    max_tsl_pairs = vtoc[0x27]
    if max_tsl_pairs != 122:
        problems.append(f"VTOC declares {max_tsl_pairs} max T/S pairs "
                         "per sector (expected 122)")
    return problems

-=[ WALKING THE CATALOG CHAIN ]=-

The VTOC hands off a track/sector pointer to the first catalog sector, and from there the catalog is a linked chain: each sector points to the next, seven directory entries per sector, until a sector points to (0, 0) to mark the end. That linked-chain structure is exactly the kind of thing that goes wrong on damaged media. A bad read can turn a chain terminator into a bogus pointer, or worse, into a pointer that loops back on itself and hangs a naive reader forever.

So the walk here does two things a lot of quick-and-dirty catalog readers skip: it bounds every step against the disk's actual geometry before following it (a pointer to track 40 on a 35-track disk gets flagged immediately, not chased), and it tracks every sector it's already visited so a loop gets caught and reported instead of spinning forever.

Each entry that survives that walk gets a few more sanity checks: is the filename mostly printable ASCII, is the type byte one of the eight DOS 3.3 recognizes, is the claimed sector count something that could plausibly fit on a 560-sector disk. Individually these are minor checks. Together, they're the difference between "this looks like a normal, if damaged, DOS 3.3 catalog" and "this looks deliberately scrambled," which matters, because a heavily protected or modified-DOS disk can look catastrophically "corrupted" by these checks while being completely intentional. More on that distinction in the edge-cases post later in the series. For now, the pipeline just notes it and moves on rather than flagging every copy-protected game in the collection as BAD.

-=[ FOLLOWING EVERY FILE TO ITS ACTUAL END ]=-

This is the part that matters most for catching real damage. Each catalog entry points to a track/sector list: a sector full of pointers to the actual data sectors that make up the file, chained the same way the catalog is, up to 122 pointers per T/S list sector before it links to another one for a larger file.

This pipeline walks that chain all the way to the end, for every single cataloged file, not just the first one, not a sample. That's a deliberate choice. A corrupted seventh file on a ten-file disk is exactly as easy to miss with a spot-check as a corrupted first file is easy to catch, and there's no principled reason to only check the file you happen to look at first. Along the way, the same three failure modes get caught for every file individually:

  • Off-disk pointers. A T/S list entry pointing to a track or sector that doesn't exist.
  • Loops. A chain that revisits a sector it's already been through.
  • Truncation. The catalog claims a file is, say, 40 sectors, but only 22 are actually reachable by following the chain. That's the signature of a file that got cut off partway through a bad transfer.

And one check that's easy to overlook if you're only thinking about single files in isolation: does any sector get claimed by more than one thing at once? If a T/S list for "GAME.BAS" and a T/S list for "GAME.DATA" both claim the exact same data sector, that's not a coincidence. A sector can't belong to two files simultaneously on a healthy disk, so this is unambiguous evidence something's wrong, whichever file it turns out to belong to.

-=[ WHAT THIS CATCHES THAT A PLAIN BYTE-COMPARE DOESN'T ]=-

None of this requires a reference copy to compare against. A disk can be internally, structurally broken (a T/S list pointing off the edge of the disk, two files silently overlapping) and this pass catches it from the disk's own bytes alone, before any archive cross-check ever enters the picture later in this series.

There's one more DOS 3.3 check that deserves its own post rather than a paragraph here, because it's the single most effective thing in this whole validation pass, and it's the one almost nobody writes: cross-referencing every sector this walk found in use against DOS's own record of which sectors are allocated. That's next.

Fair winds and following seas,
The Norseman

← ENTRY 5 ENTRY 7 →