The drive is not a USB stick

Rusty Nail loads games four ways today: over WiFi, over ethernet, from a flash chip inside a Game Boy cartridge shell, and from a couple of blobs baked into the firmware. The tour post called that "one loader, many sources", and the design has always had an empty fifth slot pencilled in: removable media. Today I filled the pencil in. The fifth source is going to be a 1.44MB floppy disk in a USB floppy drive hanging off the Nucleo's user USB port, and before writing a line of firmware I wrote the whole implementation plan.

A planning day makes a quieter devlog than a bring-up day, but this one earned its post, because the research kept turning up the same message: a USB floppy drive is not a USB stick that happens to be slow. It is a museum piece wearing a USB connector, and it speaks dialects that modern USB stacks have quietly stopped understanding.

Four dialects of 1998

Plug in a modern thumb drive and the interface descriptor reads class 0x08 (mass storage), subclass 0x06 (transparent SCSI), protocol 0x50 (Bulk-Only Transport). Every stack on earth speaks that. A USB floppy drive also says class 0x08, and then it gets creative:

The four subclass/protocol combinations a USB floppy can report, and what each one means

The subclass 0x04 rows are UFI, a command set defined in its own spec just for USB floppies: every command is padded to exactly 12 bytes, there is no 6-byte MODE SENSE, and geometry comes from READ FORMAT CAPACITIES because READ CAPACITY cannot be trusted while media is changing. The protocol 0x00/0x01 rows are CBI (control/bulk/ interrupt): commands do not travel in the bulk pipe at all, they go out as class control transfers, and completion status comes back on an interrupt endpoint. Unless the drive is protocol 0x01 - plain CB - in which case there is no status phase at all, and you infer success from the data phase and a follow-up REQUEST SENSE.

That last row is not a corner case. The Linux kernel's unusual_devs.h quirk table is twenty-five years of accumulated field truth, and it force-flags the TEAC FD-05PUB to CB, and early-firmware Y-E Data Flashbuster-U units (the OEM mechanism inside most Sony, IBM, HP and NEC branded drives) to CB as well. The drives most likely to be sitting in a drawer are the ones speaking the dialect with no status phase.

And here is the kicker: nobody current supports it. TinyUSB's MSC host class is BOT-only, with an open discussion where a TEAC floppy fails to mount for exactly this reason. ST's Cube USB host middleware implements BOT, full stop. Even the brand-new embassy MSC host class matches subclass 0x06 protocol 0x50 and nothing else. Whatever stack I stand on, the floppy transport layer is mine to write. So the plan writes it properly: a new fcusb crate with BOT and CBI/CB state machines behind one trait, host-tested against scripted mock pipes, with the UFI command builders golden-tested byte for byte.

A host stack that did not exist in March

The firmware pins embassy-stm32 0.6.0, and 0.6.0 has no USB host mode at all - the OTG peripheral driver exposes device constructors only. I went digging upstream and found the situation had changed under my feet: embassy's git main now carries a genuine host stack - controller and pipe traits, a DWC2 host driver for exactly this Synopsys OTG core, an embassy-stm32 HostDriver::new_fs_host() behind a usb-host feature, and a released embassy-usb-host crate with enumeration and class drivers, published this May. None of it is in a crates.io embassy-stm32 release yet.

That set up the one real architecture decision of the day. Migrating the whole firmware to embassy git main buys the host driver at the cost of API churn across a 13,700-line main.rs and a full hardware re-verification of every subsystem that already works: the HDMI link, the audio path, ethernet, the cartridge slot. The console is a working machine; I am not re-soaking all of it to read a floppy. So the plan vendors instead: the upstream host driver files come into the firmware at one recorded git revision, the rest of the firmware stays untouched on 0.6.0, and when embassy ships a release with the usb-host feature the vendored copy gets deleted in a contained migration.

One genuine freebie surfaced while reading the reference manual: the H7's OTG_FS core is the slave-mode DWC2 variant. No bus-master DMA - the CPU copies every packet out of a FIFO. On this chip, where the D-cache has made every other DMA peripheral earn an MPU region and a 16KB-aligned linker section, the USB host needs none of it. The entire cache-coherency section of the plan is one sentence: there is nothing to be coherent with.

FAT12 by definition

The disk itself is FAT12, and not by convention - by definition. FAT type is determined by the count of data clusters, nothing else, and the determination is four lines:

// FAT type is decided by the data-cluster count, nothing else.
let root_dir_sectors = (root_entries * 32).div_ceil(512);
let first_data = reserved + num_fats * fat_size + root_dir_sectors;
let cluster_count = (total_sectors - first_data) / sectors_per_cluster;
let is_fat12 = (1..4085).contains(&cluster_count);

Run the standard 1.44MB geometry through it - 2880 total sectors, 1 reserved, two 9-sector FATs, 224 root entries, 1 sector per cluster - and you get 2880 - 1 - 18 - 14 = 2847 clusters, comfortably under the 4085 line: FAT12, always, for any floppy that fits in the drive. Which is awkward, because the embedded Rust filesystem crate everyone reaches for, embedded-sdmmc, supports FAT16 and FAT32 only, and the alternative, rust-fatfs, keeps its clean no_std story in an unreleased 0.4 line and always compiles its write machinery. For a read-only parser of a forty-year-old frozen format, the plan does what this project always does with storage formats: a bespoke crate, fcfat, around 750 lines, tested on the host against generated disk images.

The 12-bit entries are the only fiddly part: FAT12 packs two entries into every three bytes, so the unpack splits on the parity of the cluster number:

/// Two 12-bit entries per three bytes: entry n starts at n + n/2,
/// even entries take the low 12 bits, odd entries the high 12.
fn fat12_entry(fat: &[u8], n: usize) -> u16 {
    let off = n + n / 2;
    let pair = u16::from_le_bytes([fat[off], fat[off + 1]]);
    if n % 2 == 0 { pair & 0x0fff } else { pair >> 4 }
}

A file is the chain you get by feeding each cluster back through that function until an end-of-chain marker. On a yanked or hand-mangled disk the chain can loop, and the classic defence is a visited bitmap. The plan spends a counter instead of a bitmap: the volume only has cluster_count clusters, so any walk longer than that has necessarily revisited one -

let mut steps = 0;
while cluster < 0xff8 {
    steps += 1;
    if steps > cluster_count {
        return Err(ChainIssue::Cycle); // pigeonhole: must have looped
    }
    cluster = fat12_entry(fat, cluster as usize) as u32;
}
  • which detects every possible cycle in constant memory, the right shape for a firmware crate whose entire mount state is 1KB (one cached FAT sector plus one scratch sector).

The other planned throughput trick is run coalescing. Floppies written by a desktop are almost never fragmented, so instead of issuing 1024 single-sector reads for a 512KB file, the reader extends each read over the maximal contiguous run: consecutive clusters collapse into one multi-sector USB transaction. On a full-speed bus where every transaction costs real time, that is the difference between the medium being the bottleneck (fine) and the protocol being the bottleneck (embarrassing).

Source number five

Everything above the filesystem already exists, which is the pay-off of the one-loader design. The plan's stack, colour-coded by how much of it is actually new:

The planned layer stack from the launcher down to CN13, coloured by new, vendored, reused and hardware

A floppy game is discovered by extension (.p8, .p8.png, .gb, .gbc, .fcb - the same classifier every other source uses), copied into the same staging buffer a WiFi download lands in, CRC-verified with the same checksum, and launched through the same hot-swap signal. Game Boy ROMs stream into the same internal flash bank the ethernet installer already uses. The launcher gets a FloppySlot state that is a deliberate clone of the cartridge slot's, generation counter and all:

pub enum FloppySlot {
    NoDrive,             // nothing on the port
    NoDisk,              // drive present, no disk - a POSITIVE
                         // observation from the drive's sense data,
                         // never an inferred failure
    Reading,             // mounting + scanning
    Ready { count: u8 }, // games found on the disk
    Unsupported,         // readable, but not ours (MBR, FAT16...)
    Unreadable,          // IO failed during mount or scan
}

The disk is never read during play. Mount, scan, copy, verify, launch from the copy - after that the floppy can come out mid-game and nothing happens, which neatly deletes the whole "safe removal" problem for the read-only release. Load time is honest, though: the medium sustains 25-45KB/s on a good day, plus roughly 0.8 seconds per internal-flash sector erase for the big ROMs. A 32KB PICO-8 cart is a second or two; a full 1MB Game Boy Color ROM is a 20 to 35 second install plus eight erase stalls. The progress bar will be doing real work.

The 500 milliamp problem

The hardware chapter of the plan has one number circled: the Nucleo sources CN13's VBUS through an STMPS2151 switch rated at 500mA, and a floppy's spindle-plus-stepper spin-up transient brushes right up against that. The board manual also moved the switch's enable pin between board revisions (PD10 on this one, PG6 on the older sibling), which is exactly the kind of trap that eats an evening if you trust an example project instead of the schematic.

The bring-up answer is already in a drawer: a powered OTG Y-cable.

The Y-cable power topology: the charger feeds the drive, CN13 carries data only, the board's VBUS switch stays off

The drive drinks from a wall charger, the Nucleo only carries D+/D-, and the board's own VBUS switch stays off so two supplies never fight over the same rail. The only homework is a multimeter pass over the specific cable first, because cheap Y-cables differ on whether the charger's 5V also reaches the micro-B plug.

The probe comes first

The plan's first firmware milestone deliberately does almost nothing. No FAT, no sector reads, no transport - just: power the port, enumerate whatever appears, and print the truth:

INFO  floppy: VBUS on (PD10), settling
INFO  floppy: attach detected, port reset (FS)
INFO  floppy: dev 057B:0000 rev 01.44  ep0 mps=8
INFO  floppy: mfr="Y-E DATA"  prod="USB-FDU"  ser="-"
INFO  floppy: if0 class=08 sub=04(UFI) proto=00(CBI)
INFO  floppy: eps: bulk-in 81 mps=64, bulk-out 02 mps=64, int-in 83 mps=2 int=32ms
INFO  floppy: transport = CBI/UFI (interrupt completion)

That last line is the whole point. Every hard decision downstream - which transport state machine to build first, which quirks to encode, whether the drive in my hand is a BOT pussycat or a CB museum piece - is answered by one enumeration of one real device. Until the probe runs, everything in the plan about transports is a well-researched guess; after it runs, it is a work order.

Next post on this thread should have a real drive's descriptors in it.