summaryrefslogtreecommitdiff
path: root/src/board9p.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/board9p.zig')
-rw-r--r--src/board9p.zig874
1 files changed, 874 insertions, 0 deletions
diff --git a/src/board9p.zig b/src/board9p.zig
new file mode 100644
index 00000000..b5b18bf2
--- /dev/null
+++ b/src/board9p.zig
@@ -0,0 +1,874 @@
+//! THE BOARD AS A FILESYSTEM, generated from a comptime table of what the board can do.
+//!
+//! The ESP32-P4 already exposes its pads and its address space — by TYPING A WORD into a tag.
+//! `Gpio 20` flips a pin and prints `GPIO 20: 0->1`, `Gpio` alone draws JP1, `Peek`, `Poke` and
+//! `Hexdump` reach all 2³² addresses (`src/board_memory.zig`), and every one of the four caps at
+//! 4,096 bytes because the answer has to fit down a 115200-baud console. Nothing about that is
+//! machine-readable and nothing about it is remote: the answer lands in an output pane, for a person
+//! to read (`docs/registry.typ` `9P-11`, review note).
+//!
+//! This file is that same capability WITH NAMES INSTEAD OF VERBS. `cat gpio/pinout` is `Gpio`;
+//! `echo 1 > gpio/20/value` is `Gpio 20`, except that it says which level it wants instead of asking
+//! for whichever one it is not. A shell pipeline can do it, a script on a laptop can do it over the
+//! UART, and neither needs a terminal emulator or a pane.
+//!
+//! WHY A TABLE, which is the whole design and not a flourish. A hand-written tree is a `Node`
+//! packing, a `lookup`, a `getattr`, a `readdir`, a `read` and a `write` — six places that have to
+//! agree about what exists — and the cost of adding `uptime` to it is an edit to all six plus a new
+//! node id nobody else is using. The board's capabilities are a LIST, they will grow, and the entry
+//! that describes one should be the only place it is described. So `caps` below is the tree: the
+//! directories, the files, the per-pin fan-out, the permissions, the handlers and even the size of
+//! the answer buffer are all derived from it at comptime, and the six functions at the bottom read
+//! the derived table and know nothing about GPIO at all.
+//!
+//! WHAT A SECOND CAPABILITY COSTS, entry by entry, because "extensible" is a claim and this is the
+//! evidence for it. Not implemented here — none of them is needed to serve a pin — but each is one
+//! `Cap` and its handlers, and NO tree code:
+//!
+//! * `mem/` — `peek` and `poke` over `board_memory.readWord`/`writeWord`
+//! (`src/board_memory.zig:136-144`), which are four lines of `*allowzero volatile` and already
+//! compile for this target. `poke` is a WRITE handler that parses `<hex addr> <hex value>`, so
+//! it needs `Fault.Malformed` and nothing else; `peek` needs an address to read, which a
+//! stateless file cannot carry, so it is either a write-then-read pair (`echo 4ff40000 > addr;
+//! cat word`, one more file and one `u32` of state) or a fan over a comptime list of interesting
+//! registers. The second is free: `fan` below already generates a directory per key.
+//! * `hexdump` — the same, with `scratch = 4096`: the one field that makes the shared answer
+//! buffer grow, and the reason that field is in the table rather than a constant at the top.
+//! * `prof` — three cycle counts from `pardes_esp32p4_frame_prof`, which the editor object already
+//! exports (`src/esp32p4.zig:985`). It is the one capability that is NOT available in this
+//! image: that symbol lives in the pardes object and the 9P image links none, so serving it
+//! would mean either linking the editor or moving the counters. Worth saying out loud rather
+//! than listing it as cheap.
+//! * `uptime` and `heap` — `hal.systimer` and the heap's own free count, both of which the
+//! runtime (`src/esp32p4_9p.zig`) can reach today. Two read handlers, `scratch = 24`, one
+//! `Cap` each. These are the cheapest of the four and the reason the table's `board` parameter
+//! is a TYPE rather than a pair of function pointers: adding `board.uptimeMs()` to the seam
+//! adds a capability without changing anything here but the table.
+//!
+//! THE ABI IS `acmefs`'s, VERBATIM — `Op`, `Status`, `Req`, `Reply`, `Reply.Attr` with the same
+//! fields and the same meanings — so `src/9p.zig`'s `Server` serves this tree with no translation
+//! layer, exactly as it serves the editor's. That is the point of `Server` being a generic over the
+//! filesystem rather than an importer of one (`src/9p.zig:1994-2010`), and it is what makes a board
+//! image possible at all: `acmefs.zig` reaches `pardes.zig` and the whole core, and this file
+//! reaches `std` and one leaf table.
+//!
+//! NO ALLOCATOR, NO OS, ONE REQUEST AT A TIME. Same rules as `acmefs`: `handle(req) -> Answer` is a
+//! pure transaction, the answer's bytes are either `.rodata` or the one shared buffer, and they are
+//! borrowed until the next call. Nothing here blocks, so `Status.again` never appears — the board
+//! has no `event` file and no reader to park.
+const std = @import("std");
+const board_pins = @import("board_pins.zig");
+
+/// The errno values this tree returns. `acmefs.E`'s subset — the four a tree with no panes, no
+/// blocking and no allocation can produce — with the same numbers, because they are Linux's and a
+/// second spelling would be a second thing to check against `9p.errString`.
+pub const E = struct {
+ pub const NOENT: u16 = 2;
+ pub const IO: u16 = 5;
+ pub const NOTDIR: u16 = 20;
+ pub const INVAL: u16 = 22;
+};
+
+/// How a HANDLER refuses, as against how the tree refuses. The tree answers ENOENT and ENOTDIR
+/// itself, out of the table, before any handler runs; this is the set of things only the handler can
+/// know.
+///
+/// One variant today, and it is the honest count: a pad takes `0` or `1` and nothing else. A
+/// capability that can refuse for a second reason adds a variant here and a prong to `errnoOf`,
+/// which is the whole of what "another kind of no" costs.
+pub const Fault = error{
+ /// the bytes offered are not a value this file takes
+ Malformed,
+};
+
+/// The one place a `Fault` becomes a number.
+fn errnoOf(f: Fault) u16 {
+ return switch (f) {
+ error.Malformed => E.INVAL,
+ };
+}
+
+/// A file's two halves, as POINTERS rather than function types: the derived table below is an
+/// ordinary runtime array, and a struct holding a bare `fn` is comptime-only.
+///
+/// `key` says which pad, address or counter the call is about, and `out` is the slice of the shared
+/// answer buffer this file's table entry declared — exactly `scratch` bytes, so a handler cannot
+/// write past its own budget. A read may also ignore `out` entirely and answer out of `.rodata`,
+/// which is what the JP1 drawing does.
+const ReadFn = *const fn (key: u16, out: []u8) Fault![]const u8;
+const WriteFn = *const fn (key: u16, bytes: []const u8) Fault!u32;
+
+/// One FILE in the table. `key` is not here: it comes from the directory the file is generated
+/// into, which is what makes one entry serve eleven pins.
+///
+/// The MODE is derived, never declared: a file with both handlers is 0o600, a read handler alone is
+/// 0o400, a write handler alone is 0o200, and neither is a compile error. A declared mode is a
+/// fourth thing that can disagree with the three that decide it.
+pub const FileSpec = struct {
+ name: []const u8,
+ read: ?ReadFn = null,
+ write: ?WriteFn = null,
+ /// Bytes of the shared answer buffer this file's read needs. ZERO when the read answers out of
+ /// `.rodata` and copies nothing, which is what `gpio/pinout` does — the JP1 drawing is 468
+ /// bytes of static text and there is no reason to stage it. The largest `scratch` in the table
+ /// is one of the two numbers that size `Tree.out`.
+ scratch: u32 = 0,
+};
+
+/// One generated subdirectory of a capability, and its KEY: the pad, address or counter every file
+/// inside it is about. `gpio/20/value` is `key = 20`.
+pub const FanDir = struct { key: u16, name: []const u8 };
+
+/// A capability's fan-out: one directory per key, each holding the same files. THE REASON the tree
+/// has exactly the pins this board has — the dirs are collected from `board_pins.gpio_pins`, which
+/// is collected from the JP1 rows, which are the schematic.
+pub const Fan = struct { dirs: []const FanDir, files: []const FileSpec };
+
+/// ONE CAPABILITY = ONE DIRECTORY under the root. Always a directory, even for a capability with a
+/// single file: a flat root would put every capability's names in one u4 (see `block` below) and
+/// would make `ls /` a list of files whose grouping a reader has to infer. `ls /` here is the list
+/// of things this board can do.
+pub const Cap = struct {
+ name: []const u8,
+ files: []const FileSpec = &.{},
+ fan: ?Fan = null,
+};
+
+/// One node of the derived tree. Flat, because a table of fifteen entries scanned linearly is
+/// faster than any structure with pointers in it and is the same shape `src/9p.zig`'s own test stub
+/// uses — and because a scan cannot disagree with itself about what the tree contains.
+const Entry = struct {
+ node: u64,
+ /// Where `..` goes. See `block`: this is also the value `src/9p.zig`'s `parentOf` derives from
+ /// the node id, for every entry but a fan leaf, and the test at the bottom asserts it.
+ parent: u64,
+ name: []const u8,
+ dir: bool,
+ mode: u16,
+ /// the pad this file is about, or zero
+ key: u16 = 0,
+ read: ?ReadFn = null,
+ write: ?WriteFn = null,
+ scratch: u32 = 0,
+};
+
+/// THE NODE ID PACKING, and it is not ours: it is `acmefs.Node`'s, `{ file: u4, serial: u60 }`,
+/// because `src/9p.zig:1955` `parentOf` READS node ids to answer `..` and has that packing built in.
+/// A tree that numbered its nodes freely would get a wrong answer to `cd ..` and no diagnostic.
+///
+/// The rule, restated as arithmetic: a node's parent is the node rounded down to a multiple of 16,
+/// except that a node already at a multiple of 16 — or below 16 — is a child of the root.
+///
+/// * the root is 1: serial 0, so `..` is itself, which is POSIX's rule and `intro(5)`'s.
+/// * a capability directory is its own BLOCK BASE, `(index + 1) * 16`, so its `..` is the root.
+/// * everything inside a capability — its files AND its fan directories — is a member of that
+/// block, `base + 1 .. base + 15`, so their `..` is the capability directory. Correct, which is
+/// what matters for the one `..` a client actually performs: `cd /gpio/20; cd ..`.
+/// * a fan LEAF (`gpio/20/value`) cannot be expressed. Its parent is a block member, and
+/// `parentOf` can only produce block bases. So leaves get blocks of their own, above every
+/// capability's, and `..` from one lands on an unallocated block base, which this tree answers
+/// ENOENT. That is the honest failure: a walk that cannot be expressed is refused rather than
+/// silently landing on a different file. No client does it — `..` from a file requires having
+/// walked INTO a file, and a file is not a directory — and the fix, if one is ever wanted, is a
+/// `parent` hook on `Server` so a filesystem deeper than two levels answers `..` itself. That
+/// is exactly the wall `parentOf`'s own doc comment says it is (`src/9p.zig:1950-1954`), and
+/// `acmefs`'s `pty/` subtree stands on the same side of it today.
+const block: u64 = 16;
+
+/// The root, and the value the runtime hands `Server.init` as `Options.root`. One, for the same
+/// reason `acmefs.TopFile.root` is one: node 0 is `{ file: 0, serial: 0 }` and cannot be a root
+/// (`src/9p.zig:2190-2192`).
+pub const root: u64 = 1;
+
+/// Every key the GPIO fan generates, re-exported for the RUNTIME's benefit: `src/esp32p4_9p.zig`
+/// checks at comptime that each one is a pad `hal.gpio` will accept, which is the one thing this
+/// file cannot check for itself — `max_pin` is a property of the chip package and lives in the
+/// toolchain repository, and importing it here would make the tree unbuildable on a host.
+pub const pins = board_pins.gpio_pins;
+
+/// The board's own tree, over a `board` seam the runtime supplies.
+///
+/// GENERIC over the board for exactly the reason `Server` is generic over the filesystem: the pads
+/// are four register files behind `hal.gpio` in the toolchain package, which exists only for
+/// riscv32, and a tree that imported it could not be tested on a host at all. The seam is two
+/// functions, both about the level the board is DRIVING:
+///
+/// * `board.level(pin: u8) u1`
+/// * `board.drive(pin: u8, level: u1) void`
+///
+/// `src/esp32p4_9p.zig` implements them over `hal.gpio`, in the same four calls
+/// `src/esp32p4/app.zig:200-209` uses for the `Gpio` word — the same seam, a second caller, not a
+/// second copy of the register sequence. The tests below implement them over a recording stub, the
+/// way `src/9p.zig`'s server tests implement a filesystem.
+pub fn Tree(comptime board: type) type {
+ return struct {
+ const Self = @This();
+
+ // -- the ABI, which is `acmefs`'s ------------------------------------
+ //
+ // A MIRROR, not a redefinition: `Server(acmefs)` is the instantiation that proves the
+ // shape, and a field that drifts from it is a compile error the moment `Server(Tree(...))`
+ // is built — which the tests at the bottom do.
+
+ pub const Op = enum(u8) { lookup, getattr, setattr, open, read, write, release, readdir, statfs };
+
+ /// `again` is here because the ABI has it, and it never occurs: nothing on this board
+ /// blocks. The board's answer to "what is this pin at" is a register read.
+ pub const Status = enum(u8) { ok, again, err };
+
+ pub const Req = struct {
+ tag: u64,
+ op: Op,
+ node: u64,
+ handle: u32 = 0,
+ off: u64 = 0,
+ size: u32 = 0,
+ data: []const u8 = &.{},
+ truncate: bool = false,
+ };
+
+ pub const Reply = struct {
+ tag: u64,
+ status: Status = .ok,
+ errno: u16 = 0,
+ attr: Attr = .{},
+ handle: u32 = 0,
+ written: u32 = 0,
+
+ pub const Attr = struct {
+ node: u64 = 0,
+ dir: bool = false,
+ size: u64 = 0,
+ mode: u16 = 0o600,
+ };
+
+ /// `acmefs.Reply.fail`'s twin, so a refusal is one expression here as it is there.
+ pub fn fail(tag: u64, e: u16) Reply {
+ return .{ .tag = tag, .status = .err, .errno = e };
+ }
+ };
+
+ /// A reply and the bytes it points at, borrowed until the next `handle`. `Server.reply`
+ /// takes exactly this pair.
+ pub const Answer = struct { reply: Reply, bytes: []const u8 = "" };
+
+ // -- the handlers ----------------------------------------------------
+
+ /// `gpio/pinout` — JP1, as the `Gpio` word draws it, TO THE BYTE. The same
+ /// `board_pins.jp1_text` the word prints (`src/board_memory.zig:364`), returned out of
+ /// `.rodata` rather than staged, so this read costs no buffer and no copy.
+ fn readPinout(_: u16, _: []u8) Fault![]const u8 {
+ return board_pins.jp1_text;
+ }
+
+ /// `gpio/<n>/value` — the level this board is DRIVING on pad `n`, as `0` or `1` and a
+ /// newline.
+ ///
+ /// THE DRIVEN LEVEL and not the pad's, for the reason `src/esp32p4/app.zig:196-199` gives:
+ /// the pad's own level is what the outside world says, and on an unconnected header pin that
+ /// is noise. The driven level is defined for every pin, which is what a file that a script
+ /// reads in a loop needs.
+ ///
+ /// The trailing newline is not decoration: `cat gpio/20/value` in a terminal and `$(cat
+ /// ...)` in a script both want it, and the write side accepts it back, so `cp` of one pin's
+ /// value onto another's is a legal round trip.
+ fn readValue(key: u16, out: []u8) Fault![]const u8 {
+ out[0] = '0' + @as(u8, board.level(@intCast(key)));
+ out[1] = '\n';
+ return out[0..2];
+ }
+
+ /// `gpio/<n>/value` — drive pad `n` to `0` or `1`.
+ ///
+ /// WRITING THE OPPOSITE OF THE CURRENT LEVEL IS THE `Gpio` WORD'S TOGGLE, through the same
+ /// seam; writing the level it is already at is not a no-op, because the FIRST write to a pad
+ /// is what makes it an output at all (`hal.gpio.configureOutput`, four register files). So
+ /// this always drives, and `echo 0 > value` on a fresh boot is a meaningful command: it
+ /// takes the pad off whatever the IO MUX had it pointed at and holds it low.
+ ///
+ /// `0`, `1`, `0\n` and `1\n` are the whole language. Anything else is EINVAL, including
+ /// `true`, `high`, `01` and the empty write — a file whose only two values are one character
+ /// each has no room for a spelling debate, and guessing at `on` would be the beginning of
+ /// one.
+ fn writeValue(key: u16, bytes: []const u8) Fault!u32 {
+ const want = try oneBit(bytes);
+ board.drive(@intCast(key), want);
+ // The whole write is consumed, trailing newline included: a short count would make
+ // `echo` retry the tail and drive the pin a second time.
+ return @intCast(bytes.len);
+ }
+
+ /// `0` or `1`, with at most one trailing newline (and the `\r` a Windows-ish client may put
+ /// in front of it). Nothing else.
+ fn oneBit(bytes: []const u8) Fault!u1 {
+ var end = bytes.len;
+ while (end > 0 and (bytes[end - 1] == '\n' or bytes[end - 1] == '\r')) end -= 1;
+ if (end != 1) return error.Malformed;
+ return switch (bytes[0]) {
+ '0' => 0,
+ '1' => 1,
+ else => error.Malformed,
+ };
+ }
+
+ // -- THE TABLE -------------------------------------------------------
+
+ /// The pin directories, one per P4 GPIO the header brings out, named by the pin number in
+ /// DECIMAL — the number the schematic, the silkscreen and the datasheet all use, and the one
+ /// literal in `board_memory.zig` that is not hex (`:394-399`). Generated from
+ /// `board_pins.gpio_pins`, so this list cannot contain a pin JP1 does not have.
+ const gpio_dirs = dirs: {
+ var out: [board_pins.gpio_pins.len]FanDir = undefined;
+ for (board_pins.gpio_pins, 0..) |pin, i| out[i] = .{
+ .key = pin,
+ .name = std.fmt.comptimePrint("{d}", .{pin}),
+ };
+ break :dirs out;
+ };
+
+ /// EVERYTHING THIS BOARD OFFERS, and the only place any of it is described. The tree, the
+ /// permissions, the handlers, the node ids and the answer buffer all come out of here.
+ const caps = [_]Cap{
+ .{
+ .name = "gpio",
+ .files = &.{
+ .{ .name = "pinout", .read = readPinout },
+ },
+ .fan = .{
+ .dirs = &gpio_dirs,
+ .files = &.{
+ .{ .name = "value", .read = readValue, .write = writeValue, .scratch = 2 },
+ },
+ },
+ },
+ };
+
+ /// How many nodes the table generates, counted separately because it is an array length.
+ const node_count = count: {
+ var n: usize = 1; // the root
+ for (caps) |c| {
+ n += 1 + c.files.len;
+ if (c.fan) |f| n += f.dirs.len * (1 + f.files.len);
+ }
+ break :count n;
+ };
+
+ /// THE DERIVED TREE. Built once at comptime and `const`, so it lands in `.rodata` and costs
+ /// the image its bytes and the board's RAM nothing.
+ const table: [node_count]Entry = build: {
+ var out: [node_count]Entry = undefined;
+ out[0] = .{ .node = root, .parent = root, .name = "/", .dir = true, .mode = 0o500 };
+ var at: usize = 1;
+ // Blocks 1..caps.len are the capability directories; fan leaves take the ones above,
+ // which is what keeps a leaf's unexpressible parent from landing on a real node.
+ var next_block: u64 = caps.len + 1;
+ for (caps, 0..) |c, ci| {
+ const dir_node = (ci + 1) * block;
+ out[at] = .{ .node = dir_node, .parent = root, .name = c.name, .dir = true, .mode = 0o500 };
+ at += 1;
+ // The u4 in the node id, spent one per name inside this capability. Directories and
+ // files come out of the same fifteen, which is the wall `acmefs.PaneFile`'s doc
+ // comment describes from the other side.
+ var slot: u64 = 1;
+ for (c.files) |f| {
+ out[at] = fileEntry(dir_node + slot, dir_node, f, 0);
+ at += 1;
+ slot += 1;
+ }
+ if (c.fan) |fan| for (fan.dirs) |d| {
+ const fan_node = dir_node + slot;
+ slot += 1;
+ out[at] = .{ .node = fan_node, .parent = dir_node, .name = d.name, .dir = true, .mode = 0o500 };
+ at += 1;
+ const leaf_base = next_block * block;
+ next_block += 1;
+ for (fan.files, 0..) |f, l| {
+ out[at] = fileEntry(leaf_base + 1 + l, fan_node, f, d.key);
+ at += 1;
+ }
+ };
+ if (slot >= block) @compileError(
+ "capability '" ++ c.name ++
+ "' has more than 15 names in it, and a node id has four bits for them:" ++
+ " `acmefs.Node.file` is a u4 and `9p.parentOf` reads it. Split it into two" ++
+ " capabilities, or widen the packing in acmefs.zig, 9p.zig and here at once.",
+ );
+ }
+ break :build out;
+ };
+
+ /// One file's entry, with the mode derived from which handlers it has.
+ fn fileEntry(node: u64, parent: u64, f: FileSpec, key: u16) Entry {
+ const mode: u16 = if (f.read != null and f.write != null)
+ 0o600
+ else if (f.read != null)
+ 0o400
+ else if (f.write != null)
+ 0o200
+ else
+ @compileError("file '" ++ f.name ++ "' has no read and no write, so it is a name and not a file");
+ return .{
+ .node = node,
+ .parent = parent,
+ .name = f.name,
+ .dir = false,
+ .mode = mode,
+ .key = key,
+ .read = f.read,
+ .write = f.write,
+ .scratch = f.scratch,
+ };
+ }
+
+ /// THE ONE BUFFER, and both numbers that size it come out of the table: the largest
+ /// `scratch` any read declares, and the widest directory's worth of staged entries. Never
+ /// both at once — one request is in flight at a time — so one buffer serves both, and the
+ /// board pays for the larger.
+ const out_max = size: {
+ var most: usize = 0;
+ for (table) |e| most = @max(most, e.scratch);
+ for (table) |d| {
+ if (!d.dir) continue;
+ var n: usize = 0;
+ for (table) |e| if (e.parent == d.node and e.node != d.node) {
+ n += dirent_fixed + e.name.len;
+ };
+ most = @max(most, n);
+ }
+ break :size most;
+ };
+
+ /// `node[8] dir[1] namelen[1]` — `acmefs`'s staging format for a readdir
+ /// (`acmefs.zig:942-957`), which is what `Server` decodes. Ten bytes and then the name.
+ const dirent_fixed = 8 + 1 + 1;
+
+ /// Formatted answers and staged directory entries. Valid until the next `handle`, which is
+ /// the borrow window `Server.reply` documents.
+ out: [out_max]u8 = undefined,
+
+ /// Every request the board has been asked, for the runtime's own diagnostics. Not a
+ /// protocol counter — `Server` keeps those — and not a statistic anybody has to read: it is
+ /// the one number that distinguishes "nothing is arriving" from "everything is being
+ /// refused" on a board with no second console to ask.
+ calls: u32 = 0,
+
+ fn find(node: u64) ?*const Entry {
+ for (&table) |*e| if (e.node == node) return e;
+ return null;
+ }
+
+ fn attrOf(t: *Self, e: *const Entry) Reply.Attr {
+ return .{ .node = e.node, .dir = e.dir, .mode = e.mode, .size = t.sizeOf(e) };
+ }
+
+ /// A file's size is WHAT ITS READ ANSWERS, asked rather than declared. That means a
+ /// `getattr` of `gpio/20/value` reads the pad's output register, which is a load from a
+ /// peripheral and nothing more; the alternative is a second declaration in the table that
+ /// can disagree with the handler, on a tree whose whole claim is that there is one place per
+ /// fact. A write-only file has no size and reports zero, which is what `acmefs` reports for
+ /// every file it cannot cheaply measure.
+ fn sizeOf(t: *Self, e: *const Entry) u64 {
+ const read = e.read orelse return 0;
+ const bytes = read(e.key, t.out[0..e.scratch]) catch return 0;
+ return bytes.len;
+ }
+
+ /// ONE OPERATION, and the whole of what this filesystem is. Pure: no allocation, no
+ /// blocking, no state but `out` and the counter.
+ pub fn handle(t: *Self, req: Req) Answer {
+ t.calls += 1;
+ const e = find(req.node) orelse return .{ .reply = .fail(req.tag, E.NOENT) };
+ switch (req.op) {
+ .lookup => {
+ if (!e.dir) return .{ .reply = .fail(req.tag, E.NOTDIR) };
+ for (&table) |*c| {
+ if (c.parent != req.node or c.node == req.node) continue;
+ if (!std.mem.eql(u8, c.name, req.data)) continue;
+ return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(c) } };
+ }
+ return .{ .reply = .fail(req.tag, E.NOENT) };
+ },
+ .getattr => return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(e) } },
+ // The only `setattr` that reaches here is a truncate, from `Topen` with `OTRUNC`
+ // (`src/9p.zig:2816-2822`) — which is what `echo 1 > gpio/20/value` opens with.
+ // Every file here is a fixed-length register view, so there is nothing to truncate
+ // and nothing to refuse either: answering EINVAL would make the shell's own
+ // redirection fail on a pin that is perfectly writable.
+ .setattr => {
+ if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) };
+ return .{ .reply = .{ .tag = req.tag, .attr = t.attrOf(e) } };
+ },
+ // No per-open state, so one handle for every open. `Server` checks the mode against
+ // the fid's cached permissions before it gets here (`src/9p.zig:2075-2079`).
+ .open => return .{ .reply = .{ .tag = req.tag, .handle = 1 } },
+ .release => return .{ .reply = .{ .tag = req.tag } },
+ .read => {
+ if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) };
+ const read = e.read orelse return .{ .reply = .fail(req.tag, E.INVAL) };
+ const all = read(e.key, t.out[0..e.scratch]) catch |f| {
+ return .{ .reply = .fail(req.tag, errnoOf(f)) };
+ };
+ // Past the end is the empty read every client uses to stop, not an error.
+ if (req.off >= all.len) return .{ .reply = .{ .tag = req.tag } };
+ const from = all[@intCast(req.off)..];
+ return .{ .reply = .{ .tag = req.tag }, .bytes = from[0..@min(from.len, req.size)] };
+ },
+ .write => {
+ if (e.dir) return .{ .reply = .fail(req.tag, E.INVAL) };
+ const write = e.write orelse return .{ .reply = .fail(req.tag, E.INVAL) };
+ // A REGISTER IS NOT A STREAM. Every file here is one value, so the only offset
+ // that means anything is zero; a client that seeks and writes is describing an
+ // edit to a byte range this file does not have. `echo`, `9p write` and
+ // `cat > file` all write at zero.
+ if (req.off != 0) return .{ .reply = .fail(req.tag, E.INVAL) };
+ const n = write(e.key, req.data) catch |f| {
+ return .{ .reply = .fail(req.tag, errnoOf(f)) };
+ };
+ return .{ .reply = .{ .tag = req.tag, .written = n } };
+ },
+ .readdir => {
+ if (!e.dir) return .{ .reply = .fail(req.tag, E.NOTDIR) };
+ return .{ .reply = .{ .tag = req.tag }, .bytes = t.stage(req.node, req.off) };
+ },
+ // 9P2000 has no `Tstatfs` — that is a `.L` message (`src/9p.zig:24-28`) — so
+ // nothing reaches this. It is answered rather than `unreachable` because the ABI
+ // names it and a panic in a server is worse than an empty answer.
+ .statfs => return .{ .reply = .{ .tag = req.tag } },
+ }
+ }
+
+ /// A directory's children in `acmefs`'s staging format, from an ENTRY INDEX rather than a
+ /// byte offset — `Server` does that coordinate change and advances both cursors
+ /// (`src/9p.zig:2836-2849`). The whole of the widest directory fits `out` by construction,
+ /// so this never stages a short list for want of room; `Server` still takes only what one
+ /// reply holds and asks again.
+ fn stage(t: *Self, node: u64, skip: u64) []const u8 {
+ var n: usize = 0;
+ var seen: u64 = 0;
+ for (&table) |*e| {
+ if (e.parent != node or e.node == node) continue;
+ if (seen < skip) {
+ seen += 1;
+ continue;
+ }
+ std.mem.writeInt(u64, t.out[n..][0..8], e.node, .little);
+ t.out[n + 8] = @intFromBool(e.dir);
+ t.out[n + 9] = @intCast(e.name.len);
+ @memcpy(t.out[n + dirent_fixed ..][0..e.name.len], e.name);
+ n += dirent_fixed + e.name.len;
+ }
+ return t.out[0..n];
+ }
+ };
+}
+
+// ---------------------------------------------------------------------------
+// tests
+// ---------------------------------------------------------------------------
+//
+// A RECORDING STUB FOR THE PADS, exactly as `src/9p.zig`'s server tests use a stub filesystem: the
+// seam is two functions, so the test can hold the pads still and check what was asked of them. Every
+// claim below is one a host can answer — the tree's shape, the bytes of an answer, which pin the
+// seam was called with — and the one claim it cannot is stated as such: whether `hal.gpio` drives
+// the pad, which only the die knows.
+
+const testing = std.testing;
+
+/// The pads, faked. `driven` is the board's output register.
+const StubPads = struct {
+ var driven: [64]u1 = @splat(0);
+ var log: [16]Call = undefined;
+ var log_len: usize = 0;
+
+ const Call = struct { pin: u8, level: u1 };
+
+ fn reset() void {
+ driven = @splat(0);
+ log_len = 0;
+ }
+
+ fn level(pin: u8) u1 {
+ return driven[pin];
+ }
+
+ fn drive(pin: u8, want: u1) void {
+ driven[pin] = want;
+ log[log_len] = .{ .pin = pin, .level = want };
+ log_len += 1;
+ }
+};
+
+const Board = Tree(StubPads);
+
+/// The tree, walked by name the way a client walks it: `lookup` after `lookup` from the root, which
+/// is the only way to find out what the generated table actually offers.
+fn walk(t: *Board, path: []const []const u8) !Board.Reply.Attr {
+ var at: u64 = root;
+ var attr: Board.Reply.Attr = .{ .node = root, .dir = true, .mode = 0o500 };
+ for (path) |name| {
+ const a = t.handle(.{ .tag = 1, .op = .lookup, .node = at, .data = name });
+ if (a.reply.status == .err) return switch (a.reply.errno) {
+ E.NOENT => error.NoEntry,
+ E.NOTDIR => error.NotDirectory,
+ else => error.Refused,
+ };
+ attr = a.reply.attr;
+ at = attr.node;
+ }
+ return attr;
+}
+
+fn readAll(t: *Board, node: u64) !Board.Answer {
+ const open = t.handle(.{ .tag = 1, .op = .open, .node = node });
+ try testing.expectEqual(Board.Status.ok, open.reply.status);
+ return t.handle(.{ .tag = 2, .op = .read, .node = node, .handle = open.reply.handle, .size = 65535 });
+}
+
+test "board9p: the generated tree has exactly the header's pins, and nothing else" {
+ var t: Board = .{};
+
+ // The capability directory, and its one hand-written file.
+ try testing.expect((try walk(&t, &.{"gpio"})).dir);
+ try testing.expect(!(try walk(&t, &.{ "gpio", "pinout" })).dir);
+
+ // Every pin JP1 brings out is a directory with a `value` in it. Eleven of them, generated.
+ for (board_pins.gpio_pins) |pin| {
+ var name: [4]u8 = undefined;
+ const dir = try std.fmt.bufPrint(&name, "{d}", .{pin});
+ try testing.expect((try walk(&t, &.{ "gpio", dir })).dir);
+ const value = try walk(&t, &.{ "gpio", dir, "value" });
+ try testing.expect(!value.dir);
+ try testing.expectEqual(@as(u16, 0o600), value.mode);
+ }
+
+ // And a pin the board does not bring out is not there. 6 and 21 are real ESP32-P4 GPIOs that
+ // JP1 simply does not route, which is the distinction the table exists to keep: the tree has
+ // the pins the BOARD has, not the pins the CHIP has.
+ try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "6" }));
+ try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "21" }));
+ try testing.expectError(error.NoEntry, walk(&t, &.{ "gpio", "20", "level" }));
+ try testing.expectError(error.NoEntry, walk(&t, &.{"mem"}));
+}
+
+test "board9p: a read of gpio/pinout is the bytes the Gpio word draws" {
+ var t: Board = .{};
+ const at = try walk(&t, &.{ "gpio", "pinout" });
+ // `board_memory.zig:364`'s `pinout` IS this declaration, so this is the word's own output and
+ // not a copy of it. The bytes themselves are pinned by `board_pins.zig`'s golden test.
+ const a = try readAll(&t, at.node);
+ try testing.expectEqualStrings(board_pins.jp1_text, a.bytes);
+ // The size a client is told matches what it gets, which is what makes `cat` stop in one read.
+ try testing.expectEqual(board_pins.jp1_text.len, at.size);
+ // Read-only: the drawing is the header's, not the client's.
+ try testing.expectEqual(@as(u16, 0o400), at.mode);
+ const w = t.handle(.{ .tag = 3, .op = .write, .node = at.node, .data = "x" });
+ try testing.expectEqual(E.INVAL, w.reply.errno);
+}
+
+test "board9p: writing 1 then 0 drives the pad twice, through the seam" {
+ StubPads.reset();
+ var t: Board = .{};
+ const at = try walk(&t, &.{ "gpio", "20", "value" });
+
+ // A fresh pad reads 0 — the level the board is DRIVING, which is defined before anybody has
+ // written anything.
+ const before = try readAll(&t, at.node);
+ try testing.expectEqualStrings("0\n", before.bytes);
+
+ const one = t.handle(.{ .tag = 4, .op = .write, .node = at.node, .data = "1" });
+ try testing.expectEqual(Board.Status.ok, one.reply.status);
+ try testing.expectEqual(@as(u32, 1), one.reply.written);
+ try testing.expectEqualStrings("1\n", (try readAll(&t, at.node)).bytes);
+
+ // `echo 0 > value`, newline and all: the whole write is consumed, so the shell does not retry
+ // the tail and drive the pin a second time.
+ const zero = t.handle(.{ .tag = 5, .op = .write, .node = at.node, .data = "0\n" });
+ try testing.expectEqual(@as(u32, 2), zero.reply.written);
+ try testing.expectEqualStrings("0\n", (try readAll(&t, at.node)).bytes);
+
+ // TWO CALLS, the right pin, the right levels, in order. This is the whole of what the host can
+ // check about the seam; that `hal.gpio` then moves the pad is the die's to answer.
+ try testing.expectEqual(@as(usize, 2), StubPads.log_len);
+ try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 1 }, StubPads.log[0]);
+ try testing.expectEqual(StubPads.Call{ .pin = 20, .level = 0 }, StubPads.log[1]);
+}
+
+test "board9p: a pad takes 0 and 1 and refuses everything else, without touching the pads" {
+ StubPads.reset();
+ var t: Board = .{};
+ const at = try walk(&t, &.{ "gpio", "45", "value" });
+
+ for ([_][]const u8{ "2", "", "01", "x", "true", "high", "\n", "1 ", " 1", "10" }) |bad| {
+ const a = t.handle(.{ .tag = 6, .op = .write, .node = at.node, .data = bad });
+ try testing.expectEqual(Board.Status.err, a.reply.status);
+ try testing.expectEqual(E.INVAL, a.reply.errno);
+ }
+ // A refused write is a pad that was never driven, which is the part that matters: a half-parsed
+ // command must not leave the board in a state nobody asked for.
+ try testing.expectEqual(@as(usize, 0), StubPads.log_len);
+
+ // A register is one value, so a write at an offset is refused too, and refused before the pads.
+ const off = t.handle(.{ .tag = 7, .op = .write, .node = at.node, .off = 1, .data = "1" });
+ try testing.expectEqual(E.INVAL, off.reply.errno);
+ try testing.expectEqual(@as(usize, 0), StubPads.log_len);
+}
+
+test "board9p: every node's parent is the one 9p.parentOf derives, or an unallocated block" {
+ // THE ENCODING'S OWN TEST, and it defends the one thing this file cannot see: `src/9p.zig`
+ // answers `..` from the node id alone, by the rule restated at `block` above. A node numbered
+ // outside that rule would make `cd ..` land somewhere else with no diagnostic, so the rule is
+ // applied here to every generated node and compared against the table's own `parent`.
+ for (&Board.table) |*e| {
+ const serial = e.node >> 4;
+ const file = e.node & 0xF;
+ const derived: u64 = if (e.node == root or serial == 0 or file == 0) root else serial << 4;
+ if (derived == e.parent) continue;
+ // The one exception, and it must be exactly the one documented: a fan leaf, whose parent is
+ // a block MEMBER and therefore unexpressible. Its derived parent has to be a node that does
+ // not exist, so the walk is refused rather than landing on the wrong file.
+ try testing.expectEqualStrings("value", e.name);
+ var t: Board = .{};
+ const a = t.handle(.{ .tag = 8, .op = .getattr, .node = derived });
+ try testing.expectEqual(E.NOENT, a.reply.errno);
+ }
+}
+
+test "board9p: a directory read lists what the table generated, in table order" {
+ var t: Board = .{};
+
+ // The root is the capability list, and today that is one name.
+ try testing.expectEqualStrings("gpio", (try names(&t, root, 0))[0]);
+ try testing.expectEqual(@as(usize, 1), (try names(&t, root, 0)).len);
+
+ const gpio = (try walk(&t, &.{"gpio"})).node;
+ const listing = try names(&t, gpio, 0);
+ try testing.expectEqual(board_pins.gpio_pins.len + 1, listing.len);
+ try testing.expectEqualStrings("pinout", listing[0]);
+ for (board_pins.gpio_pins, 0..) |pin, i| {
+ var buf: [4]u8 = undefined;
+ try testing.expectEqualStrings(try std.fmt.bufPrint(&buf, "{d}", .{pin}), listing[i + 1]);
+ }
+
+ // The cursor is an ENTRY INDEX, which is what `Server` advances between reads of a directory
+ // bigger than one reply.
+ const rest = try names(&t, gpio, 5);
+ try testing.expectEqual(board_pins.gpio_pins.len + 1 - 5, rest.len);
+ try testing.expectEqualStrings("5", rest[0]);
+}
+
+/// The names in one staged directory read, decoded out of `acmefs`'s `node[8] dir[1] namelen[1]
+/// name[]` records — the same decode `Server` does.
+var name_slots: [32][]const u8 = undefined;
+fn names(t: *Board, node: u64, skip: u64) ![][]const u8 {
+ const a = t.handle(.{ .tag = 9, .op = .readdir, .node = node, .off = skip, .size = 65535 });
+ try testing.expectEqual(Board.Status.ok, a.reply.status);
+ var n: usize = 0;
+ var i: usize = 0;
+ while (i < a.bytes.len) {
+ const len = a.bytes[i + 9];
+ name_slots[n] = a.bytes[i + 10 ..][0..len];
+ n += 1;
+ i += 10 + len;
+ }
+ return name_slots[0..n];
+}
+
+test "board9p: the whole tree costs one buffer, and the table says how big" {
+ // The two numbers the board's RAM budget is quoted from. `out` is the ONLY buffer this
+ // filesystem has, and both of its bounds come out of the table: the widest directory's staged
+ // entries (gpio's twelve) and the largest read scratch (a pin's two bytes).
+ try testing.expectEqual(@as(usize, 143), Board.out_max);
+ try testing.expect(@sizeOf(Board) <= 160);
+ // The JP1 drawing is not in it, and that is the point of `scratch = 0`: 468 bytes of static
+ // text are served straight out of `.rodata`.
+ try testing.expect(board_pins.jp1_text.len > Board.out_max);
+}
+
+// The proof that the ABI claim in this file's header is true, and the only place the two halves meet
+// on the host: `Server` is a generic over exactly `Op`, `Status`, `Req`, `Reply` and `Reply.Attr`,
+// so a field that drifts from `acmefs`'s is a compile error HERE, and a real client's bytes are what
+// comes out.
+//
+// A PATH IMPORT, and it took two goes to get here. The first was
+// `@import("9p.zig")`, which did not compile while `src/9p.zig` was also the
+// ROOT of a named `ninep` module in the same link — a file belongs to exactly
+// one module, and it was both. The second was a named module, declared twice in
+// `build.zig`; that compiled and made this file unbuildable by anyone but
+// `build.zig`, which is what broke the board image the moment the firmware
+// link moved to the toolchain repository and stopped injecting modules.
+//
+// The path form works now because nothing declares `src/9p.zig` as a module
+// root any more: `fs9_service.zig` and `fs9_client.zig` reach it by path too,
+// so every link that contains it contains it once. The gain is that this file
+// and `src/esp32p4_9p.zig` are self-contained — `zig test src/board9p.zig`
+// works with no flags, and any builder can root an image at `nine.zig` without
+// being told what modules to inject.
+const ninep = @import("9p.zig");
+
+test "board9p: a real 9P client reads a pin's value off this tree" {
+ StubPads.reset();
+ const Server = ninep.Server(Board);
+ // The board's own buffers, at the board's own msize. See `src/esp32p4_9p.zig` for why 1024.
+ var in: [1024]u8 = undefined;
+ var out: [2048]u8 = undefined;
+ var fsys: Board = .{};
+ var srv = Server.init(.{ .in = &in, .out = &out, .root = root });
+
+ var scratch: [256]u8 = undefined;
+ const send = struct {
+ fn call(s: *Server, f: *Board, buf: []u8, tag: u16, msg: ninep.Msg) !void {
+ const bytes = try ninep.encode(msg, tag, buf);
+ try testing.expectEqual(bytes.len, s.push(bytes));
+ while (s.retry()) |req| {
+ const a = f.handle(req);
+ s.reply(&a.reply, a.bytes);
+ }
+ while (s.next()) |req| {
+ const a = f.handle(req);
+ s.reply(&a.reply, a.bytes);
+ }
+ }
+ }.call;
+ const reap = struct {
+ fn call(s: *Server) !ninep.Decoded {
+ const queued = s.output();
+ const len = ninep.frameLen(queued) orelse return error.NoReply;
+ const got = try ninep.decode(queued[0..len]);
+ s.wrote(len);
+ return got;
+ }
+ }.call;
+
+ try send(&srv, &fsys, &scratch, ninep.notag, .{ .tversion = .{ .msize = 8192, .version = "9P2000" } });
+ const v = try reap(&srv);
+ // Clamped to what the board's buffers hold, which is the number the RAM budget was chosen for.
+ try testing.expectEqual(@as(u32, 1024), v.msg.rversion.msize);
+
+ try send(&srv, &fsys, &scratch, 1, .{ .tattach = .{ .fid = 0, .afid = ninep.nofid, .uname = "goblin", .aname = "" } });
+ try testing.expectEqual(root, (try reap(&srv)).msg.rattach.qid.path);
+
+ var wname: [ninep.max_welem][]const u8 = @splat("");
+ wname[0] = "gpio";
+ wname[1] = "20";
+ wname[2] = "value";
+ try send(&srv, &fsys, &scratch, 2, .{ .twalk = .{ .fid = 0, .newfid = 1, .nwname = 3, .wname = wname } });
+ try testing.expectEqual(@as(u16, 3), (try reap(&srv)).msg.rwalk.nwqid);
+
+ try send(&srv, &fsys, &scratch, 3, .{ .topen = .{ .fid = 1, .mode = ninep.ordwr } });
+ _ = try reap(&srv);
+
+ // `echo 1 > /mnt/board/gpio/20/value`, as bytes on a wire.
+ try send(&srv, &fsys, &scratch, 4, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "1\n" } });
+ try testing.expectEqual(@as(u32, 2), (try reap(&srv)).msg.rwrite.count);
+ try testing.expectEqual(@as(u1, 1), StubPads.driven[20]);
+
+ // ...and `cat` of the same file.
+ try send(&srv, &fsys, &scratch, 5, .{ .tread = .{ .fid = 1, .offset = 0, .count = 512 } });
+ try testing.expectEqualStrings("1\n", (try reap(&srv)).msg.rread.data);
+
+ // The refusal reaches the client as an error STRING, which is 9P's only channel for "no": EINVAL
+ // becomes the wording `9p.errString` gives it, and the pad is not touched.
+ try send(&srv, &fsys, &scratch, 6, .{ .twrite = .{ .fid = 1, .offset = 0, .data = "on" } });
+ try testing.expectEqualStrings(ninep.errString(E.INVAL), (try reap(&srv)).msg.rerror.ename);
+ try testing.expectEqual(@as(usize, 1), StubPads.log_len);
+}