<feed xmlns='http://www.w3.org/2005/Atom'>
<title>esp32p4.git/tools/console.zig, 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-08-26T12:24:17Z</updated>
<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>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>pardes as P4 firmware: the seam, and a flash-mapping bug in this toolchain</title>
<updated>2026-08-25T17:38:25Z</updated>
<author>
<name>Gabriel Schneider</name>
<email>gbrls@0x4200.cafe</email>
</author>
<published>2026-08-25T16:06:05Z</published>
<link rel='alternate' type='text/html' href='https://git.0x4200.cafe/esp32p4.git/commit/?id=174991b8f3f8e9c792eede7a52ad7beb10a08b05'/>
<id>urn:sha1:174991b8f3f8e9c792eede7a52ad7beb10a08b05</id>
<content type='text'>
The editor arrives as one freestanding OBJECT exporting a seven-function C ABI
(src/pardes/app.zig declares it, ../02-pardes-code/src/p4.zig implements it), not
as a package dependency. A build.zig.zon path dependency was built first and
reverted: merely DECLARING it nested pardes's ~30-package graph under this one and
broke every build here - std/Build.zig:2091 exceeded its 1000-branch comptime
quota via ghostty's lazyImport, seven cached tree_sitter versions use APIs removed
in 0.16, and the fetch wrote 2.6 GB across 42,736 files into this working copy.

The seam is bytes in and bytes out, which is what a serial line is anyway: the
editor owns vaxis and the ANSI encoding, this side owns the UART, the heap and the
clock, and neither names the other's types. It is versioned, because linkers do
not type-check C symbols and a drifted signature would link cleanly and then
corrupt the stack.

THE BUG WORTH THE COMMIT. .flash.text was ALIGN(64), and the image builder's
anchor makes two mapped segments share an MMU page safely - as long as rodata does
not END inside the page where text BEGINS. With a 578 KB image it does. A volatile
read of a string literal at 0x4004a1d1 returned 37 09 fa 4f, which disassembles to
"lui s2, 0x4ffa0": this image's own .flash.text. Every literal in that last shared
page read as code, so the first thing the firmware tried to print was machine code
and it died on an instruction access fault. .flash.text is now ALIGN(0x10000),
making the segments page-disjoint. The packing trick this project opened with only
ever mattered when the alternative was 64 KiB of zeros in a 1 KB image.

Two more findings, both recorded in README.md:

  * A linker symbol declared as an anyopaque OBJECT gives the optimiser a
    zero-sized object, so ordinary stores through a pointer derived from its
    address are dead code it may drop - and did, silently. The allocator's first
    block header read back as size=2988759312 next=0x14284684 and the free-list
    walk never terminated. @extern with a many-pointer has no size to lose.
    examples/memprobe.zig could not have caught it: it writes through a volatile
    pointer, which the optimiser must leave alone.

  * The RTC watchdog is armed at handover. Every example here had been resetting on
    a ten-second cycle, invisibly, because no run had ever lasted eight seconds.

State, honestly: the firmware boots, clears .bss, brings up the console, disables
the watchdog, starts the systimer, checks the ABI version, initialises the 384 KiB
heap and calls into the editor, which sets up its sink and its environment. It then
faults inside pardes_p4_init on the first allocation. The cause is measured but not
fixed: a load from .flash.rodata page 3 returns the contents of the page 0x50000
higher - exactly the vaddr distance between the rodata and text segments - while
pages 0, 2 and 4 read correctly. The bisect markers that localised it are still in
place, deliberately, because the next step needs them.


--- correction, measured after the above was written ---

Two mapped segments is NOT a choice, and the earlier comment in tools/image.zig
was right for a reason I initially got wrong and then measured.

I first read bootloader_utility.c's `#else` branch, which classifies segments by
address window with two independent ifs - and since the P4's DROM and IROM windows
are the identical range (soc.h:146-149), I concluded the last mapped segment wins
both roles and the first is never mapped. That branch does not run on this chip.
The P4 takes the SOC_MMU_DI_VADDR_SHARED branch (bootloader_utility.c:805-851),
whose own comment says it: "On chips with shared D/I external vaddr, we don't
divide them into either D or I, as essentially they are the same." It collects
mapped segments POSITIONALLY into rom_addr[2] and ends with

    assert(rom_index == 2);

Shipping a one-segment image proved it, on the board:

    Assert failed in unpack_load_app, bootloader_utility.c:842 (rom_index == 2)

So the split stays, image.zig keeps enforcing exactly two - turning that boot-time
abort into a build-time error - and both are now documented with the branch that
actually runs and the assert that actually fires.

What DOES change is alignment. .flash.text was ALIGN(64). Two mapped segments may
share a 64 KiB MMU page only if they also share a flash page, which the image
builder's anchor guarantees - and that holds right up until an application is large
enough for rodata to END inside the page where text BEGINS. With a 578 KB image it
does. Measured on the die: a volatile read of a string literal at 0x4004a1d1
returned 37 09 fa 4f, which disassembles to "lui s2, 0x4ffa0" - this image's own
.flash.text. Every literal in that shared page read as code, so the first thing the
firmware tried to print was machine code, and it died on an instruction access
fault. .flash.text is now ALIGN(0x10000), which makes the segments page-disjoint.
It costs up to 64 KiB of image padding against a 1.5 MiB partition; the packing
trick this project opened with only mattered when the alternative was 64 KiB of
zeros in a 1 KB image.

With that fixed the firmware gets much further: entry, .bss cleared, console up,
watchdog disabled, systimer running, ABI version checked, the 384 KiB heap
initialised, into the editor, its sink and environment ready - and the literal at
0x4004a1d1 now reads back correctly.

Still open, and characterised rather than guessed: pardes_p4_init faults on its
first allocation. The allocator struct crosses the seam intact (its function
pointers land in .flash.text), but the std.mem.Allocator vtable at 0x40035a1c reads
back as instruction bytes, and the dispatch at .flash.text+0xade2 jumps through it.
Ruled out with measurements: the ELF and the image agree at that address, the flash
is MD5-verified against the image, the wrong bytes are identical across three
resets and two reflashes (so not a stale cache), the corruption is a contiguous run
rather than 64-byte lines, and mmu_hal_map_region's arithmetic
(page_num = ceil(len/page), entry from vaddr) is correct for the segments as now
laid out. The next measurement is the one that settles it: read the MMU entry
registers from the running application and print vaddr -&gt; flash for every page. The
register model in src/soc.zig can do that; the bisect markers are left in place for
it.
</content>
</entry>
</feed>
