//! A small framed protocol for measuring the board's serial link, shared verbatim by the host tool //! and the firmware that answers it. //! //! WHY A PROTOCOL AND NOT A STOPWATCH. Timing an editor's keystrokes measures the editor, the //! renderer and the link at once, and cannot tell a dropped byte from a slow one: RX overrun on this //! UART is silent in hardware and uncounted in the driver, so a missing keystroke and a late one look //! identical from the host. A frame with a length and a checksum turns both into facts. If the CRC //! matches, every byte of that payload crossed intact; if a frame never completes, bytes were lost //! and the count says how many. A throughput number that is not checksummed is a guess about how //! fast data was corrupted. //! //! THE SHAPE. One fixed 9-byte header, little-endian, then the payload: //! //! "P4" op:u8 len:u16 crc:u32 payload[len] //! //! The CRC covers the payload only. The header carries it rather than trailing it so a receiver //! knows, before it has read a single payload byte, exactly how many to expect and what they must //! hash to - which is what lets the firmware verify a stream with one 4-byte accumulator and no //! buffer at all. //! //! `max_payload` is 1024 and that is a memory decision, not a wire one. The firmware has a 384 KiB //! heap it must share with an editor, and a bulk test that needed a 64 KiB frame buffer would be //! measuring a configuration nobody ships. Bulk transfers are therefore many frames, which is also //! the honest shape: it is the per-frame overhead a real protocol would pay. //! //! Both directions use the same header, and a reply's op has the high bit set, so a stray reply can //! never be mistaken for a request by a resynchronising receiver. const std = @import("std"); pub const magic = "P4"; pub const header_len = 9; pub const max_payload = 1024; pub const Op = enum(u8) { /// Echo the payload back as `pong`. Both directions verified in one exchange, which is what /// makes it the right stimulus for a latency measurement. ping = 1, /// Payload is data to be consumed. The board accumulates a running count and CRC and answers /// nothing, so the host can keep the uplink full and measure it without return traffic /// competing for the same wire. sink = 2, /// Ask for the accumulated `sink` count and CRC, then reset them. report = 3, /// Payload is a u32 count: send exactly that many pattern bytes back, in `data` frames, /// followed by a `stat`. source = 4, pong = 0x81, /// Payload is `Stat`, packed little-endian. stat = 0x83, /// A chunk of `source` output. data = 0x84, pub fn isReply(o: Op) bool { return @intFromEnum(o) & 0x80 != 0; } }; /// What the board reports about a stream it received or sent. Encoded by hand rather than by /// `@bitCast` of a packed struct: this crosses between a riscv32 firmware and an x86_64 host, and a /// layout that depends on either compiler's padding rules is a bug waiting for a target change. pub const Stat = struct { /// Payload bytes accumulated. bytes: u32, /// CRC-32 over exactly those bytes, in order. crc: u32, /// Frames whose CRC did not match. Nonzero means the link corrupted data rather than losing it, /// which is a different fault with a different fix. bad_frames: u32, /// Bytes the firmware's UART driver gave up on writing. Its own counter, surfaced here because /// the host cannot see it any other way. tx_dropped: u32, pub const encoded_len = 16; pub fn encode(s: Stat, out: *[encoded_len]u8) void { std.mem.writeInt(u32, out[0..4], s.bytes, .little); std.mem.writeInt(u32, out[4..8], s.crc, .little); std.mem.writeInt(u32, out[8..12], s.bad_frames, .little); std.mem.writeInt(u32, out[12..16], s.tx_dropped, .little); } pub fn decode(in: []const u8) ?Stat { if (in.len < encoded_len) return null; return .{ .bytes = std.mem.readInt(u32, in[0..4], .little), .crc = std.mem.readInt(u32, in[4..8], .little), .bad_frames = std.mem.readInt(u32, in[8..12], .little), .tx_dropped = std.mem.readInt(u32, in[12..16], .little), }; } }; pub fn crc(bytes: []const u8) u32 { return std.hash.Crc32.hash(bytes); } /// The deterministic byte at stream offset `i`. /// /// A counter would be checksummed correctly by an implementation that lost exactly 256 bytes, and a /// constant by one that lost any amount. This is an 8-bit xorshift-ish walk whose period is long /// enough that no realistic loss aligns with it, so the CRC catches a gap wherever it falls. pub fn patternByte(i: u32) u8 { var x: u32 = i +% 1; x ^= x << 7; x ^= x >> 3; x ^= x << 5; return @truncate(x); } pub fn fillPattern(buf: []u8, offset: u32) void { for (buf, 0..) |*b, k| b.* = patternByte(offset +% @as(u32, @intCast(k))); } /// Write a frame into `out`, returning the used slice. `out` must hold `header_len + payload.len`. pub fn encode(out: []u8, op: Op, payload: []const u8) []u8 { std.debug.assert(payload.len <= max_payload); std.debug.assert(out.len >= header_len + payload.len); out[0] = magic[0]; out[1] = magic[1]; out[2] = @intFromEnum(op); std.mem.writeInt(u16, out[3..5], @intCast(payload.len), .little); std.mem.writeInt(u32, out[5..9], crc(payload), .little); @memcpy(out[header_len..][0..payload.len], payload); return out[0 .. header_len + payload.len]; } pub const Header = struct { op: Op, len: u16, crc: u32, }; /// Read a header out of `buf`. Returns null when fewer than `header_len` bytes are present, and /// `error.BadFrame` when the magic or the op is not one of ours - which is how a receiver that has /// lost sync tells "wait for more" from "throw a byte away and try again". pub fn parseHeader(buf: []const u8) error{BadFrame}!?Header { if (buf.len < header_len) return null; if (buf[0] != magic[0] or buf[1] != magic[1]) return error.BadFrame; const op = std.enums.fromInt(Op, buf[2]) orelse return error.BadFrame; const len = std.mem.readInt(u16, buf[3..5], .little); if (len > max_payload) return error.BadFrame; return .{ .op = op, .len = len, .crc = std.mem.readInt(u32, buf[5..9], .little) }; } test "a frame round-trips through encode and parseHeader" { var buf: [header_len + 4]u8 = undefined; const f = encode(&buf, .ping, "abcd"); try std.testing.expectEqual(@as(usize, header_len + 4), f.len); const h = (try parseHeader(f)).?; try std.testing.expectEqual(Op.ping, h.op); try std.testing.expectEqual(@as(u16, 4), h.len); try std.testing.expectEqual(crc("abcd"), h.crc); try std.testing.expectEqualStrings("abcd", f[header_len..]); } test "a short buffer is incomplete, not invalid" { var buf: [header_len]u8 = undefined; const f = encode(&buf, .report, ""); try std.testing.expectEqual(@as(?Header, null), try parseHeader(f[0 .. header_len - 1])); } test "wrong magic and unknown ops are rejected rather than misread" { var buf: [header_len]u8 = undefined; var f = encode(&buf, .report, ""); f[0] = 'X'; try std.testing.expectError(error.BadFrame, parseHeader(f)); f[0] = magic[0]; f[2] = 0x7f; try std.testing.expectError(error.BadFrame, parseHeader(f)); } test "a truncated stream is caught by the CRC" { // Losing bytes is the failure this protocol exists to detect, so prove the checksum notices a // gap that leaves the length plausible. var full: [64]u8 = undefined; fillPattern(&full, 0); var gapped: [64]u8 = undefined; fillPattern(gapped[0..32], 0); fillPattern(gapped[32..], 33); // one byte skipped mid-stream try std.testing.expect(crc(&full) != crc(&gapped)); } test "the pattern does not repeat inside a byte-aligned loss" { // A plain counter would hash identically after losing exactly 256 bytes. This must not. var a: [128]u8 = undefined; var b: [128]u8 = undefined; fillPattern(&a, 0); fillPattern(&b, 256); try std.testing.expect(crc(&a) != crc(&b)); } test "Stat survives the trip between a riscv32 firmware and an x86_64 host" { const s: Stat = .{ .bytes = 0x11223344, .crc = 0xdeadbeef, .bad_frames = 7, .tx_dropped = 9 }; var buf: [Stat.encoded_len]u8 = undefined; s.encode(&buf); const back = Stat.decode(&buf).?; try std.testing.expectEqual(s, back); try std.testing.expectEqual(@as(?Stat, null), Stat.decode(buf[0 .. Stat.encoded_len - 1])); }