summaryrefslogtreecommitdiff
path: root/build.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-25 13:01:13 -0300
committerGabriel Schneider <[email protected]>2026-08-25 17:13:54 -0300
commit939e3a7288d6782139cac36f091ccd1d41cdfc0a (patch)
tree6b7b86c2ba4ade9f34c8f174354425a5d31a150a /build.zig
parente5f9e172330bc4500995ddd5954ed94f94e07af6 (diff)
downloadpardes-939e3a7288d6782139cac36f091ccd1d41cdfc0a.tar.gz
pardes-939e3a7288d6782139cac36f091ccd1d41cdfc0a.zip
A fourth platform: pardes as ESP32-P4 firmware, bytes in and bytes out
`-Dplatform=p4 -Dtarget=riscv32-freestanding` emits a single freestanding OBJECT exporting a seven-function C ABI, not an executable. The board's toolchain (../05-zig-p4) owns `_start`, the linker script and the UART driver and links this in. The seam is bytes rather than types, so neither side can accidentally depend on the other's internals, and a signature that drifts fails at link time. The serial line is the whole of the I/O. `src/p4.zig` drives vaxis unchanged over it: the renderer is a byte writer and `queryTerminalSend` is a byte writer, so the terminal emulator on the host answers the capability handshake and the firmware sees a real terminal. Measured going out over the wire on attach: alt screen, in-band resize, cursor report, kitty keyboard, kitty graphics, DA1. THREE WORDS EXIST ONLY HERE. `src/board_memory.zig` implements `Peek`, `Poke` and `Hexdump`, gated on `builtin.os.tag == .freestanding and !isWasm()` - derived from the TARGET, because they are a property of running with no OS under you rather than a product option, and because wasm is freestanding too and is exactly what must be excluded: in a browser an address is an offset into the linear memory this editor's own heap lives in. Every access goes through `*allowzero volatile`: a peripheral register is not memory, and address 0 is an ordinary unmapped address on this bus. One 4 KiB cap per command, set by the console rather than the memory - an unbounded dump would wedge the only console the board has for eleven hours. Measured on ESP32-P4 rev v1.3 silicon, driven from a host terminal: Peek 0x501101a4 0x0e63ce71, then 0xaeaa6919 on a second read - the RNG register, so the volatile loads are not folded Poke 0x5011002c 0xdeadbeef LP_STORE0; a later Peek returned 0xdeadbeef Hexdump 0x5011002c 32 16 bytes a row, hex columns and an ASCII gutter Peek 0x50110001 `peek: MisalignedAddress` on the message row That last line is the one that matters. A misaligned 32-bit access traps, and a trap in firmware is a watchdog reset that takes the session with it, so the check that turns it into a message is the reason the file is hand-written rather than a generic reader. BARE METAL BOOTS AN EMPTY OUTPUT BUFFER. Every other boot layout in `init` makes a shell, and on this platform that is not a preference but an impossibility: nothing to fork, no pty to give a terminal pane. Booting one anyway produced precisely what that describes - a pane whose tag ends in `Filter`, no gutter, no buffer, and every keystroke vanishing into the Fallback's silent pty. An output buffer is also what the platform's own words want, since Peek, Poke and Hexdump each fill one. Sized for the board rather than for a desktop: * `allocators.zig` gains a p4 tier that is ALL fallback - every capacity is zero, so each arena spills immediately to the 384 KiB heap the firmware hands over, and no megabyte-shaped static reservation lands in `.bss`. * `source_manifest.zig`'s allowlist is EMPTY on p4. The table is ~0.95 MiB of rodata against a 1.5 MiB flash partition; the firmware's filesystem is the serial host's, through the Host vtable. * The grid is clamped and the clamp is measured, not guessed: every cell is paid for four times (vaxis Screen + InternalScreen, pardes Surface + previous_cells), so 40x12 fits and 80x24 exhausts the heap during `Pardes.init`. * `Vaxis.resize` deinits both screens before allocating replacements, so a failed resize leaves vaxis rendering nothing. The p4 shell keeps the previous geometry on failure instead of leaving a half-applied one. Also here: `output_pane_integration_test.zig` had an exhaustive switch over `Platform` that adding `.p4` left unhandled, which broke `zig build unit-test` outright - the native test binary is the one consumer no platform build compiles. 346 tests pass again.
Diffstat (limited to 'build.zig')
-rw-r--r--build.zig102
1 files changed, 91 insertions, 11 deletions
diff --git a/build.zig b/build.zig
index 061d1f2c..01fed2b6 100644
--- a/build.zig
+++ b/build.zig
@@ -4,7 +4,10 @@ const mupdf_build = @import("mupdf.zig");
const snap_build = @import("build/snap.zig");
const grammar_manifest = @import("src/grammar_manifest.zig");
-pub const Platform = enum { tty, gui, web, macos };
+/// `p4` is not a shell in this package at all: it is a MODULE (`pardes_p4`)
+/// compiled for riscv32-freestanding, which the zig-p4 firmware package
+/// imports and gives a serial host. See the p4 branch below.
+pub const Platform = enum { tty, gui, web, macos, p4 };
/// The oldest macOS pardes.app claims to run on, spelled ONCE. Three things
/// have to agree about it or the bundle is a lie: the target this build gives
@@ -39,7 +42,7 @@ const gui_shaders = [_][]const u8{
/// committed SPIR-V, so the builtin cannot print code other than what produced
/// the bytecode that build executes.
pub fn build(b: *std.Build) void {
- const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos)") orelse .tty;
+ const platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos, p4)") orelse .tty;
// Default target is the Steam Deck (deckcap's trick): x86_64 linux-gnu
// with the glibc version pinned low, so a binary built on a rolling-
// release host runs on SteamOS — a native build references the host's
@@ -60,12 +63,30 @@ pub fn build(b: *std.Build) void {
// Anywhere else it stays plain native, which is the whole point of the
// Linux dev loop: `zig build unit-test -Dplatform=macos` has to produce a
// binary that machine can actually execute.
- const target = b.standardTargetOptions(.{ .default_target = switch (platform) {
+ const p4_target: std.Target.Query = .{
+ .cpu_arch = .riscv32,
+ .os_tag = .freestanding,
+ .abi = .none,
+ .cpu_model = .{ .explicit = &std.Target.riscv.cpu.generic_rv32 },
+ .cpu_features_add = riscvFeatures(&.{ .m, .a, .f, .c, .zicsr, .zifencei }),
+ };
+ const requested_target = b.standardTargetOptions(.{ .default_target = switch (platform) {
.macos => if (builtin.os.tag.isDarwin()) .{
.cpu_arch = builtin.target.cpu.arch,
.os_tag = .macos,
.os_version_min = .{ .semver = macos_min_version },
} else .{},
+ // The P4 firmware target, spelled out here so `-Dplatform=p4` alone is a
+ // working command line. The CPU FEATURES are part of that spelling and
+ // are not optional: the object this build emits is linked into an image
+ // whose other halves are compiled `generic_rv32+m+a+f+c+zicsr+zifencei`,
+ // and `f` decides the float ABI. Leaving the model implicit produced a
+ // soft-float object and `ld.lld: cannot link object files with different
+ // floating-point ABI` — at LINK time in the other repo, far from here.
+ // Espressif's GCC adds the vendor extensions xesploop/xespv2p1 on top;
+ // upstream LLVM has neither and ordinary code never emits them, so this
+ // matches the base ISA the firmware uses exactly.
+ .p4 => p4_target,
.tty, .gui, .web => .{
.cpu_arch = .x86_64,
.os_tag = .linux,
@@ -73,6 +94,14 @@ pub fn build(b: *std.Build) void {
.glibc_version = .{ .major = 2, .minor = 38, .patch = 0 },
},
} });
+ // `standardTargetOptions` honours `default_target` ONLY when `-Dtarget` is absent, so the
+ // documented `-Dplatform=p4 -Dtarget=riscv32-freestanding` discarded the CPU features above and
+ // silently produced a soft-float object. The features are not a preference here - `f` decides
+ // the float ABI, and the object is linked into an image whose other halves have it - so p4 takes
+ // the pinned query whatever was asked for. `-Dtarget` stays accepted, and the check further down
+ // still rejects anything that is not riscv32-freestanding, so a wrong `-Dtarget` is an error
+ // rather than something quietly ignored.
+ const target = if (platform == .p4) b.resolveTargetQuery(p4_target) else requested_target;
const requested_optimize = b.standardOptimizeOption(.{});
const static = b.option(bool, "static", "statically link") orelse false;
const dump_path = b.option([]const u8, "dump", "dump .zon embedded into the web shell (-Dplatform=web)");
@@ -83,7 +112,13 @@ pub fn build(b: *std.Build) void {
else
&.{};
const is_web = platform == .web;
- const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web off; -Dmupdf=false disables)") orelse !is_web;
+ const is_p4 = platform == .p4;
+ // Platforms with no host libc: the browser and the P4 firmware. Every
+ // dependency below that exists only because a target links libc — the
+ // image decoder, ZLS, ghostty's C++ simd, MuPDF — is off for both, and the
+ // reason is freestanding-ness rather than the browser.
+ const freestanding_core = is_web or is_p4;
+ const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web/p4 off; -Dmupdf=false disables)") orelse !freestanding_core;
// JPEG 2000, and with it scanned PDFs: a scan is one /JPXDecode image per
// page, so without this MuPDF decodes nothing and every page comes back
// blank. On by default — a viewer that cannot open scans is the more
@@ -92,6 +127,7 @@ pub fn build(b: *std.Build) void {
// mupdf.zig.
const enable_jpx = b.option(bool, "jpx", "JPEG 2000 in PDFs, for scanned documents (default on; -Djpx=false drops openjpeg)") orelse true;
const is_web_target = target.result.cpu.arch == .wasm32 and target.result.os.tag == .freestanding;
+ const is_p4_target = target.result.cpu.arch == .riscv32 and target.result.os.tag == .freestanding;
// wasm: size is the budget
const optimize = if (is_web) .ReleaseSmall else requested_optimize;
// The vendored C is never what we are debugging, and at -O0 it dominates
@@ -104,8 +140,9 @@ pub fn build(b: *std.Build) void {
// The browser keeps its useful default grammar without acquiring a host
// libc contract: Tree-sitter and the generated Zig parser are linked into
// the freestanding module against src/web/libc's tiny in-module shim.
- const default_grammars: TreeSitterGrammars = if (is_web) .zig else .full;
- const tree_sitter_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full") orelse default_grammars;
+ const default_grammars: TreeSitterGrammars = if (is_web) .zig else if (is_p4) .disabled else .full;
+ const requested_grammars = b.option(TreeSitterGrammars, "tree-sitter", "tree-sitter grammar set: disabled, zig, minimal (c/c++/zig), full");
+ const tree_sitter_grammars = requested_grammars orelse default_grammars;
const tracy = b.option([]const u8, "tracy", "enable Tracy profiling; supply the path to a Tracy source checkout");
// Who signs pardes.app. Ad-hoc ("-") is what makes a bundle launchable on
// the machine that built it and needs no keychain; a Developer ID here is
@@ -166,6 +203,9 @@ pub fn build(b: *std.Build) void {
if (!is_web and (is_web_target or target.result.os.tag == .emscripten)) return failBuild(b, web_step, "wasm browser targets require -Dplatform=web");
if (platform == .web and dump_path == null) return failBuild(b, web_step, "-Dplatform=web requires -Ddump=<dump.zon> (the browser has no ptys; state replays from an embedded dump)");
if (enable_mupdf and is_web) return failBuild(b, web_step, "-Dmupdf=true is supported only by the native tty/Kitty and gui/SDL backends");
+ if (is_p4 and !is_p4_target) return failBuild(b, web_step, "-Dplatform=p4 requires -Dtarget=riscv32-freestanding (ESP32-P4 firmware)");
+ if (enable_mupdf and is_p4) return failBuild(b, web_step, "-Dmupdf=true is supported only by the native tty/Kitty and gui/SDL backends");
+ if (is_p4 and requested_grammars != null and requested_grammars.? != .disabled) return failBuild(b, web_step, "-Dplatform=p4 has no tree-sitter: the grammars' parse tables are megabytes and the flash partition is 1.5 MiB (-Dtree-sitter=disabled)");
// build-time IO: slurps the grammars' highlights.scm queries, and reads
// vendor/themes to find the theme sources
@@ -181,9 +221,13 @@ pub fn build(b: *std.Build) void {
.root_source_file = b.path(switch (platform) {
.web => "src/web.zig",
.macos => "src/macos.zig",
+ // p4 roots at its own flat C ABI too, for the same reason web and
+ // macOS do: the firmware's `_start`, its linker script and its UART
+ // live in the zig-p4 package, which LINKS the object this emits.
+ .p4 => "src/p4.zig",
.tty, .gui => "src/main.zig",
}),
- .link_libc = !is_web,
+ .link_libc = !freestanding_core,
});
// helix differential harness (test/hxdiff.zig): a second compilation of
// the core, driven headlessly. Mirrors root_mod's wiring for
@@ -276,7 +320,7 @@ pub fn build(b: *std.Build) void {
// the web shell does not, because it has no threads and no-ops the
// lsp effect anyway, so paying to compile an analyser it can never call
// would be pure wasm.
- const zls_backend = !is_web;
+ const zls_backend = !freestanding_core;
const opts = b.addOptions();
opts.addOption(Platform, "platform", platform);
@@ -417,7 +461,7 @@ pub fn build(b: *std.Build) void {
// zstbi (C stb_image): image pane decode. The freestanding core has no C
// allocator ABI, so its module is a compatible no-decode shim; browser IO
// can grow native image decoding independently of the core.
- const zstbi_dep = if (!is_web) b.dependency("zstbi", .{ .target = target, .optimize = c_optimize }) else null;
+ const zstbi_dep = if (!freestanding_core) b.dependency("zstbi", .{ .target = target, .optimize = c_optimize }) else null;
const zstbi_mod = if (zstbi_dep) |dep| dep.module("root") else b.createModule(.{
.target = target,
.optimize = optimize,
@@ -599,7 +643,7 @@ pub fn build(b: *std.Build) void {
// without it); gate that block on `b.graph.host.result.os.tag == .linux`.
// Ghostty's pinned translate-c tarball also 404s now (codeberg dropped
// the archive endpoint) — seed zig-pkg/ from a machine that has it.
- const ghostty_simd = !is_web and (!target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin());
+ const ghostty_simd = !freestanding_core and (!target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin());
// app-runtime none + emit-xcframework off: only the ghostty-vt module is
// consumed, and the defaults otherwise drag ghostty's app graph into the
// build — gtk4 header translation via host pkg-config for linux targets,
@@ -614,7 +658,13 @@ pub fn build(b: *std.Build) void {
// own safety checks come from OUR optimize mode and are unaffected, since
// ghostty-vt is a module compiled into this binary. See reflow.snap.
const ghostty_optimize: std.builtin.OptimizeMode = if (optimize == .Debug) .ReleaseSafe else optimize;
- const ghostty_dep = b.lazyDependency("ghostty", .{ .target = target, .optimize = ghostty_optimize, .simd = ghostty_simd, .@"app-runtime" = .none, .@"emit-xcframework" = false });
+ // The P4 firmware has no terminal panes at all (see `terminal_panes` in
+ // src/pardes.zig), so src/term_pane.zig's `@import("ghostty-vt")` sits in a
+ // dead comptime branch and `wireCore` below is handed a null. The
+ // dependency is therefore not merely unused, it is never REQUESTED: this is
+ // a lazyDependency, so a p4 build does not need the ghostty package (nor
+ // its translate-c tarball, nor its simd C++) present at all.
+ const ghostty_dep = if (is_p4) null else b.lazyDependency("ghostty", .{ .target = target, .optimize = ghostty_optimize, .simd = ghostty_simd, .@"app-runtime" = .none, .@"emit-xcframework" = false });
if (ghostty_dep) |dep| {
const ghostty_vt = dep.module("ghostty-vt");
ghostty_vt_for_snap = ghostty_vt;
@@ -794,6 +844,28 @@ pub fn build(b: *std.Build) void {
run_web_harness.step.dependOn(web_step);
b.step("web-harness", "run the dependency-free JS/WASM DOM harness").dependOn(&run_web_harness.step);
b.getInstallStep().dependOn(web_step);
+ } else if (is_p4) {
+ // ONE freestanding object, exporting the C ABI in src/p4.zig. Not an executable, because
+ // the firmware's `_start`, its generated linker script and its UART driver all live in the
+ // zig-p4 package; not a library, because `addLibrary` bundles compiler_rt and the firmware
+ // already has its own; and not a MODULE exposed through build.zig.zon, which is what this
+ // was first and is the interesting part.
+ //
+ // A path dependency was tried and reverted. Nesting this package's ~30-package graph under
+ // zig-p4's broke every build in that repo, not just the firmware one: `std/Build.zig:2091`
+ // exceeded its 1000-branch comptime quota through ghostty's `SharedDeps.zig:874`
+ // `lazyImport`, seven cached tree-sitter versions failed to compile because their build.zig
+ // uses APIs removed in 0.16, and the fetch materialised 2.6 GB across 42,736 files into a
+ // repo whose entire claim is that Zig is its only dependency. An object has none of that,
+ // and the seam it leaves is bytes rather than types, which is the right seam for a serial
+ // line anyway.
+ //
+ // The object is also the compile probe: rooted at src/p4.zig it drags the whole core through
+ // the riscv32 backend by actually calling it, so `llvm-size` on the result is a real number
+ // to hold against the board's 1.5 MiB factory partition.
+ const obj = b.addObject(.{ .name = "pardes-p4", .root_module = root_mod });
+ b.getInstallStep().dependOn(&b.addInstallFile(obj.getEmittedBin(), "pardes-p4.o").step);
+ web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>").step);
} else if (platform == .macos) {
// The native macOS shell is a static library plus a Swift app: Zig
// keeps the core, the ptys and every effect; Swift owns AppKit and
@@ -1435,3 +1507,11 @@ fn wireCore(m: *std.Build.Module, d: struct {
if (d.vaxis) |x| m.addImport("vaxis", x);
if (d.uucode) |x| m.addImport("uucode", x);
}
+
+/// The CPU feature set for the ESP32-P4 firmware target, as a `Cpu.Feature.Set`. Spelled as a
+/// helper because `cpu_features_add` wants a set and there is no literal syntax for one.
+fn riscvFeatures(comptime features: []const std.Target.riscv.Feature) std.Target.Cpu.Feature.Set {
+ var set = std.Target.Cpu.Feature.Set.empty;
+ for (features) |f| set.addFeature(@intFromEnum(f));
+ return set;
+}