summaryrefslogtreecommitdiff
path: root/src/board9p.zig
blob: b5b18bf2ee2fd1cf80ca75066f571caa42359f93 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
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);
}