From fbc194068687e49a8490c85c9f1257a2f2bb9079 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 26 Aug 2026 12:40:03 -0300 Subject: 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. --- src/p4.zig | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) (limited to 'src/p4.zig') 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 -- cgit v1.3