summaryrefslogtreecommitdiff
path: root/src/esp32p4_9p.zig
blob: dd0346eff30dbf1f48092495fa6e44d7eac4b8c3 (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
//! THE BOARD AS A 9P SERVER, and nothing else: the reset entry, one UART, and a pump.
//!
//! This is the SECOND ESP32-P4 image and it is not a second role for the first one. `app.zig` is
//! the editor — a real `pardes.Pardes` core with vaxis on top, emitting ANSI down UART0 to a
//! terminal emulator on the far end. This image links none of that. Same board, same UART, same
//! flash partition, one at a time, because the editor owns UART0 bidirectionally and JP1 exposes no
//! second P4 UART (`docs/registry.typ` `9P-11`: "The board is either an editor or a filesystem at
//! any one time. Say that plainly rather than implying both").
//!
//! THE UART CARRIES ONLY 9P. That is the whole difference from the other image and it is the point.
//! No ANSI, no vaxis, no escape sequences, no `MARK` boot markers, no `soc.rom.print` — not even
//! the heap report `app.zig:320-329` prints on every boot, which would be the single most useful
//! line here and is still not allowed, because a byte on this wire that is not part of a 9P message
//! is a byte that desynchronises whatever is parsing it. The proof that this image booted is that it
//! answers `Tversion`.
//!
//! The one thing that had to be said in some other language is a PANIC and a TRAP, and they are said
//! in 9P too: an `Rerror` carrying the message, tagged `NOTAG`. No client is waiting for that tag,
//! so `9p` reports it as an unexpected reply and prints the string — which is exactly the diagnosis
//! wanted ("the board died, here is why") delivered without putting one non-protocol byte on the
//! wire. See `panicImpl` and `trapReport`.
//!
//! ## What it serves
//!
//! `src/board9p.zig`, which is the board's own capabilities as a tree: `gpio/pinout` is the JP1
//! drawing the editor's `Gpio` word prints, and `gpio/<n>/value` is one pad's driven level, readable
//! and writable. Both come out of a comptime table, and adding a capability to that table adds files
//! here with no code in this file changing at all.
//!
//! Deliberately NOT `src/acmefs.zig`, and the reason is the same one that makes this a second image.
//! That file is the EDITOR's control filesystem: every operation in it is about a pane, and a pane
//! only exists because a `pardes.Pardes` exists. Serving it would mean linking the editor object
//! (809,536 B of image) and instantiating the core, at which point this is `app.zig` with a
//! different output encoding rather than a 9P server. It compiles for this target — `llvm-nm` finds
//! 21,548 B of `acmefs.*` in `zig-out/pardes-esp32p4.o` — and that fact is what made this image
//! worth building, because it is what proved the filesystem layer has no host dependency. The ABI is
//! what got reused, not the tree: `src/9p.zig`'s `Server` is a generic over the filesystem, and
//! `board9p` implements `acmefs`'s `Op`/`Status`/`Req`/`Reply` verbatim, so the same server serves
//! either one and neither knows about the other.
//!
//! ## The loop
//!
//! Four lines, and every one of them is a `Server` method doing what its doc comment says:
//!
//!     read bytes off the UART   ->  srv.push(bytes)
//!     pump                      ->  srv.retry() / srv.next() -> fsys.handle(req) -> srv.reply(...)
//!     write what is queued      ->  srv.wrote(uart.writeSome(srv.output()))
//!
//! NOTHING BLOCKS. `uart.read` is non-blocking, `uart.writeSome` hands over what the transmit FIFO
//! has room for and answers how much, and `Server.wrote(n)` takes a partial write as an ordinary
//! answer rather than an error (`src/9p.zig:2248-2257`). So a client that stops reading cannot stall
//! this loop, and a reply larger than the 128-byte FIFO leaves over several trips round it. That is
//! the same sans-io contract `src/fs9_service.zig` gives the desktop's unix socket; the difference
//! is that there is no `poll` here and no need for one, because there is exactly one connection and
//! it is the wire.
//!
//! ## The numbers, measured rather than costed
//!
//! `.bss` IS THE WHOLE RAM BILL, because this image has no allocator: not a heap, not an arena, and
//! the 384 KiB span the editor's image hands `heapmod` is not even mapped by anything here. So the
//! board's ≈336 KB of free heap (`docs/registry.typ` `FIX-2`) is untouched at 100%, and what this
//! program spends is the 240 KiB of low L2MEM that `9P-11`'s built note names as the real binding
//! constraint. `llvm-size` on the ELF says `.bss` is 16,656 B, and every byte of it is accounted
//! for:
//!
//!     9,192  `srv`             — `Server(Tree(Pads))` on riscv32. `9P-11` measured 9,488 on the
//!                                host; a 32-bit target's slices are half the width, and the park
//!                                table has thirty-two of them.
//!     1,024  `in_buf`          — one msize
//!     2,048  `out_buf`         — two, so no reply can fail to be queued
//!     4,108  the rescue ring   — `input_rescue.Ring` inside `uart.zig`, which comes with the UART
//!       148  `fsys`            — the whole tree: one 143-byte answer buffer and a counter
//!       128  `stage`
//!     ------
//!    16,648 + 8 of alignment and `uart.dropped` = 16,656
//!
//! Add the 32,768-byte `.stack` the shared linker script gives every image built through
//! `firmware()` and the low-L2MEM total is 49,424 B, 20% of the 240 KiB — against the editor's
//! 75,236 B (20,408 `.data` + 22,060 `.bss` + the same stack). The stack is the largest single item
//! and it is inherited rather than chosen: 32 KiB is sized for the CORE's recursive layout pass
//! (`build.zig:1088-1090`), and nothing in this image recurses at all.
//!
//! FLASH: the image is 88,080 B of the 1,536,000 B partition — 5.7%, against the editor image's
//! 812,688 B (52.9%). Only 28,066 B of that is content (22,504 `.flash.text`, 5,562 B of real
//! `.flash.rodata`, 80 B of image header and checksum); the rest is the gap between the end of the
//! rodata segment and the 64 KiB-aligned origin the code segment must start on, because the ESP32
//! flash MMU maps in 64 KiB pages and the two segments cannot share one. A tiny image pays up to
//! 64 KiB for that and there is nothing to be done about it here — it is the generated linker
//! script's arithmetic (`05-zig-p4/build.zig`), and it is why the estimate of "≈39 KiB" in `9P-11`
//! was closer to the CONTENT than to the image.
//!
//! ## Build it, flash it, talk to it
//!
//!     zig build -Dplatform=esp32p4 -Desp32p4-firmware -Desp32p4-9p esp32p4-9p-flash
//!     zig build -Dplatform=esp32p4 -Desp32p4-firmware -Desp32p4-9p esp32p4-9p-size   # no board needed
//!
//! There is no `esp32p4-9p-attach`, and that absence is the design: what belongs on the far end of
//! this wire is a 9P client opened at `baud`, not a terminal. `9p` and `9pfuse` speak to a SOCKET,
//! so reaching this board with either means a program that copies bytes between the tty and a unix
//! socket in both directions — which is nine lines of anything and is not this file's business.
//! pardes's own client (`src/fs9_client.zig`) needs no such bridge, because a tty is already a
//! bidirectional byte stream and that is all 9P has ever asked for (`docs/registry.typ` `9P-19`).
//!
//! FLASHING THIS REPLACES THE EDITOR. Both images are written to `img.opts`'s one offset, on
//! purpose: there is one partition and the board is one thing at a time. `zig build esp32p4-flash`
//! puts the editor back.

const std = @import("std");
const soc = @import("soc");
const hal = @import("hal");
const config = @import("config");
// PATH imports, not named modules, and that is what lets any builder root an
// image here: the toolchain repository links this file with the four platform
// modules it owns (`soc`, `hal`, `config`, `heap`) and nothing else, so a
// `@import("ninep")` here was a module only pardes's own build.zig knew to
// inject — and the image stopped building the moment that build.zig stopped
// linking it. See `src/board9p.zig`'s note on the same change.
const ninep = @import("9p.zig");
const board9p = @import("board9p.zig");
const uart = @import("esp32p4/uart.zig");

/// THE PADS, and this is the whole seam between the tree and the silicon.
///
/// The same four `hal.gpio` calls `src/esp32p4/app.zig:200-209` makes for the editor's `Gpio` word,
/// for the reason that file gives at length: a toggle is not a write to GPIO_OUT. `configureOutput`
/// points the pad's IO MUX at the GPIO function, routes the GPIO matrix's output to it, sets the
/// drive strength and input buffer, clears the pulls and only then enables the driver — four register
/// files indexed by a per-pin table, which live in the toolchain package where `zig build diff`
/// checks their numbers against ESP-IDF's own headers. A second copy would be a second copy under no
/// test. This is a second CALLER, which is the opposite thing.
///
/// `getDrivenLevel` and not `getLevel`: the answer is the level this board is DRIVING, which is
/// defined for every pin including one with nothing attached, where the pad's own level is whatever
/// the air says. `readback = true` enables the input buffer anyway, so a client that wants the pad
/// rather than the register has something to compare against.
///
/// SPLIT INTO `level` AND `drive` rather than the editor's single `toggle`, because a file can say
/// which level it wants and a keystroke cannot. `Gpio 20` has one argument and has to mean "the
/// other one"; `echo 1 > gpio/20/value` says 1, which is what makes it idempotent and therefore
/// scriptable. Writing the level a pad is already at still calls `configureOutput`, and that is not
/// a wasted write: on a freshly booted board it is the call that makes the pad an output at all.
///
/// BOTH ARE `pub` AND HAVE TO BE, for the same reason `src/esp32p4/selftest.zig:44-46` says its
/// `FakePort`'s methods are: `board9p` is a MODULE here, and duck typing across a module boundary
/// still needs the declaration to be visible from outside the file it is in. Nothing else in this
/// image is `pub`.
const Pads = struct {
    pub fn level(pin: u8) u1 {
        return hal.gpio.getDrivenLevel(pin);
    }

    pub fn drive(pin: u8, want: u1) void {
        hal.gpio.configureOutput(pin, .{ .readback = true });
        if (want == 1) hal.gpio.setHigh(pin) else hal.gpio.setLow(pin);
    }
};

comptime {
    // Every pin the tree generates has to be a pad this chip package has, and the check belongs here
    // rather than in `board9p.zig`: `max_pin` is 56 on this package and lives in the toolchain
    // repository, which a host-testable tree cannot import. A JP1 row edited to name GPIO 60 is a
    // compile error in this image instead of an out-of-bounds register index on the die.
    for (board9p.pins) |pin| {
        if (pin > hal.gpio.max_pin) @compileError("JP1 names a pad this chip package does not have");
    }
}

/// The board's tree, over the real pads.
const Fs = board9p.Tree(Pads);
const Server = ninep.Server(Fs);

/// THE msize, and it is 1,024 rather than the 4,096 everything else in this tree assumes.
///
/// The 4,096 floor is the LINUX KERNEL's and nobody else's: `linux/net/9p/client.c:840-843` refuses
/// to mount below it, which is why `9p.min_msize` is 4,096 and why the desktop daemon serves that.
/// Plan 9's devmnt, plan9port's `9p` and pardes's own client all accept 512
/// (`docs/registry.typ` `9P-11`), and no Linux kernel is ever going to mount this image: the far end
/// of this wire is a serial port, and a `mount -t 9p` needs a socket or a virtio channel, neither of
/// which a CH340 is. So the floor that applies here is `9p.msize_min` — 217 bytes, DERIVED from the
/// largest reply whose size the client does not choose (`src/9p.zig:1873-1881`).
///
/// 1,024 and not 217, because the number to size against is the widest DIRECTORY READ. `gpio/` has
/// twelve entries, a `stat` record in a directory read is 49 bytes of fixed fields plus the name plus
/// three copies of the client's `uname` (`src/9p.zig:3251-3260`), so a `goblin` reading `ls gpio/`
/// wants 12 × ~73 = ~880 bytes to get the listing in ONE round trip. At 217 it would take five, and
/// each one costs a `Tread` and an `Rread` on a wire. Everything else here is tiny: the largest file
/// in the tree is the 468-byte JP1 drawing and the largest write is two bytes.
///
/// What it costs: `in` is one msize and `out` is two — one message going out and one being built,
/// which is what makes every reply in the server infallible — so 3,072 B for the buffers against
/// 12,288 B at a 4,096 msize. Nine kilobytes of the board's low L2MEM for a round trip nobody needs.
const msize: u32 = 1024;

/// One whole T-message, and the ceiling on the msize this connection will agree to.
var in_buf: [msize]u8 = undefined;

/// Two, for the reason above. `Server.hasRoom` reserves one msize before it hands any request to the
/// filesystem, which is what makes back-pressure land on `next()` returning null instead of on a
/// half-written reply.
var out_buf: [2 * msize]u8 = undefined;

/// Bytes off the receiver on their way into the server, and the ONE buffer in this file.
///
/// 128 is the transmit and receive FIFO depth (the toolchain package's `src/hal/uart.zig:52`), so one
/// `uart.read` can never leave more behind than one FIFO's worth, and the tail that `push` would not
/// take is re-offered next time round the loop. It is not a reassembly buffer — `Server.in` is that,
/// and it holds a whole message — it is the handover between a driver that fills a slice and a server
/// that takes what it has room for.
var stage: [128]u8 = undefined;

/// The wire's rate, and the host must be opened to match or nothing works and nothing says so.
///
/// 921600 rather than the 115200 the bootloader leaves behind: `docs/registry.typ` `BOARD-1`. One
/// `UART_CLKDIV_SYNC` write on the existing 40 MHz XTAL, int 43 frag 6, +0.064% error, and it takes
/// a byte from 86.8 µs to 10.85 µs — which on this loop is a warm `cat gpio/20/value` going from
/// 10.8 ms to 1.35 ms and a 1 KiB `Tread` from 89 ms to 11 ms. 2 Mbaud is representable and this
/// CH340 is unreliable there, corroborated by the flasher's own choice at `build.zig:1136-1138`.
///
/// It is programmed before the first reply and after the input drain, which is the one moment when
/// there can be nothing in either FIFO to be corrupted by the change.
const baud: u32 = 921600;

/// The server and the tree, both in `.bss` and both fixed for the life of the image. No allocator
/// exists in this program at all — not a heap, not an arena, not the `heapmod` the editor's image
/// hands over 384 KiB to — so `zig build esp32p4-9p-size` reporting `.bss` is reporting the whole
/// of what this server costs in RAM.
var srv: Server = undefined;
var fsys: Fs = .{};

export fn zig_main() noreturn {
    // FIRST, before anything reads `.rodata`, exactly as `app.zig:275` does it and for the same
    // reason: the JP1 drawing this image serves is 468 bytes of `.rodata` in flash, and a read of it
    // through a stale cache returns whatever was there at reset.
    soc.flushFlashCache();

    // The same clock the editor's image runs at, so a latency measured on one is a latency on the
    // other. A divider change that disturbs neither UART0 (XTAL) nor the flash interface (SPLL).
    if (config.cpu_mhz != 90) hal.clkrst.setCpuFreq(switch (config.cpu_mhz) {
        180 => .mhz180,
        360 => .mhz360,
        else => .mhz90,
    });

    // The RTC watchdog is armed at reset and this loop never feeds anything. Without this the board
    // resets a few seconds in, which over a wire that carries only 9P looks exactly like a client
    // that cannot reach it.
    _ = hal.rwdt.disable();

    // WHAT THE BOOTLOADER LEFT ON THE WIRE, discarded before the divider changes: its own chatter
    // has already been echoed at the host, and the host bridge injects a synthetic window-size
    // report before this program exists. Neither is 9P, and either would be the first bytes of a
    // message that never was.
    _ = uart.drainInput();

    // The rate, then. A refusal is not fatal and must not be: an unreachable divider leaves 115200
    // in place, which is a slow board rather than a silent one, and a client opened at the wrong rate
    // finds out immediately because `Tversion` gets no answer it can parse.
    _ = uart.setBaud(baud);

    srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = board9p.root });

    // THE PUMP. `stage_len` is the only state outside the server.
    var stage_len: usize = 0;
    while (true) {
        // IN. Non-blocking, rescued bytes first (`uart.read`), and never more than the staging
        // buffer's room, so a burst larger than one FIFO simply arrives over two iterations.
        if (stage_len < stage.len) stage_len += uart.read(stage[stage_len..]);
        if (stage_len != 0) {
            // A SHORT PUSH IS NORMAL AND IS NOT A LOSS: it is the only back-pressure a sans-io
            // server has (`src/9p.zig:2229-2233`). What it would not take stays here and is offered
            // again after the pump has made room by finishing a message.
            const took = srv.push(stage[0..stage_len]);
            if (took != stage_len) std.mem.copyForwards(u8, stage[0 .. stage_len - took], stage[took..stage_len]);
            stage_len -= took;
        }

        // PUMP, in the order `src/fs_service.zig:209-222` requires: every parked request offered
        // once, then everything the wire has, both loops to null.
        //
        // NOTHING ON THIS BOARD PARKS — the answer to "what level is this pad" is a register read,
        // and there is no `event` file and no reader to block — so `retry()` answers null on the
        // first call, every time. It is here because the contract is the contract, and because the
        // first capability that does block (an interrupt-driven `gpio/<n>/edge`) needs this line to
        // already exist rather than to be remembered.
        while (srv.retry()) |req| {
            const a = fsys.handle(req);
            srv.reply(&a.reply, a.bytes);
        }
        while (srv.next()) |req| {
            const a = fsys.handle(req);
            srv.reply(&a.reply, a.bytes);
        }

        // OUT. Whatever fits in the transmitter right now, and the server keeps the rest.
        const queued = srv.output();
        if (queued.len != 0) srv.wrote(uart.writeSome(queued));

        // THE STREAM WAS NOT 9P, and there is no resynchronising from that: a `size` no encoder
        // could have produced, an R-message from something that thought it was the server, a
        // message larger than the negotiated msize. On a socket the answer is to close the
        // connection and let the client notice; on a wire that cannot be closed, the answer is to
        // reset it — pay the filesystem whatever `release`s the dead fids owe it, throw away every
        // byte in flight in both directions, and start a fresh connection in the same silence a
        // reboot would have. A client resynchronises by sending `Tversion`, which is what a client
        // does after any failure anyway.
        if (srv.dead) {
            srv.hangup();
            while (srv.next()) |req| {
                const a = fsys.handle(req);
                srv.reply(&a.reply, a.bytes);
            }
            _ = uart.drainInput();
            stage_len = 0;
            srv = Server.init(.{ .in = &in_buf, .out = &out_buf, .root = board9p.root });
        }
    }
}

// --------------------------------------------------------------------------- dying in protocol

/// A message this image is about to die with, as an `Rerror` on `NOTAG`.
///
/// THE ONE PLACE A NON-REPLY IS SENT, and it is still a legal 9P message, which is the whole trick.
/// `NOTAG` is the tag of the `Tversion` exchange and no client has a request outstanding under it, so
/// `9p` and pardes's own client both report an unexpected reply AND PRINT THE STRING — "the board
/// panicked at 0x4000a1b8", delivered through a parser rather than past it. The alternative is what
/// the editor's image does, `MARK PARDES_PANIC` in plain text, which on this wire would be a frame
/// header of 0x4b52414d followed by garbage: an unrecoverable stream instead of a diagnosis.
///
/// Blocking `uart.write` and not `writeSome`, because there is no loop left to come back round: this
/// is the last thing the image does, and a bounded spin that gets the whole message out is worth
/// more here than one that returns.
fn die(msg: []const u8) noreturn {
    var buf: [ninep.errmax + ninep.header_len + 2]u8 = undefined;
    const bytes = ninep.encode(
        .{ .rerror = .{ .ename = msg[0..@min(msg.len, ninep.errmax)] } },
        ninep.notag,
        &buf,
    ) catch unreachable;
    uart.write(bytes);
    while (true) {}
}

/// Eight hex digits into `buf`, computed arithmetically. Hand-rolled rather than `std.fmt`, for the
/// reason `uart.dumpWord` gives: this runs in a trap handler, where the less of the image it depends
/// on the more likely it is to run at all.
fn hex8(buf: *[8]u8, v: u32) void {
    var shift: u5 = 28;
    for (buf) |*slot| {
        const nib: u8 = @intCast((v >> shift) & 0xf);
        slot.* = if (nib < 10) '0' + nib else 'a' + (nib - 10);
        shift -%= 4;
    }
}

/// `mtvec` is set in DIRECT mode by `_start`, so every trap and every interrupt lands here.
///
/// A trap handler exists for the reason `app.zig:432-441` gives — the mask ROM's "Guru Meditation"
/// only prints while ITS handler is installed, and a silent fault over a serial line is
/// indistinguishable from an infinite loop — and it reports through 9P for the reason `die` gives.
export fn trapEntry() linksection(".text.entry") callconv(.naked) noreturn {
    asm volatile ("j trapReport");
}

export fn trapReport() noreturn {
    const mcause = asm volatile ("csrr %[o], mcause"
        : [o] "=r" (-> u32),
    );
    const mepc = asm volatile ("csrr %[o], mepc"
        : [o] "=r" (-> u32),
    );
    const mtval = asm volatile ("csrr %[o], mtval"
        : [o] "=r" (-> u32),
    );
    // The three registers that name a RISC-V fault, in the order a reader wants them: what happened,
    // where, and to which address.
    var msg = "trap mcause=00000000 mepc=00000000 mtval=00000000".*;
    hex8(msg[12..20], mcause);
    hex8(msg[26..34], mepc);
    hex8(msg[41..49], mtval);
    die(&msg);
}

// --------------------------------------------------------------- the root's own duties
//
// This is a ROOT, so it owns std's configuration for this compilation unit. The editor's image has
// two of these (`app.zig` and `src/esp32p4.zig`, one per object); this image is one object and has
// one.

/// `page_size_min`/`max`: no MMU and no pages here, but std derives alignment from them, and 4 KiB
/// is this chip's cache and DMA granularity.
///
/// `logFn` is not cosmetic and it is not optional. std's default log implementation reaches
/// `std.debug_io`, which instantiates `std.Io.Threaded` — a thread pool, `getrandom`, `IOV_MAX`,
/// `mremap` — and one `log.warn` anywhere in the graph drags all of it into the image. This one
/// DISCARDS, which is the only honest thing it can do: there is nowhere for a log line to go on a
/// wire that carries only 9P, and a log line that went out anyway would break the connection it was
/// trying to explain. Nothing in this image's graph logs; this is the wall that keeps it that way.
pub const std_options: std.Options = .{
    .page_size_min = 4096,
    .page_size_max = 4096,
    .logFn = logFn,
};

fn logFn(
    comptime _: std.log.Level,
    comptime _: @EnumLiteral(),
    comptime _: []const u8,
    _: anytype,
) void {}

pub const panic = std.debug.FullPanic(panicImpl);

fn panicImpl(msg: []const u8, first_trace_addr: ?usize) noreturn {
    // The address is what makes it actionable — `addr2line` against the ELF in zig-out turns it into
    // a source line — so it goes in front of the message, where `errmax`'s 128-byte truncation
    // cannot reach it. A panic message names a KIND of failure; the address names which one.
    var buf: [ninep.errmax]u8 = undefined;
    @memcpy(buf[0..7], "panic 0");
    buf[7] = 'x';
    hex8(buf[8..16], @truncate(first_trace_addr orelse 0));
    buf[16] = ' ';
    const n = @min(msg.len, buf.len - 17);
    @memcpy(buf[17..][0..n], msg[0..n]);
    die(buf[0 .. 17 + n]);
}

/// Reset entry, identical in shape to `app.zig:528-546` and for the identical reasons: the bootloader
/// hands over with an unspecified stack pointer and the FPU off, so enable the F extension
/// (`mstatus.FS`), establish a stack, install the trap vector, clear `.bss`, and jump into Zig.
///
/// `.bss` MATTERS MORE HERE THAN ANYWHERE. Everything this image owns is in it — the server, its two
/// buffers, the tree, the staging buffer — so this loop is what makes the fid table empty and the
/// msize zero, and skipping it would start the server mid-connection with a client that does not
/// exist.
export fn _start() linksection(".text.entry") callconv(.naked) noreturn {
    asm volatile (
        \\ li t0, 1 << 13
        \\ csrs mstatus, t0
        \\ la sp, __stack_top
        \\ mv fp, sp
        \\ la t0, trapEntry
        \\ csrw mtvec, t0
        \\ la t0, __bss_start
        \\ la t1, __bss_end
        \\ bgeu t0, t1, 2f
        \\1:
        \\ sw zero, 0(t0)
        \\ addi t0, t0, 4
        \\ bltu t0, t1, 1b
        \\2:
        \\ j zig_main
    );
}