Disk Format Reference
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 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:
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.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:
JMP WBOOT at 0000, so the word at
0001 is BIOS+3 — that names the BIOS.SELDSK (BIOS+27) with the drive in C. It returns the
address of the disk parameter header in HL.
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.
Token altair8. 337,568 bytes: 77 tracks × 32 sectors × 137 bytes. The DISKnn.DSK images in the Altair-Duino / altairclone sets.
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.
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.
| Byte | Tracks 0–5 (boot format) | Tracks 6–76 (data format) |
|---|---|---|
0 | track number + 0x80 | track number + 0x80 |
1 | 0x00 | sector ID — see below |
2 | 0x01 | 0x01 |
3 | first data byte | enters the checksum |
4 | data | checksum |
5–6 | data | enter the checksum |
7… | data | first data byte |
131 | 0xFF stop byte | data |
132 | checksum | data |
135 | 0x00 | 0xFF stop byte |
136 | 0x00 | 0x00 |
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.
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.
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.
| Region | Data at | Check byte | Covers |
|---|---|---|---|
| tracks 0–5 | offset 3 | byte 132 | the 128 data bytes |
| tracks 6–76 | offset 7 | byte 4 | the 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.
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.
cpmtools Cannot Write These DisksThe 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.
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.
| Byte | Tracks 0–5 | Tracks 6–76 |
|---|---|---|
0 | track + 0x80 | track + 0x80 |
1 | 0x00 | sector ID, in the shifted order |
2 | 0x01 | 0x01 |
3 | data (0xE5) | 0xE5 |
4 | data | checksum 0x30 |
5–6 | data | 0xE5 |
| data | 3–130, all 0xE5 | 7–134, all 0xE5 |
| stop | 131 = 0xFF | 135 = 0xFF |
| check | 132 = 0x80 | (byte 4, above) |
| tail | 133–136 = 0x00 | 136 = 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 | How |
|---|---|
| Telnet / SSH | Settings → CP/M Emulator → I → N |
| Web | AI, Browser, Weather & CP/M → More → Mount CP/M drives → New blank disk |
| Desktop | Same group → More → Mount CP/M Drives → New 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.
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.
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.
| Port | Direction | Meaning |
|---|---|---|
08h | OUT | Drive select. Bit 7 deselects/clears; bits 0–3 pick the drive. |
08h | IN | Status — 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. |
09h | OUT | Drive 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. |
09h | IN | Sector position. Bits 1–5 are the sector number × 2; bit 0 is “sector true”, zero when that sector is under the head. |
0Ah | OUT | Write data. |
0Ah | IN | Read data. |
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.
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.
| Part | What it is |
|---|---|
| CPU | A 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. |
| Memory | A flat 64 KB. The guest's operating system owns all of it. |
| Console | An 88-2SIO at 10h/11h, bridged to the session both ways. |
| Sense switches | Port FFh, reading 0x00 — see the warning below. |
| Virtual modem | The second 88-2SIO port at 12h/13h, where a real Altair put its modem, when the selected profile fits. |
| Unknown ports | Read 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.
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.
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:
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.
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.
ibm3740256,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.
altairhd4,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
cromemcodd and cromemcodsdd625,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.
cromemcodd | cromemcodsdd | |
|---|---|---|
| Records per track | 64 | 128 (a cylinder) |
| Records per sector | 4 — a 512-byte sector | |
| Block size | 2,048 | |
| Directory entries | 128 | 256 |
| Reserved | 2 tracks | 1 cylinder |
| Data area | 254 blocks (520,192) | 608 blocks (1,245,184) |
| Sector translation | interleave, 16 entries | interleave 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.
z80packhd4,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.
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.
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.
| Situation | What happens |
|---|---|
| Two sessions boot the same image | The 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 running | Refused by name. |
| Mounting one image on two drives | Refused, naming the drive that already has it. |
| Booting while your disks are mounted | Each 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 in | Refused. 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 lent | Reads 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.
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.
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.
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.
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.
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 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.
| Octal | Hex | Dir | What |
|---|---|---|---|
160 | A0 | IN | Controller Ready. Bit 7 high when the controller has finished. Cleared by reading 161. |
161 | A1 | IN | Command status — the error byte. Reading it clears the ready flag at 160. |
162 | A2 | IN | Command acknowledged. Bit 7 high when the controller has taken the command. Cleared by writing 163. |
163 | A3 | OUT | Command byte — writing it starts the command. (Reading it clears the acknowledge flag at 162.) |
164 | A4 | IN | Read data available. Bit 7 high when a byte is waiting at 165. Cleared by reading 165. |
165 | A5 | IN | Primary data in — read-buffer data and status data. |
166 | A6 | IN | Write data accepted. Bit 7 high when the controller will take a byte at 167. Cleared by writing 167. |
167 | A7 | OUT | Data 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.
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.
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.
| Command | High byte | Bits |
|---|---|---|
| Seek to cylinder | CSEEK 00h | 0–8 cylinder (0–405), 9 unused, 10–11 unit, 12–15 zero |
| Write sector | CWRSEC 20h | as read sector — see the deviation below |
| Read sector | CRDSEC 30h | 0–4 sector (0–23), 5–7 head, 8–9 buffer, 10–11 unit |
| Write buffer | CWRBUF 40h | as read buffer |
| Read buffer | CRDBUF 50h | 0–7 length, 8–9 buffer |
| Read status | CRSTAT 60h | 10–11 unit, plus an IV byte address 0–255 in the low byte |
| Set byte | CSETIV 80h | an IV byte address in the low byte; the data follows at 167 |
| Read unformatted sector | CRUSEC A0h | as read sector, without checking the header — also the write-protect probe |
| Format | CFORMT C0h | unit in high-byte bits 2–3; low byte 7–6 platter, 5 side |
| Initialize | CINIT 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.
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.
| Bit | Set means |
|---|---|
| 0 | Drive not ready |
| 1 | Illegal sector |
| 2 | CRC error in sector read |
| 3 | CRC error in header read |
| 4 | Header has wrong sector |
| 5 | Header has wrong cylinder |
| 6 | Header has wrong head |
| 7 | Write protect |
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 source | Meaning |
|---|---|
CSEEK equ 00h, CRDSEC equ 30h | Confirms the command nibbles: seek is 0000, read sector is 0011. |
CSIDE equ 020h, CFPLTR equ 0C0h, CUNIT equ 00Ch | Bit 5 is side, bits 6–7 are platter, and the unit mask sits in the high byte. |
STRTSEC equ 2 | CP/M starts at sector 2; sector 0 is the volume label and sector 1 is spare. |
SECTCNT equ (CPMLEN/256)-1 | How many sectors the system occupies, derived rather than fixed. |
HD0SKEW equ 1, HD1SKEW equ 13 | There 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 D7 → LXI SP,0D700h, so
CCPBASE is D700h on this 63 K system, then
F3 DI, and shortly after
DB FF — IN 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 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.
| Image | Label text | [0x28] sector | [0x2A] count | First bytes there |
|---|---|---|---|---|
| HDSK03, HDSK04 | 63k CP/M 2.2b | 7 | 1 | 31 00 D7 F3 — LXI SP,0D700h then DI |
| HDSK01 | 300-5-A HDSK BASIC | 24 | 124 | F3 C3 40 00 — DI then JMP 0040h |
| HDSK02 | 300-5-A HDSK BASIC | 24 | 122 | F3 C3 92 75 — DI 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.
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.
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.
| Command | Manual | Observed |
|---|---|---|
| Read Sector | 0011 | 0011 — agrees |
| Write Sector | 0001 | 0010 |
| Read Buffer | 0101 | 0101 — agrees |
| Write Buffer | 0100 | 0100 — 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.
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.
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.
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 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:
| Bit | Status (IN, port 10₃) | Control (OUT, port 11₃) |
|---|---|---|
| D0 | ENWD — enter new write data | Step IN |
| D1 | Move Head — head movement allowed | Step OUT |
| D2 | HS — head status; also enables the sector channel | Head Load |
| D3 | not used | Head Unload |
| D4 | not used | Interrupt Enable |
| D5 | INTE — interrupt enabled | Interrupt Disable |
| D6 | TRACK 0 | HCS — head current switch |
| D7 | NRDA — new read data available | Write 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:
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:
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.
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:
0110
and Set Byte's address in bits 0–7. Both had been
implemented from the disks' source first.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.)
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.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.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.
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.
| ADEXER | Exercises | Result |
|---|---|---|
IC | Initialize (E0h) | “Resetting Controller” |
IW 34 55 / IR 34 | Set Byte + Read Status round trip | “IV Byte 34 = 55h” |
TB | all four controller buffers | “Buffers OK” |
SK / SR | Seek, Read Sector, Read Buffer | reports the cylinder it was sent to |
SW + CC | Write Sector, read back, compare | “Mismatches: 0” |
SB | Read Unformatted Sector (A0h) | reads; reports write protection |
RE | restore through the IV positioner | heads return to cylinder 0 |
FS | Format (C0h) | one surface erased, the other untouched |
It found three things no test here could have.
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.”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.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.
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:
70xx as a Read Status
where we took it as nothing.
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.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.
255. Get this
wrong and every seek goes to the complement of where it
was asked to go.
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 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.
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.
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.
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 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 B380h–CCFFh,
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.
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.
| Port | Direction | Meaning |
|---|---|---|
30h–33h | both | The FD1771's own four registers — command/status, track, sector, data. |
34h | OUT | Disk control. Bits 0–3 drive select, one-hot; bit 6 double density; bit 7 enable the auto-wait. |
34h | IN | Disk status. Bit 0 INTRQ, bit 1 DRQ, bit 5 motor up to speed. Bit 2 must read clear. |
04h | OUT | Auxiliary latch. Bit 1 is side select, and set selects side 0. |
04h | IN | Drive 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.
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.
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.
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.
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.
Three media, and no two are the same length, so a file names its own geometry:
| Bytes | Layout | Sample |
|---|---|---|
| 256,256 | 77 cyl × 1 side × 26 × 128, single density throughout | CDISK01 — CDOS 2.58 |
| 625,920 | 77 cyl × 1 side; track 0 26×128, then 16×512 | CDISK02 — MICAH 64K CP/M 2.2 |
| 1,256,704 | 77 cyl × 2 sides; track 0 26×128, then 16×512 | CDISK03 — 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.
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.
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.
| Image | What it is | Console input | Console output | State |
|---|---|---|---|---|
TDISK01 | CP/M 1.3 | 10h/11h | same | Boots — TARBELL 62K CPM V1.3 OF 8-13-77 |
TDISK02 | CP/M 2.2 | 10h/11h | same | Boots — Micro Resources 62K CP/M Ver. 2.2 of 1/15/82, reads, writes, two drives |
TDISK05 | CP/M 2.2 for VDM-1 | 04h/05h, ready-low | CALL 0C019h — CUTER ROM | Boots — Tarbell 48K CPM 2.2, DIR, STAT |
TDISK04 | CP/M 1.4 for VDM-1 | 04h/05h, ready-low | memory-mapped VDM-1 at CC00 | Boots — TARBELL 48K CPM V1.4 OF 2-15-78, DIR; its screen is in the web UI |
TDISK03 | Comal 80 | 00h/01h | OUT 01h | Not a Tarbell disk at all — see below |
TDISK06 | VDM-1 programs | — | — | Correctly 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.
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.
| Value | Machine | Status convention |
|---|---|---|
auto | The default. Read the board out of the disk's own boot code; fall back to altair_2sio | Whatever the machine it picks uses |
altair_2sio | Altair 88-2SIO, 10h/11h — what auto falls back to | 6850 ACIA: bit 0 RX, bit 1 TX, active high |
altair_sio | Altair 88-SIO, 00h/01h | Active low |
console_04 | Console at 04h/05h | Active low — ready when bit 0 is clear |
console_04_cuter | As above, printing through a CUTER ROM | Active low; output is a CALL, not an OUT |
z80pack | z80pack cpmsim — console at 00h/01h, and its own disk device in place of the Altair boards | Whole-byte: FFh means a character is waiting |
cromemco | Cromemco TU-ART at 00h/01h, with the 4FDC/16FDC in place of the Altair boards | Bit 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.
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.
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.
| Address | Bytes | Instruction | Why |
|---|---|---|---|
C019h | F5 | PUSH AF | Its own BIOS source demands it: “ALL REGISTERS MUST BE SAVED AND RESTORED BY YOUR VIDEO DRIVER IN ORDER TO BE COMPATIABLE WITH CPM.” |
C01Ah | 78 | MOV A,B | CUTER takes the character in B |
C01Bh | D3 05 | OUT (05h),A | The machine's own console data register — taken from the selected machine, not hardcoded |
C01Dh | F1 | POP AF | A stub that clobbered A would print a sign-on and then corrupt whatever the CCP was holding |
C01Eh | C9 | RET |
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 B380h–CCFFh, 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.
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 CC00–CFFF;
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”.
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.
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.
| Item | State |
|---|---|
| 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 8000h–FFFFh
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 run — DISK0B
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.