A USB 1.1 full-speed host for the Parallax Propeller 2 that runs in one cog, with compile-time-selectable class drivers (HID keyboard/mouse, serial — CDC-ACM plus FTDI/CP210x/PL2303 USB-serial bridges, MSC mass storage, USB Audio Class 1 playback + capture over isochronous endpoints) and two filesystems layered over MSC: a greenfield exFAT driver (read/write, subdirectories) and an adapted FAT32 driver. Supports devices attached directly or behind a USB hub, including multiple devices at once.
⚠ Project status: living code, no promises. This project is in active flux and changing rapidly, with no fixed roadmap and no declared end state — my own instruments are the roadmap. I (Shannon Mackey) use this driver extensively in real projects and continue to flush out issues on real hardware; interfaces move, behavior gets re-derived whenever the silicon disagrees with the documentation, and anything here may change without notice. Extensive regression harnesses live in
harness/and everything claimed below was validated on the bench when written — but I make no claims and offer no warranties of any kind: the MIT license's AS-IS terms (LICENSE) are the entirety of the promise. If it breaks on your bench, a field report with reproduction steps (seedocs/feedback/) is the most valuable thing you can send.
Everything documented here was validated on real hardware; see Validation status.
Hardware requirements
| Requirement | Value |
|---|---|
| System clock | Keep above 180 MHz. Characterized full-stack floor is 165 MHz (160 MHz is marginal - real media start failing bring-up intermittently; ≤ 150 MHz fails). The TX path self-adjusts its smart-pin gap around a ~168 MHz breakpoint; 180 MHz gives margin over both. No runtime check. Developed at 200 MHz; characterized 165-300 MHz. |
| USB data pins | D− and D+ on an adjacent even/odd pin pair, D− on the even pin (P_USB_PAIR smart-pin requirement). |
| VBUS | 5 V to the device/hub. An active-high enable pin (load switch) is supported but optional. |
| Devices | USB 1.1 full-speed (12 Mbit/s). Low-speed devices are reached through a hub's transaction translation only if the hub presents them as FS; not otherwise supported. An LS leaf behind a hub is now named explicitly in the log (leaf is LOW-SPEED, ticket 1210) instead of failing silently; LS host support is tracked as ticket 1190 (design: docs/design/low-speed-host.md). |
The P2 Eval "USB serial host" accessory header maps as: base+0 = LED, base+1 = EN (VBUS enable), base+2 = D−, base+3 = D+.
Architecture
top-level object (your code)
│
usb_app.spin2 ───────────── facade: open/close, class binding, kbd_* /
│ ser_* / disk_* APIs, fs_* filesystem facade
│ (format dispatch + exFAT/FAT32 auto-detect)
├── usb_hub_host.spin2 hub enumeration: addresses, port reset,
│ config-descriptor parsing for class endpoints;
│ one level of NESTED hub (1205) - combo
│ hub+reader units enumerate their subtree
├── usb_class_hid/cdc/msc.spin2 class protocol (reports, ACM, BOT/SCSI)
├── usb_class_ftdi.spin2 FTDI USB-serial bridges (FT232R/FT-X plus
│ H-series FT2232H/FT4232H/FT232H, multi-channel
│ aware); shares the ser_* API
├── usb_class_pl2303.spin2 Prolific PL2303 bridges (HX/HXD/TA/TB);
│ same ser_* API
├── usb_class_cp210x.spin2 SiLabs CP210x bridges; same ser_* API
├── usb_class_uac.spin2 USB Audio Class 1: descriptor parse (speaker
│ + mic ISO endpoints), SET_CUR rate, Feature-Unit
│ volume/mute; streaming itself is the PHY cog
│ servicing hub-RAM rings on every SOF (aud_* API)
├── usb_hid_report.spin2 HID report-descriptor parser (2150): turns
│ any gamepad/joystick's descriptor into a
│ control map at bind time - axes, hats, button
│ ranges with offsets/ranges (joy_* API)
├── usb_class_midi.spin2 USB-MIDI 1.0 host (2140): 32-bit event
│ packets over bulk endpoints, poll-once IN,
│ pack/decode helpers (midi_* API)
├── usb_ser_expect.spin2 expect-style scripted serial exchange
│ (send/expect with glob patterns + timeouts,
│ tokenizer) for instruments; binds any transport
│ via method pointers
├── usb_fat32_fs.spin2 FAT32 (adapted from IronSheep P2 SD driver, MIT);
│ cogless since 2088, 6 file handles
├── usb_fs_job.spin2 OPT-IN async job service: whole file operations
│ (write/copy/...) on one worker cog with
│ progress/cancel while your loop keeps running
├── usb_exfat_fs.spin2 exFAT (written for this project); no extra cog,
│ 4 file handles
└── usb_host.spin2 ──── the ONE USB cog: PHY (P_USB_PAIR smart pins),
bus reset, SOF, control/bulk/interrupt
transfers, CRC5/16, error recovery.
Commanded through a lock-guarded hub-RAM mailbox.
Key structural facts:
- One cog does all USB. Classes and filesystems are libraries running on the caller's cog; they talk to the USB cog through a mailbox in hub RAM guarded by a hardware lock, so any cog may call the APIs.
- Filesystems see a block device, not USB. Each FS driver is bound to
storage through two Spin2 method pointers with signature
(lba, nblocks, p_buf) : ok(ok ≠ 0 on success) plus a block count.usb_app.fs_mount()binds them todisk_read/disk_write(MSC READ(10)/ WRITE(10), 512-byte blocks). The same interface would accept any other 512-byte block device. - Both filesystems are cogless. The exFAT driver was written that way;
the FAT32 port's worker cog (inherited from its SD original, which needed
cog-local SPI pins) was retired in ticket 2088 - commands now dispatch
directly on the caller's cog (build with
-D SD_USE_WORKER_COGto restore the old mode and its native per-call async API). - Long operations without blocking (ticket 2088): the opt-in
usb_fs_jobservice runs whole jobs - write/append/read a file, copy, mkdir, delete - on one worker cog through the samefs_*facade, with live progress, prompt cancel (~0.1 s), and clean failure on hot detach (in-flight I/O aborts in milliseconds). Measured with an Alicat control loop at 100 ms cadence during a 192 KB SSD copy: successful exchanges kept a 30 ms round trip; the same work done synchronously broke the cadence on every chunk (290 ms slips). Data movement details: docs/dataflow.md. - Format auto-detect. With both FS gates compiled in,
fs_mount()probes for exFAT first and falls back to FAT32 only when the media is genuinely not exFAT.fs_kind()reports which driver owns the mount (1 = FAT32, 2 = exFAT); allfs_*calls dispatch on it.
Resource usage
| Resource | Amount |
|---|---|
| Cogs | 1 (USB host — including all isochronous audio streaming, which the PHY cog services on every SOF). Both filesystems are cogless (FAT32's legacy worker retired in 2088; -D SD_USE_WORKER_COG restores it). The optional async job service (usb_fs_job) adds 1 while started; a dedicated DSP cog for audio is the recommended application pattern, not a driver cost. |
| Locks | 1 (host mailbox) + 1 per filesystem driver in use. Allocated with LOCKNEW, returned on stop. Audio ring operations are lock-free by design. |
| Hub RAM, host | UH_TOTAL_BYTES (from usb_hub_ram.spin2, layout v7) for the mailbox and transfer buffers: 12,112 bytes with HID+MSC+CDC; USB_AUDIO adds 6,208 (ISO OUT ring 4 KB + 8×256 B IN slots + control) for 18,320 all-on; shrinks when classes are compiled out. |
| Hub RAM, exFAT | ≈ 2.5 KB (three 512-byte sector caches, 608-byte entry-set buffer, handle table). |
| Hub RAM, FAT32 | ≈ 5 KB (sector buffers and the handle table). The worker cog is off unless SD_USE_WORKER_COG is exported. |
| Flash/RAM, code | See Memory footprint by build — 19 KB (host core alone, 2026-08-12) to 98 KB (everything, 2026-10-06). |
| Smart pins | The D−/D+ pair. EN/LED are plain pins. |
Memory footprint by build
Total hub RAM occupied while running — code + all DAT (PHY engine, hub-RAM
workspace, filesystem sector buffers) plus VAR — measured with
pnut-ts -m (memory-map output) on a minimal one-object top
(harness/top_footprint.spin2; pnut-ts compiles every method of an
instantiated object, so nothing is stripped and these are honest maxima).
The P2 has 512 KB of hub RAM; even the everything-enabled build uses ~19 %.
| Build gates | Code+DAT | VAR | Total running |
|---|---|---|---|
| (none — host core, enumeration, hot-plug only) | 15,832 | 3,644 | 19,476 |
USB_HID |
17,212 | 4,956 | 22,168 |
USB_HID + USB_JOY (gamepad parse + mapping) |
19,484 | 5,352 | 24,836 |
USB_CDC (incl. FTDI/CP210x/PL2303 bridges) |
19,060 | 6,804 | 25,864 |
USB_MSC |
17,672 | 12,160 | 29,832 |
USB_AUDIO |
17,628 | 10,156 | 27,784 |
USB_MIDI |
16,532 | 3,948 | 20,480 |
USB_HID + USB_CDC + USB_MSC |
22,328 | 16,632 | 38,960 |
USB_MSC + USB_EXFAT |
39,028 | 13,376 | 52,404 |
USB_MSC + USB_FAT32 |
48,632 | 13,376 | 62,008 |
USB_MSC + USB_FAT32 + FAT32_NO_LFN |
46,352 | 13,376 | 59,728 |
USB_MSC + USB_EXFAT + USB_FAT32 |
63,240 | 13,380 | 76,620 |
USB_HID + USB_CDC + USB_MSC + USB_EXFAT |
45,204 | 18,348 | 63,552 |
USB_HID + USB_CDC + USB_MSC + USB_AUDIO |
24,160 | 23,144 | 47,304 |
...+ USB_EXFAT |
47,052 | 24,900 | 71,952 |
| Everything (all 6 classes + both filesystems) | 74,412 | 25,644 | 100,056 |
Bytes, pnut-ts v1.55.0. Rows with no filesystem were measured 2026-08-12
(after 2140 MIDI / 2150 gamepad / root hot-plug additions). exFAT-only rows
were remeasured 2026-10-05. Rows that include FAT32 were remeasured 2026-10-06
(ticket 6290). The default FAT32 image leaves out the SD SPI engine, which is
what dropped those rows; FAT32_NO_LFN is a further opt-out and long names
stay on unless it is set. Forcing SD_CARD_ENGINE on USB_MSC + USB_FAT32
measures 51,820 / 13,376 / 65,196. The everything row is USB_HID + USB_JOY +
USB_CDC + USB_MSC + USB_AUDIO + USB_MIDI + both filesystems;
harness/top_footprint.spin2 re-exports USB_JOY and USB_MIDI so those
gates reach the child objects. Not included: your application code
and stacks; the opt-in usb_ser_expect (≈ 2 KB) and usb_fs_job (≈ 3 KB
incl. its 400-long worker stack) objects, which cost nothing unless
instantiated. Compiling with -d adds only ~0.4–0.6 KB of code, but at
runtime DEBUG reserves the top 16 KB of hub RAM for the debugger —
budget for it in debug builds. Reproduce any row:
pnut-ts -q -m -I src -D USB_MSC -D USB_EXFAT harness/top_footprint.spin2
grep "Total Size" harness/top_footprint.map
Measured throughput (tickets 1170/6250, 2026-09-23/24; two FAT32 thumbs, one per Serial Host channel, content-verified):
| 300 MHz | 200 MHz | |
|---|---|---|
Raw reads, 32 KB commands (disk_set_max_chunk(32768)) |
≈ 1,066 KB/s | ≈ 1,004 KB/s |
| File reads, 32 KB+ calls (FAT32) | ≈ 1,066 KB/s | ≈ 1,001 KB/s |
| File reads, 4 KB calls / 512 B calls | ≈ 944 / 511 KB/s | ≈ 875 / 511 KB/s |
| File writes, 32 KB+ calls | 600–930 KB/s (the stick's own GC sets it) | 685–860 KB/s |
| Both buses interleaved, reads | ≈ 1,066 KB/s combined | ≈ 1,004 KB/s combined |
Full-speed bulk is 1,216 KB/s at best (19 × 64-byte packets per 1 ms frame); the frame guards leave about 16–17 packets per frame, so reads are at this engine's ceiling. Both buses share one PHY cog, so two drives share that rate rather than doubling it. Single-block latency ≈ 1 ms. Creating a directory (one cluster zero-fill on 128 KB-cluster media) ≈ 0.3 s. Full measured profile: docs/perf-1tb-ssd.md.
Building with pnut-ts
Class drivers and filesystems are selected with -D gates at compile time;
code for disabled gates is not compiled in.
| Gate | Enables | Requires |
|---|---|---|
USB_HID |
keyboard/mouse APIs (kbd_*, mouse_*) |
— |
USB_JOY |
gamepad/joystick (joy_*): report-descriptor parse, control introspection, mapping wizard primitive (awaiting hardware validation, ticket 2150) |
USB_HID |
USB_CDC |
serial APIs (ser_*): CDC-ACM gadgets and FTDI / CP210x / PL2303 USB-serial bridges, plus the expect layer |
— |
USB_MSC |
mass-storage APIs (disk_*) |
— |
USB_AUDIO |
UAC1 audio (aud_*): isochronous playback + capture, volume/mute |
— |
USB_MIDI |
USB-MIDI 1.0 host (midi_*): event read/write + decode helpers (awaiting hardware validation, ticket 2140) |
— |
USB_EXFAT |
exFAT filesystem (fs_*) — the primary/default filesystem |
USB_MSC |
USB_FAT32 |
FAT32 filesystem (fs_*). Long names are included |
USB_MSC |
FAT32_NO_LFN |
FAT32 8.3 names only. Create or rename of a longer name returns −95. Listings return the 8.3 alias | USB_FAT32 |
A filesystem gate without USB_MSC fails the build deliberately, with an
undefined symbol whose name states the constraint (pnut-ts has no #error
directive).
The FAT32 driver has its own size switches, exported the same way as the class
gates. Long names are on unless FAT32_NO_LFN is set. The SD SPI engine is
not on the USB mount path (initCard is a block-device stub) and is left out
of the default image. It is compiled when SD_INCLUDE_RAW,
SD_INCLUDE_REGISTERS, SD_INCLUDE_SPEED, or SD_INCLUDE_DEBUG is exported,
or when SD_CARD_ENGINE is exported on its own. SD_INCLUDE_SPEED requires
SD_INCLUDE_REGISTERS. Defrag, async I/O, and the stack check stay off unless
their own SD_INCLUDE_* / SD_USE_WORKER_COG symbols are exported.
# typical filesystem build (exFAT only)
pnut-ts -d -I <path-to>/src -D USB_MSC -D USB_EXFAT your_top.spin2
# everything, with FAT32 fallback auto-detect
pnut-ts -d -I <path-to>/src -D USB_HID -D USB_CDC -D USB_MSC -D USB_EXFAT -D USB_FAT32 your_top.spin2
# load and watch debug() output
pnut-term-ts --headless -r your_top.bin -p <PORT> -b 2000000 --end-marker "YOUR_MARKER"
-d compiles the debug() statements in; omit it for a silent build. Your
top file must re-export the gates to child objects:
#IFDEF USB_MSC
#PRAGMA EXPORTDEF USB_MSC
#ENDIF
' ...one block per gate you use
Do not default the gates on in your top file
A consumer of this driver lost a day to the obvious-looking version of that block:
' WRONG - this defeats every gate you have
#IFNDEF USB_MSC
#DEFINE USB_MSC
#ENDIF
#IFDEF USB_MSC
#PRAGMA EXPORTDEF USB_MSC
#ENDIF
The #IFNDEF/#DEFINE turns a gate the build left off back on, and the
EXPORTDEF then pushes it down here — so -D selection silently does nothing
and every combination produces a byte-identical image. It reads like a helpful
default, and it is invisible: the build succeeds, the driver works, and the
footprint never moves.
Two things make it hard to spot. It is easy to write the same block in more than one file, so removing it from one changes nothing and looks like proof that the gates are broken. And the symptom — a switch that has no effect — points at the driver rather than at the top file.
Export the gate, never define it. Let -D be the only source of truth, and
check with a byte comparison rather than by eye:
pnut-ts -q -I src -D USB_MSC -D USB_HID your_top.spin2 && md5sum your_top.bin
pnut-ts -q -I src -D USB_MSC your_top.spin2 && md5sum your_top.bin
# the two MUST differ
Integration: bring-up
One call does it (facade v2, ticket 0180):
OBJ
usb : "usb_app"
PUB main()
ifnot usb.begin(16) ' serial-host header base pin
debug(zstr_(usb.err_str()))
return
if usb.await_drive(15_000) ' storage there? (samples hub ports while waiting)
...usb.fs_open_read(...) ' filesystems mount on first use
if usb.await_serial(3000)
usb.ser_open(38400) ' CDC-ACM or any vendor bridge - same API
if usb.kbd_ready()
...usb.kbd_char()
begin() starts the host cog, runs a sampled reset→probe ladder (the
bus answering GET_DESCRIPTOR at address 0 is the readiness signal — no fixed
power dwells or boot allowances), keeps SOF running for the session,
enumerates the whole bus, and binds every compiled class: storage, serial
(first vendor bridge — FTDI, CP210x or PL2303 — if any, else first CDC-ACM), keyboard and mouse by HID
boot protocol. await_drive/serial/kbd(ms) sample hub port status while
waiting, so devices that assert connect seconds after power (SSD bridges:
6–9 s measured) still arrive; rescan() does one non-blocking pass for idle
loops. err_str() explains failures in words.
Hot-plug (insert / remove / rearrange) is first-class: put usb.poll()
in your idle loop (internally rate-limited — free between samples) and
optionally bind usb.on_change(@handler) to be told about every
EV_ATTACH/EV_DETACH after the driver has reacted — bindings dropped on
removal (a mounted filesystem is abandoned with zero I/O to the dead
device), new devices enumerated and bound on arrival. Coverage includes the
root device of each bus (2026-08-12 field report): a yanked hub tears
down its whole subtree, and a re-inserted root — hub or direct device — is
recovered by poll() alone, without begin() and without touching the
other bus. The full usage guide — handler rules, per-class detach/re-attach
semantics, waiting styles, troubleshooting — is
docs/hotplug.md;
harness/top_hotplug.spin2 validates the whole cycle live, including a
programmatic port-power detach of the mounted SSD and remount after restore;
harness/top_rootplug.spin2 validates root death/recovery via EN-gated hub
power.
Getting started, per class
One clean, copy-paste guide per feature — what to buy, the build line, the smallest complete program (every one compile-verified verbatim), what bring-up actually does step by step, and a troubleshooting table:
| Guide | You get | Status |
|---|---|---|
| Gamepad / joystick | any pad's buttons/sticks/hats discovered by descriptor, plus the press-the-control-you-want mapping wizard | awaiting hw (2150) |
| USB-MIDI | note/CC/bend events from any class-compliant controller, decoded; optional synth | awaiting hw (2140) |
| Audio | 48/44.1 kHz playback + microphone through a $10 UAC1 adapter, fed from a ring the USB cog services itself | validated |
| Keyboard & mouse | boot-protocol keys/ASCII and mouse deltas, composite receivers included | validated |
| Serial | one API over FTDI/PL2303/CP210x/CDC + the expect engine for instruments | validated |
| Storage & files | files on exFAT/FAT32 media, mount-on-first-use, hot-plug-safe | validated |
| Recovering a wedged drive | telling a hub-disabled port from a wedged device, the reset/re-bind ladder, opt-in auto-recovery, and proving a drive really came back | validated |
All of them share the same skeleton: usb.begin(16) → an await/poll for
the class → use it → usb.poll() in the idle loop for hot-plug.
How bytes actually move — the buffer inventory, the consumer-pull model, and where backpressure is absorbed at every layer — is documented in docs/dataflow.md.
Every timing constant behind this — what it paces, its spec citation or
measurement, and the former blind waits it replaced — is tabulated in the
timing constants ledger of
docs/api/usb-app-api.md, the full API manual.
The pre-v2 manual sequence (open() / probe loops / parse_classes /
bind_* / disk_open / fs_mount) remains available as the advanced layer
for exotic topologies and is documented there too.
Filesystem API (usb_app facade)
| Call | Notes |
|---|---|
fs_mount() : st |
0 on success. Auto-detects format. |
fs_unmount() : st |
Flushes and clears the volume-dirty flag. |
fs_kind() : k |
0 none, 1 FAT32, 2 exFAT. |
fs_vol_label() : p |
Zero-terminated string pointer. |
fs_open_read(path) : h fs_open_write(path) : h fs_create(path) : h |
Handle ≥ 0 or negative error. fs_open_write positions at end (append); seek to overwrite. fs_create errors if the file exists. |
fs_read(h, buf, n) : n fs_write(h, buf, n) : n |
Bytes moved (0 at EOF), negative on error. |
fs_seek(h, pos) : st fs_file_size(h) : n |
Seeking past EOF is refused (−115) on both filesystems — enforced at the facade (6170). |
fs_close(h) : st |
Flushes directory metadata for written files. |
fs_delete(path) : st |
Files, or empty directories (else −96). |
fs_mkdir(path) : st |
Parent must exist. |
fs_rename(old, new) : st |
Rename in place on both filesystems; the final component of new becomes the name in old's directory. |
fs_move(path, destdir) : st |
Move a file (or directory) into another directory, both filesystems. |
fs_max_handles() : n |
Concurrent open handles (files + directory iterations share the pool). Both drivers: 6. |
drive_bind_second(da) : ok fs_mount_second_exfat() : st fs_unmount_second() |
Two volumes at once (6140): bind a second MSC drive and mount the exFAT driver on it while the facade keeps serving its primary (typically FAT32) volume. Drive the second volume through the exFAT driver object (it's a DAT singleton). One FAT32 + one exFAT is the supported pair; a BOT-operation lock makes two-cog I/O to both volumes safe. |
fs_sync() : st |
Flush all open write handles. |
fs_dir_open(path) : h fs_dir_next(h) : p fs_dir_close(h) |
Iterate a directory; after each fs_dir_next, read fs_entry_name()/fs_entry_size()/fs_entry_attr() (attr bit 4 = directory). |
fs_time_source(mp) |
Bind an RTC; see below. |
Paths use / separators on both filesystems, absolute or relative to the
per-cog working directory (fs_chdir; root after mount). On exFAT the cwd
lives at the facade (the driver resolves full paths); on FAT32 it is the
adapted driver's own per-cog cwd, with long names (see below).
Serial instruments: the expect layer
Most lab instruments (mass-flow controllers, meters, pumps) speak short
line-oriented ASCII over a USB-serial cable — and most of those cables are
vendor-bridge silicon (FTDI, Prolific PL2303, SiLabs CP210x), not CDC-ACM.
This stack handles all of them behind one API, and
usb_ser_expect gives you tcl-expect-style scripted exchanges on top:
OBJ
usb : "usb_app"
exp : "usb_ser_expect"
usb.begin(16)
usb.await_serial(3000) ' any bridge or CDC - bound either way
usb.ser_open(38400)
exp.bind(@ser_wr, @ser_rd) ' one-line shims (manual §4)
exp.bind_baud(@ser_baud)
if exp.autobaud(string("A"), string("A*"), 0, 0) ' condition-driven rate lock
if exp.chat(string("A"), string("A*"), 0) ' 0 = default timeout
flow_milli := exp.token_milli(2) ' "+0.512" -> 512
gas := exp.token(6) ' "Air"
The engine is generic — it knows lines, glob patterns, timeouts and
tokens; your application supplies the commands, so SCPI instruments
(set_delims(string(",")), LF terminators), AT modems (expect_any over
OK/ERROR* branches), and Alicat controllers all script the same way.
Verbs: sendline / readline / expect / expect_any / chat /
drain() (ends on a sampled quiet line, never a fixed delay) / autobaud
(retries only on evidence of garbled traffic, never a blind repeat).
Dissection: ntokens(), token(i), is_num(i), token_int(i),
token_milli(i) (fixed-point → integer milli-units; no floats),
unparsed() for partial replies in error reports. Full contract, dialect
knobs and worked SCPI/AT shapes:
docs/api/usb-app-api.md.
harness/top_alicat_expect.spin2 is the worked example, validated against an
Alicat BASIS mass-flow controller: device discovery (FTDI vs CDC), autobaud
probing, frame tokenization, a multi-line info dump, and a setpoint
write/readback/zero cycle that identifies the setpoint column by diffing
frames rather than trusting a hardcoded layout. top_serial_discover.spin2
dumps every device's interfaces/endpoints when you need to identify a new
cable.
Bridge specifics handled for you — FTDI (usb_class_ftdi.spin2): vendor
bring-up (reset/purge, 8N1, no flow control, DTR/RTS), divisor baud encoding
with chip-generation detect from bcdDevice (48 MHz-base classic parts,
120 MHz-base H-series), 4 ms latency timer, the 2 status bytes FTDI prefixes
to every bulk-IN packet, and multi-channel parts (FT2232C/H, FT4232H):
channel A auto-binds, bind_ftdi_ch() reaches B..D. Prolific PL2303
(usb_class_pl2303.spin2): chip-type detect (HX/HXD/TA/TB, legacy type H,
HXN degrade), the undocumented vendor init dance, literal-baud line coding
with ser_device_baud() read-back, DTR/RTS. SiLabs CP210x
(usb_class_cp210x.spin2): IFC_ENABLE bring-up, literal 32-bit baud with
CP2101 divisor fallback, DTR/RTS, FIFO purge. Validated live: FT232R/FT-X,
FT4232H (all 4 channels), PL2303HXD, PL2303TA.
The RTC time-source contract
Without a time source, files are stamped with a fixed build-time constant. To get real timestamps, bind a method pointer before writing files:
app.fs_time_source(@rtc_now) ' from your top object
PRI rtc_now() : ts
' read your RTC here, then pack:
ts := ((year - 1980) << 25) | (month << 21) | (day << 16) | (hour << 11) | (minute << 5) | (second >> 1)
Requirements on the method you provide:
- Signature: no parameters, one return value — a single long in packed DOS/exFAT format: bits 31..25 year−1980 (0..127), 24..21 month (1..12), 20..16 day (1..31), 15..11 hour (0..23), 10..5 minute (0..59), 4..0 seconds÷2 (0..29). Values are written to the media as-is; no validation is performed.
- Calling context: it is called synchronously on whatever cog invoked
the filesystem write API (
fs_create,fs_close,fs_sync) — at file creation and at every entry-set flush. If several cogs use the filesystem, the method must be safe to call from any of them. - It must return promptly and must not re-enter this stack. It runs while
the filesystem's API lock is held. Do not call any
fs_*/disk_*/USB API from it, and do not block (a register read from an I²C/SPI RTC is fine; a wait-for-next-second loop is not). - The method pointer captures its object context (
@methodfrom any object works). Passing 0 reverts to the fixed constant. - Current wiring: the stamps land on exFAT create + last-modified fields. 10 ms-resolution fields and UTC-offset fields are written as zero.
Limits
Filesystem-independent:
- 512-byte logical blocks only. Media reporting another block size is
rejected at
disk_open. - MSC: bulk-only transport, LUN 0 only.
- One storage device mounted at a time.
- One writer per file; no file locking between cogs beyond the API lock.
Audio (USB_AUDIO, UAC1):
- One audio device bound at a time (first UAC1 device found).
- 16-bit little-endian interleaved PCM assumed;
bSubframeSizeis parsed but not enforced (commodity adapters are 16-bit). - First speaker and first mic ISO alt-setting only; no unit-topology walk. Volume/mute go to the Feature Units master channel, first-ACK-wins — which FU sits in the playback path is device luck (fanning every FU × channel combo wedged a CM108's EP0 on the bench, hence the restraint).
- No rate table:
aud_open(rate)asks the device via SET_CUR and the device is the authority — an unsupported rate STALLs and the open fails cleanly. 48,000 and 44,100 Hz validated (44.1 k exact via a 16.16 fractional accumulator). aud_readreturns whole frames only; a buffer smaller than one mic packet drains nothing (size it ≥ the mic max packet, ≤ 252 bytes).- Synchronous-mode ISO mics (e.g. CM108 capture) lock to SOF timing and degrade under heavy bulk-induced SOF jitter from a degraded bus peer (~99.3 % held with healthy peers); adaptive-mode playback is immune.
exFAT driver:
- MBR partition 1 or partitionless (superfloppy) volumes. No GPT.
- Boot-region checksum is verified at mount; a corrupt boot region fails the mount (code −83) rather than mounting best-effort.
- Names: ASCII only. Matching is case-insensitive via ASCII folding — the
on-disk up-case table is not consulted, so non-ASCII names cannot be matched
(they list with
?substituted) and case-folding of non-ASCII is wrong by spec. Name length ≤ 64 chars per component. - File sizes: 32-bit API. Files ≥ 2 GB read with size clamped to $7FFF_FFFF; writing cannot extend a file past that.
- 4 concurrent handles (files + directory iterators combined,
MAX_OPENinusb_exfat_fs.spin2). - A directory cannot grow past its initial cluster: with 128 KB clusters that is 4,096 directory entries — at 3 entries per short-named file, ≈ 1,300 files per directory. Exceeding it returns −94 rather than extending.
- No rename/move. Directory deletes require the directory to be empty.
- Timestamps: create and last-modified only; last-access is not updated.
- No TexFAT (transaction-safe exFAT); the second FAT, if present, is ignored (per spec, only FAT[0] is active without TexFAT).
FAT32 driver (adapted third-party code):
- Long file names (VFAT), ticket 6280, on by default, up to 64 ASCII
characters per path component, matching the exFAT driver's limit. Names that
fit 8.3 are stored exactly as before, as a single 8.3 entry. Any other legal
name gets LFN entries plus a unique Windows-style alias (
FLIGHT~1.CSV), so a machine without LFN support still sees a valid 8.3 name. Lookup matches the long name or the alias, ASCII case-insensitively.fs_entry_name()returns the long name when there is one. Rename and move carry long names; delete removes them.- Characters above
$7Eread back as?(lossy, as on exFAT). Illegal characters (\ / : * ? " < > |, controls), a trailing dot or space, or more than 64 characters return −95. A name longer than 64 written by another system is reached by its 8.3 alias. - Opt out with
-D FAT32_NO_LFN(ticket 6290). The build then stores and looks up 8.3 names only. Create or rename of a longer name returns −95. Open or delete of a long name returns −85 (not found), because only the alias is searchable. Directory reads skip on-disk LFN entries andfs_entry_name()returns the alias. Deleting or renaming that alias leaves the LFN run in place; readers skip those entries, andfsck.fatremoves them.BPB_RootClus, the..cluster, and names with no extension stay in this build. - Compatibility is judged by other implementations, not by this driver:
harness/top_fatcompat.spin2makes the P2 write to a real PC-formatted stick, andtools/fatcompat_check.pyrebuilds the image and runsfsck.fatand GNU mtools on it.harness/top_lfnread.spin2mounts volumes written bymkfs.fat+ mtools (tools/make_lfn_images.py) and opens every file by its long name.
- Characters above
- Honours
BPB_RootClus(6280). The driver used to assume the root directory was at cluster 2, which is true of Windows,mkfs.fatand macOS formats, but not required by the spec. - 6 concurrent handles (
MAX_OPEN_FILES). - Expects exactly 2 FATs and 512-byte sectors; FAT32 only (no FAT12/16).
- Cogless on the caller's cog. The legacy worker cog stays out unless
SD_USE_WORKER_COGis exported. Per-cog current-directory semantics come from the original driver.
Flash wear and TRIM (both filesystems)
Every medium this driver can reach is managed flash. A USB stick, an SD card in a USB reader and a SATA SSD behind a USB bridge all have a controller whose flash translation layer (FTL) maps logical blocks to physical pages and does the wear levelling itself. The filesystem only ever sees logical blocks, so it cannot level wear on top of that. What it can do is give the controller accurate information about which blocks still hold data. The two filesystems do that in two ways.
1. Freed clusters are reused first (lowest-free allocation, ticket 6270). A growing file takes the cluster right after its previous one when it is free, so streamed files stay physically contiguous (which is what lets large reads run at full speed). Otherwise allocation takes the lowest free cluster, and deleting a file makes its clusters the next ones used. Rewriting a logical block the controller has seen before lets it retire the stale physical copy. Until 2026-09-24 both drivers did the opposite (next-fit: march across the whole volume before reusing anything), which a field report correctly pointed out is counter-productive on managed flash without TRIM. After one pass, the controller believes every block ever written is still live, so its garbage collector has only its spare area left to work with.
- FAT32: the in-memory hint means "no free cluster below this". Every free
lowers it; an allocation raises it only when it consumes it. The hint starts
at cluster 2 on every mount. FSInfo's
Nxt_Freeis still written, but never trusted as a starting point, because other systems store "last allocated" there, with freed holes below it. The first allocation of a session therefore scans the FAT from the front until it finds a free cluster: a few sectors on a lightly used volume, one pass over the used part on a nearly full one. - exFAT: the same policy over the allocation bitmap, searched a 32-bit word at a time. Mount no longer scans the bitmap at all.
fs_mount_degraded()(6210) reported an aborted mount-time hint scan. Those scans no longer exist, so it now reports 0 on healthy and faulty media alike; it is kept for API compatibility.
2. Deleted clusters are discarded on media that support it (SCSI UNMAP). Over USB mass storage, TRIM is the SCSI UNMAP command. (SD's ERASE/DISCARD, CMD38, is not reachable through a USB card reader, which speaks SCSI only.) At every drive bring-up the driver checks, defensively, whether the device advertises logical block provisioning:
- Devices whose INQUIRY reports a SCSI version below SPC-3 are asked nothing. Much cheap USB firmware answers vital-product-data requests wrongly or wedges on them, which is also why Linux skips them for USB.
- Otherwise, discard is enabled only when VPD page
$B2has LBPU set and READ CAPACITY(16) has LBPME set, the rule the Linuxsddriver applies. VPD$B0's maximum UNMAP count is honoured.
When a file is deleted (or compactFile releases an old chain), each
contiguous freed run is discarded after the FAT (or bitmap) and the
directory entry say it is free on the media, so a crash can never leave a live
entry pointing at discarded blocks. Discard is advisory: failures are counted,
never retried. See disk_lbp(), disk_discard(), disk_discard_stats() and
fs_discard_stats() in the API manual.
What the bench shows (2026-09-24):
- The two
$ABCD:$1234"General UDisk" thumbs report SCSI-2 and support no provisioning: the probe sends them 0 commands, and deletes issue 0 UNMAPs. They still produce one discard request per freed run (harness/top_trim.spin2). - On these sticks, the only wear lever is lowest-free reuse. That is typical of USB thumb drives. SATA-to-USB bridges are the devices likely to pass UNMAP through (as ATA TRIM).
- The ordering is pinned on synthetic volumes:
XS_exfat_struct#13 andXS_malformed#22. Both went red when the discard was moved ahead of the metadata write. - No UNMAP has yet been accepted by a real device on this bench. That needs an SPC-3+ device with provisioning attached.
Error codes (exFAT / facade)
| Code | Meaning |
|---|---|
| −80 | not mounted / no filesystem owns the mount |
| −81 | media is not exFAT (auto-detect falls through to FAT32 on this) |
| −82 | unsupported geometry (sector size ≠ 512) |
| −83 | boot-region checksum mismatch |
| −84 | block-device I/O error |
| −85 | not found |
| −86 | out of handles |
| −87 | bad handle |
| −88 | broken cluster chain |
| −89 | no block device bound |
| −92 | already exists (fs_create, fs_mkdir) |
| −93 | no free cluster (disk full) |
| −94 | no room in directory |
| −95 | empty or over-long name |
| −96 | directory not empty (fs_delete) |
| −100 | fs_mount called before disk_open succeeded |
| −110 | not a file |
| −111 | not a directory |
| −112 | file is in use by an open handle |
| −113 | file already open for writing |
| −114 | not a valid handle of that kind (e.g. file op on a dir handle) |
| −115 | invalid parameter |
| −116 | end of file |
| −117 | no contiguous space |
| −118 | media is not the filesystem asked for |
| −119 | a driver code with no facade meaning |
| −120 | operation not supported by the mounted filesystem's driver (currently unreachable — kept for future operations) |
The table above is the facade contract on both filesystems: exFAT's driver
codes are these values natively, and FAT32's raw codes (0 to −64 range in
usb_fat32_fs.spin2) are translated by the facade. The −110..−120 block covers
conditions FAT32 can report that the original exFAT-derived table had no word
for, placed outside both drivers' raw ranges so an untranslated leak is
recognisable.
Examples
All in examples/, all on the v2 session API, each compilable standalone
with just its own gate (build lines in each file's header):
| File | Shows |
|---|---|
top_fs_example.spin2 |
begin() + await_drive(), time source, directory listing, file create/write/readback (filesystem mounts itself on first use). |
top_msc_example.spin2 |
Raw block layer: capacity, sector reads, a throughput burst. Read-only by design. |
top_cdc_example.spin2 |
Serial plane: one ser_* API over CDC-ACM or any vendor bridge, write + polled read. |
top_hid_example.spin2 |
Keyboard + mouse (bound to separate boot-protocol interfaces automatically), report polling. |
top_hotplug_example.spin2 |
Hotplug-ready application skeleton — a resilient logger implementing the docs/hotplug.md patterns: poll() in the idle loop, a minimal on_change handler, ready-checks before every use, reopen-by-path logging that survives detach, re-autobaud on serial re-attach. |
top_tone_keyboard_example.spin2 |
Audio + HID together (USB_AUDIO + USB_HID): keys 1–9 on a USB keyboard play a C-major scale through a USB audio adapter — phase-accumulator synthesis fed to aud_write, poll() + stream-health stats on the side. The first-hardware audio demo (nine clean tones, 169k frames at a sustained 1000/s). |
top_audio_loop_example.spin2 |
Audio playback + full-duplex: 3 s of synthesized tone, then a dedicated DSP cog running mic → biquad low-pass → speaker live (coefficients derived in-file). |
top_audio_dsp_example.spin2 |
The cross-cog audio pattern: a DSP cog owns the capture stream (aud_mic_open + aud_read, per-10 ms RMS/peak + 1 kHz Goertzel detect) while the main cog keeps poll() and hot-plug. |
top_midi_example.spin2 |
USB-MIDI host (USB_MIDI, ticket 2140 — awaiting hardware): with USB_AUDIO, a monophonic velocity-sensitive synth with ±2-semitone pitch bend (exact equal-temperament note table, derivations in-file); without, a decoded MIDI event monitor. |
top_joy_example.spin2 |
Gamepad/joystick (USB_JOY, ticket 2150 — awaiting hardware): discover (every axis/button/hat introspected by HID usage, no hardcoded layout), map (interactive press-the-control-you-want wizard via joy_mark()/joy_changed()), play (mapped controls read back through joy_control()). |
The FS, hotplug, serial and HID examples were each run on the bench before
this README claimed they work — against the 1 TB exFAT SSD behind the hub
(arriving via the late-attach path) with the Alicat MFC answering on the
serial plane. On the audio plane, top_tone_keyboard_example is the path's
first-hardware run (nine clean tones by ear); the loop and DSP examples
encode the patterns the acceptance harness (harness/top_audio_accept.spin2)
validated — capture rates, Goertzel detection, the cross-cog ring contract —
as compile-verified worked examples.
Filesystem regression coverage
Two gates, both green on real hardware. Full detail — including what is not covered — in docs/regression-report.md.
| gate | what it covers | result |
|---|---|---|
tests/regression/run_gate.sh |
17 RT_ suites, FAT32 driver, one drive |
346 / 346, 0 recoveries |
tests/regression/run_fs_gate.sh |
11 FS_ suites through the fs_* facade, both filesystems, the XS_ exFAT structural + malformed-media synthetic suites, the MD_ multi-drive suite (dual mounts + two-cog concurrency), and the HP_ hot-plug suite (real detaches via hub port power, ring-audited abandon honesty, sticky re-bind, churn) |
FAT32 223/223, exFAT 219/219, structural 27/27, malformed 20/20, multi-drive 13/13, hot-plug 11/11 |
Both refuse to call a run green on a technicality: run_gate.sh fails on any
missing result and on any BOT recovery event — a suite that passed after a
recovery is not the same result as one that never needed one — and
run_fs_gate.sh fails on any deviation from expected counts in either
direction, so a new failure cannot hide behind a fixed one.
FAT32 geometry matrix
Three volumes formatted to different geometries, each pinned by serial:
| drive | cluster size | data-area alignment | 16 non-defrag suites | RT_defrag_tests |
|---|---|---|---|---|
| A | 32 KB | 0 | 332 pass, 0 fail, 0 recoveries | 14 pass |
| B | 4 KB | 0 | 332 pass, 0 fail, 0 recoveries | wedges the drive |
| C | 4 KB | 5 | 332 pass, 0 fail, 0 recoveries | wedges the drive |
All three produce the identical 332. Drive C's non-zero cluster alignment is the
only geometry reaching the entryOffsetInCluster sign fix, so that path is now
exercised on live media — and alignment is thereby exonerated in the defrag
wedge: B and C differ only in alignment and behave identically both ways, so the
variable they share is the 4 KB cluster size.
Both filesystems, through the application facade
The FS_ suites talk to usb_app's fs_* facade — the interface applications
use — so one source runs on both filesystems:
| suite | FAT32 | exFAT |
|---|---|---|
FS_seek_tests |
38 / 38 | 38 / 38 |
FS_cogcwd_tests |
5 / 5 | 5 / 5 |
FS_stress_tests |
4 / 4 | 4 / 4 |
FS_fatchain_tests |
2 / 2 | 2 / 2 |
FS_delete_guard_tests |
8 / 8 | 8 / 8 |
FS_multihandle_tests |
22 / 22 | 22 / 22 |
FS_read_write_tests |
49 / 49 | 49 / 49 |
FS_dirhandle_tests |
25 / 25 | 25 / 25 |
FS_subdir_ops_tests |
18 / 18 | 18 / 18 |
FS_error_handling_tests |
19 / 19 | 18 / 18 † |
FS_directory_tests |
33 / 33 | 30 / 30 ‡ |
| total | 223 / 223 | 219 / 219 |
† the white-box MBR-preservation test is FAT32-only by design and compiles out on exFAT (18 there). ‡ the cluster-zero raw-patch group likewise (30 there).
Each FAT32 column matches its RT_ original exactly — the control proving the
ports are faithful. The exFAT column is at FULL PARITY (ticket 6170,
closed 2026-08-28): the 27-item shortfall ledger the ladder accumulated —
seek-past-EOF clamping, chdir silent success, handle-limit differences,
error-code splits, missing rename/move, the error write-on-exit gap — cleared
by five facade/driver normalizations, a facade-level per-cog cwd for exFAT,
and real rename/moveFile in the exFAT driver (entry-set rewrite with
independent checksum/hash verification in the structural suite). The only
exFAT-absent tests are the white-box-by-design ones above.
Product build matrix
run_fs_gate.sh --build-only compiles every shippable configuration with no
hardware, so it belongs in a build pipeline:
| build | size |
|---|---|
USB_MSC USB_EXFAT |
65,479 B |
USB_MSC USB_FAT32 |
74,878 B |
USB_MSC USB_EXFAT USB_FAT32 |
89,687 B |
USB_MSC USB_FAT32 FAT32_NO_LFN |
72,598 B |
Measured 2026-10-06 with pnut-ts v1.55.0. The exFAT size is the one already
recorded by the 6280 gate. The FAT32 and both-filesystem builds are smaller
than that gate because the SD SPI engine is no longer in the default image.
The four different sizes are the evidence the compile-time gating works —
each build carries only what it asked for, and a disabled filesystem leaves no
remnant.
What testing through the facade found
Pointing the same suites at both drivers surfaced defects neither driver-bound ladder could reach, since each only ever ran against its own driver:
- exFAT had no open-path table. It kept handles but nothing mapping a path to them, so it deleted open files, allowed two write handles on one file, and left state that failed nine further tests. Fixed — the identity it needed (parent-dir cluster + entry-set index) was already recorded per handle.
- The two drivers' error codes collided —
−93meant "not a dir handle" on FAT32 and "disk full" on exFAT. The facade now translates FAT32's codes into the contract the README has published all along. - Parity divergences that were silent success — all since resolved (6170):
fs_seekpast EOF was silently clamped by exFAT (now refused at the facade on both);fs_chdirreturned success on exFAT having done nothing (the facade now keeps a real per-cog cwd for it); the same misuse yielded different error codes per filesystem (now −111/−114 everywhere). Each was filed first, decided once, then fixed — the ledger is on ticket 08de1c7aaa. - The "populated-directory delete" defect was the suite's own fixture. It
built the directory via
fs_chdir+ bare names — a no-op on exFAT — so the child landed in root and the directory really was empty. Rebuilt with absolute paths and a child-enumerates-inside check, the guard fires on both filesystems (−96) and the suite is 8/8 on both. - The BOT wedge was the host's own timer arithmetic — the biggest find of
the whole port effort. Twelve sites in the USB cog compared absolute
getct()values against deadlines, which misfires when a wait straddles the 32-bit CNT wrap (a 21.47 s grid, phase fixed at reset) — spurious timeouts in the transaction engine and, worse, SOF anomalies injected between the packets of in-flight transfers. Cracked open by four consecutive runs wedging at mount + 10.66–10.70 s across a physical reseat; fixed with the wrap-safe subtract-then-sign-test form; verified by the reproducer running clean, the TX canary, and the full gate green on both filesystems (ticket1464ab6579). fs_eofleaked raw driver codes through its error leg — the normalisation detour bypassed the translation, so misuse returned FAT32's −93 where the contract says −114. Found by the ported suite's FAT32 control on its first run; fixed the same day. (fs_dir_openhad the same leak, also now fixed.)- exFAT lacks the
fs_error()write-on-exit contract: a successful operation never stores 0, so a stale error survives success. Predicted from source, confirmed on hardware; filed with the other parity gaps.
Known gaps, stated plainly
RT_defrag_tests has never passed on 4 KB media. Seven of the seventeen suites
still cannot run on exFAT — the old error-constant wall fell with the facade
error contract; what remains is the date API, driver lifecycle, and
moveFile/setVolumeLabel, plus a group that is white-box by design
(raw-sector and debugGet* access) and rightly stays FAT32-only. exFAT's
structural side (bitmap, entry sets, NoFatChain, VolumeDirty, boot region)
has its own 27-test synthetic-volume suite — which found and now guards a
real stray-FAT-write defect in the NoFatChain conversion path. Multi-drive
is covered (6140): dual FAT32+exFAT mounts, two-cog concurrent I/O with
per-volume content verification, selection churn, and the bystander invariant
asserted by witness hashes. Malformed media is covered (6190): hostile
synthetic volumes for both filesystems — FAT chain loops, cycles and off-end
chains, cross-linked files, lying geometry and FSInfo, truncated exFAT entry
sets, out-of-range system clusters — with fail-clean asserted mechanically
(bounded reads, defined codes, a shim auditing zero out-of-range writes).
Not exercised at all: hot-plug/surprise removal.
These numbers measure one drive at a time, well-formed media, no removals. A floor, not a finish line.
Validation status
All on real hardware (P2 Eval, serial-host header, 200 MHz): a 4-port hub (VID $05E3) carrying a composite CDC serial gadget ($303A:$1001), a keyboard/mouse receiver ($046D:$C52B), and a USB SD-card reader ($14CD:$1212) — enumerated and exercised concurrently.
- MSC: single and 8-block reads/writes with pattern verification.
- 1 TB exFAT SSD (ASMedia
$174C:$A89ASATA bridge, behind the hub with the three devices above): full-range LBA addressing (verified write-back at LBA 2,000,409,256), mount with boot-checksum verification (7.8 M × 128 KB clusters), small/large file write/read/verify, seek, remount persistence, cleanup — plus a raw and filesystem performance profile. Results table and writeup: docs/perf-1tb-ssd.md. This work also root-caused and fixed a content-dependent PHY TX corruption (bit-stuff geometry vs. the smart-pin byte-handoff gap) that no prior media had triggered. - FAT32 on the cogless driver (8 GB FAT32 card in the USB reader,
2026-08-10): auto-detect fallback (exFAT probe miss → FAT32 mount, 81 ms),
directory listing of a live volume, create/write/read/verify/seek/delete,
mkdir, persistence across remount, 256 KB copy verified (same
band as exFAT), small-chunk I/O — all via direct dispatch, no worker cog —
plus a 256 KB async-job copy on the same card, verified
(
harness/top_fat32_test.spin2, RESULT PASS, zero failures). - exFAT: mount with boot-checksum verification on a 64 GB card; file
create/write/readback (including a 147 KB file spanning a cluster
boundary), seek, delete; subdirectory create/list/delete; persistence
across remounts. After the write sessions the card was checked on a PC
(
fsck.exfatclean, files readable). - HID and CDC paths validated in the same multi-device sessions.
- Alicat BASIS mass-flow controller over its FTDI cable ($0403:$6015),
live through the hub: autobaud at 38,400, tokenized poll frames, setpoint
write/readback/zero with the valve-drive response observed across timed
polls — all through
usb_ser_expect+usb_class_ftdi(harness/top_alicat_expect.spin2, tickets 2055/0180). - First audio: USB Audio Class playback validated by ear (tickets
1180/2110): isochronous OUT streamed by the PHY on every SOF from a
hub-RAM ring — a C-Media CM108 adapter behind the hub played nine clean
synthesized tones driven live from a USB keyboard
(
examples/top_tone_keyboard_example.spin2), 169k frames at a sustained 1000/s. Then closed to full acceptance with a loopback jumper (headphone→mic): capture measured at exactly 48,000 and 44,100 Hz, a four-tone Goertzel matrix verified bin-by-bin, and 60 s of zero-underrun playback while the async job cog streamed file copies at 251 KB/s (harness/top_audio_accept.spin2). Bugs the campaign shook out: interrupt-IN polls carried the bulk NAK budget (~64 ms per idle keyboard poll — interrupt is poll-once, now ~1 ms); the ISO receiver lacked the own-EOP gate; the frame guards taxed both buses with the ISO reservation;aud_readclip-discard punched DSP-shredding discontinuities. - Hub-path ACK-window fix (ticket 0190, from a field report): the in-cog RX ACK now precedes all mailbox bookkeeping — USB-3 sticks in FS fallback behind hubs no longer go silent/wedged at 200 MHz. Full dual-bus suite (hostile canaries both buses, 20/20+20/20 storage/serial interleave, hot-plug) validated at 200 and 300 MHz; a co-discovered SOF-starvation bug (control-transfer bursts postponing SOF until devices legally suspended) fixed in the same pass.
- USB-serial bridge family validated live (ticket 2065): a UTEK FT4232H
quad adapter ($0403:$6011) — all 4 channels brought up through
bind_ftdi_ch()with the H-series 120 MHz-base divisor; PL2303HXD and PL2303TA ($067B:$2303) — full vendor init with the commanded baud read back via GET_LINE (38,400 then 115,200 echoed exactly by both chips); Alicat FT-X regression after the driver rework: 9/9 exchanges, PASS (harness/top_serialx.spin2). - Facade v2 session API validated live on both planes: the Alicat demo
(9/9 exchanges, ~20–28 ms round trips — faster than v1 because sampled
quiet-drain replaced fixed drains) and the FS example (
begin()+await_drive()binding the SSD via the late-attach path, mount-on-first- use, write/readback) (ticket 0180). - HID multi-interface auto-bind confirmed live (Unifying receiver $046D:$C52B): keyboard and mouse on separate boot-protocol interfaces, both endpoints answering, reports delivered (tickets 0180/0185).
- Hot-plug cycle validated live (
harness/top_hotplug.spin2): SSD attach as apoll()event; programmatic detach (hub per-port power) of the mounted volume — bindings dropped, filesystem abandoned I/O-free; re-attach, remount, file write/readback; post-cycle binding-integrity check. Also flushed out and fixed a roster-compaction bug via Spin2's auto-reversingrepeat(ticket 0185). - Nested hub validated live (2026-08-13, ticket 1205 from a field report): a dock-style combo unit (Realtek $0BDA root hub with a Prolific PL2586 hub inside) enumerated one level deep - the card reader, a Billboard device and an RTL8153 NIC behind the inner hub all rostered; the SD card behind the inner hub then passed the FULL FAT32 acceptance suite (write/verify/remount/async copy, RESULT PASS), the hostile TX pattern canary bit-exact, and 60 s of zero-underrun audio with the file-copy load running through the nested path on the same bus.
- Cross-bus multi-cog concurrency validated live (2026-08-13, ticket
1200 from field report #3): a worker cog streaming 3x64 KB file writes
through the nested hub on bus 1 while the main cog ran 84 serial
exchanges (through a 2160 serial handle) plus 84 HID polls on bus 0 -
zero serial failures, all jobs verified (
harness/top_xbus1200.spin2, RESULT PASS). This is the field-observed misrouting geometry at 20x the reported cadence, clean under the per-cog routing fix. - Root hot-plug validated live (2026-08-12, from a P2 Edge field
report;
harness/top_rootplug.spin2with EN-gated hub power as the programmatic "pull it out, put it back"): hub death → 3 leaf + 1 port-0 rootEV_DETACH, roster drained, zombie-leaf cleanup; 6 s dead-bus soak with zero phantom attaches (the bench's floating-J line caveat handled by evidence-based paced probing); EN restore → rootEV_ATTACH+ full subtree re-rostered bypoll()alone in 1.1 s; all addresses answering (RESULT PASS).
Development history, decisions, and per-feature evidence live in the Fossil
ticket ledger in this repository (fossil ticket show 1 from the checkout).
License
MIT — see LICENSE. © 2026 Shannon Mackey (Refaqtory, LLC). Third-party components keep their original MIT copyrights: the FAT32 driver derives from Chris Gadd's and Stephen M Moraco's P2 microSD driver, and the bench uses Jon McPhalen's I²C/DS3231 objects — each file carries its own notice.
This project stands on real shoulders — Chip Gracey's silicon, Ada Gottensträter's pioneering USBnew host, Iron Sheep's toolchain, and a community's worth of prior art. The full appreciation, by name and by debt, is in ACKNOWLEDGMENTS.md.