summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 12:40:03 -0300
committerGabriel Schneider <[email protected]>2026-08-26 12:40:03 -0300
commitfbc194068687e49a8490c85c9f1257a2f2bb9079 (patch)
tree4fdb314f9e2c0e25bb1faca7e93fefe108023a66 /src
parent3b8ee10301e0b83013a38d93516a345622a93aaa (diff)
downloadpardes-fbc194068687e49a8490c85c9f1257a2f2bb9079.tar.gz
pardes-fbc194068687e49a8490c85c9f1257a2f2bb9079.zip
A Gpio word that flips one pin, JP1 drawn in ASCII, and these words only on the P4
## Gpio `Gpio 33` flips one pad and answers on the message row with what it did: GPIO 33: 0->1 GPIO 33: 1->0 Bare `Gpio` draws the header instead, because the first question about a header is which pins it has. The pin number is DECIMAL and it is the only literal in board_memory.zig that is - every other one is an address, and addresses come off datasheets and linker maps that print hex, which is why that file made everything hex two commits ago. A GPIO number is not an address, it is part of a NAME: the schematic says GPIO47, the datasheet's pin table says 47, and `Gpio 20` meaning pin 32 would be a trap laid for the one argument anybody types from memory. ## The toggle is the host's, not the editor's New `Host.VTable.pull_gpio_toggle`, and a `GpioFn` in the p4 ABI (hence version 2), rather than board_memory reaching for GPIO_OUT the way `Poke` two functions above it would happily do. Writing that register is not the job. A pad has to be pointed at the GPIO function in the IO MUX, routed in the GPIO matrix, given drive strength and an input buffer with its pulls cleared, and only then driven - four register files behind a per-pin table. That code already exists in `05-zig-p4/src/hal/gpio.zig`, it is the same `configureOutput` the blink demo has always used, and its register numbers are checked against ESP-IDF's own headers on the die by `zig build diff`. A second copy inside the editor object would be a second copy under no test, and getting it wrong on a pin that boots as something else is how you lose the console you are typing on. Reported levels are the OUTPUT bits, before and after, because that is what a toggle means: the level this board is driving. A pad's input buffer on an unconnected header pin reads the air. ## JP1, read off the schematic rather than remembered The diagram is the vendor's own wiring, from sheet 2 "Expand IO" of `01-esp32p4-m3/docs/JC-ESP32P4-M3_schematic.pdf` - the only document that carries this mapping. The specification PDF's "Interface Description" page turned out to be a marketing render, and there is no board user guide; the chip datasheet has a package pinout, which is not a header. That sheet is a 872x1168 raster (`pdfimages -list` - the PDF embeds no vectors, so rendering it larger adds nothing), and at that size the rows around pin 14 are genuinely ambiguous by eye. So the mapping came from the drawing's geometry instead: thirteen wires leave each side of the symbol, a net wire runs ~100 px to its label and a power stub ~21 px. Pin 8's wire is 21 px, which is what identifies it as unconnected rather than as the first of the GPIO4x labels - the reading that had GPIO47 one row higher and shorted GPIO45 to the ground bracket. Cross-checked against a second source that has been in the tree all along: `05-zig-p4/build.zig` documents `-Dled=20` as "JP1 pin 17", and GPIO20 lands on pin 17 here. Both facts are asserted in the test, so the diagram cannot drift from either. ## Peek, Poke, Hexdump and Gpio are now the P4 build's alone `board_memory.enabled` was `os.tag == .freestanding and !isWasm()`, on the argument that these words are a property of having no operating system rather than a product configuration, and that a predicate spelled out of `builtin` cannot drift the way a hand-maintained enum can. Tidy, and it answered the wrong question. A word only exists if some shell offers it, and the shells are the platforms. `Gpio` settles it beyond argument: its whole content is one board's header, and a second freestanding port would need its own pinout rather than inheriting this one. "Bare metal" was never the requirement, "this board" was, and the two only looked identical because there is currently one of them. The old predicate's real work was excluding wasm - `freestanding` too, where an address is an offset into a linear memory the engine owns - and naming `p4` excludes it by construction instead of by a term somebody has to keep remembering. The target is now the witness rather than the gate. Absent means not compiled: the tty binary contains no `+Gpio`, no `+Hexdump`, no `ES_I2C_SDA` and no `MisalignedAddress`. ## The boot buffer's lines are checked, not eyeballed Three times now a line in that tour has been one or two characters too long for a 56-column grid, and every time it was found by reading the die's screen - the expensive way to measure a string literal. The text is a named `boot_buffer` with a test over it, six lines came down to fit with margin, and the tour gained `Gpio`. Tests: the pinout's width, its thirteen aligned pin rows, GPIO20-on-17 and pin-8-unconnected; the decimal-versus-hex distinction; every boot-buffer line. Full suite green - unit-test, snap 95/95, hxdiff 481/0, hxparity 561/0, image-harness, pdf-harness, mupdf-check - and tty, p4, gui, p4 at 80x24, p4 with the fade forced on. On the die `p4-bench --check` is 5/5, the fifth being a new one: three `Gpio 33` runs must report 0->1, 1->0, 0->1, because the alternation is the only oracle a hardcoded string could not fake.
Diffstat (limited to 'src')
-rw-r--r--src/board_memory.zig191
-rw-r--r--src/builtins.zig18
-rw-r--r--src/config.zig2
-rw-r--r--src/host.zig12
-rw-r--r--src/p4.zig26
-rw-r--r--src/pardes.zig79
6 files changed, 276 insertions, 52 deletions
diff --git a/src/board_memory.zig b/src/board_memory.zig
index d2878d2d..79a9a2cf 100644
--- a/src/board_memory.zig
+++ b/src/board_memory.zig
@@ -1,13 +1,13 @@
-//! The board's own address space, as text: the Peek, Poke and Hexdump
-//! builtins' whole implementation.
+//! The board's own address space and its pins, as text: the Peek, Poke, Hexdump and Gpio builtins'
+//! whole implementation.
//!
-//! BARE METAL ONLY (`enabled` below), and the reason is not caution but
-//! honesty: with no OS there is no MMU, no supervisor and no process — the
-//! editor IS the system software — so every one of the 2^32 addresses is
-//! legitimately this program's to read and write, and a word that could name
-//! only some of them would be lying about where it is running. Under an OS the
-//! same three words would be either a segfault or a syscall stub, so they are
-//! absent from those builds entirely rather than present and refusing.
+//! THE P4 BUILD ONLY (`enabled` below), and the reason is not caution but honesty: with no OS there
+//! is no MMU, no supervisor and no process - the editor IS the system software - so every one of the
+//! 2^32 addresses is legitimately this program's to read and write, and a word that could name only
+//! some of them would be lying about where it is running. Under an OS the same words would be either
+//! a segfault or a syscall stub, so they are absent from those builds entirely rather than present
+//! and refusing. Absent means not compiled, not hidden: nothing below is analysed for a build whose
+//! platform is not `p4`.
//!
//! Everything here goes through `*allowzero volatile` pointers. A peripheral
//! register is not memory: reading UART_STATUS twice is two reads and must not
@@ -25,30 +25,33 @@ const pardes = @import("pardes.zig");
const Pardes = pardes.Pardes;
const output_pane = @import("output_pane.zig");
-/// The one gate, and it is derived from the TARGET rather than from
-/// `pardes.platform`: these three words are not a product configuration, they
-/// are a property of running with no operating system under you, and a
-/// predicate spelled out of `builtin` cannot drift from that the way a
-/// hand-maintained platform enum can. Same idiom as allocators.zig's tiers.
+/// THE ONE GATE, and it names the p4 build, so `Peek`, `Poke`, `Hexdump` and `Gpio` are analysed
+/// and emitted for that build and for no other. Nothing in this file reaches any other target's
+/// binary: not the volatile accessors, not the JP1 pinout, not the parsers.
///
-/// Wasm is `freestanding` too — that is the `web` platform — and it is exactly
-/// what this must exclude: inside the browser's sandbox an address is an offset
-/// into a linear memory the engine owns, so a "peek" there would read a number
-/// that means nothing about any machine and a "poke" would corrupt the heap
-/// this same editor is running out of. Bare metal is the freestanding target
-/// whose addresses are the bus's.
-pub const enabled = builtin.os.tag == .freestanding and !builtin.target.cpu.arch.isWasm();
+/// This used to be derived from the target - `os.tag == .freestanding and !isWasm()` - on the
+/// argument that these words are a property of having no operating system rather than a product
+/// configuration, and that a predicate spelled out of `builtin` cannot drift the way a
+/// hand-maintained enum can. The argument was tidy and it answered the wrong question. A word
+/// only exists if some SHELL offers it, and the shells are the platforms; `Gpio` settles it beyond
+/// argument, because its whole content is one board's header, and a second freestanding port would
+/// need its own pinout rather than inheriting this one. "Bare metal" was never the requirement,
+/// "this board" was, and the two only looked identical because there is currently one of them.
+///
+/// The old predicate's real work was excluding wasm, which is `freestanding` too - inside the
+/// browser's sandbox an address is an offset into a linear memory the engine owns, so a `Peek`
+/// would read a number that means nothing about any machine and a `Poke` would corrupt the heap
+/// this same editor runs out of. Naming `p4` excludes it by construction rather than by a term
+/// somebody has to keep remembering.
+pub const enabled = pardes.platform == .p4;
-// `pardes.platform` is not the gate, but it IS an independent witness, so each
-// of the three interesting builds proves its own half of the predicate rather
-// than leaving "wasm is freestanding" as a comment nobody re-checks. The one
-// that matters is the middle line: without the `isWasm` term above, the web
-// build would silently hand a browser tab a Poke that writes into the linear
-// memory this editor's own heap lives in.
+// The target is now the WITNESS rather than the gate: whatever else `p4` means, it has to still be
+// a machine whose addresses are the bus's, and a hosted or wasm build reaching this line means the
+// platform and the target disagree about what the firmware is.
comptime {
- if (pardes.hosted and enabled) @compileError("an OS is not bare metal");
- if (pardes.platform == .web and enabled) @compileError("wasm is not bare metal");
- if (pardes.platform == .p4 and !enabled) @compileError("the P4 firmware is bare metal");
+ if (enabled and pardes.hosted) @compileError("an OS is not bare metal");
+ if (enabled and builtin.os.tag != .freestanding) @compileError("the P4 firmware is freestanding");
+ if (enabled and builtin.target.cpu.arch.isWasm()) @compileError("wasm addresses are not a bus");
}
/// How much of the address space ONE command may render.
@@ -83,6 +86,11 @@ pub const Error = error{
/// the message row instead.
MisalignedAddress,
ExtraArgument,
+ /// not a number, or a number the part does not have a pad for
+ BadPin,
+ /// the host brought no pads: every build but the firmware, where the word
+ /// is not registered at all, and a firmware too old to pass the hook
+ NoPads,
};
/// EVERY literal these three words take is HEX, with or without an `0x`, and there is no way to
@@ -247,6 +255,49 @@ test "every literal is hex, with or without the prefix" {
try std.testing.expectError(Error.BadValue, parseValue("0x1_0000_0000"));
}
+// The pinout is the one thing here whose CORRECTNESS IS ITS SHAPE: a header drawn in two columns
+// stops being a header the moment a row wraps, and it wraps on the board rather than on a
+// developer's terminal, which is the worst place to find out. So the width is asserted against the
+// grid the board is actually built with, and the alignment is asserted against the column the pin
+// numbers are supposed to share.
+test "the pinout fits the board's own grid, in two aligned columns" {
+ const cols: usize = @import("pardes_config").p4_cols;
+ // Seven columns of the shell's grid go to the line-number gutter before a pane's text starts.
+ const usable = cols - 7;
+
+ var rows: usize = 0;
+ var pins: usize = 0;
+ var first_bar: ?usize = null;
+ var it = std.mem.splitScalar(u8, pinout, '\n');
+ while (it.next()) |line| {
+ try std.testing.expect(line.len <= usable);
+ rows += 1;
+ // A pin row is one with two numbers in it; every one must put its bars in the same place,
+ // which is what "aligned in two columns" means when the check is mechanical.
+ const bar = std.mem.indexOfScalar(u8, line, '|') orelse continue;
+ if (line[line.len - 1] == '+') continue;
+ pins += 1;
+ if (first_bar) |b| try std.testing.expectEqual(b, bar) else first_bar = bar;
+ }
+ try std.testing.expectEqual(@as(usize, 13), pins);
+ try std.testing.expect(rows > 15);
+
+ // Two independent facts about the board, each with a witness outside this file: GPIO20 is
+ // `05-zig-p4/build.zig`'s documented `-Dled` default ("JP1 pin 17"), and pin 8 is the one
+ // header pin the vendor schematic leaves unconnected.
+ try std.testing.expect(std.mem.indexOf(u8, pinout, "GPIO 20 | 17 |") != null);
+ try std.testing.expect(std.mem.indexOf(u8, pinout, "| 8 | --") != null);
+}
+
+// The exception to the file's own rule, so it is written down as a test rather than only as a
+// comment: a pin number is part of a name and is read as decimal, while every address beside it is
+// hex. `Gpio 20` must mean the pin the schematic calls GPIO20, not 0x20.
+test "a pin number is decimal, unlike every address in this file" {
+ try std.testing.expectEqual(@as(u16, 20), try std.fmt.parseInt(u16, "20", 10));
+ try std.testing.expectEqual(@as(u32, 0x20), try parseAddr("20"));
+ try std.testing.expect(20 != 0x20);
+}
+
/// `Peek <addr> [count]` — count 32-bit words at addr, one `addr: value` row
/// each. One word per row rather than four so that every row carries its own
/// address: the rows are then ordinary Look targets, and `Peek` or `Poke`
@@ -296,6 +347,86 @@ pub fn poke(p: *Pardes, id: usize, argument: []const u8) !void {
) catch unreachable);
}
+/// JP1, the 26-pin header down the left edge of the JC-ESP32P4-M3-DEV, as the board wears it: two
+/// columns, odd pins on the left, even on the right, pin 1 at the top.
+///
+/// READ OFF THE VENDOR SCHEMATIC, sheet 2 "Expand IO"
+/// (`01-esp32p4-m3/docs/schematics/2_EXPAND_IO&BAT.png`), which is the only document that carries
+/// this mapping - the specification PDF's "Interface Description" page is a marketing render, and
+/// there is no board user guide. The sheet is a 872x1168 raster, so the assignment was taken from
+/// the drawing's own geometry rather than by eye: thirteen wires leave each side of the symbol, a
+/// net wire runs ~100 px to its label and a power stub ~21 px, which is what identifies pin 8 as
+/// unconnected rather than as the first of the GPIO4x labels. Cross-checked against a second,
+/// independent source: `05-zig-p4/build.zig` has always documented `-Dled=20` as "JP1 pin 17", and
+/// GPIO20 lands on pin 17 here.
+///
+/// `--` is a pin the header brings out with nothing behind it. `C6_*` are the ESP32-C6 companion's
+/// pads, not the P4's, and toggling a P4 GPIO cannot reach them. `ES_I2C_*` is the audio codec's
+/// bus, shared - driving either one by hand while the codec is live is a collision, which is a
+/// reason to know the pin is there rather than a reason to hide it.
+const pinout =
+ \\JP1 header - 26 pins, pin 1 top left.
+ \\Every number here is DECIMAL.
+ \\
+ \\ +---------+
+ \\ 3V3 | 1 | 2 | 5V
+ \\ 3V3 | 3 | 4 | 5V
+ \\ GND | 5 | 6 | GND
+ \\ GPIO 1 | 7 | 8 | --
+ \\ GPIO 2 | 9 | 10 | GPIO 47
+ \\ GPIO 3 | 11 | 12 | GPIO 46
+ \\ GPIO 4 | 13 | 14 | GPIO 45
+ \\ GPIO 5 | 15 | 16 | GND
+ \\ GPIO 20 | 17 | 18 | 3V3
+ \\ GPIO 32 | 19 | 20 | C6_U0RXD
+ \\ GPIO 33 | 21 | 22 | C6_U0TXD
+ \\ES_I2C_SDA | 23 | 24 | C6_IO9
+ \\ES_I2C_SCL | 25 | 26 | C6_CHIP_PU
+ \\ +---------+
+ \\
+ \\Gpio <pin> flips one: 0->1 or 1->0.
+ \\
+;
+
+/// `Gpio <pin>` flips one pad and says what it did; `Gpio` alone draws JP1.
+///
+/// THE PIN NUMBER IS DECIMAL, and it is the one literal in this file that is. Every other one is
+/// hex because every other one is an address, and addresses come off datasheets and linker maps
+/// that print hex. A GPIO number is not an address - it is part of a NAME. The schematic says
+/// `GPIO47`, the silkscreen says 47, the datasheet's pin table says 47, and `Gpio 20` meaning pin
+/// 32 would be a trap laid for the one argument a person types from memory. One rule per KIND of
+/// literal beats one rule per file when the kinds are this different.
+///
+/// The toggle is the host's to perform (`Host.VTable.pull_gpio_toggle`) even though `Poke` two
+/// functions up would happily write GPIO_OUT_REG directly. Writing that register is not the job:
+/// a pad has to be pointed at the GPIO peripheral in the IO MUX, routed in the GPIO matrix, have
+/// its driver and input buffer enabled, and only then be driven - and getting that wrong on a pin
+/// that boots as something else is how you lose the console you are typing on.
+///
+/// Reported levels are the OUTPUT bits, before and after, because that is what a toggle means: the
+/// level this board is DRIVING. A pad's input buffer on an unconnected header pin reads whatever
+/// the air says.
+pub fn gpio(p: *Pardes, id: usize, argument: []const u8) !void {
+ var it = std.mem.tokenizeAny(u8, argument, " \t\r\n");
+ const tok = it.next() orelse {
+ // No argument is not an error and not inert: it is the question "which pins are there",
+ // and the answer is a picture of the header.
+ const content = try p.gpa.dupe(u8, pinout);
+ errdefer p.gpa.free(content);
+ return fill(p, id, .{ .cmd = .Gpio }, content);
+ };
+ if (it.next() != null) return Error.ExtraArgument;
+ const pin = std.fmt.parseInt(u16, tok, 10) catch return Error.BadPin;
+
+ const toggle = p.host.vtable.pull_gpio_toggle orelse return Error.NoPads;
+ var was: u8 = 0;
+ var now: u8 = 0;
+ if (!toggle(p.host.ctx, pin, &was, &now)) return Error.BadPin;
+
+ var buf: [48]u8 = undefined;
+ p.setMessage(id, std.fmt.bufPrint(&buf, "GPIO {d}: {d}->{d}", .{ pin, was, now }) catch unreachable);
+}
+
/// Bytes per dumped row, and it is a different number on the board.
///
/// `hexdump -C`'s sixteen is the layout everyone can already read, and it needs 79 columns: ten for
diff --git a/src/builtins.zig b/src/builtins.zig
index 0c9993a7..23ae18c3 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -985,3 +985,21 @@ pub const Hexdump = struct {
c.p.reportError(c.id, "hexdump", err);
}
};
+
+/// `Gpio <pin>` — flip one pad, answered on the message row as `0->1`. Bare `Gpio` draws JP1's
+/// pinout into a pane instead, because the first question about a header is which pins it has.
+///
+/// The only word here whose argument is DECIMAL, and `board_memory.gpio` says why at length: a
+/// GPIO number is part of a name, not an address.
+pub const Gpio = struct {
+ pub const takes_arg = true;
+ pub const enabled = board_memory.enabled;
+ pub const output: OutputTraits = .{ .name = config.gpio_buffer };
+ pub fn run(c: Ctx) void {
+ if (comptime enabled) apply(c) else unreachable;
+ }
+ fn apply(c: Ctx) void {
+ board_memory.gpio(c.p, c.id, c.arg orelse "") catch |err|
+ c.p.reportError(c.id, "gpio", err);
+ }
+};
diff --git a/src/config.zig b/src/config.zig
index c9d414ca..15a21866 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -235,6 +235,7 @@ pub const leader_path = paths: {
table.set(.Peek, null);
table.set(.Poke, null);
table.set(.Hexdump, null);
+ table.set(.Gpio, null);
}
// The pane-local PDF commands exist only in MuPDF builds through their
// explicit registry availability, so name their paths inside the same
@@ -799,6 +800,7 @@ pub const changelog_buffer = "+Changelog";
/// from every hosted build along with the builtins that name them.
pub const peek_buffer = "+Peek";
pub const hexdump_buffer = "+Hexdump";
+pub const gpio_buffer = "+Gpio";
/// The empty buffer New and Newcol open: no file behind it yet, so Save asks
/// for a path (prefilled with the inherited directory).
pub const scratch_buffer = "+New";
diff --git a/src/host.zig b/src/host.zig
index e44318da..9ca882b1 100644
--- a/src/host.zig
+++ b/src/host.zig
@@ -80,6 +80,18 @@ pub const Host = struct {
/// must not re-enter the core.
pull_tty_taken: ?*const fn (ctx: ?*anyopaque, pane: u8) bool = null,
+ // ---- the board's own pads ----
+ /// Flip one GPIO and report the level it held and the level it now holds. False means the
+ /// host would not do it: a pin number outside the part, or no pads at all.
+ ///
+ /// A pull, because there is one answer. The HOST answers it rather than the core reaching
+ /// for the registers itself - which `Peek` and `Poke` do two functions away - because
+ /// driving a pad correctly is not one register. It is the IO MUX function select, the GPIO
+ /// matrix output route, the pad's drive and input-buffer bits, and the output enable, keyed
+ /// by a per-pin table. The firmware already owns that code and checks it against ESP-IDF's
+ /// own headers on the die; a second copy in here would be a second copy nobody tests.
+ pull_gpio_toggle: ?*const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) bool = null,
+
// ---- the filesystem ----
/// `pane` travels with the bytes only so a host that posts a "saved"
/// message row can name the right pane; the core already resolved the
diff --git a/src/p4.zig b/src/p4.zig
index 05610eca..8e46b2ff 100644
--- a/src/p4.zig
+++ b/src/p4.zig
@@ -93,7 +93,9 @@ fn panicImpl(msg: []const u8, _: ?usize) noreturn {
// cheapest possible defence: the firmware calls it first and refuses to continue on a mismatch.
/// Bumped whenever any signature below changes, including a type.
-const abi_version: u32 = 1;
+/// 2 added `GpioFn` to `pardes_p4_init`. A firmware built against 1 passes five arguments where six
+/// are read, which is exactly the silent-corruption case this counter exists to turn into a message.
+const abi_version: u32 = 2;
export fn pardes_p4_abi_version() callconv(.c) u32 {
return abi_version;
@@ -115,6 +117,15 @@ pub const Allocator = extern struct {
/// How finished runs of ANSI leave this object.
pub const WriteFn = *const fn (ctx: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(.c) void;
+/// Flip one pad and report the level before and after; false if the firmware declines. OPTIONAL on
+/// the wire, so a host with no pads (or one that has not implemented them yet) passes null and the
+/// `Gpio` word answers "no pads" instead of the object having to know which firmwares exist.
+///
+/// The board's side, not the editor's, because a correct toggle is the IO MUX, the GPIO matrix, the
+/// pad's own bits and the output enable - four register files behind a per-pin table that the
+/// firmware already has and checks against ESP-IDF. See `Host.VTable.pull_gpio_toggle`.
+pub const GpioFn = *const fn (ctx: ?*anyopaque, pin: u16, was: *u8, now: *u8) callconv(.c) bool;
+
// ------------------------------------------------------------------- the allocator, rebuilt
// One `std.mem.Allocator` whose vtable forwards to the four pointers above. The indirection is the
// price of the seam and it is paid once per allocation, which on a first-fit heap is already the
@@ -158,6 +169,7 @@ fn gpa() std.mem.Allocator {
var out_write: WriteFn = undefined;
var out_ctx: ?*anyopaque = null;
+var host_gpio: ?GpioFn = null;
var out_buf: [8192]u8 = undefined;
var out: std.Io.Writer = undefined;
@@ -247,12 +259,14 @@ fn vaxisSize() vaxis.Winsize {
export fn pardes_p4_init(
alloc: *const Allocator,
write: WriteFn,
+ gpio: ?GpioFn,
ctx: ?*anyopaque,
cols: u16,
rows: u16,
) callconv(.c) u32 {
host_alloc = alloc.*;
out_write = write;
+ host_gpio = gpio;
out_ctx = ctx;
out = .{ .vtable = &.{ .drain = drain }, .buffer = &out_buf };
@@ -594,7 +608,15 @@ export fn pardes_p4_quit() callconv(.c) bool {
// ------------------------------------------------------------------------------------ the host
-const pardes_host: pardes.Host.VTable = .{ .push_present = present };
+const pardes_host: pardes.Host.VTable = .{ .push_present = present, .pull_gpio_toggle = gpioToggle };
+
+/// The `Gpio` word's one seam to the board. Nothing here knows what a pad is; it forwards, and
+/// answers false when the firmware brought none, which is what puts "gpio: NoPads" on the message
+/// row rather than a trap.
+fn gpioToggle(_: ?*anyopaque, pin: u16, was: *u8, now: *u8) bool {
+ const f = host_gpio orelse return false;
+ return f(out_ctx, pin, was, now);
+}
/// The canonical surface -> the wire. Same shape as the tty shell's (`src/tty/tty.zig:1096`) minus
/// the panel compositor and the kitty image path: neither has a reason to exist on a board with no
diff --git a/src/pardes.zig b/src/pardes.zig
index 9f20e0a3..4da64c3f 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -2418,6 +2418,64 @@ comptime {
/// syntax, terminal ANSI palettes, and PDF tint colors deliberately are not
/// here: those switch to `theme()` immediately while this small palette moves
/// between themes over a handful of display frames.
+/// WHAT THE BOARD BOOTS WITH. An empty buffer is honest and useless: the three words that make
+/// this board interesting take an address, and a board's address space is precisely the thing you
+/// cannot guess. So the buffer is a tour of it - every address below comes from this repository
+/// rather than from memory, which is why they are worth trusting: the two flash figures and the two
+/// RAM ones are the linker script's own ORIGINs (`05-zig-p4/build.zig`'s MEMORY block), and the
+/// peripheral bases are `DR_REG_*` from ESP-IDF's headers as `05-zig-p4/src/hal` uses them.
+///
+/// Each command sits alone on its line because an argument list ends at the last argument - a
+/// trailing comment would be `ExtraArgument` - so the notes go above the lines they describe. Run
+/// one by putting the cursor on it, `x` to select the line, Tab to execute.
+///
+/// EVERY LINE IS SHORT ENOUGH TO RENDER WHOLE, which is asserted rather than eyeballed: see the
+/// test below. A tour whose lines wrap is a worse first screen than no tour.
+const boot_buffer =
+ \\x selects a line, Tab runs it. 0x optional.
+ \\
+ \\-- flash: this image's rodata, then its code
+ \\Hexdump 40000020 40
+ \\Hexdump 40050000 40
+ \\-- L2MEM: firmware data, then the heap
+ \\Hexdump 4ff00000 40
+ \\Hexdump 4ff40000 40
+ \\-- the mask ROM
+ \\Hexdump 4fc00000 20
+ \\-- UART0, the console you are reading on
+ \\Peek 500ca000 4
+ \\-- LP_STORE0: write a word, read it back
+ \\Poke 5011002c deadbeef
+ \\Peek 5011002c
+ \\-- RNG_DATA: not memory. Run it twice.
+ \\Peek 501101a4
+ \\Peek 501101a4
+ \\-- the pins: Gpio draws JP1, Gpio 33 flips
+ \\Gpio
+ \\Gpio 33
+;
+
+// Three times in this port a line in that buffer has been one or two characters too long for the
+// board's 56-column grid, and every time it was found by reading the die's screen rather than by
+// reading the source - which is the expensive way to find a string literal's length. The bound is
+// the grid minus the line-number gutter minus a column, and the margin below it is deliberate:
+// pinning the exact gutter width would make this test a restatement of the renderer instead of a
+// statement about the text.
+test "every line of the board's boot buffer renders whole" {
+ const cols: usize = @import("pardes_config").p4_cols;
+ var it = std.mem.splitScalar(u8, boot_buffer, '\n');
+ while (it.next()) |line| {
+ std.testing.expect(line.len + 8 <= cols) catch |err| {
+ std.debug.print("boot buffer line is {d} of {d} usable: \"{s}\"\n", .{ line.len, cols - 8, line });
+ return err;
+ };
+ }
+ // and the tour still visits what it says it visits
+ try std.testing.expect(std.mem.indexOf(u8, boot_buffer, "Hexdump 40000020") != null);
+ try std.testing.expect(std.mem.indexOf(u8, boot_buffer, "Poke 5011002c deadbeef") != null);
+ try std.testing.expect(std.mem.indexOf(u8, boot_buffer, "\nGpio\n") != null);
+}
+
pub const ChromeTheme = struct {
tag_bg: [3]u8,
tag_fg: [3]u8,
@@ -5785,26 +5843,7 @@ pub const Pardes = struct {
// LP_SYSTEM_REG_RNG_DATA, the hardware random generator. Between them they demonstrate
// the whole point of a volatile read: one address gives back what was written and the
// other never gives the same answer twice. Both verified on this die.
- const content = try p.gpa.dupe(u8,
- \\x selects a line, Tab runs it. Hex, 0x optional.
- \\
- \\-- flash: this image's rodata, then its code
- \\Hexdump 40000020 40
- \\Hexdump 40050000 40
- \\-- L2MEM: firmware data, then the editor's heap
- \\Hexdump 4ff00000 40
- \\Hexdump 4ff40000 40
- \\-- the mask ROM
- \\Hexdump 4fc00000 20
- \\-- UART0, the console you are reading this on
- \\Peek 500ca000 4
- \\-- LP_STORE0: write a word, then read it back
- \\Poke 5011002c deadbeef
- \\Peek 5011002c
- \\-- RNG_DATA: a register is not memory. Run twice.
- \\Peek 501101a4
- \\Peek 501101a4
- );
+ const content = try p.gpa.dupe(u8, boot_buffer);
errdefer p.gpa.free(content);
_ = try output_pane.open(p, 0, "", .{ .cmd = .New }, "", content);
p.ncol = 1;