Why This Page Exists

Everything here was measured from real disks, and most of it cost more than it should have. This is the record so it never has to be measured again.

Reading a vintage disk image directly — without a machine in the loop — means knowing three things that the disk normally tells its own computer and nobody else: where the 128-byte CP/M records sit inside the file, which physical sector holds which logical one, and what the CP/M Disk Parameter Block says. Get any of them wrong and you do not get an error. You get a plausible file listing and quietly wrong file content.

The failure that mattered. The Altair 88-DCDD floppy sat in this codebase for months looking correct. Its directory read perfectly. Text files extracted with no corrupt bytes. Both of those were true and neither meant anything: a directory is only sixteen records and reads the same under the wrong table, and a scrambled text file is still all text. The format was withdrawn once the error was found, and stayed withdrawn until it could be measured rather than argued about.


The Method — Ask the Disk's Own Computer

The technique that solved this is worth more than any table below, because it generalises to every disk the gateway can boot.

Earlier attempts scored each hypothesis against a heuristic: an assembler listing on the disk has addresses in it, so do those addresses ascend? That sounds rigorous and is not. The correct table scored 81%, no translation scored 59%, an inverted one 70% — and 81% is exactly the region where such a score stops telling “nearly right” from “right”. Four hypotheses were ruled out this way without the cause ever being found.

The gateway can boot these disks on an emulated MITS 88-DCDD controller, and a booted disk runs its own operating system with its own BIOS — software that has always been right about this disk. So stop guessing and ask it:

  1. Boot a tool disk in drive 0 and the disk being measured in drive 1, mounted read-only so the measurement cannot alter its subject.
  2. Run the guest's own PCPUT B:FILE.EXT B — Mike Douglas's XMODEM sender — over the gateway's virtual modem on 2SIO port B, and collect the bytes. That is the file's true content, read by the BIOS written for this disk.
  3. De-frame the image and index every 128-byte physical record by (track, position).
  4. For each 128-byte slice of the captured file, find which physical record holds those exact bytes. That yields logical record → physical position directly, as a measurement.
  5. Read the pattern off the map. Do not guess before this step produces it.

The other half: ask the BIOS for its parameters

Byte-matching above recovers the mapping — where a logical record physically lives. It does not tell you the block size, the directory size, or where the data area starts. Those are in the disk parameter block its own BIOS hands to CP/M, so ask for that too, by CP/M's own route:

  1. Boot the disk and let it reach its prompt, so the BIOS has finished initialising.
  2. Page zero holds JMP WBOOT at 0000, so the word at 0001 is BIOS+3 — that names the BIOS.
  3. Call SELDSK (BIOS+27) with the drive in C. It returns the address of the disk parameter header in HL.
  4. The DPB address is at DPH+10, and the sector translate table — or zero — at DPH+0. Read the fifteen bytes: SPT, BSH, BLM, EXM, DSM, DRM, AL0, AL1, CKS, OFF.

This is a declaration, the same class of evidence as a boot loader's port operands, and for the Cromemco disks it was the only authority available: cpmtools has no Cromemco definition to cross-check against. Two independent disks per format agreed on every field.

Use both halves, and check the mapping last. A DPB tells you nothing about skew, and a zero XLT does not mean there is none — a BIOS may translate inside SETSEC where CP/M never sees it. A format built from the DPB alone mounted, listed its directory perfectly, and returned scrambled file content. The directory parsing correctly proves only that the directory parses.

447 records, eight files, twenty tracks, no ambiguity. The map came out in one run and the pattern was visible by eye. Choose files that straddle every boundary you suspect — that is what caught the track-6 change, which two files alone would have missed entirely.

In the source: cpm::boot_machine::tests::test_capture_altair_ground_truth captures, and cpm::image::fs::tests::test_altair_extraction_matches_the_booted_guest holds our reader to SHA-256 hashes of what the guest produced. Hashes rather than the bytes, because those files come from third-party images this project does not redistribute.


MITS Altair 88-DCDD 8″ Floppy

Token altair8. 337,568 bytes: 77 tracks × 32 sectors × 137 bytes. The DISKnn.DSK images in the Altair-Duino / altairclone sets.

The disk's own Disk Parameter Block

Found in the BIOS on the boot tracks, at offset 0x1CA9 of the de-framed image — fourteen bytes before the sector-translation table, which is the usual BIOS arrangement of DPB then XLT.

SPT 32   BSH 4   BLM 15   EXM 0
DSM 149  DRM 63  AL0 0xC0  AL1 0x00  OFF 2

So: 2 KB allocation blocks, 150 of them, 64 directory entries, two reserved tracks, two blocks reserved for the directory. Two of those contradict what the standard derivations give — DRM 63 means 64 directory entries rather than 128, and EXM 0 rather than 1. See EXM.

Framing — and it changes at track 6

Each 137-byte sector carries its 128 CP/M data bytes at an offset that is not the same all the way down the disk. Tracks 0–5 are written in “boot format” and tracks 6–76 are not.

ByteTracks 0–5 (boot format)Tracks 6–76 (data format)
0track number + 0x80track number + 0x80
10x00sector ID — see below
20x010x01
3first data byteenters the checksum
4datachecksum
56dataenter the checksum
7datafirst data byte
1310xFF stop bytedata
132checksumdata
1350x000xFF stop byte
1360x000x00

Data at offset 3 below track 6 and offset 7 from track 6 on. The directory lives in the boot region and reads perfectly at the first offset, so a reader that misses this gets a correct file listing and then mangles every byte of file content.

Sector translation — two effects, not one

This is the part that was unsolved for months, and the reason is that two independent things stand between a logical record and its position in the file. Either one alone gives an answer that is mostly right.

1. The BIOS translation. A four-way interleave, sixteen evens then sixteen odds, recovered from the disk's own BIOS at de-framed offset 0x1CB8 (stored there 1-based; shown here 0-based). It maps a logical record to a sector ID:

ALTAIR_BIOS_XLT
  0,  8, 16, 24,  2, 10, 18, 26,  4, 12, 20, 28,  6, 14, 22, 30,
  1,  9, 17, 25,  3, 11, 19, 27,  5, 13, 21, 29,  7, 15, 23, 31

2. A sector ID is not its position in the file. On the data tracks the odd sectors were laid down half a revolution from where their number suggests: ID n is at position n when n is even, and at (n + 16) mod 32 when it is odd. This is not inferred — every data sector states its own ID in header byte 1, and on every data track of every CP/M disk in the Altair-Duino set the positions hold 0, 17, 2, 19, 4, 21, … rather than 0, 1, 2, 3, …

ALTAIR_SECTOR_ORDER   (indexed by sector ID, gives position)
  0, 17,  2, 19,  4, 21,  6, 23,  8, 25, 10, 27, 12, 29, 14, 31,
 16,  1, 18,  3, 20,  5, 22,  7, 24,  9, 26, 11, 28, 13, 30, 15

Compose them and you get the table the reader actually uses on tracks 6–76. Tracks 0–5 have no sector IDs and no such shift, so they use the BIOS table unmodified — which means the skew changes at track 6, the same boundary as the framing, for the same reason.

ALTAIR_SKEW   (tracks 6-76: logical record -> position in file)
  0,  8, 16, 24,  2, 10, 18, 26,  4, 12, 20, 28,  6, 14, 22, 30,
 17, 25,  1,  9, 19, 27,  3, 11, 21, 29,  5, 13, 23, 31,  7, 15

Why this hid so well. The two tables are identical for logical sectors 0–15 and differ only in the second half. A 64-entry directory is exactly sixteen records, so the directory reads correctly under either one. Only file content past the first half of a track comes back wrong — and only in a pattern that is locally scrambled rather than obviously broken. A good directory listing proves nothing about the mapping on this disk.


The Two Sector Checksums

Needed only for writing — and not optional there. The Altair BIOS verifies every sector it reads.

Both formulas are a one-byte sum with wraparound, and both were verified against every sector of six real disks: 192 of 192 boot sectors and 2,272 of 2,272 data sectors, each time.

RegionData atCheck byteCovers
tracks 0–5offset 3byte 132the 128 data bytes
tracks 6–76offset 7byte 4the 128 data bytes plus header bytes 2, 3, 5 and 6

A stale check byte is a silent write. The host write succeeds, the image looks fine, and the disk is unreadable on the machine it was written for: the guest's BIOS reports Bdos Err On A: Bad Sector. There is no way to notice this without a real BIOS reading the sector back, which is why the write test boots the disk afterwards.

What a write touches, and what it must not

A write here changes the 128 data bytes and the check byte, and nothing else. The track and sector numbers, the stop byte, and the two header bytes on the data tracks whose meaning is still unidentified all stay exactly as found. That is the smallest edit that can be correct, and it does not require understanding bytes we have not measured.

So a write edits an already-formatted image. Creating one from nothing is a separate job with its own measurement behind it — see Making a Blank Disk.


EXM — Why cpmtools Cannot Write These Disks

The extent mask is stated by the disk, not derived from it, and this disk states something the standard rule does not produce.

The usual derivation says: 2 KB blocks, fewer than 256 of them, so block numbers are one byte, so a 16-byte allocation map holds sixteen of them, so one directory entry covers 32 KB — two 16 KB extents — so EXM 1. This disk's BIOS says EXM 0: one entry covers a single extent and uses only eight of its sixteen allocation slots. The disks agree; look at any multi-extent file on DISK01.DSK and its first entry has exactly eight blocks in it.

diskdefs has no field for EXM, so cpmtools derives it and cannot be told otherwise. Writing an Altair floppy with it produces a single directory entry marked EX=01 holding nine blocks, with no extent 0 anywhere on the disk. CP/M's DIR lists extent-0 entries, so the guest correctly does not show the file, and it would be truncated if it did. Changing maxdir to the disk's real 64 makes no difference. This is not a bug to work around — do not patch cpmtools; use a writer that can be told the EXM.

In this codebase that is a Format::exm field, explicit and optional: None derives it the standard way, and a disk that states something else gets to say so. Other vendors made the same unusual choice, so this is a field rather than a special case.



Making a Blank Disk

A file full of 0xE5 is not a blank floppy. It mounts, lists as empty, accepts writes — and is refused by the first real machine that reads it, because there is not one sector header on it.

So the blank was measured too, the same way everything else on this page was. A booted Altair CP/M was pointed at 337,568 bytes of nothing in drive B: and told to run its own FORMAT.COM:

A>FORMAT
*** DISKETTE INITIALIZER ***

DISK DRIVE (A-P)? B

SECTORS PER TRACK: 32
SECTORS PER BLOCK: 16
BLOCKS PER DISK:   149
RESERVED TRACKS:   2
TRACKS PER DISK:   77

COMMAND: FULL
WARNING: THIS COMMAND DESTROYS ALL DATA ON THE DISK
TYPE C TO CONTINUE, A TO ABORT WITHOUT LOSS.
PROCESSING TRACK# 0 … PROCESSING TRACK# 76
INIT DONE - NOW VERIFYING DISKETTE.
PROCESSING TRACK# 0 … PROCESSING TRACK# 76
NO ERRORS FOUND ON THIS DISKETTE.

Two things fell out of that for free. FORMAT prints the disk's parameters, and they are exactly the DPB read out of the BIOS — an independent confirmation of the geometry from a completely different piece of software. And its verify pass read back all 77 tracks through the emulated controller and found no errors, which is a stronger statement about the controller than any test written here.

What a freshly formatted Altair disk holds

ByteTracks 0–5Tracks 6–76
0track + 0x80track + 0x80
10x00sector ID, in the shifted order
20x010x01
3data (0xE5)0xE5
4datachecksum 0x30
56data0xE5
data3130, all 0xE57134, all 0xE5
stop131 = 0xFF135 = 0xFF
check132 = 0x80(byte 4, above)
tail133136 = 0x00136 = 0x00

Both check bytes are exactly what the formulas above compute for 128 0xE5 bytes: 0x80 on a boot track, and 0x80 + 0x01 + three 0xE5 = 0x30 on a data track. Two independently measured things agreeing is worth more than either on its own.

The sector IDs a fresh format lays down are the same shifted order the shipped disks carry — the third independent sighting of it, after the BIOS table and the disks themselves.

The gateway generates this, byte for byte. Our blank image is required by test to hash identically to the one FORMAT.COM wrote. And the loop is closed at the other end too: a disk created and filled entirely on the host is booted, and the guest's own DIR lists the file and its own PCPUT sends it back byte-identical.

Where to make one

WhereHow
Telnet / SSHSettings → CP/M Emulator → IN
WebAI, Browser, Weather & CP/M → More → Mount CP/M drivesNew blank disk
DesktopSame group → More → Mount CP/M DrivesNew blank disk

You name the disk, not the file: the gateway writes <format>_<name>.dsk, because the format prefix is what makes an image mount read-write. A blank disk you could not write to would be a puzzle rather than a feature. Nothing is ever overwritten — a name already in use is refused, since there is no undo for the disk that used to have it.

All three formats can be created. The two unframed ones are trivial — a formatted ibm3740 or altairhd really is nothing but 0xE5, because those images have no per-sector headers to author. The Altair is the one that needed measuring. A format whose blank layout had not been measured would simply not be offered, rather than being guessed at.


How Our 88-DCDD Controller Works

The emulated MITS 88-DCDD is what makes everything else on this page possible: it is how a disk gets to run its own operating system, and therefore how the disk can be asked what its own layout is.

Note what the controller does not do. It has no skew table, no directory, no idea what a file is. It serves raw 137-byte sectors in physical rotation order and lets the guest's BIOS do every bit of translation. That is exactly why a hardware emulator could read these disks perfectly while our filesystem reader could not, and it is the reason this path was built first.

The register interface

Three ports. Taken from the published description of the 88-DCDD and cross-checked against observed behaviour — the same clean-room posture as Punter, HBIOS and EGT8080. Other emulators' sources were a check on measurements, not a source of code.

PortDirectionMeaning
08hOUTDrive select. Bit 7 deselects/clears; bits 0–3 pick the drive.
08hINStatus — active low, the byte is returned inverted. Bit 7 new-read-data-available, bit 6 head at track 0, bit 5 interrupts enabled, bit 2 head loaded, bit 1 safe to move the head, bit 0 controller wants the next write byte.
09hOUTDrive control. Bit 0 step in, bit 1 step out, bit 2 head load, bit 3 head unload, bit 4 interrupt enable, bit 5 interrupt disable, bit 6 lower head current (ignored), bit 7 begin write.
09hINSector position. Bits 1–5 are the sector number × 2; bit 0 is “sector true”, zero when that sector is under the head.
0AhOUTWrite data.
0AhINRead data.

Rotation, and why it was built first

A real disk turns, and software waits for the sector it wants to come round by polling the position register. So the position has to advance on its own or every guest hangs in a poll loop — and a guest hung in a poll loop looks exactly like a runaway program, a misdiagnosis this project has already made once. Rotation was therefore modelled first and tested first.

The model: each read of the position register flips a “sector true” flag, and the sector advances on the flip to false. Every sector therefore gets two reads at the head — one positioned, one not.

That second read is not decoration. Some software waits for the flag to go false rather than true, and a controller that only ever reports “positioned” leaves it spinning forever. MBASIC saving a file under CP/M is the known example.

A guest that polls the same sector for an implausible number of instructions is counted rather than ignored, and the count is reported alongside the program counter. A silent guest and a stuck guest are different faults with the same symptom, and that counter is what tells them apart.

Cold start

A real Altair boots from a small PROM: select drive 0, load the head, wait for sector 0, copy its data into low memory, jump there. We do not have the PROM, so that sequence is written out — a deliberate substitution, and it has to land the payload at exactly the address the PROM would or the boot sector's own jumps go nowhere.

The address is not a guess. A real Altair CP/M boot sector begins 31 00 DF (LXI SP,0DF00h), F3 (DI), then talks to the controller with D3 08 / DB 08. Its absolute jumps target 0007h, 0015h, 0020h, 0030h and 0048h — and each matches the offset of the corresponding instruction within the payload itself. The jump to 0007h lands on the DB 08 at payload offset 7. That only works if the payload sits at 0000h.

The bootstrap reads the loader with a 2:1 interleave. Every Altair disk here — CP/M and MITS alike — uses it, and autodetecting it was tried and found actively harmful, so it is a constant.

The rest of the machine

PartWhat it is
CPUA Z80 by default, or an 8080 — cpm_cpu, and the same key serves the emulator next door. The Altair shipped with an 8080, but the Z80 is the default because it is a superset that runs all of it, Altairs were commonly fitted with Z80 upgrade boards, and the emulator next door is on the same setting. The 8080 no longer costs the terminal: EGT8080.COM is placed on drive A: beside EGT8080.COM and runs on either processor. Choose the 8080 when you are running period 8080 software — notably diagnostics that identify the CPU from DCR A setting parity rather than overflow, which are right to fail on a Z80.
MemoryA flat 64 KB. The guest's operating system owns all of it.
ConsoleAn 88-2SIO at 10h/11h, bridged to the session both ways.
Sense switchesPort FFh, reading 0x00 — see the warning below.
Virtual modemThe second 88-2SIO port at 12h/13h, where a real Altair put its modem, when the selected profile fits.
Unknown portsRead as inert rather than as whatever was last on the bus.

MITS software finds its console through the front panel. Port FFh is the sense switches, and Altair DOS, Disk BASIC and Time Sharing BASIC read it to choose a terminal driver. 0x00 selects the 88-2SIO at 10h/11h, which is the console we emulate. Leaving the port floating at the unknown-port 0xFF is not “no answer” — it is a valid reading meaning 88-SIO, and it cost a day spent looking at the disk while the guest was working perfectly and printing to a board that was not there.

Writing

The controller does not own the images. It says which sector it needs and the host moves the bytes, which keeps every file access on the side where the bounds checking and the read-only rules already live.

Where a written sector belongs is captured when the write starts, not when it is committed. The head can move in between. A guest writing a directory entry and then seeking away to write the file's data had its directory sector land on whatever track it had stepped to — a sector whose own header said track 2 was committed to track 69, and CP/M reported Bdos Err On A: Bad Sector. This was found by pushing a file into a booted guest and reading it back.

More than one drive

The controller addresses sixteen units — the drive-select register carries four bits — and a booted guest is handed every mounted image, each on the unit its drive letter names. What it calls them, and how many it can reach at all, belongs to the disk's own BIOS.

Measured: stock Altair CP/M supports four. Both 2.2mits and 2.2b answer Bdos Err On E: Select at E: while happily using A: through D:. That is their BDOS refusing, not our controller — we served fifteen disks without complaint. Nothing here patches a guest BIOS to change that; the whole reason booting exists is that the disk's own software is already right about its own hardware.

Two facts about unit 0, both measured, that decide the layout:

  • The bootstrap can load a system from any unit. Booting a CP/M disk parked in unit 1 signs on perfectly.
  • But the system it loads comes up as its own A: and reads unit 0 from then on. Boot from unit 1 with a blank disk in unit 0 and the guest goes silent — it loaded fine and then ran against the wrong disk.

So the disk being booted always occupies unit 0, and the rest fill the units their letters name. An empty unit in between answers nothing at all, exactly as the real board does, and a guest that selects one waits forever for a head that never loads — the boot banner warns which units are empty, and ESC twice still works.

What booting costs

Every guard that worked by understanding the guest's request is gone, because there is no request to understand — just sectors. Two blunt ones remain: an image opens writable unless cpm_boot_writable says otherwise, and it is held exclusively by one session. There is deliberately no instruction ceiling: a booted operating system is the session and is meant to sit at its prompt indefinitely, so the session idle timeout bounds an abandoned one instead.

Source: src/cpm/dcdd.rs (controller), src/cpm/boot.rs (cold start), src/cpm/boot_machine.rs (the machine around them). Reached from the CP/M settings screens — see the CP/M Reference.

The Other Formats

IBM 3740 8″ SSSD — ibm3740

256,256 bytes: 77 tracks × 26 sectors × 128. Two reserved tracks, 1 KB blocks, 64 directory entries, records laid end to end with no framing at all. A plain skew of 6, written out as an explicit permutation so no reader has to remember which convention a bare number implies:

IBM3740_SKEW
  0,  6, 12, 18, 24,  4, 10, 16, 22,  2,  8, 14, 20,  1,  7, 13,
 19, 25,  5, 11, 17, 23,  3,  9, 15, 21

The closest thing CP/M had to a universal disk. Tarbell and Cromemco single-density 8″ images are both this, as are the IMSAI / z80pack ones.

Altair 88-HDSK hard disk — altairhd

4,988,928 bytes. 812 tracks × 24 sectors × 256 bytes, so two CP/M records ride in every physical sector and the skew moves them as a pair. Getting that wrong scatters every second record — the drive can only start reading at a sector boundary, so the two records inside one sector always travel together. 4 KB blocks, 192 directory entries, two reserved tracks, no framing. A three-way interleave over 24 sectors:

ALTAIR_HDSK_SKEW
  0,  7, 14, 21,  4, 11, 18,  1,  8, 15, 22,  5,
 12, 19,  2,  9, 16, 23,  6, 13, 20,  3, 10, 17

Cromemco 8″ double density — cromemcodd and cromemcodsdd

625,920 bytes single sided, 1,256,704 double sided. Neither number factors into a tidy geometry, and that is the clue to what they are: track 0 is recorded in single density so that a single-density boot ROM can read the disk at all, and everything after it is double density with sixteen 512-byte sectors to a track.

cromemcodd     3,328 + 76 × 8,192 =   625,920   (77 tracks, one side)
cromemcodsdd   3,328 + 153 × 8,192 = 1,256,704   (77 cylinders, two sides)

So the reserved area is 11,520 bytes and not a whole number of data tracks — which is exactly where both disks' directories begin. The single-sided BIOS calls that two tracks; the double-sided one calls it one cylinder, because it counts a cylinder (both sides, 128 records) as a track. Two ways of describing the same 11,520 bytes.

cromemcoddcromemcodsdd
Records per track64128 (a cylinder)
Records per sector4 — a 512-byte sector
Block size2,048
Directory entries128256
Reserved2 tracks1 cylinder
Data area254 blocks (520,192)608 blocks (1,245,184)
Sector translationinterleave, 16 entriesinterleave of 4, 32 entries

The single-sided format declares less disk than it has: 254 blocks where the medium would hold 300, leaving the last eleven and a half tracks outside the filesystem. Both MICAH disks measured say so, so it is the format and not one odd disk — and deriving 300 instead would be a write defect, allocating past what the guest's own BIOS addresses. It would also flip CP/M's 8-bit/16-bit allocation choice at the 255-block boundary, so the directory would have been misparsed too.

CROMEMCO_DD_SKEW     (16 sectors, from the disk's own XLT, stored 0-based)
  0, 11,  6,  1, 12,  7,  2, 13,  8,  3, 14,  9,  4, 15, 10,  5

CROMEMCO_DSDD_SKEW   (32 sectors: an interleave of 4 within each side)
  0,  4,  8, 12,  1,  5,  9, 13,  2,  6, 10, 14,  3,  7, 11, 15,
 16, 20, 24, 28, 17, 21, 25, 29, 18, 22, 26, 30, 19, 23, 27, 31

The double-sided disk says it does not translate sectors, and translates anyway. Its disk parameter header's XLT pointer is zero, which is CP/M's way of saying there is no skew. Believing it produced a format that mounted, listed its directory perfectly, and returned scrambled file content — the same shape of failure the Altair mapping needed four hypotheses to escape, and one no consistency check can see. That BIOS interleaves inside its own SETSEC rather than through CP/M's SECTRAN, so XLT says nothing either way. The single-sided disk of the same physical geometry interleaves differently again: skew belongs to the BIOS, not to the medium.

z80pack cpmsim hard disk — z80packhd

4,177,920 bytes: 255 tracks × 128 sectors × 128. 2 KB blocks, 1,024 directory entries, no translation — and no reserved tracks at all. There is no boot area to skip: the directory starts at byte zero and every byte of the 4 MB is data. That is a simulator's disk rather than a machine's, and it shows.

Read from the BIOS of the system disk that uses it, because the volume cannot speak for itself: hd-tools.dsk carries no operating system, so it was mounted at the hard-disk slot — cpmsim drive i, which is slot 8 — of a booted cpm22-62khd.dsk, and its DPB fetched through that BIOS's own SELDSK. Then checked the way everything here is checked: a 21 KB file read back through the guest's own CP/M and compared character for character.

Named versus sniffed

An image whose filename carries a format token (altair8_wordstar.dsk) mounts read-write: the operator has said what it is. An image without one is not second class. No two formats here are the same size, so the size names the format outright; what inspection then decides is whether the file really holds that filesystem. The whole directory is read and checked — every allocation block, no block claimed twice, every record count matching the blocks it claims — and an image that passes mounts read-write just the same.

One that does not pass still mounts, but read-only, and says what was wrong. That is the honest answer for a file which is the right size and is not this filesystem: a UCSD p-System disk is 256,256 bytes without being CP/M at all. A misidentified format is the one failure no consistency check can catch — every offset comes from the wrong geometry, so every check agrees with every other check — which is why naming a format is an override that skips the inspection, and why doing it to a disk that was rightly refused is the one dangerous use of the convention.



Who Owns a Disk While It Is Open

An image is one file, and more than one thing in this gateway can want it at once. Getting that wrong does not look like an error — it looks like files disappearing.

A mounted image is not a copy, it is a live object: the gateway keeps it open with its CP/M directory and allocation bitmap cached in memory, and that object outlives the session that caused it. A booted disk is the opposite shape — the guest holds all 330 KB in memory and writes the whole thing back over the file when it leaves. Put those two on one file and the second one to finish silently erases the first's work, and the loser is then caching a directory for bytes that no longer exist: its next write allocates blocks the other side already used and lays a directory entry over live data.

The rule is one session per image, and it has to hold in every direction. Boot against boot, boot against mount, mount against mount. It was originally enforced only between two boots, and each missing direction was a separate way to lose a disk.

What the gateway does

SituationWhat happens
Two sessions boot the same imageThe second is refused. The claim is keyed on the canonical path, because a boot target and a mount path are built from one setting by two routes and only one of them resolves the name — comparing them as text let one file be claimed twice.
Mounting an image somebody is runningRefused by name.
Mounting one image on two drivesRefused, naming the drive that already has it.
Booting while your disks are mountedEach mount is lent to the boot — taken out of service, not forgotten — and restored, opened fresh, when the session ends. However the session ends: an error, a dropped connection, the idle timeout.
Booting a disk somebody is working inRefused. The boot disk's own mount must go out of service, so unlike another drive it cannot simply be left out.
A drive that is lentReads as held in all three interfaces, keeps its place in cpm_mounts, and cannot be changed underneath the guest.

A lent drive is not an empty drive. It is a drive whose disk has been taken out, and it has to behave that way everywhere — not just when a filename is looked up. Guarding only that one path left DIR listing the host folder behind the drive, free space measuring it, and the file matching that ERA runs on deleting it. An operator would have been shown a folder's files believing they were the image's, and ERA B:*.* would have erased them.

Why writes are staged

Every write of a whole image — a booted session's save, and creating a blank — goes to a temporary beside the file and is then renamed over it. A rename within a directory is atomic, so a reader sees the old image or the new one and never a truncated file with no directory. That matters more than it sounds: a blank hard disk is 4.99 MB, and a write that stopped partway used to leave something that mounted, looked plausible, and could not be recreated because the name was taken.

One consequence worth knowing: rename needs permission on the directory, not on the file. So staging would happily replace an image you had deliberately made read-only, where a plain write failed. The save checks for that and refuses, and carries the old file's permissions across when it does write.

None of the above was designed in one go. It is the result of eight review passes over this feature, which between them found 45 defects — most of them in the fixes for earlier ones. Three separate times the defect was the same shape: a rule written in more than one place, holding in one of them. The mount-list builder that kept losing lent drives is now private to its module, so the compiler enforces what the comments could not.


The MITS 88-HDSK Interface

Groundwork for booting the 4.9 MB hard-disk images. Taken from the published manual, which — unlike the floppy one — does document the programming interface.

Status: it boots and reads; writing is not finished. A saved file's data and directory sectors do reach the platter in plausible places, but the guest's own DIR does not list it afterwards — one step still to find. Read-only use is solid. An Altair hard disk runs its own CP/M off a 4.9 MB image — 63K CP/M 2.2b ver 1.5 / For MITS 88-HDSK, then an A> prompt, DIR listing all 48 files and STAT reporting 3744k free. HDSK03 and HDSK04 both. The two non-CP/M hard disks (Disk BASIC and the Accounting system) do not, which is expected — they are not booted the same way. This section exists so the reading does not have to be done twice. Nothing in the gateway speaks this protocol yet; `HDSK*.DSK` images still mount as altairhd and do not boot.

The machine now holds a set of controllers

Done before the second board rather than after, which is what the plan asked for and is simple arithmetic: adapting one implementation to a trait is mechanical, unpicking three interleaved ones is not. A controller now answers three questions and the machine asks nothing else of it — which ports do you claim, can you carry an image this size, and what bytes do you want moved.

The third is the choice that matters. The 88-DCDD thinks in 137-byte sectors addressed by track and sector; the 88-HDSK thinks in 256-byte sectors addressed by cylinder, head and sector, reached through a command protocol rather than a rotating position register. Neither vocabulary belongs above the controller, so a controller now does its own address arithmetic and asks the machine for a byte range. The machine's job shrinks to "copy these bytes out of, or into, that image" — the same job for every board there will ever be.

One thing is deliberately still board-specific: cold-starting a disk. The floppy bootstrap is the sequence a PROM would run, driving the controller's port state machine directly; the hard disk's is a different sequence through its command protocol. Folding both behind one method before the second exists would mean designing an abstraction from a single example, which is how the wrong one gets made. It stays as an explicit, documented escape hatch until there are two bootstraps to generalise against.

Behaviour-preserving, and measured rather than asserted: the whole sample set was booted before and after and the results are identical — 19 disks reach a sign-on, 3 stay silent, 1 is refused, 13 are not 88-DCDD media. The three byte-exact ground-truth gates still pass.

Where to read it

88-HDSK.pdf on deramp.com — 170 scanned pages, no text layer. The part that matters is §3-5, Computer-Controller Communications, printed pages 33–43, plus §3-4 Assembly Language Operation from page 22. Several pages carry loose errata sheets that correct the body text, and they matter: one of them fixes a sample routine the manual admits is “nonfunctional”, and another says the body has the wrong bit for the CRC result. Read the errata beside each page, not after.

Contrast with the floppy: the 88-DCDD manual on altairclone is the assembly documentation and says on its first page that the Theory of Operation is not in it. This one has the interface. The floppy's equivalent was eventually found elsewhere — see The floppy's register table, found after the fact below.

It boots on its own

Worth stating plainly, because the hard disk is reached through a command protocol rather than the floppy's port state machine and it would be reasonable to assume it needs a floppy to start it. It does not. §3-2 of the manual:

“install the Boot Loader PROM (identified by the label HD-LDR, 103292) … The Boot Loader PROM address is 176000 octal (374000).” — “Examine location 176000, set sense switches as required, and RUN.”

So the hard disk has its own boot PROM at octal 176000 = 0xFE00, separate from the floppy's, and on a Turnkey B the machine auto-starts there at power-on. No floppy is involved.

The images agree: HDSK03.DSK sector 0 begins with the ASCII sign-on 63k CP/M 2.2b and 8080 code follows from sector 1. The geometry matches the manual exactly — 406 cylinders × 2 heads × 24 sectors × 256 bytes = 4,988,928, which is the file size.

“Set sense switches as required.” The front panel again, on port FFh. That is the detail that cost a day on the floppy side, where a floating 0xFF is not “no answer” but a valid reading naming hardware that is not there. Whatever the HD-LDR wants from the switches has to be established before concluding a boot has failed.

We do not have the PROM's contents — exactly as with the floppy, where the bootstrap is written out here rather than dumped, and its load address was justified by disassembling a real boot sector and matching its own internal jumps. The same method applies.

The host ports

The controller talks to the Altair through an 88-4PIO board, so the host side is four PIA channels rather than a bespoke register set. The manual gives them in octal; both are shown here because the code will want hex and the manual's own examples want octal.

OctalHexDirWhat
160A0INController Ready. Bit 7 high when the controller has finished. Cleared by reading 161.
161A1INCommand status — the error byte. Reading it clears the ready flag at 160.
162A2INCommand acknowledged. Bit 7 high when the controller has taken the command. Cleared by writing 163.
163A3OUTCommand byte — writing it starts the command. (Reading it clears the acknowledge flag at 162.)
164A4INRead data available. Bit 7 high when a byte is waiting at 165. Cleared by reading 165.
165A5INPrimary data in — read-buffer data and status data.
166A6INWrite data accepted. Bit 7 high when the controller will take a byte at 167. Cleared by writing 167.
167A7OUTData out — command parameters, write-buffer data, set-byte data.

Every status flag is bit 7 and every one is cleared by touching its paired data port. That is PIA behaviour, not something the controller invented: the flag lives in the channel's control register and reading or writing the data register clears it.

The handshake, from the manual's own example

This is the corrected routine from errata sheet 88-HDSK-ME05 — the version in the manual body is described there as nonfunctional because it never clears Controller Ready. It sends one “Set IV Byte” command (80h):

in   163        ; clear the command-acknowledge flag
out  167, addr  ; low byte of the command
in   167        ; clear the data-port-available flag
out  163, 80h   ; command byte -- this starts it

wait:  in 166 / test bit 7 / loop   ; controller will take a byte
out  167, data                      ; the parameter

wait2: in 160 / test bit 7 / loop   ; controller has finished
in   161                            ; clears ready, returns the error byte

Two things worth carrying into an implementation. The errata's advice is to wait for the controller at the end of a command rather than at the start, because that is when the error byte is meaningful. And the real hardware sets these flags within microseconds, so guest code often does not spin at all — an emulation that makes a flag take time will diverge from software that assumes it is already set.

The nine commands

Each is 16 bits, sent as low byte to 167 first, then high byte to 163 — and writing the high byte is what starts it. Buffers are four 256-byte areas of the controller's own 1 K memory, so a sector transfer is two steps: move it between disk and buffer, then between buffer and the Altair.

CommandHigh byteBits
Seek to cylinderCSEEK 00h0–8 cylinder (0–405), 9 unused, 10–11 unit, 12–15 zero
Write sectorCWRSEC 20has read sector — see the deviation below
Read sectorCRDSEC 30h0–4 sector (0–23), 5–7 head, 8–9 buffer, 10–11 unit
Write bufferCWRBUF 40has read buffer
Read bufferCRDBUF 50h0–7 length, 8–9 buffer
Read statusCRSTAT 60h10–11 unit, plus an IV byte address 0–255 in the low byte
Set byteCSETIV 80han IV byte address in the low byte; the data follows at 167
Read unformatted sectorCRUSEC A0has read sector, without checking the header — also the write-protect probe
FormatCFORMT C0hunit in high-byte bits 2–3; low byte 7–6 platter, 5 side
InitializeCINIT E0h

The equate names in that table are the disks' own. Four of the hard-disk images carry the assembler source of the 88-HDSK software itself — the CP/M BIOS on HDSK03/04, and on HDSK08–0B a controller diagnostic with IR/IW commands for reading and writing IV bytes and a platter formatter. It states the command set outright, which is how this table came to have nine rows. The manual has seven: “There are seven currently defined commands which may be issued to the Datakeeper Controller” — and that word is doing real work, because CFORMT and CINIT are nowhere in it. They were added after October 1977, and the only record of them we have found is the software on the disks.

The manual does give Read Status's bits, in the prose of §3-4 rather than in its summary table: “Bit 12 is zero, bit 13 is one, bit 14 is one and bit 15 is zero” — nibble 0110, exactly the CRSTAT equ 060h the disks define. Two independent witnesses to a command this project first implemented from the disk alone.

That mattered more than a documentation tidy-up. Decoding on bit 15 — which the manual invites, because 80h is the only value it shows with that bit set — folds CFORMT (C0h) and CINIT (E0h) into Set Byte, and both then succeeded while doing nothing. A format that reports success and erases nothing is the same failure as the write bit below, from the same cause: reasoning from a bit condition instead of reading what the software sends. Decode the whole four-bit nibble.

A transfer length of 0 means 256 bytes, not none. Heads are 0–7 with the fixed platter at 2 and 3 and the removable at 0 and 1; a drive with fewer platters narrows that range. Cylinder runs 0–405, sector 0–23.

The error byte (port 161)

From errata 88-HDSK-ME03, which supplies a table the body text omits. All bits read as 1 on the first read after power-on — an emulated controller has to reproduce that or a driver may mistake a fresh machine for a failing drive.

BitSet means
0Drive not ready
1Illegal sector
2CRC error in sector read
3CRC error in header read
4Header has wrong sector
5Header has wrong cylinder
6Header has wrong head
7Write protect

The disk documents its own boot loader

The best find of the exercise, and worth looking for on any disk before reverse-engineering anything: HDSK03 carries the assembler source of its own boot loader, in plain ASCII at cylinder 1, head 1, sectors 8–12. It is ground truth from the people who wrote the hardware's software, and it settles several things the manual leaves ambiguous.

From the sourceMeaning
CSEEK equ 00h, CRDSEC equ 30hConfirms the command nibbles: seek is 0000, read sector is 0011.
CSIDE equ 020h, CFPLTR equ 0C0h, CUNIT equ 00ChBit 5 is side, bits 6–7 are platter, and the unit mask sits in the high byte.
STRTSEC equ 2CP/M starts at sector 2; sector 0 is the volume label and sector 1 is spare.
SECTCNT equ (CPMLEN/256)-1How many sectors the system occupies, derived rather than fixed.
HD0SKEW equ 1, HD1SKEW equ 13There is a sector skew, and it differs by platter — 1 on the removable, 13 on the fixed.
“the CP/M image fits entirely in cylinder zero”No seeking during the load.
“the hard disk bootloader ROM (HDBL) loads this program into memory at address zero”Two stages: the PROM loads a boot program to 0000, and that loads CP/M.

The apparent contradiction was not one. The source looks at first like it disagrees with the manual about the command bits — the manual says head is bits 5–7, the source names a side bit and a platter field. They are the same thing: a head number 0–7 is platter×2 + side, which is why the fixed platter is heads 2 and 3 and the removable is 0 and 1. The masks settle it, and no code needed changing.

Sector 7 of the image is that first-stage program, and disassembling its opening bytes confirms the arithmetic: 31 00 D7LXI SP,0D700h, so CCPBASE is D700h on this 63 K system, then F3 DI, and shortly after DB FFIN 0FFh, the front-panel sense switches. Its own comments say why: “If A11 is down, boot is from the removable platter. If A11 is up, boot is from the fixed platter.” The switches decide which platter the machine boots, exactly the class of thing that cost a day on the floppy.

The volume label says where the boot program is

The loader source above names sector 7, and hard-coding that booted two of the four hard-disk images. The other two — the Disk BASIC pair — have nothing but zeros in sector 7, so a fixed sector could never have served them. The answer was in sector 0 all along. It is a volume label, and two little-endian words inside it record where that disk's boot program lives: 0x28 is its first sector and 0x2A is how many sectors it occupies.

ImageLabel text[0x28] sector[0x2A] countFirst bytes there
HDSK03, HDSK0463k CP/M 2.2b7131 00 D7 F3LXI SP,0D700h then DI
HDSK01300-5-A HDSK BASIC24124F3 C3 40 00DI then JMP 0040h
HDSK02300-5-A HDSK BASIC24122F3 C3 92 75DI then JMP 7592h

Three things make this a reading of the disk rather than a guess. The CP/M entry agrees with what the loader source on that same disk says of itself. The count is not decorative — BASIC is 124 sectors where CP/M is one, and loading a fixed 256 bytes of BASIC gets nowhere. And the two BASIC counts differ from each other (124 against 122) while sharing a label, which no constant can express.

So Controller::boot_program is handed the image and returns (offset, length, load address) read out of it. All four hard disks boot from one code path: the CP/M pair to A>, the BASIC pair to MEMORY SIZE? — and HDSK01 names itself ALTAIR HARD DISK BASIC VERSION 300-5-F [16MAY79], which is a sign-on no wrong answer produces.

The general lesson, twice over now. The floppy work found the layout on the disk (the skew table in its own boot tracks); this found the boot location on the disk too. Before deducing a constant from one example, check whether the medium already states it — these systems had to boot media they were not built around, so they tend to write down what varies.

What booting it took

Very little, once the disk had been asked. The PROM's job is just read one sector and jump, so the emulated HDBL copies cylinder 0 head 0 sector 7 to address 0000 and sets the program counter there. Everything after that is the disk's own code driving the controller.

Which is what makes the sign-on such a strong result: it cannot be produced by a plausible wrong answer. The 4PIO handshake, the command word layout, the two-step buffer transfer and the cylinder/head/sector arithmetic all have to be right together before a single byte of CP/M reaches memory. The confirmation came before the text did — page zero held JMP ED03 and JMP DF06, which are exactly the BIOS and BDOS entries the loader's own source computes from CCPBASE = D700h.

Gate: test_an_altair_hard_disk_boots_and_lists_its_files. It requires the sign-on, a DIR that lists files — the sign-on alone only proves the loader ran, not that the BIOS can still find sectors — and a STAT free space, which needs the whole 4.9 MB addressed correctly rather than just cylinder zero.

A deviation from the manual, on the write bit

Recorded as a deviation rather than a verdict. It began as one observation of one CP/M implementation; there are now two independent witnesses, and the second is documentary.

§3-4 says Write Sector is Read Sector “except that bit 13 must be zero rather than one”, which makes the command nibble 0001. Read Sector is 0011, so on that reading bit 13 is the direction bit. But the CP/M that shipped on this hardware issues 0010 for a write — observed directly, eight times per SAVE. While the manual was followed those commands were decoded here as unrecognised, and the symptom was silent: the guest filled a buffer, never committed it, and the file simply did not appear.

CommandManualObserved
Read Sector00110011 — agrees
Write Sector00010010
Read Buffer01010101 — agrees
Write Buffer01000100 — agrees

Taking 0010 also makes the set internally consistent: bit 12 then means direction for sectors exactly as the manual and the hardware already agree it does for buffers. That is a reason to prefer it, not proof.

The second witness: the disks say so in words. The 88-HDSK source carried on the hard disks themselves defines the command as

CWRSEC  equ 020h    ;Write Sector
CWRSEC  equ 20h     ;same bit fields as CRDSECT

— two copies of the same equate, from the CP/M BIOS and from the controller diagnostic, both giving 20h and one saying explicitly that the fields are otherwise those of Read Sector. This is not our inference from watching a running guest: it is the source of the software that shipped with the hardware, telling the assembler what byte to emit. Observation and documentation now agree, from different directions.

Written before the firmware was found; see “The firmware settles the write bit” below for the answer. Two witnesses agreeing is a much stronger position than one, but they are witnesses to the same thing: what MITS-lineage software sends. The errata for this very section already corrects several other bit assignments, and it remains entirely possible the two bits are numbered the other way round somewhere in the chain between the 8X300 firmware, the 4PIO and the prose — in which case the manual is right about the pin and wrong only about the numbering. The honest statement is that the hardware's own software does this, so we do too. What would settle it is a schematic or the controller's own firmware — and both turned up; the firmware decided it, and the schematic explains why it could not have.

The IV bytes, and the two commands that reach them

CSETIV and CRSTAT are a matched pair: they write and read the controller's own internal registers, which MITS calls IV bytes. No guest operating system here uses either — seeking goes through CSEEK — but the diagnostic on HDSK08–0B is built almost entirely out of them, and it is the only software that exercises this corner at all.

Both carry the address in the command's low byte. Set Byte then takes its data as a separate byte at port 167, which is the detail worth having from the disk's own WRITIV:

WRITIV: mov  e,a       ;save IV data
        in   ACMD      ;reset CMDACK
        mov  a,c       ;recover address
        out  ADATA     ;low byte of command  <- the ADDRESS
        in   ADATA     ;clear ADPA
        mvi  a,CSETIV
        out  ACMD      ;start it
        ...            ;wait on 166, then out ADATA with the DATA

So the command is not finished when it is issued: it completes when the data byte arrives. An implementation that raises ready immediately and has nowhere to put that byte will take it as half of the next command word instead, which is exactly what happened here — harmless only because the next command overwrote the low half first.

Read Status is the mirror, but not symmetrically, and this is the detail that will bite whoever writes it next. Set Byte finishes when its parameter arrives — the errata's example waits on the write flag, sends the byte, and only then waits on Ready. Read Status raises Ready and the data flag together: READIV waits for Ready first and takes the byte afterwards, noting “HDCMD returns when CRDY - so CDA should already be set”. Model it as a one-byte Read Buffer, finished only once the guest has read it, and that routine spins on Ready forever — in the only software that uses the command at all. It also carries one rule that is not in the manual we have, stated plainly in the diagnostic's own comments:

“Note that no IV Bytes can be read without the Disk Interface Card present and the Ready line on that card active, because the Datakeeper Read Status command insists that the selected Unit be ready before it will return status.”

What is modelled and what is not. The gateway keeps 256 IV bytes — the range the diagnostic itself allows, MAXIV equ 0FFh — where a write is remembered and a read returns it. That is exactly what the diagnostic's IV byte test asks for: write a pattern, read it back, compare. It is deliberately not a model of the 8X300's registers, because the same source shows why that would be guesswork: some IV bytes are inputs reflecting drive state (“the IV Byte on the Disk Data Card is an input, so this test…”), and others drive the head positioner directly — IV 18 and 19 are the cylinder latches, and the cylinder bits there are inverted. Nothing we have says which address is which, so the store is real and the meanings are absent, and the head-positioning parts of that diagnostic will not behave like the real board.

Do not “fix” the store by reversing its bits. The errata note above — that bit order is reversed between the controller's internal user-data pins and the way the Altair sees them, user bit 0 being Altair bit 7 — is a real property of the hardware and is exactly the wrong thing to apply here. It sits between the pins and the Altair, so it applies on the way in and again on the way out: a write followed by a read is reversed twice, and the Altair sees the byte it wrote. Adding a reversal to Set Byte or Read Status would break the one thing this store is for. The inversion on IV 17–19 is a different matter, and a real one — but it belongs to what those latches mean to the drive, which is the part deliberately not modelled.

Format, and what a formatted surface holds

CFORMT formats one whole side of one platter — which is what the utility announces (“Formatting platter N, side M”) and what its 60-second timeout is sized for. Its operands are split across the word, and the source's comment on the equate is the only statement of that we have found: unit in the high byte's bits 2–3, then ADATA bits 7–6 platter and bit 5 side. Those fold into the same head number the sector commands use, since head 0–7 is platter×2 + side.

The fill byte is measured, not chosen: the entire second half of HDSK03 is 9,744 sectors of uniform E5, and HDSK04's free space is the same. So at the level this controller works, a format is “the data comes back erased”. What is not reproduced is the sector headers a real format writes — these images hold sector data only, with no header bytes anywhere, which is the same reason a blank file is not a blank floppy on the 88-DCDD side.

A surface is not contiguous in the image: one head's 24 sectors sit once per cylinder, a whole cylinder apart. So the request the controller hands down is a strided fill, 406 runs of 6,144 bytes — and the test asserts on the last cylinder as well as the first, because a fill that treated a surface as contiguous would erase the first half of the disk, both heads, and pass any check that only looked at the front.

Proving the write path needs two sessions

Two real bugs sat in Write Sector, and both were found by watching the guest rather than by reading the code: the command decode had the wrong nibble (see above), and Write Sector never set its ready flag, so a guest that waited for the write to finish waited forever.

The gate that now holds them down deliberately boots twice. One session proves almost nothing: the guest can write to entirely the wrong sector and still see its own file afterwards, because it reads the directory back from the same wrong place. So the test saves a file, takes the bytes as they would reach the host's .dsk, and boots a fresh machine on nothing but those bytes. For the file to appear then, the directory sector, the data blocks and the allocation bitmap all have to have landed where this disk's own BIOS goes looking.

It checks free space too, and does it as a before and after reading rather than against a number. That matters: these images ship with very different amounts free — 3744 k on HDSK03, 1060 k on HDSK04 — so a constant threshold silently passes on one disk whether the write happened or not. A directory entry whose blocks were never claimed is the shape of corruption that only surfaces on the next file written, which is far too late to diagnose.

Gate: test_a_file_written_in_a_booted_hard_disk_survives_a_reboot, run against both HDSK03 and HDSK04. Mutation-checked — disabling the Write Sector decode fails it.

The floppy's register table, found after the fact

The 88-DCDD was brought up without its register documentation: the manual on altairclone is the assembly guide and says on its first page that the Theory of Operation is elsewhere, so every status and control bit here was established by watching real guests. The document does exist. It is Altair 88-DCDD Disk Drive System on deramp.com — 171 pages, and unlike the altairclone scan it has an OCR text layer, so it is searchable. The part that matters is the appendix “ALTAIR DISK CONTROLLER I/O INFORMATION”, which gives all three registers bit by bit.

Every bit we had inferred is where the manual puts it, and none is missing except two that carry no emulation meaning. The register map was right:

BitStatus (IN, port 10₃)Control (OUT, port 11₃)
D0ENWD — enter new write dataStep IN
D1Move Head — head movement allowedStep OUT
D2HS — head status; also enables the sector channelHead Load
D3not usedHead Unload
D4not usedInterrupt Enable
D5INTE — interrupt enabledInterrupt Disable
D6TRACK 0HCS — head current switch
D7NRDA — new read data availableWrite Enable

Status is inverted — “True condition: 0, False: 1” — and all-false when the drive is not enabled or holds no disk, which is what an empty unit answering nothing really means. Sector position: D0 is Sector True, true when zero, and the sector number sits above it. Both of those had been inferred correctly.

Reading the whole appendix rather than the bit table found three real divergences — all in one place, the select port's D7, and every one of them a sentence that is easy to skip:

  • D7 clears disk control, and D0–D5 are “don't care”. We were reading the low bits anyway, so a guest deselecting drive 2 reset drive 0's mechanism and left drive 2 with its head still down — a drive that then looked ready to read without ever being told to load its head.
  • “Interrupt Circuit also disabled by clearing disk control.” That sentence is under control D5, not in the select port's own paragraph, and we had missed it: status D5 went on reporting interrupts enabled after the control had been cleared.
  • A write in progress is finished, not dropped. “Once Write is enabled, it holds the head loaded for the required time”, and the write circuit “will continue writing the last byte outputted to the end of that sector”. We discarded it, silently losing a sector the guest believed it had written — the same fault as a write abandoned when the head steps, which this controller had already been fixed for once.

The interrupt flag also moved from the drive to the controller, where the manual puts it: held per drive, selecting another drive appeared to change the setting, which no real board does. None of this was reachable by the 24 images that boot — which is the point of reading the document even after the thing works.

Two things in the table are not modelled, and now that the source is in hand they can be stated rather than left as unknowns:

  • HCS, control D6. “Must be true when outputting a write instruction with the head on tracks 43–76. This reduces head current and optimizes resolution on inner tracks.” It is an analogue property of a real head; there is nothing for an emulator to do with it, and a guest that sets it is not affected by our ignoring it.
  • Interrupt delivery. Control D4/D5 enable and disable disk interrupts and status D5 reports the state — we track the flag and report it faithfully, but we never raise an interrupt, because nothing here has an interrupt path. Software that enables disk interrupts and then waits for one would hang. None of the 24 images that boot does so; they all poll. Worth knowing before adding a 25th.

The same document also confirms the sector length from the other end: “MAX. no. of data bytes per sector 137 (including SYNC)”, and that the 138th byte written must be zero. And the disks are “hard Sectored (32 Sector holes, 1 index hole) … not IBM compatible”, which is the sentence that explains why no IBM-format reasoning ever applied to them.

The measured sector format is confirmed too, by a source we never used. retrocmp.de's Altair floppy page states the format for both regions, and it agrees with what was recovered from the disks byte for byte: system tracks keep the checksum of bytes 3–130 at 132; data tracks keep the checksum of 2–3 and 5–134 at byte 4, which is exactly the “data plus header bytes 2, 3, 5 and 6” formula measured here. Better still, it gives the skew in closed form — skewed sector = (sector × 17) MOD 32 — where this project derived a table from the sector IDs on the disks. The two are the same map: 17×17 = 289, and 289 MOD 32 = 1, so the function is its own inverse, which is why reading it as position→ID or ID→position gave the same answer. A measurement standing up to an independent source it was not derived from is the best outcome available here.

Auditing the hard disk against its manual, page by page

The 88-HDSK was built from this manual, so an audit is a weaker exercise than the floppy's — but the parts that had been taken from the disks rather than the document needed checking against it, and the errata sheets needed reading rather than summarising. Everything held:

  • Every command nibble in the table above is confirmed, including Read Status as 0110 and Set Byte's address in bits 0–7. Both had been implemented from the disks' source first.
  • The flag/data pairing of all four channels is confirmed signal by signal in §3-5 A, down to “Input on 163 to reset bit 7, Port 162” — reading the command port clears the acknowledge flag, which looks odd in code and is exactly right.
  • Port 167 carries all three of the low command byte, write buffer data and Set Byte data; port 166's flag means “ready for write data transmission, or Set byte data”. That is the phase the Set Byte parameter now waits in, and it is documented.
  • Read Status data returns on port 165, alongside read buffer data. Also documented, also as implemented.
  • Heads narrow with the platter count — “the head range of a Drive containing only one platter is 0 through 1” — which is why a 4.9 MB image is two heads' worth of surface. It is not why the board stops at head 1, and reading it that way was a defect for months: see Altair Hard Disk BASIC counts platters, not drives below.
  • The write-bit deviation is confirmed as a deviation: the manual really does say Write Sector is Read Sector “except that bit 13 must be zero rather than one”. The disks really do say 20h. Both statements are now quoted from their sources.

Three findings from the document itself, one of them a fix. (Three more came later from running the diagnostic the disks carry — see ADEXER below.)

  • The manual's own 4PIO initialisation was consuming the power-on error byte. Table 3-C tells a driver to set port directions with, among others, OUT 163,255 — which arrives at the command port and decodes as an unrecognised command. That was clearing the “nothing has run yet” flag, so a guest which initialised the board as documented and then looked for errata ME03's all-ones signature saw zero instead. A PIA direction write is not a controller command; the flag now survives one.
  • Read Status “will not complete if the selected Unit is not Ready”, says errata ME02 — confirming the diagnostic's comment from a second direction. We deviate deliberately and in the forgiving direction: the hardware simply never raises Ready, leaving the caller to fall out through its own timeout (which is why READIV carries a one-second one), while we complete and set the not-ready bit. The guest reaches the same conclusion from the error byte without a wait this emulator would report as a stalled controller.
  • Read Status rewrites seven bits of IV byte 17 as a side effect — user bits 4–7 from the command's unit bits, user bits 1–3 always 011b, user bit 0 untouched. Not modelled, and deliberately not guessed at: how two unit bits become four is not stated anywhere we have, and inventing a mapping is precisely how the sector layout went wrong four times. A guest that writes IV 17, reads status, and reads IV 17 back sees what it wrote here and something else on real hardware.

One more contradiction, quoted in the code so nobody has to find it twice. On Read Buffer, §3-4 says “the transfer length 0 through 255 (transfer length = n-1; n=# of databytes)”, which makes a stored zero mean one byte. Two paragraphs later, on Write Buffer: “a value of 0 in the transfer length implies a transfer of 256 bytes”. The second is the behaviour of a counter loaded with zero and counted to wrap, and it is what the CP/M BIOS on these disks depends on — it asks for a whole sector with a length of zero, and what comes back is byte-exact against the guest's own PCPUT. No software here ever asks for a partial transfer, so if n-1 is right for non-zero lengths, nothing we have would notice.

Worth recording for the next board: unlike the floppy manual, this one does contain a full Theory of Operation (§IV, from page 47) with block diagrams per card, and deramp also holds the Datakeeper controller schematics and a disassembly of the 8X300 firmware. Those are the documents that could settle the write-bit question outright — the first source yet found that is neither the manual's prose nor MITS software.

ADEXER: the oracle that was on the disk all along

Four of the hard-disk images carry ADEXER, the Altair Datakeeper Exerciser — Martin Eberhard's diagnostic, version 1.14, September 2017. It was written to exercise this controller against real hardware, which makes it the strongest oracle available here for everything the guest operating systems never touch. A booted CP/M runs it, and it exercises exactly the corners that had been implemented from documents alone.

ADEXERExercisesResult
ICInitialize (E0h)“Resetting Controller”
IW 34 55 / IR 34Set Byte + Read Status round trip“IV Byte 34 = 55h”
TBall four controller buffers“Buffers OK”
SK / SRSeek, Read Sector, Read Bufferreports the cylinder it was sent to
SW + CCWrite Sector, read back, compare“Mismatches: 0”
SBRead Unformatted Sector (A0h)reads; reports write protection
RErestore through the IV positionerheads return to cylinder 0
FSFormat (C0h)one surface erased, the other untouched

It found three things no test here could have.

  • A ninth command. ADEXER sends A3xx, which decoded as unrecognised. Its source names it: CRUSEC equ 0A0h ;Read Unformatted Sector — a read that skips the header check, which on images that hold pure sector data is simply a read. It matters more than that, though: DUMYRD issues one with an error mask of 80h to probe write protection before writing, its comment reading “0 if not write protected, a=80h if write protected”. With the command unrecognised the probe saw a clear error byte, so ADEXER believed a read-only disk was writable. It now prints “Disk is write-protected.”
  • The heads had no reported position. After SK 3, ADEXER said cylinder 511. It recovers the position by reading IV bytes 18 and 19 and complementing them, and with those left as an inert store a stored zero inverts to all ones. Modelling the two of them — from `GETCYL`'s own arithmetic and the errata's inversion rule — makes it report 3, and 100, and 405.
  • Restore did nothing. RE printed “Restoring.” while the heads stayed put, because IVICRN, “Cyl Restore (active low)”, was being stored rather than acted on. It is a level rather than an edge, so it needs no timing guess; the cylinder strobe beside it does, and is left alone, because every operating system here seeks with the Seek command.

And a third witness on the write bit. Traced live, ADEXER's SW sends hdsk cmd 2003 — nibble 0010, the same value the MITS CP/M BIOS uses and not the 0001 the manual describes. ADEXER is an independent implementation written forty years later by someone with the hardware in front of him. Three witnesses now agree against the manual's prose, from three different decades; what would still settle it outright is the schematic or the 8X300 firmware, both of which deramp holds.

The firmware settles the write bit

deramp holds the Datakeeper's own 8X300 firmware — 512 words of it as hex, and an 11-page disassembly carrying the original engineer's handwritten annotations. That is the end of the argument, because the Datakeeper has no hardware command decoder: the 8X300 reads the command word off the IV bus and dispatches it in software. The firmware is the decoder, so the firmware is the authority.

It takes three bits and indexes a jump table:

0019: MOVE LB.IV RR 5 L 3 --> AUX   ; command bits 15:13
0024: XEC  AUX + 0025               ; execute one of eight JMPs
0025: JMP 002D   seek
0026: JMP 0042   write sector / read sector      <- ONE entry
0027: JMP 004D   write buffer / read buffer      <- ONE entry
0028: JMP 0072   read status
0029: JMP 007E   set byte
002A: JMP 0088   (read unformatted sector)
002B: JMP 0091   (format)
002C: JMP 0000   reset controller (used for HOME)

§3-4 cannot be right as written. It says Write Sector is Read Sector “except that bit 13 must be zero rather than one” — but bit 13 is part of that three-bit index, and it is 1 for both sector commands, which is precisely why they share one table entry. A bit the dispatcher has already consumed cannot also be the direction.

The real direction bit is named in the firmware, in the buffer routine, and the annotator wrote “Read/WRITE?” beside it:

0053: AND  AUX AND R01 RR 4 --> R01     ; R01 holds command bits 12:8,
0055: NZT  R01,0064                    ; so bit 4 of it is bit 12
0056: ...  write                       ; falls through when bit 12 = 0
0064: ...  read buffer                 ; jumps here when bit 12 = 1

So bit 12 is the direction, for sectors and buffers alike, which is exactly the “more coherent reading” this project adopted from observation long before the firmware turned up. Four witnesses now agree — the MITS CP/M BIOS, the disks' CWRSEC equ 020h, ADEXER's traced 2003, and the controller's own code — and only the last of them is a mechanism rather than another observation of software.

So is the manual wrong? About which bit, yes, and now we can say why rather than only that it disagrees with everything else: bit 13 is a group selector. That is a narrow, mechanical correction to one sentence in §3-4, of the same kind its own errata sheets make elsewhere. It is worth keeping the distinction in view, though — every other statement in that section held up under audit, and the sentence is wrong in the way a transposed digit is wrong, not in the way a misunderstood design is.

Two more corrections came out of the same eleven pages, both verified afterwards by running ADEXER:

  • There is no unrecognised command. Three bits index eight entries exhaustively, so every 16-bit word is some command, and bit 12 is a plain don't-care outside the two transfer groups. This had been decoding four bits against a list of ten exact values — stricter than the board, which would take 70xx as a Read Status where we took it as nothing.
    It also retired a “fix” made a few hours earlier. The manual's 4PIO initialisation includes OUT 163,255, and that had been special-cased as an unrecognised command so it would not consume errata ME03's power-on all-ones. FF00 is group 7: the documented init really does reset the controller, and the special case was protecting a behaviour the hardware does not have.
  • Initialize and Format both bring the heads home. Group 7 jumps to 0000, whose opening sequence is annotated “CYL. RESTORE” — which is why the same hand labelled that entry “reset controller (used for HOME)”. The format entry at 0091 opens with the same pair of writes, marked “seek track 0” and “cyl restore”. Neither did anything to the head position here. ADEXER now confirms both: SK 100 then IC reports cylinder 0, and so does a format.

The schematics (Figure 4-9, two sheets for the processor card alone) corroborate rather than decide, for the reason above — but one note on sheet 1 is worth having in plain sight: “IV0-IV7 BUS LINES ACTIVE LOW”. That is the electrical reason the errata has to warn that IV bytes 17-19 invert their data and that the user bits arrive reversed, and it is why writing 255 selects cylinder 0.

Traps the errata flag

  • IV bytes 17, 18 and 19 invert their data. To select cylinder 0 you write 255. Get this wrong and every seek goes to the complement of where it was asked to go.
  • Bit order is reversed between the controller's internal user-data pins and the way the Altair sees them through Set Byte and Read Status — user bit 0 is Altair bit 7.
  • Read Status will not complete if the selected unit is not ready. A controller with no drive attached hangs the command rather than answering it.
  • The body text puts the CRC result on bit 3; the errata says it is only on bit 5.

Altair Hard Disk BASIC counts platters, not drives

A Datakeeper unit is a drive; the media on it are platters, and a 4.9 MB image is one platter, not one drive. The board addresses up to four platters per unit and names them through the head field — head 0–7 is platter×2 + side, which every source here already said: the manual's head range, CFORMT's “Bits 7:6 = Platter #, Bit 5 = Side #”, and the boot loader's own comment about A11 choosing the removable platter or the fixed one.

We modelled a unit as carrying exactly one platter and stopped the head range at 1. Everything that had ever been measured still passed — the CP/M hard disk boots and lists its own files from heads 0 and 1 — and the defect stayed invisible until a guest asked for a second disk:

The symptom. Boot HDSK01 (Altair Hard Disk BASIC 300-5-F), answer HIGHEST DISK NUMBER? with anything above 0, and a bare MOUNT — which mounts every disk that answer declared — fails with AFMS I/O ERROR CODE=9F, while MOUNT 0 works. The trace says why in one line: MOUNT 1 seeks unit 0 and reads heads 2 and 3. BASIC's disk number is the platter. No arrangement of images could have helped — putting a second hard disk on unit 1 changed nothing, because the guest never addressed unit 1.

So a slot is a platter, and the mapping is slot = unit×4 + platter — unit-major, so a machine with one Datakeeper has its four platters at slots 0–3, which is exactly the numbering BASIC uses. The rows call them unit 0.1 rather than a bare number, because both coordinates matter to whoever is deciding where an image lands. The positioner stays per unit — one set of heads carries every platter on a drive, so a seek moves all of them.

Proved the strong way, against the guest's own operating system: with HDSK02 (the Altair Accounting System) at slot 1, MOUNT answers OK and FILES 1 lists that disk's own directory — $A.GLTRA, AP MENU, the payroll and ledger files — none of which are on the boot disk.

And the 88-HDSK CP/M has a B: after all. This page and the tests said for months that its BIOS carried exactly one drive, on the evidence that DIR B: answered Bdos Err On B: Bad Sector whether or not a disk was mounted. Both halves of that were true and the conclusion was wrong: its B: is the drive's fixed platter, which we never served. Put an image on slot 1 now and the same unmodified BIOS lists it.

A control only proves the guest is not seeing your disk. Which of the two sides is at fault is a separate question, and “the guest's BIOS is limited” is the comfortable answer to reach for — especially in a project whose whole premise is that the disk is right about its own hardware.

The lesson is the one this page keeps relearning. The narrowing was justified by a real quotation — “the head range of a Drive containing only one platter is 0 through 1” — applied to the wrong subject. That sentence is about the drive a customer bought; it says nothing about what the controller may be asked for. A fact about one image's geometry had been promoted into a limit on the board, and every test we had agreed, because every disk we had booted used one platter.

Same clean-room posture as everything else here: this comes from the published manual and will be cross-checked against observed behaviour, not transcribed from another emulator.

The Tarbell 1011 Floppy Interface

The third board, and the first whose chip is shared: the Western Digital FD1771 lives in its own module because Cromemco's 4FDC and 16FDC use the same part. Build the chip once, wrap it per board — boards differ only in ports, drive select and how they wait.

Documentation is good here. The Tarbell Floppy Disk Interface Manual on bitsavers has a text layer, a real Theory of Operation, a walkthrough of the boot PROM, and — usefully — the whole FD1771 data sheet reprinted as its §7-2, including the command summary, the flag summary and the Table 6 status matrix. The archive.org copy of Western Digital's own FD1771 manual returns a server error; use the Tarbell reprint.

Two things unlike any earlier board

The status register means different things depending on the command in flight. Bit 2 is Track 00 after a Type I command and Lost Data after a Type II or III; bit 6 is Write Protect or the record-type MSB. So the board has to remember which command is running and assemble status from it. A fixed status byte satisfies a driver that only tests Busy, and misleads every driver that does more.

Reading port FCh stalls the CPU until DRQ or INTRQ — the driver's inner loop is a single IN with no polling around it. The manual: “If the most significant bit is 0 … it was the INTRQ … If 1, it was the DRQ.” We cannot stall and do not need to: nothing here takes time, so the event the driver is waiting for has already happened. Same posture as the 88-HDSK flags.

Two defects only real disks could find

A synthesised cold start must leave the board in the state the real PROM left it. The Tarbell PROM loads the boot sector with a genuine Read Sector command, so the loader begins running with the FD1771's status typed as a transfer and reporting no error. Copying the sector straight out of the file instead left the chip in its power-on Type I state — where bit 2 means Track 00, and the head genuinely is on track 0. TDISK02's loader does IN F8h / ANI 9Dh, read that bit as Lost Data, retried ten times and halted at its own HLT. Hence the Controller::cold_started hook. Generalise it: whenever a bootstrap is synthesised, ask what state the real one leaves behind. The 88-DCDD had needed the same thing for a different reason — its open transfer held the safe-to-move-head bit low.

The drive-select latch bit is active low. The manual describes sibling pads as inverted (“E33 — latch bit 3 inverted”, “E42 — latch bit 1 inverted”), so the board exposes Q*; and E29 picks the low drive “when E29 is low”. The proof is on the disk: TDISK02's CP/M writes F2h — all latch bits high — while working perfectly with drive 0. Read it active-high and the board switches to an empty drive, so the disk signs on and then reports Bdos Err On A: Bad Sector for ever.

A 30-second pre-flight that paid for itself. This PROM loads one sector at 0000 and, if status came back clean, JZ 07Dh — the last three bytes of the sector it just read are a jump to the real loader. So ColdStart::Program needed a separate entry distinct from its load address. Checking for C3 00 00 at offset 125 of the sample images — 4 of 6 have it — settled that before a line of code was written. Do that again.

Geometry

77 tracks × 26 sectors × 128 bytes = 256,256 bytes exactly. Unframed — no sector header or trailer, unlike the 88-DCDD's 137-byte sectors — and sectors are numbered from 1. No skew table here: the guest's own BIOS owns the translation, which is the entire reason booting works where a filesystem reader struggled.

Note that CDISK01 is also 256,256 bytes, so the Tarbell claims it and cannot boot it. Matching an image to a board by file length has run out of road; see the machine setting below. (This paragraph used to add that Cromemco cold-starts from a 4 KB ROM monitor rather than a sector run. It does not — see the next section, where that prediction is one of three the disks refuted.)

Diagnostic: CPM_TARBELL_TRACE=1 prints every command with its drive, track and sector, and every extended command with its decoded function. That trace found both defects above.

The Cromemco 4FDC / 16FDC

The fourth board, and the one that paid off the FD1771 module: the chip was already built and tested, so this board is ports, drive select, geometry and density. All three sample disks boot, sign on and take a command — and they run three different operating systems, which is the strongest thing a board here can be said to do.

Three predictions this page made about this board were wrong, and they were wrong in the same direction. Before it was written, the open-questions table said the Cromemco would need a 4 KB ROM monitor at C000 to cold-start from, the synthesised-ROM mechanism the CUTER stub introduced, and bank switching — because all three boot sectors open with MVI A,1 / OUT 40h and nothing here models memory management. None of the three was needed. The disks load a boot sector like every other board, and that OUT 40h is what removes the ROM: CDISK03 then loads its operating system across B380hCCFFh, straight through C000h, which no ROM could survive. We never map one in, so the write has nothing to undo and port 40h is left to the machine's unclaimed-port handling. Each prediction came from reading a listing on a disk rather than the code that actually runs — the identical mistake that recorded the Tarbell's console polarity backwards one section ago.

The register interface

Measured from the three CDISK* images' own code: the boot sector each carries, and the driver inside the operating system it loads. What a working driver does to a register is that register's definition as far as anything we have to satisfy is concerned. Published Cromemco documentation is a cross-check, never the transcription source.

PortDirectionMeaning
30h33hbothThe FD1771's own four registers — command/status, track, sector, data.
34hOUTDisk control. Bits 0–3 drive select, one-hot; bit 6 double density; bit 7 enable the auto-wait.
34hINDisk status. Bit 0 INTRQ, bit 1 DRQ, bit 5 motor up to speed. Bit 2 must read clear.
04hOUTAuxiliary latch. Bit 1 is side select, and set selects side 0.
04hINDrive status. Bit 6 is a not-ready line and must read clear. Not a read-back of the latch.

Three of those entries are stated as “must read clear” rather than by meaning, and that is deliberate: each is a measurement of what happens when it is set, which is all the disks establish. CDISK01's seek wait is IN A,(34h) / BIT 2,A / JR NZ,<give up>, so a set bit 2 abandons every seek the driver makes; its drive wait spins on IN A,(04h) / AND 40h with no way out, so a set bit 6 hangs it. That last one is also what proves 04h is a status input and not a read-back — the driver writes values with bit 6 set and then waits for bit 6 to clear, which could never happen if a read returned what was written.

Drive select is one-hot, deduced rather than assumed: every boot sector writes 31h while reading the disk it was booted from, which is drive 0. Read the low nibble as a binary drive number and 31h names drive 1, which is empty — nothing would boot at all.

Where the boot sector goes, and how that was settled

0080h, entered at 0080h — not 0000h, where every other board here loads. Nothing in the sector announces this, so it was established sideways. Each of the three loaders reads two absolute bytes, LD A,(00FCh) and LD A,(00FAh), and compares each with 44h — ASCII D. Load the sector at 0080h and those two addresses land on its own bytes at sector offsets 7Ch and 7Ah, which on the two 8″ disks read LGSSDD and LGDSDD: LarGe, Single/Double Sided, Double Density. The flags then match all three disks' documented formats exactly. Load the sector anywhere else and both reads land on memory the loader never wrote.

Two further things fall into place at that address and nowhere else: the loader's own restart target resolves to the front of the sector, and CDISK01's exit branch resolves to 0100h, where it has just loaded its operating system. 0080h is also the CP/M default DMA address, which is presumably why the ROM used it.

Two densities on one disk

Track 0 of a Cromemco double-density floppy is recorded single-density, so that a single-density boot ROM can read it at all; everything after it is double-density. The loaders say so directly — each reads one track, and only then does SET 6,D, setting the density bit in the value it writes to the control port from then on.

The arithmetic agrees exactly, which is what makes this measured rather than reasoned: 3,328 + 76 × 8,192 = 625,920, the length of CDISK02; and 3,328 + 153 × 8,192 = 1,256,704, the length of CDISK03. Both directories then begin at 11,520 — the third track — which is where two reserved tracks put them. A sector is 512 bytes, 16 to a track on those tracks: CDISK03's BIOS says so in its SETSEC, which stores the CP/M record number and then does SRL A twice, and four 128-byte records to a sector is a 512-byte sector.

The control port's density bit is latched but not obeyed. On real hardware, selecting the wrong data separator means the ID fields cannot be read at all; here the medium is authoritative, because the geometry table knows which tracks of which disk are recorded which way. A driver that asks for the wrong one is answered correctly rather than with an error — more forgiving than the board and never less, the same direction the Tarbell's WAIT port and the 88-HDSK's status flags chose.

A read that had already answered

The one real defect in the board itself, and it needed a real disk to find. These loaders read a whole track with a single command, and a multiple-record transfer fetches its next sector on the read that empties the previous one. That read has therefore already handed the guest a byte — the last of the sector that just finished — and the machine must not answer it again.

It did. The fetch and the re-read were the same code path as an ordinary sector fill, so the machine discarded that last byte and returned the first byte of the next sector instead: exactly one byte lost in every 128. That loads an operating system which is almost right and does nothing. The fix is a separate HostRequest::ReadAhead rather than a flag on the existing one, so that the two situations cannot be conflated again by accident — they are the same work and a different obligation.

The TU-ART console, and a console that was too fast

These machines find their console at 00h/01h on a Cromemco TU-ART: bit 6 RX available, bit 7 TX ready, both active-high. No existing convention here puts either bit in those positions, so it is a UART family of its own — and it was measured from two of the three disks independently. CDISK03's CP/M BIOS is CONOUT: IN A,(00h) / AND 80h / JR Z,<again> / LD A,C / OUT (01h),A; CDISK01's CDOS is CONST: IN A,(00h) / AND 40h / RET Z.

Then the console turned out to be too fast, and every board here shared the fault. CDOS 2.58 reads the data register twice per character — a lookahead that on a real serial line finds the wire still empty and costs nothing. Our console was a queue handed over as fast as the guest could ask for it, so the second read found the next keystroke every time and threw it away. DIR arrived as DR; a burst of ABCDEFGH came out as ACEG. Every other character, with the queue drained — which is what proves it is two reads rather than a lost byte.

A person typing never provokes it, so this survives any amount of interactive use. Pasting a command does, and so does anything that drives a guest from a script. The console now models a character time: a received byte is not readable until roughly a character's worth of instructions has passed, which is the only clock this machine has. The regression guard drives the ports directly rather than a disk, so it holds for every machine and needs no image.

Geometry, and what boots

Three media, and no two are the same length, so a file names its own geometry:

BytesLayoutSample
256,25677 cyl × 1 side × 26 × 128, single density throughoutCDISK01 — CDOS 2.58
625,92077 cyl × 1 side; track 0 26×128, then 16×512CDISK02 — MICAH 64K CP/M 2.2
1,256,70477 cyl × 2 sides; track 0 26×128, then 16×512CDISK03 — ITC 56K CP/M 2.2

Sides are interleaved by cylinder, not by half the disk: cylinder 1 side 0 is the third track in the file, and cylinder 0 side 1 is the second. That is the order the loaders read in, alternating the side bit between steps. Getting it wrong reads a plausible-looking wrong track rather than failing. A single-sided disk ignores the side bit entirely, whatever the latch says — the alternative is every track landing at double its real offset.

CDISK01 is the one that matters for identification: at 256,256 bytes it is indistinguishable by size from a Tarbell disk and a z80pack disk, so only its boot loader's own registers can name it. They do — all three disks are detected as cromemco with nothing configured.

Diagnostic: CPM_CROMEMCO_TRACE=1 prints every command with its drive, cylinder, side and sector, plus the control register decoded — including the guest's own view of the density, which the medium overrides, so a disagreement is exactly what a bring-up trace needs to show.

Which Machine — Where a Booted Disk Finds Its Console

A disk that loads its operating system perfectly and then goes silent is almost never a disk we cannot read. It is a guest printing to hardware that is not in the machine — and it will sit polling a keyboard port for ever, which looks exactly like a crash.

Every board above answers “what disk is this?”. The machine setting answers the other half: what does the guest print to, and type from? The two are genuinely separate, and the Tarbell images proved it. Three of the six load their operating system, read hundreds or thousands of sectors, and then go looking for a console somewhere we were not.

What the six Tarbell disks actually want

Measured by scanning every IN and OUT in each disk's installed system tracks — not from a BIOS listing sitting in a file on the disk, which is a trap these images set: they carry several BIOS variants for different consoles, and the one you happen to read is usually not the one that was installed.

ImageWhat it isConsole inputConsole outputState
TDISK01CP/M 1.310h/11hsameBootsTARBELL 62K CPM V1.3 OF 8-13-77
TDISK02CP/M 2.210h/11hsameBootsMicro Resources 62K CP/M Ver. 2.2 of 1/15/82, reads, writes, two drives
TDISK05CP/M 2.2 for VDM-104h/05h, ready-lowCALL 0C019h — CUTER ROMBootsTarbell 48K CPM 2.2, DIR, STAT
TDISK04CP/M 1.4 for VDM-104h/05h, ready-lowmemory-mapped VDM-1 at CC00BootsTARBELL 48K CPM V1.4 OF 2-15-78, DIR; its screen is in the web UI
TDISK03Comal 8000h/01hOUT 01hNot a Tarbell disk at all — see below
TDISK06VDM-1 programsCorrectly refused: 256,256 bytes of E5h, a blank

The ready bit is not a detail, and getting it backwards is worse than getting it missing. On the 04h/05h board a waiting key is signalled by bit 0 being clear. TDISK04's CONIN is IN 04h / ANI 01h / JNZ CONIN — it loops while the bit is set. Our idle bus reads FFh, so with no console fitted it waits for ever, which is correct behaviour. But read the polarity as active-high, and the guest would believe a key was waiting on every single poll and consume a stream of garbage — presenting as a corrupt disk rather than as a mis-set console. An earlier note in this project's own records had recorded this board as ACIA-convention, from the wrong branch of a listing on the disk. The installed code settled it.

The selectable machines

One config key, cpm_boot_machine, with its choices in one shared list (src/cpm/console.rs) so telnet, web and desktop cannot drift — the same arrangement cpm_emu_uart and the boot-image list already use. It applies only to a booted disk; our CP/M emulator has no console to place, because it services BDOS calls instead.

ValueMachineStatus convention
autoThe default. Read the board out of the disk's own boot code; fall back to altair_2sioWhatever the machine it picks uses
altair_2sioAltair 88-2SIO, 10h/11hwhat auto falls back to6850 ACIA: bit 0 RX, bit 1 TX, active high
altair_sioAltair 88-SIO, 00h/01hActive low
console_04Console at 04h/05hActive low — ready when bit 0 is clear
console_04_cuterAs above, printing through a CUTER ROMActive low; output is a CALL, not an OUT
z80packz80pack cpmsim — console at 00h/01h, and its own disk device in place of the Altair boardsWhole-byte: FFh means a character is waiting
cromemcoCromemco TU-ART at 00h/01h, with the 4FDC/16FDC in place of the Altair boardsBit 6 RX, bit 7 TX, active high

The fallback is not a preference — it is the machine this path has always been. Every Altair disk that boots does so because its console is at 10h/11h, so any other fallback would silence a working gateway on upgrade, with no error anywhere to explain it. That is also why auto may only ever add to what boots: it keeps the fallback whenever the disk does not say plainly, and the boot screen reports which of the two happened.

Why this is a setting and never a detection

Because guessing is the mistake this path has already made. The sector step was briefly autodetected by trying four candidates and keeping whichever printed something — and a wrong layout that scribbles at a console beats a right one still loading, so the wrong answer won for five disks. A console is worse, not better. TDISK04 sits on IN 04h for ever, but so would any program waiting on a keyboard that happens to be at 04h on some other machine, and there is no reply we could send that tells the two apart. The operator says which machine, once, and we are simply that machine.

Confirmation that the setting does something real: with console_04 selected, TDISK01 and TDISK02 stop booting — they are 88-2SIO machines — while TDISK05 starts. A setting that improved every disk at once would mean we had built a detector by accident.

The CUTER ROM, synthesised

TDISK05's BIOS assembles with VIDEO EQU TRUE and prints with one instruction: CALL 0C019h, having put the character in B and cleared A to select Processor Technology CUTER's output device 0. Its own source names the address — OUTADDR EQU 0C019H, commented “PUT OUTPUT ADDRESS HERE” — and its installed system tracks contain exactly one CALL into C0xx and no JMP at all. So that single entry point is the whole of what the disk needs.

We do not have CUTER, so the entry is synthesised — the same substitution this project already makes for boot PROMs it does not have, and documented rather than quiet. What makes it honest is that the guest cannot tell: these are real Z80 instructions at the real address, doing what the routine's caller is entitled to expect. Nothing traps; nothing in the port dispatch knows it exists.

AddressBytesInstructionWhy
C019hF5PUSH AFIts own BIOS source demands it: “ALL REGISTERS MUST BE SAVED AND RESTORED BY YOUR VIDEO DRIVER IN ORDER TO BE COMPATIABLE WITH CPM.”
C01Ah78MOV A,BCUTER takes the character in B
C01BhD3 05OUT (05h),AThe machine's own console data register — taken from the selected machine, not hardcoded
C01DhF1POP AFA stub that clobbered A would print a sign-on and then corrupt whatever the CCP was holding
C01EhC9RET

The ROM is laid down after the boot program, not before: a ROM is not memory a loader owns. Nothing in the sample set loads that high — TDISK05 is a 48 KB system, so C000 is above its memory top — but the failure this ordering prevents would present as a disk that signs on and then goes silent, which is the hardest kind of fault to attribute.

The mechanism is a list of placements rather than one stub, which is still the right shape but no longer for the reason given here. This paragraph used to say its next user would be Cromemco's 16FDC, cold-starting from a 4 KB ROM at C000 with a dozen distinct entry points called by CDISK03. That was wrong, and instructively so: CDISK03 loads its operating system across B380hCCFFh, so every one of those C0xx addresses is inside code the disk has just loaded itself, and there is no ROM in the address space by the time any of them is called. The Cromemco board needs no synthesised ROM at all. The entry points were read out of a listing; what the machine runs was never checked against them.

TDISK04 — the VDM-1, and where its screen went

Built. This section was written while the VDM-1 was deferred, and the reasoning below is kept because it is what settled the design. What changed is the last paragraph's conclusion: the screen is not repainted into an ANSI session. It is served by the web UI at /vdm, which needs no cursor addressing at all and works for a PETSCII C64 and an ASCII terminal alike — the person watching the screen need not even be the person typing at it. See the CP/M reference.

TDISK04 carries the complete driver on the disk, header and all: ; VDM DRIVER FOR CBIOS. 9-24-77 VERSION, VDMB EQU 0CC00H, VDMP EQU 0CCH, VDMD EQU 0C8H. The Processor Technology VDM-1 is a 1976 S-100 video card — 64 characters by 16 lines, memory-mapped. A character appears by being stored into memory at CC00CFFF; there is no data port at all. Port C8h is only its control register, selecting which line is displayed first, which is how the driver scrolls.

Neither TDISK04 nor TDISK05 ever writes a console character to any port — verified by scanning both disks' system tracks for OUT 05h, OUT 01h and OUT 11h: none appear. So with the right console fitted, TDISK04 takes keystrokes perfectly and the session it was started from stays blank for ever — there is no stream to show. Give it that console, run it, and its sign-on is sitting in screen memory, which is where the viewer reads it from:

--- VDM-1 screen at 0xcc00 ---
TARBELL 48K CPM V1.4 OF 2-15-78
VDM VERSION.
HOW MANY DISKS?

scroll=0x03, driven=true
pc=0xbed3   (its CONIN loop, waiting for a key)

That is a passing test (test_a_vdm_guest_writes_its_signon_into_screen_memory), and it now runs through the shipped path rather than reading the window itself: the machine publishes a snapshot, the screen registry hands it over and the renderer draws it — the same three steps the browser's poll takes. A test that sampled memory and then described what a display would do with it is how a plausible-but-wrong renderer survives.

What made it a real piece of work, not a fifth table entry: every console the gateway has is a byte stream. The VDM-1 is a grid you sample, so showing it needs cursor addressing. ANSI clients have that; a 40-column PETSCII C64 cannot show 64 columns at all, and an ASCII terminal has no cursor addressing whatsoever. A stream could be derived from the writes instead — the driver advances a cursor pointer, wraps at 64 and scrolls by moving the register — but that needs three heuristics to avoid garbage (the boot-time clear fills the whole window with blanks; the scroll moves no memory; the cursor cell is written as inverse video and is not output at all). Repaint is literal and cannot be wrong about the guest; derivation is an interpretation of what the guest meant.

So repaint it is — but into a browser rather than into the session, which dissolves the terminal problem instead of accepting it. The web UI already has a listener, credentials, a lockout map and a page style; a screen there costs two JSON routes and needs no cursor addressing from anybody. The cursor comes along free, because on this card the cursor is an inverse-video cell rather than an output character. The honest caveat is that this page authenticates the administrator while the person typing at the guest is on telnet or SSH; on a gateway whose web UI already shows the password and the API key that is not a new privilege, but it is a different sentence from “the operator sees their own screen”.

TDISK03 — not a Tarbell disk, and now its own machine

Built. The section below describes what this disk turned out to be; the device is now emulated as src/cpm/z80pack.rs, selected by the z80pack machine. TDISK03 reaches Comal 80's A> and lists its programs, and nine disks in z80pack's own cpmsim library boot too — CP/M 1.3, 1.4, a 1975 build, 2.2, 3.0, MP/M and UCSD p-System IV. It is the one device here that is derived rather than clean-room, for the reason given at the end of this section, and it carries its notice in THIRD-PARTY-NOTICES.md.

Its installed system tracks say 64K CP/M Vers. 2.2 (Z80 CBIOS V1.2 for Z80SIM, Copyright 1988-2007 by Udo Munk), and its boot sector is unambiguous:

c3 19 00        JMP  0019
"BOOT: error booting"
af              XRA  A
d3 0a           OUT  (0Ah)     ; drive
78 d3 0b        OUT  (0Bh)     ; track
79 d3 0c        OUT  (0Ch)     ; sector
7d d3 0f        OUT  (0Fh)     ; DMA address low
7c d3 10        OUT  (10h)     ; DMA address high
af d3 0d        OUT  (0Dh)     ; command 0 = read
db 0e           IN   (0Eh)     ; status, 0 = OK

That is z80pack's cpmsim simulated disk I/O, and its console is at 00h/01h with a convention of its own: CONST: IN A,(0) / RET returns the port straight to CP/M, so port 0 must read FFh or 00h as a whole byte rather than a bit. It shares the 256,256-byte 8″ format by coincidence, which is why the Tarbell claims it.

It is DMA, which no board here is: the sector lands directly in guest memory at an address the guest latched, with no data port. Our Controller trait cannot express that — HostRequest::Read fills the controller's own buffer — so it would need a new Dma variant alongside Fill. The board itself is the simplest here by a distance: no rotation, no status typing, no wait port, no latch polarity.

It is the one device here that is derived rather than clean-room, and the reason is evidence, not convenience. Every other board on this page has a manufacturer's manual — an independent authority describing bits no disk here exercises. This hardware never existed, so the simulator's own source is the only specification there is, and an implementation built from it is derived work. That is permissible (z80pack is MIT; MIT into GPL-3.0-or-later is the compatible direction) but it must be labelled as derived and carry its notice. It does: a static “Non-crate components” section in about.hbs, which is where such a notice has to live because THIRD-PARTY-NOTICES.md is generated from the Cargo dependency tree and a notice added to the output would be deleted by the next regeneration.

Two things the machine needed for it

A DMA request. Every other board fills its own buffer and lets the guest clock bytes out through a data register. This one has no data register: the guest latches a memory address, writes the command register, and the sector appears in its memory. So HostRequest gained a Dma variant — the only one that names an address in the guest's address space.

A console that blocks. Its CBIOS is CONIN: IN A,(1) / RET, with no status poll at all — it relies on the port to stall the processor until a character arrives, which is a design only a simulator can have. Answer such a read anyway and the CCP takes the byte as a keystroke: TDISK03 signed on perfectly and then printed NULs without end. We cannot stall a CPU, so the guest waits the other way round — if an instruction read an empty console, the program counter goes back to where it started and the read happens again. That is sound only because the instruction is a bare IN, which has no effect but the read; a block input (INI, INIR) would already have moved HL and B. A blocked read is also this machine's idle signal, and has to be: the driver paces on console-status polls, and this guest never makes one, so without that a session would sit at its prompt burning a core.

What Is Still Open
ItemState
Header bytes 3, 5 and 6 on Altair data tracks Unidentified, and now known not to matter to CP/M: a fresh FORMAT leaves all three at 0xE5 and the disk works. On shipped disks they hold leftovers from whatever wrote them. Writes preserve them either way, so their meaning has never had to be known.
1,113,536-byte Altair images Uniform 137-byte sectors with data at offset 3, unlike the split layout above. Framing is understood; the CP/M parameters are not yet confirmed against real content.
Altair mini-disk (76,800 bytes) Not a CP/M disk in the sets seen — Altair Mini-Disk BASIC and DOS. A case for booting, not mounting.
The MITS 88-DCDD manual The one on altairclone.com (Altair Floppy (88-DCDD).pdf, 249 scanned pages) is the assembly documentation — parts lists and schematics. Its own first page says the Theory of Operation is not in it, and its parts list ships that as a separate item, 101531. So it does not contain the register table, and it has no text layer to search. What would help: that Theory of Operation manual, or the Disk Extended BASIC manual, which documents the boot PROM and the sector format.
Controllers for Tarbell and Cromemco Both are done — see their sections above. The Tarbell 1011 boots, reads, writes across a reboot and copies between two drives, and its FD1771 sits in its own module, which is what made the Cromemco 4FDC/16FDC mostly ports and geometry. All three CDISK* images now boot, sign on and take a command, on three different operating systems — CDOS 2.58, MICAH 64K CP/M and ITC's CP/M. The auto-wait bit did carry the no-stall posture over as expected. Three other predictions recorded here were wrong — no ROM monitor at C000, no synthesised-ROM mechanism and no bank switching were needed; the boot-sector load address is 0080h; and what the board actually required instead was 512-byte sectors, mixed density on one disk, and a fix to multiple-record transfers. The refutations are written up in the section, because how they were arrived at is the reusable part.
The Processor Technology VDM-1 Done, after two deliberate deferrals — the 1 KB window at CC00 plus the C8h scroll register, rendered as a 64×16 grid in the web UI at /vdm. It is the first console here that is a screen rather than a byte stream, which is what made it real work: showing it needs cursor addressing, which is fine on ANSI, impossible on ASCII, and hopeless on a 40-column PETSCII client. The browser dissolves that question rather than answering it. Sampling cannot disturb the guest — it is a read of the guest's own RAM through its own MMU — so every booted session offers a screen, and only a session nobody is watching does no work at all. It buys TDISK04 and altairsim's cpm14-vdm; TDISK05 reaches its prompt through the CUTER stub without any VDM-1 at all, no Cromemco disk uses one (checked: zero OUT C8h across all three whole images), and DISK11 still stays dark because its VDM driver is in a CUTER ROM we do not have rather than on the disk.
The Cromemco bank select, and Cromix Built, and it is not enough on its own. Cromix — Cromemco's Unix-like operating system — boots on the 4FDC, asks for its root device and takes the answer, then needs memory banking: port 40h, a bitmap of eight 64 KB banks, one bit each. Clean-room from the 64KZ-II Instruction Manual (023-2020), which also settles the part no port map could: the card is two 32 KB blocks, each placeable in any combination of banks, so a Cromix machine makes 8000hFFFFh common to every bank by putting the system board's upper block in all of them. Without that, Cromix's bank-switch trampoline at FF90 fetches its next instruction from a blank bank and slides through 64 KB of NOP for ever — measured. 32 KB is the only granularity the hardware has, which is what ruled out a smaller common area the guest could not distinguish. It still stops at Unable to open console: its TU-ART is armed for interrupts and waits for one, and this emulator has never delivered an interrupt to any guest.
A z80pack cpmsim controller Done — see the section above. It reaches TDISK03 (Comal 80) plus nine disks in z80pack's own library, including MP/M and UCSD p-System IV. Two of its media are supported (the 241K 8″ and the 4 MB large disk); the library's two large images are tool disks rather than system disks, so they mount as a second drive rather than booting. It is the only device here that is derived rather than clean-room, and the only one whose machine carries a different set of boards.
88-HDSK corners — now closed, by asking the disks The three corners recorded here as “reasoned rather than run” turned out to be findable after all, because four of the hard disks carry the 88-HDSK source and a controller diagnostic. Read Status and Set Byte are implemented against that source, including the 256-byte IV store and the rule that Read Status refuses an unready unit; Format and Initialize exist at all, having previously decoded as Set Byte and silently succeeded; and per-unit head position has a test. Then the diagnostic on those same disks was actually run, which closed most of what was left: the IV bytes that carry the head position are modelled and ADEXER reports the cylinder correctly, its restore works, and a ninth command turned up. What stays open is now small and stated: the IV bytes have no hardware meanings beyond the position and restore lines, the cylinder strobe is not modelled (nothing needs it), the head count is still 2 (what the Datakeeper had, and what these images are), and no guest here reaches a second hard disk to try — stock Altair CP/M only addresses four drives at all.
Boot coverage as it stands 30 of the 34 images in the working set boot and sign on with nothing configured — the machine setting defaults to auto and the disk names its own board. By family: 19 of 22 DISK*; all 3 HDSK* here, and separately all eleven once the removable backup volume is included (the working set stops at HDSK03; HDSK05–0B had never been tried before and all reach 63K CP/M 2.2b); 5 of 6 TDISK* — the fifth being TDISK04, which signs on to a screen rather than to a port; and all 3 CDISK*. Separately, 9 of the disks in z80pack's own library boot. The four that do not are each accounted for, and none of them is still work — all four are correctly refused. TDISK04, which used to be the fifth and the only one that was work, waited on the VDM-1 and now has it, painting into the web UI rather than into the session. TDISK06 is a blank, and DISK0D, DISK0B and DISK0F are the data companions of disks that do boot, carrying no boot program at all. The last two of those used to load and go quiet, which read like a fault and was recorded here as the one open item in this row; they were in fact being runDISK0B executing its own volume label as instructions — and are now refused with the reason. Between them these reach CP/M 1.3, 1.4, 1975, 2.2 and 3.0, MP/M, UCSD p-System IV, CDOS, Altair DOS, Disk Extended BASIC, Time Sharing BASIC and Hard Disk BASIC.

The rule this page is really about. A check that can only score a hypothesis will stall at “nearly right” forever. Find an oracle that answers exactly — here, the disk's own operating system — and the answer arrives in one run.