<feed xmlns='http://www.w3.org/2005/Atom'>
<title>esp32p4.git, branch main</title>
<subtitle>ESP32-P4</subtitle>
<id>https://git.0x4200.cafe/esp32p4.git/atom?h=main</id>
<link rel='self' href='https://git.0x4200.cafe/esp32p4.git/atom?h=main'/>
<link rel='alternate' type='text/html' href='https://git.0x4200.cafe/esp32p4.git/'/>
<updated>2026-09-16T14:28:40Z</updated>
<entry>
<title>Make the toolchain a package another build can drive, and move the editor's glue to the editor</title>
<updated>2026-09-16T14:28:40Z</updated>
<author>
<name>Gabriel Schneider</name>
<email>gbrls@0x4200.cafe</email>
</author>
<published>2026-08-26T16:28:33Z</published>
<link rel='alternate' type='text/html' href='https://git.0x4200.cafe/esp32p4.git/commit/?id=b42ecaed412be2e30b9e780eb7c9e46e1535f26f'/>
<id>urn:sha1:b42ecaed412be2e30b9e780eb7c9e46e1535f26f</id>
<content type='text'>
</content>
</entry>
<entry>
<title>Offer the editor the board's pads, and check one flips on the die</title>
<updated>2026-08-26T15:40:24Z</updated>
<author>
<name>Gabriel Schneider</name>
<email>gbrls@0x4200.cafe</email>
</author>
<published>2026-08-26T15:40:24Z</published>
<link rel='alternate' type='text/html' href='https://git.0x4200.cafe/esp32p4.git/commit/?id=38bb891dd6bd0074894cbfedbf9185e303cc549e'/>
<id>urn:sha1:38bb891dd6bd0074894cbfedbf9185e303cc549e</id>
<content type='text'>
## The board half of Gpio

`pardes_p4_init` gained a `GpioFn` and the ABI version went to 2, which is what turns a mixed
pair of builds into a refusal to boot rather than five arguments read as six.

`gpioToggle` is four lines over `hal.gpio`: configure the pad as a readable output, read the level
it is driving, drive the other one, read it again. It is deliberately here and not in the editor.
A toggle is not a write to GPIO_OUT - `configureOutput` sets the IO MUX function, the GPIO matrix
route, the drive strength, the input buffer and the pulls, then the output enable, indexed by a
per-pin table - and that code is already in this repo, already the call `src/main.zig` blinks with,
and already checked against ESP-IDF's headers by `zig build diff`. The editor object gets a
function pointer instead of a second copy nobody tests.

`getDrivenLevel`, not `getLevel`: the answer is the level the board is driving, which is defined
for every pin. The pad's own level is what the outside world says, and on an unconnected header pin
that is noise. The input buffer is enabled anyway so `Peek` of GPIO_IN_REG can be compared to it.

## A fifth hardware check

`p4-bench --check` runs `Gpio 33` three times and requires 0-&gt;1, 1-&gt;0, 0-&gt;1.

The alternation is the oracle, not either answer. `0-&gt;1` alone is what a firmware printing a
hardcoded string would also say; two runs that disagree can only come from a level that was stored
and read again. Three, so the third rules out an ordering coincidence. GPIO33 because
`src/oracle/ledc_cases.zig` already documents it as a free pin on this board's JP1 header - pin 21
on the diagram the editor now draws. GPIO20 is the blink demo's pin and may have a wire on it.

This is the only check here that crosses the whole seam: editor word, C ABI, HAL, pad, and the
level back out through the message row. Nothing smaller exercises the ABI at all.

## -Dtheme-animation forwarded

Same path as the geometry, for the same reason: baked into the object, wanted from here.

All five checks pass on the die; host tests green; the board is flashed with md5 932898f34ca1dc31.
</content>
</entry>
<entry>
<title>Pass -Dtheme-animation through to the editor's build</title>
<updated>2026-08-26T15:00:20Z</updated>
<author>
<name>Gabriel Schneider</name>
<email>gbrls@0x4200.cafe</email>
</author>
<published>2026-08-26T15:00:20Z</published>
<link rel='alternate' type='text/html' href='https://git.0x4200.cafe/esp32p4.git/commit/?id=85cf1a7dd912db4a5fc3ac2cb9d8550050ffbdb0'/>
<id>urn:sha1:85cf1a7dd912db4a5fc3ac2cb9d8550050ffbdb0</id>
<content type='text'>
The firmware build already rebuilds pardes's object when the grid changes, for the reason
that changing it in one directory and linking in another silently links the previous answer.
The chrome fade is the same shape of setting - baked into the object, wanted from here - so
it travels the same path: -Dtheme-animation is forwarded to the nested build, and like the
geometry it is skipped entirely when -Dpardes-obj names an object explicitly.

Measured either way on the die: the fade costs 1,097 ms of wire per theme change against 215
without it, and compiling it out takes 2,336 bytes off the image. Both numbers are in the
comment above the option in ../02-pardes-code/build.zig.

Board reflashed with the default (md5 018a9554e4a8ae51); p4-bench --check 4/4; host tests
green.
</content>
</entry>
<entry>
<title>Set the grid from the firmware build, and check Poke against Peek on the die</title>
<updated>2026-08-26T14:12:04Z</updated>
<author>
<name>Gabriel Schneider</name>
<email>gbrls@0x4200.cafe</email>
</author>
<published>2026-08-26T14:12:04Z</published>
<link rel='alternate' type='text/html' href='https://git.0x4200.cafe/esp32p4.git/commit/?id=adb23572bc2d7aae09cf8605825f6d48ca23ba4a'/>
<id>urn:sha1:adb23572bc2d7aae09cf8605825f6d48ca23ba4a</id>
<content type='text'>
## -Dcols / -Drows

The geometry is baked into pardes's object, and that object is built by the other
repository - so changing the grid was two commands in two directories, and the second one
silently linked whatever the first had left behind. That is the shape of mistake that ends
with a firmware whose grid is not the grid you think you flashed.

`-Dcols` and `-Drows` now drive the editor's own build before linking it. A nested
`zig build`, not a package dependency: the seam between these repositories stays a file,
for the reasons the comment above it has always given. This only reaches across to ask for
the file to be made a particular way, and it stands aside entirely when `-Dpardes-obj`
names an object explicitly, because then the caller has already said which one they want.

Verified by the segment table: `-Dcols=80 -Drows=24` takes the loaded segment from 20,408
to 49,944 bytes, which is the shadow grid going from 784 cells to 1,920.

## Poke and Peek, checked against each other

Two more checks in `p4-bench --check`, and they are checked against each other because
that is the only way to check either one without a second debugger: a Peek alone cannot
tell a correct read from a stuck one, and a Poke alone cannot tell a write from a no-op.

Both oracles were wrong before they were right, and both failures are worth recording.

The Escape check asked whether an `x` came back on the wire. That was true until the boot
buffer gained a line beginning "x selects a line", at which point a passing check began
failing for a reason that had nothing to do with Escape. It now reads the CURSOR COLUMN
after `0`, which the document cannot spell: 8 in normal mode, three further right if the
`0` had been inserted instead.

The Poke and Peek checks searched the raw byte stream for `deadbeef` and could not find it -
because this renderer emits only the cells that CHANGED, jumping between runs with absolute
cursor positioning, so a word on screen is frequently not a word on the wire. `stripAnsi`
removes the escapes first. The Poke assertion then had to be loosened from one phrase to
three facts, because the message row is 49 columns here and a wrapped line puts a row
boundary inside whichever phrase straddles it.

Four checks, all passing on the die:

    lone Escape still leaves insert mode      ok (cursor col 8)
    a click split byte-by-byte lands at 18    ok (cursor 3,18)
    Poke writes a word and reads it back      ok
    and a later Peek still finds it           ok
</content>
</entry>
<entry>
<title>A test suite that runs on the die, and two build steps for reading an image</title>
<updated>2026-08-26T12:56:28Z</updated>
<author>
<name>Gabriel Schneider</name>
<email>gbrls@0x4200.cafe</email>
</author>
<published>2026-08-26T12:56:28Z</published>
<link rel='alternate' type='text/html' href='https://git.0x4200.cafe/esp32p4.git/commit/?id=55743cc5d564f6ef6d6f8a0eb1a614a74b021d3a'/>
<id>urn:sha1:55743cc5d564f6ef6d6f8a0eb1a614a74b021d3a</id>
<content type='text'>
## zig build selftest

Seventeen checks, on the board, chosen by one rule: a check belongs there only if the die
can answer it and a host cannot. Every serious bug this port has produced was invisible to
a host test. `std.mem.eql` compares a byte at a time on this target, so the firmware's
largest read was three times slower than it needed to be and nothing on a laptop could
tell. A lone ESC resolves to the Escape key, which is right when a kernel hands over a
whole sequence and wrong when a 115200 line hands over one byte every 87 us. A full
transmit FIFO stopped anything draining the receiver, and the FIFO depth is a hardware
number.

So: the volatile promise (two reads of a live counter are two reads, which is what `Peek`
rests on), word-wise equality checked against `std.mem.eql` itself at every difference
position and both alignments, the input rescue against a fake port with a FIFO that loses
what arrives into a full one, the allocator on real L2MEM, and the cycle counter against
the systimer - which is clocked from the crystal and therefore cannot be flattered by a
wrong CPU divider.

Anything that is pure logic stays in `zig build test`, which is faster and needs no
hardware. Duplicating those here would make the suite longer and no stronger.

Not a `zig test` binary, deliberately: Zig's runner wants an OS and `std.testing.allocator`
is a debug allocator over the page allocator, which on freestanding is either a compile
error or a lie. The harness is thirty lines.

`selftest` has its own application, image and flash chain so it is one command with no
flags to remember, and it makes the board's verdict the build's - a suite whose result a
human has to read out of a scrolling log is a suite that gets ignored on the first busy
afternoon. Proven both ways: 17/17 with exit 0, and exit 1 naming the check when one is
deliberately inverted.

The clock check earned its place immediately. The first version read `config.cpu_mhz` and
compared the die against it without ever performing the raise, so `-Dcpu-mhz=360` failed
with `khz=90001 want=360000`. The check was right and the expectation was wrong; it now
calls `setCpuFreq` itself, which makes it a test of the raise rather than a tautology.
90001 kHz at 90, 360004 at 360.

## zig build layout, and a loader error that explains itself

Both of these exist because of an hour I spent that they would have saved.

`NotTwoMappedSegments` said the count was wrong and nothing about what the segments were,
which is the only thing that says which section grew, shrank or stopped being emitted. I
diagnosed one by hand with readelf on an artifact that turned out to be a stale install,
then guessing at the linker script. The image step now prints the segment table with the
error - it has to happen there, because a rejected image is never written, so no later step
can show it - and `zig build layout` prints the same table for an ELF plus the loader's
verdict as TEXT, which `size` cannot do because it reads a finished image and the moment you
need the table is when there isn't one.

They immediately paid for themselves. The reason the suite would not build was that it had
no `_start`: `-fentry=_start` found no such symbol, `--gc-sections` discarded every
function as unreachable, and `.flash.text` was empty. `layout` prints `entry 0x0` for
exactly that, in one line. The second failure - a silent board - was a missing app
descriptor, which is the same class of thing and now has a comment where it happened.

## The panic handler already existed, and was printing past the end of its message

`msg` is a Zig slice and `%s` reads until a NUL, so handing `msg.ptr` to the ROM's printf
printed the message and then whatever followed it in memory. String literals get away with
it; std's own panics do not, because they are formatted into a buffer - "index out of
bounds: index 5, len 3" - and carry no terminator. It now goes out through `uart.write`,
which takes a length, with the fault address after it so addr2line can find the line.
</content>
</entry>
<entry>
<title>Fix the console: std.time.milliTimestamp does not exist, and zig build test could not see it</title>
<updated>2026-08-26T12:24:17Z</updated>
<author>
<name>Gabriel Schneider</name>
<email>gbrls@0x4200.cafe</email>
</author>
<published>2026-08-26T12:24:17Z</published>
<link rel='alternate' type='text/html' href='https://git.0x4200.cafe/esp32p4.git/commit/?id=ed8c5632e0228b1b821c87b511beb474c6a41f0c'/>
<id>urn:sha1:ed8c5632e0228b1b821c87b511beb474c6a41f0c</id>
<content type='text'>
`zig build interact` did not compile. The mouse coalescer dated its held report with
`std.time.milliTimestamp`, and this Zig's `std.time` has constants and `epoch` and no clock
at all: the repo's own idiom is `std.Io.Timestamp`, already wrapped as `serial.Port.nowMs`,
which is what the three call sites now use.

The reason it survived every check I ran is worth writing down, because the same hole will
swallow the next one. `zig build test` compiles `tools/console.zig` as a test module, and
Zig only analyses what is REACHABLE: no test calls `attach`, so the loop containing the bad
call was never looked at. Seven passing MouseFilter tests and a green `zig build test` said
nothing whatsoever about whether the file compiles as part of an executable. Nor does plain
`zig build` - p4-console is not in the default install step - so the only thing that would
have caught it is building or running the named step, which is also the documented way to
use this repo: `zig build interact -Dpardes`.

Verified the way it should have been the first time: `zig build interact -Dpardes
-Dcpu-mhz=360` with piped stdin flashes, attaches, takes the keystrokes and detaches at
EOF. Also compiled every step's artifacts - elf, console, bench - and re-ran the matrix
that matters for the editor side: default, Debug, ReleaseFast, ReleaseSafe, ReleaseSmall,
and -Dplatform=p4 at Debug and ReleaseSmall, plus -Dp4-cols/-Dp4-rows at 40x12 and 80x24.

Note for anyone reading a failing `zig build console` or `bench`: those RUN their tool, so
they fail with DeviceBusy when something else is attached to the port. That is not a build
error, and it is how this one hid in plain sight in the middle of the output.
</content>
</entry>
<entry>
<title>Report: Experiment 5, the three bugs, and a resize that fixed itself</title>
<updated>2026-08-26T05:06:49Z</updated>
<author>
<name>Gabriel Schneider</name>
<email>gbrls@0x4200.cafe</email>
</author>
<published>2026-08-26T05:06:49Z</published>
<link rel='alternate' type='text/html' href='https://git.0x4200.cafe/esp32p4.git/commit/?id=ddee44e2a70e8b9be7b2778390059de6279dd454'/>
<id>urn:sha1:ddee44e2a70e8b9be7b2778390059de6279dd454</id>
<content type='text'>
Adds the geometry curve - 0.87 us per cell, measured across nine grids from 40x12 to
140x42 - and the two ways it ends: 200x60 refused by the linker because '.bss' cannot hold
the shadow grid, 160x48 linking and then trapping. Neither is a heap problem any more,
which is the finding: the heap has 336 KB spare while '.bss' runs out, because vaxis's two
unread grids stopped being allocated.

Also the three bugs the latency work walked straight past, none of which any number in this
document could see: keystrokes lost while the transmitter was full, every escape sequence
shredded because a lone ESC arrives alone on a 115200 line, and a mouse that was never
enabled. The middle one had been there since the port began and explains the other two -
a click arriving as ten key presses, with the '0' among them moving the cursor to column
zero, which is what made it look like a coordinate bug.

And Table 15 changes from 'not fixed' to 'fixed, and not on purpose'. The vx.resize failure
that three experiments characterised and left alone was an allocation of vaxis's two grids;
the emitter stopped needing them, so the path that failed is gone rather than repaired.
Verified on the die - an in-band report re-lays the screen from 56 columns to 30 and back.
Recorded as a consequence rather than as a fix, because nobody set out to repair it.

Eleven pages.
</content>
</entry>
<entry>
<title>Ask the shell for its own grid, and report the heap on every boot</title>
<updated>2026-08-26T05:04:57Z</updated>
<author>
<name>Gabriel Schneider</name>
<email>gbrls@0x4200.cafe</email>
</author>
<published>2026-08-26T05:04:57Z</published>
<link rel='alternate' type='text/html' href='https://git.0x4200.cafe/esp32p4.git/commit/?id=c64012e35c6370804f8588a62964e358631323b9'/>
<id>urn:sha1:c64012e35c6370804f8588a62964e358631323b9</id>
<content type='text'>
Two small changes that the geometry sweep needed.

The firmware asked for 80x24 and let the shell clamp it, which made this file a second
opinion about the board's geometry - one opinion too many, and wrong the moment the shell
could render more than that. It now asks for 255x255 so the shell's own ceiling is what
governs, and the shell reports what it settled on.

And the heap is printed on every boot rather than only when init fails. A geometry that
fits with 2 KB to spare and one that fits with 80 KB are not the same answer, and from the
host the difference was invisible. That line is what showed the sweep that memory had
stopped being the constraint at all: 336 KB free at every size tried, while '.bss' - the
shadow grid, sized at comptime - is what actually runs out.
</content>
</entry>
<entry>
<title>Send mouse events over the wire, thinned, and check the two things only hardware can</title>
<updated>2026-08-26T04:42:35Z</updated>
<author>
<name>Gabriel Schneider</name>
<email>gbrls@0x4200.cafe</email>
</author>
<published>2026-08-26T04:42:35Z</published>
<link rel='alternate' type='text/html' href='https://git.0x4200.cafe/esp32p4.git/commit/?id=cea01b8c08c30fa68fcbf18266937274a080507e'/>
<id>urn:sha1:cea01b8c08c30fa68fcbf18266937274a080507e</id>
<content type='text'>
## The console thins mouse reports

The board asks for DEC 1002, so the terminal reports presses, releases and motion while a
button is held. A press is one report; a DRAG is one report per cell crossed, each about a
dozen bytes. A hand crosses forty cells in a tenth of a second, which is ~500 bytes, which
is 43 ms of a 115200 line - and every one of those bytes is input the board must parse
while it is trying to paint the result of the previous one. Unthinned, a drag makes the
editor unusable for as long as the drag lasts and for a while after.

`MouseFilter` holds the newest motion report and drops the ones it supersedes, on a 40 ms
window: ~25 reports a second, about 2.6% of the line. Newest-wins is right for motion and
only for motion - where the pointer PASSED THROUGH is not information the editor can use,
since a selection is defined by where the drag began and where it is now, so an intermediate
report already stale by the time it reaches the wire is pure cost. Presses, releases and
wheel notches are never held: each one means something different and dropping one loses a
click.

Ordering is the part worth testing. A held report is released before any non-motion byte
that follows it, so a release cannot overtake the motion it ends, and a drag that stops
moving still delivers its final position when the window expires. The seven tests cover
those, a report split across a read boundary, a non-mouse escape sequence passing through
untouched, and - the one that would hurt most - a lone ESC not being swallowed, because
that is how you leave insert mode.

## Two hardware checks: p4-bench --check

Both are regressions that no host test can see and no latency number can show.

A lone Escape still leaves insert mode. The shell now holds a solitary ESC for 10 ms
because on this wire the first byte of every sequence arrives alone; if that hold ever
stops expiring, Escape stops working and the editor is unusable.

A click split byte-by-byte lands at the column clicked. This is the bug that made the mouse
look unimplemented, and it only appears when the bytes arrive separately - which the wire
does anyway, 87 us apart. The check sends them as ten separate writes with no gap, because
a gap longer than the hold would expire it and the check would be exercising nothing.

Two things this file deliberately does NOT check, both because a check that cannot fail
honestly is worse than no check. Input loss during transmit belongs to the deterministic
host test in `src/pardes/input_rescue.zig`, which loses 67 bytes with the fix removed and
needs no board. And every hardware oracle for it that was tried here was worse: the cursor
stops being reported past 160 characters because the wrapped line outgrows the viewport, and
a screen reconstruction cannot be rebuilt mid-session because the board only sends what
changed. Both false starts are recorded in the file so the next person does not repeat them.

The check also found its own bugs before it found any of the firmware's: 12 ms gaps between
the click's bytes expired the very hold it meant to test, `$` produces no frame when the
cursor is already at the end of the line, and reading the cursor after a press alone
measures the revert rather than the click.
</content>
</entry>
<entry>
<title>Drain the receiver while the transmitter is full: keystrokes were being lost</title>
<updated>2026-08-26T04:13:28Z</updated>
<author>
<name>Gabriel Schneider</name>
<email>gbrls@0x4200.cafe</email>
</author>
<published>2026-08-26T04:13:28Z</published>
<link rel='alternate' type='text/html' href='https://git.0x4200.cafe/esp32p4.git/commit/?id=e1526395233aad15dc9e2fdb49d5842de3e7e77b'/>
<id>urn:sha1:e1526395233aad15dc9e2fdb49d5842de3e7e77b</id>
<content type='text'>
Reported as "a key is stuck and is only sent when I send a new event". It was neither
stuck nor late - it was gone, and a later frame repainting those cells is what made it
look like it arrived eventually.

The loop is read, apply, render, write, and `uart.write` blocks while the transmit FIFO is
full. That wait is real backpressure and should stay: dropping half an escape sequence
leaves the host terminal in the wrong colour for the rest of the session. But NOTHING
drained the receive FIFO during it, and that FIFO is 128 bytes - 11 ms of wire at 115200.

Measured on the die, typing a burst in one host write and counting what the firmware's
loop actually took off the UART:

    burst    before    after   after + chunked input
      128       128      128       128
      200       197      200       200
      300       257      300       300
      600       478      600       600
     1200         -     1200      1200
     2400         -     2316      2400
     4096         -     3611      4096

Two windows had to close, and the second was only visible once the first was shut.

`input_rescue.pump` drains the receiver on every iteration of the wait for transmitter
room. That is the big one, and it is the whole reason this policy lives in its own file:
`uart.zig` cannot be tested without the chip because every line of it is an MMIO access,
while `pump` takes its port as `anytype` and runs against a fake with a two-byte transmit
FIFO and an eight-byte receive FIFO in `zig build test`. The fake models the receive FIFO
the way the hardware behaves - a byte arriving into a full FIFO is simply gone - so the
test fails by 67 lost bytes with the rescue removed, which is the die's 88-of-200 in
miniature. It also caught a flaw in its own first draft: a fake whose transmit FIFO drains
as fast as it fills never blocks, so `pump` never waits and the test proves nothing.

The second window was APPLYING the input. A keystroke costs 44 us on an empty line and
63 us at 640 characters, so handing the editor a full 128-byte batch is up to 8 ms in
which nothing drains the receiver - against 11 ms of FIFO. The loop now feeds the editor
eight bytes at a time and rescues between chunks. Splitting a burst at an arbitrary byte
is already safe, because `pardes_p4_input` keeps whatever it could not parse; that is how
it survives an escape sequence split across two UART reads. One render still happens per
loop iteration, so this costs no extra wire. Eight rather than thirty-two by measurement:
32 left 2400 and 4096 lossy, 8 does not.

Beyond 4096 bytes in one burst the editor genuinely cannot keep up, and the ring reports
what it abandoned instead of losing it silently - `rxdrop` in the PROF line, alongside a
running count of received bytes. That counter is the other lesson here: the first attempt
at measuring this counted characters on the reconstructed screen, which cannot distinguish
"never arrived" from "arrived but off the edge of the viewport", and it disagreed with the
hardware in both directions.

No cost to latency: round trip median 3687 us over 60 trials against 3687 before, maximum
3866, screen byte-identical to the vaxis reference, host tests green.
</content>
</entry>
</feed>
