# Minimal NBD client for MCU heads (ESP32 + RP2040) — reference note

Implementation blueprint for the firmware repo: a bare-bones NBD client on
lwIP that turns a reversefs export into sectors for a PIO-IDE (ZuluIDE-style)
or USB device head. Everything below was verified against
`reversefs serve-nbd`; the subset is standard NBD (fixed newstyle), so
`qemu-nbd`/`nbd-server` work as fallback test servers.

**All integers are big-endian (network order).**

## 1. Handshake (once per connection)

```
TCP connect (port 10809 by default), set TCP_NODELAY.

<- 18 bytes:  "NBDMAGIC" (8)  "IHAVEOPT" (8)  handshake_flags u16
              expect bit0 set (FIXED_NEWSTYLE); bit1 = NO_ZEROES offered
-> 4 bytes:   client_flags u32 = 0x00000003
              (bit0 FIXED_NEWSTYLE, bit1 NO_ZEROES -> skips the 124-byte pad)
-> 16 bytes:  "IHAVEOPT" (8)  option u32 = 1 (NBD_OPT_EXPORT_NAME)
              length u32 = 0            (default export, no name)
<- 10 bytes:  export_size u64           transmission_flags u16
              (if you did NOT set NO_ZEROES: +124 zero bytes follow)
```

`transmission_flags` bits worth reading:

| bit | meaning | firmware action |
|---|---|---|
| 0 | HAS_FLAGS | always set |
| 1 | **READ_ONLY** | reversefs sets it for ISO/XISO/GC/raw/archives → the whole write path can be compiled out / skipped |
| 2 | SEND_FLUSH | flush (type 3) allowed |
| 5 | SEND_TRIM | trim (type 4) allowed |
| 8 | CAN_MULTI_CONN | a second socket is safe (e.g. one per IDE drive) |

No other options are needed: no LIST, no GO, no structured replies. After
this exchange the connection is in the transmission phase.

## 2. Transmission phase (request → reply, strictly sequential is fine)

Request — fixed 28 bytes (+ payload for writes):

```
u32 0x25609513   magic
u16 0            command flags
u16 type         0=READ 1=WRITE 2=DISC 3=FLUSH 4=TRIM
u64 cookie       any value; echoed back (use a counter)
u64 offset       byte offset into the export
u32 length       byte count (0 for FLUSH/DISC)
[length bytes of payload — WRITE only]
```

Simple reply — fixed 16 bytes (+ payload for reads):

```
u32 0x67446698   magic
u32 error        0 = OK, else errno (5=EIO, 22=EINVAL, 28=ENOSPC)
u64 cookie       must equal the request cookie
[length bytes of data — successful READ only]
```

Notes:

- One request in flight is enough for a ~1 MB/s head; the server handles
  each connection sequentially anyway. Pipelining is legal but pointless here.
- Server-side cap is 32 MiB/request; from an MCU stay ≤ 128 KiB.
- `DISC` (type 2) has **no reply** — send it and close. The server also runs
  its final reconcile on plain EOF, so a dropped link is safe.
- On any socket error: close, reconnect, redo the handshake (size/flags
  will be identical), resume. Idempotent reads make this trivial.

## 3. Sector mapping and performance

- The export is a plain byte device; IDE LBA `n` = offset `n * 512`.
- **Coalesce**: consoles read sequentially — when the IDE side requests
  LBA k..k+m, fetch a larger aligned window (64–128 KiB) into a PSRAM ring
  and serve subsequent sectors from it. This one trick dominates throughput
  on WiFi (fewer round-trips ≫ raw bandwidth).
- Readahead: after serving a window, prefetch the next one while IDE is
  busy transferring (ATA BSY/DRQ pacing gives you the time).
- Reference budget measured on our stack: handshake = 1 RTT + 44 bytes;
  per-request overhead = 44 bytes; ESP32 (lwIP, TCP) realistically moves
  0.8–1.5 MB/s → comfortable for CD-era consumers (PS1 ~300 KB/s), tight
  for PS2-CD, insufficient for DVD streaming (use a Pi-class head there).

## 4. Optional: cache-freshness endpoint (skip in v1)

If the head ever persists blocks (SD), the serving side's
`--status-listen` port answers plain-text HTTP:

```
GET /generation          -> "G\n"
GET /dirty?since=G       -> "gen H\nR <offset> <len>\n..."   (410 = wipe)
```

Read the body by `Content-Length` (the socket stays open). For a RAM-only
ESP32 head this is unnecessary — every boot starts cold, and within one
connection reads are always served consistent by the server.

## 5. Test matrix for the firmware

```bash
# primary test server (any layout works; raw is the simplest):
reversefs serve-nbd --layout raw --source test.img --listen 0.0.0.0:10809
# read-only flag path:
reversefs serve-nbd --layout iso9660 --source /some/dir --listen 0.0.0.0:10809
# interop check against a foreign server:
qemu-nbd --port 10810 --format raw test.img
```

Validate: sizes match `blockdev --getsize64`-style expectations, a full
sequential read equals `reversefs dump` output byte-for-byte, reconnect
mid-read resumes cleanly, and the RO flag is honored (no write frames sent).

## 6. C skeleton (lwIP BSD sockets)

```c
typedef struct { int fd; uint64_t size; uint16_t tflags; uint64_t cookie; } nbd_t;

int nbd_connect(nbd_t *n, const ip_addr_t *ip, uint16_t port) {
    n->fd = socket(AF_INET, SOCK_STREAM, 0);
    /* connect(); setsockopt TCP_NODELAY */
    uint8_t hello[18]; read_full(n->fd, hello, 18);
    if (memcmp(hello, "NBDMAGIC", 8) || memcmp(hello + 8, "IHAVEOPT", 8)) return -1;
    write_u32(n->fd, 0x3);                      /* fixed newstyle + no zeroes */
    write_full(n->fd, "IHAVEOPT", 8);
    write_u32(n->fd, 1); write_u32(n->fd, 0);   /* EXPORT_NAME, empty */
    n->size = read_u64(n->fd); n->tflags = read_u16(n->fd);
    return 0;
}

int nbd_read(nbd_t *n, uint64_t off, void *buf, uint32_t len) {
    uint8_t req[28] = {0x25,0x60,0x95,0x13};
    put_u16(req+6, 0 /* READ */); put_u64(req+8, ++n->cookie);
    put_u64(req+16, off); put_u32(req+24, len);
    write_full(n->fd, req, 28);
    uint8_t rep[16]; read_full(n->fd, rep, 16);
    if (get_u32(rep) != 0x67446698 || get_u64(rep+8) != n->cookie) return -1;
    if (get_u32(rep+4)) return -(int)get_u32(rep+4);
    return read_full(n->fd, buf, len);
}
```

RP2040 link (SPI between ESP32 and RP2040, ATA state machine in PIO) is out
of scope here — this note covers only the network side the reversefs server
guarantees.
