Device protocol

This page describes the protocol between tw and the Tapewyrm firmware: where its codes are defined, how both ends are generated, what goes over USB and the limits each verb must respect.

The single source of truth

protocol/protocol.toml defines every code both ends share. Neither end hard-codes them. protocol/generate.py writes the firmware header and the Python module from it, and CI fails if either generated file is stale.

Table Generated C Generated Python Holds
version TW_PROTO_VERSION PROTO_VERSION The protocol version (currently 1).
caps TW_CAP_<NAME> = 1u << i CAPS (a frozenset of names) Capability flags INFO advertises, bit i in list order: verbs, capture, markers.
[[transaction]] TW_TXN_<NAME> Txn.<NAME> Host-to-device command codes, 0x80 and up.
[[marker]] TW_MARK_<NAME> Marker.<NAME> In-stream capture markers, 0xF0 to 0xF4.
[[event]] TW_EVT_<NAME> Event.<NAME> EVENT marker sub-codes.
[[end_reason]] TW_END_<NAME> EndReason.<NAME> END marker reason codes.

Each array entry has a name, a code and an optional desc, which becomes a comment on both sides:

[[transaction]]
name = "BUILD_INFO"
code = 0x88
desc = "Firmware build identity: git commit (40 hex chars) + dirty flag"

Generate the files

protocol/generate.py is a stdlib-only PEP 723 script (it uses tomllib). It writes two files:

File Contents
firmware/inc/protocol.h #defines for the version and capability bits, and one enum per table. The firmware build reaches it as <protocol.h> through the inc include path.
packages/tapewyrm-cli/tapewyrm/link/protocol.py PROTO_VERSION, CAPS and one IntEnum per table: Txn, Marker, Event, EndReason.

Both open with GENERATED by protocol/generate.py from protocol.toml — DO NOT EDIT. Never edit them by hand.

just gen          # write both files (CRLF)
just gen-check    # exit 1 and name each stale file; writes nothing

--check compares content after normalising line endings, so it does not flag a file that differs only in CRLF versus LF. When writing, the script writes CRLF to match the repository.

Four things keep the ends in step:

  • just gen-check and the contract.yml workflow, which runs python protocol/generate.py --check on any change to protocol/ or either generated file.
  • just ci, which runs gen and then git diff --exit-code.
  • packages/tapewyrm-cli/tests/test_protocol.py, which runs the check, compares the generated enums to the TOML, and asserts that tapewyrm_archive.twrf.WireMarker equals Marker.
  • tapewyrm-cli's ruff configuration excludes protocol.py, so formatting never rewrites generated output.

Generation covers codes, not layouts

The opcodes and marker codes are generated. The byte layout of each verb's request and response, the marker payloads and the capability-bit names are not. They are written out by hand in firmware/src/qic/qic.c, packages/tapewyrm-cli/tapewyrm/link/device.py (_CAP_BITS and the struct formats) and packages/tapewyrm-archive/tapewyrm_archive/twrf.py. A layout change must be made in every copy. Moving the layouts into protocol.toml is an open TODO in device.py.

The USB transaction protocol

The board enumerates as a USB CDC-ACM serial port. The Tapewyrm verbs are ordinary Greaseweazle command packets, dispatched by GW's process_command(), so the framing is GW's own:

request  : cmd:u8  total_len:u8  payload...     total_len = 2 + len(payload)
response : cmd:u8  ack:u8        payload...     payload only when ack == ACK_OKAY
capture  : a continuous byte stream after the CAPTURE response

A response has no length field. The host knows how many payload bytes each command returns, as GW's own host tools do, and SerialTransport.recv_frame takes the expected length. On any ack other than ACK_OKAY the device sends only the two header bytes, so the host must not try to read a payload. All multi-byte fields are little-endian.

An earlier design specified u16-length frames with JSON payloads. The firmware never implemented it; the host matches the firmware.

Verb payloads

Transaction Code Request payload Response payload (after cmd, ack)
INFO 0x80 none proto_ver:u8, caps:u32, sram_bytes:u32, sample_hz:u32 (13 bytes)
SET_TIMING 0x81 pulse_us:u16, inter_pulse_us:u16, terminate_gap_us:u16, tack_us:u16, tbit_us:u16, report_on_index:u8 (11 bytes) none. Refused unless idle.
COMMAND_TXN 0x82 cmd_n:u8, report_bits:u8 flags:u8 (bit 0 ack, bit 1 final OK, bit 2 timed out), bits:u16, nbits:u8
WAIT_READY 0x83 timeout_s:u16 status:u8: 0 ready, 1 timed out
CAPTURE 0x84 motion_n:u8, rate:u16, tpt:u16, direction:u8, pass_id:u16, byte_budget:u32 (12 bytes) none; the capture stream follows
SELECT 0x85 reserved, not implemented
ABORT 0x86 reserved, not a command
SCOPE 0x87 cmd_n:u8, duration_ms:u16 (duration capped at 10 s) initial:u8, n_edges:u8, overflow:u8, counts:4×u16, then n_edges × {t_us:u32, state:u8}; at most 10 edges
BUILD_INFO 0x88 none commit:40 bytes ASCII hex (zero-filled if unknown), dirty:u8

Notes on each:

  • Capability gate. DeviceLink.open() first sends GW's GET_INFO for the board identity, then INFO. It refuses a device whose proto_ver is below PROTO_VERSION or that lacks the verbs and capture capabilities. Stock GW answers INFO with ACK_BAD_COMMAND.
  • COMMAND_TXN is the verbatim seam. The host sends a QIC-117 command as a number of STEP pulses and never its meaning. The host raises if the ack or final bit is missing.
  • Drive select uses GW's own SET_BUS_TYPE, SELECT, DESELECT and MOTOR. Code 0x85 exists only in the table.
  • CAPTURE sends the motion command, then arms GW's flux read with no index limit and no deadline. It does not wait for ready first, because a status report can swallow the first segment. With byte_budget above 0 the device stops itself after that many flux bytes and seals the stream with END. With 0 it runs until the host stops it.
  • Stopping a capture. The host stops a running capture out of band with GW's clear-comms baud change (BAUD_CLEAR_COMMS, handled in floppy_configure). ABORT names that action; it is never sent as a packet. The firmware also ends a pass by itself when the tape stops at logical EOT (no flux for 1 s).
  • BUILD_INFO is its own verb rather than more INFO fields, so INFO's layout stays stable. Older images answer ACK_BAD_COMMAND.

The capture stream

After CAPTURE, the device streams GW flux bytes with markers on GW's 0xFF opcode escape:

0xFF  marker_code:u8  len:u8  payload...
Marker Code Payload
SESSION_START 0xF0 rate:u16, clock:u32, tpt:u16, direction:u8, pass_id:u16 (11 bytes)
SEGMENT 0xF1 ticks:u32, index:u32 (8 bytes), one per INDEX edge
EVENT 0xF2 code:u8 (an Event: MOTION_STARTED, HOLE_EOT, OVERFLOW, GAP)
END 0xF3 reason:u8, flux_count:u32, byte_count:u32, checksum:u32 (13 bytes)
HEARTBEAT 0xF4 Defined; optional keepalive

There is no byte stuffing. 0xFF is an escape only as the lead byte of a token and appears freely as data, so the host walks the stream token by token (tapewyrm_archive.twrf.parse_body). END lets the host verify its parse: the flux byte count and additive checksum must match what it read. The stream format, as stored in a TWRF file, is specified in TWS-1.

The [[marker]] table in protocol.toml carries a TODO(bench) comment saying the marker values are placeholders to be reconciled with GW's opcode space. That reconciliation is done: GW's flux opcodes are 1 to 3, the markers are 0xF0 to 0xF4, and real captures parse and verify against END. The comment is stale.

The 64-byte response limit

GW sends a command response as one 64-byte USB packet. Anything longer is cut off. On the bench a 253-byte SCOPE response arrived truncated at 64 bytes and the host desynchronised.

Every verb response, including the 2-byte cmd/ack header, must therefore fit in 64 bytes. Data that cannot fit must be streamed, as CAPTURE (and GW's READ_FLUX) do. Current sizes:

Verb Response size
INFO 2 + 13 = 15
COMMAND_TXN 2 + 4 = 6
WAIT_READY 2 + 1 = 3
SCOPE 2 + 3 + 8 + 10 × 5 = 63, which is why the edge log is capped at 10
BUILD_INFO 2 + 41 = 43

Add a verb

  1. Add a [[transaction]] entry to protocol/protocol.toml with the next free code at 0x80 or above, and a desc.
  2. Run just gen. Commit protocol.toml, firmware/inc/protocol.h and tapewyrm/link/protocol.py together.
  3. In firmware/src/qic/qic.c, add #define CMD_QIC_<NAME> TW_TXN_<NAME> and a handler. Document the request and response layout in the comment above it, little-endian, with the total size. Check the response against the 64-byte limit.
  4. Add the case CMD_QIC_<NAME>: to process_command() in src/floppy.c.
  5. Add the call to DeviceLink in tapewyrm/link/device.py with the same layout, and a test that drives it through FakeTransport.
  6. If the verb is optional, make the host tolerate ACK_BAD_COMMAND from an older image, as BUILD_INFO does. Otherwise, bump version in protocol.toml so the capability gate refuses images without it.

Add a marker or event

  1. Add the entry to [[marker]], [[event]] or [[end_reason]] and run just gen.
  2. Update WireMarker in tapewyrm_archive/twrf.py to match (the test in tapewyrm-cli fails until you do) and teach parse_body its payload.
  3. Emit it from qic.c with the same payload layout.
  4. Update TWS-1.

See also