summaryrefslogtreecommitdiff
path: root/build.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-26 13:27:46 -0300
committerGabriel Schneider <[email protected]>2026-08-27 09:47:39 -0300
commit11f380f6d7222f2cad93c2cdf13701ea1f903d47 (patch)
tree803194ee5853a6b4cda93f90a95e28d1f02e69ae /build.zig
parentfbc194068687e49a8490c85c9f1257a2f2bb9079 (diff)
downloadpardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.tar.gz
pardes-11f380f6d7222f2cad93c2cdf13701ea1f903d47.zip
One core behind N frontends, the board's own runner moved in, and every board cap on one screen
## The wire is the effect stream, not a new protocol `pardes --detach` leaves a core running with no terminal; `pardes --attach` is a frontend that owns a terminal and a socket and nothing else. N frontends on one core all look at the same screen — `screen -x`, not N sessions. The codec (`src/detached/wire.zig`) carries exactly one `Event` or one `Host.VTable` call per message. That is not a coincidence and it is why there is no third vocabulary to keep in step: the core's IO seam was already a struct of function pointers with plain-data arguments, so a socket is a legal implementation of it. `nested.zig`'s socket could not be reused — it carries a builtin command line, and a command line cannot carry a frame. ARCHITECTURE-NEUTRAL on purpose, not as decoration. The frontend on the far end may be riscv32-freestanding on the ESP32-P4 while the core is x86_64 Linux, so every field is an explicit little-endian fixed width and no message is a blit of a native struct. A protocol that only works between two builds of the same compiler would have thrown away the one frontend that motivated it. ## The board comes in; its toolchain stays out `src/p4.zig` becomes `src/esp32p4.zig`, and the pardes half of `../05-zig-p4` — the vaxis-over- serial runner, the UART editor terminal, the keystroke rescue ring, the on-die test suite — moves into `src/esp32p4/`. `build.zig.zon` gains `.zig_p4 = .{ .path = "../05-zig-p4" }`, so `zig build -Dplatform=esp32p4 -Desp32p4-firmware` builds, flashes, monitors and self-tests the board from this repo's `build.zig`. The DIVISION is the point. What moved is what only pardes wants: the runner that drives a pardes core over a serial line. What stayed is everything a second project would also want — the HAL, the register/radio/oracle layers, the linker script, `_start`. `zig_p4` declares no dependencies of its own and its `build()` early-returns when it is not the root package, so this costs the package graph exactly zero packages and the editor's own builds nothing at all. ## limits.zig: nine forgettable places become one budget Nine `platform == .esp32p4` capacity tests lived in nine files. They were never nine decisions — they are ONE decision, how much memory this build may spend, taken nine times where no reader could see the total. `src/limits.zig` puts the whole budget on one screen with every cap named against what it is measured against, derived from two booleans. The payoff is testability on a machine that is not the board: the caps are ordinary comptime values, so a host build can be compiled against the board's numbers and the parking, eviction and clamping paths a 240 KiB core takes get exercised by the normal test suite instead of only over a UART. ## A bare `zig build` `zig build` with no arguments now builds the tty and GUI binaries and installs them into `~/.local/bin`, and says so once on stdout with the flag that overrides it. The old default built one binary into `zig-out` — a path nothing on a `PATH` ever looks at, which made "build it" and "use it" two different commands for no reason.
Diffstat (limited to 'build.zig')
-rw-r--r--build.zig676
1 files changed, 545 insertions, 131 deletions
diff --git a/build.zig b/build.zig
index 07c180d3..07620ee1 100644
--- a/build.zig
+++ b/build.zig
@@ -4,10 +4,10 @@ const mupdf_build = @import("mupdf.zig");
const snap_build = @import("build/snap.zig");
const grammar_manifest = @import("src/grammar_manifest.zig");
-/// `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 };
+/// `esp32p4` is not a shell in this package at all: it is one freestanding
+/// OBJECT, compiled for riscv32-freestanding, which the `zig_p4` firmware
+/// package links beside its own `_start`. See the esp32p4 branch below.
+pub const Platform = enum { tty, gui, web, macos, esp32p4 };
/// 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
@@ -42,7 +42,50 @@ 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, p4)") orelse .tty;
+ // `-Dplatform` names ONE shell. Absent, this build makes BOTH native
+ // shells — the tty cli and the SDL gui — because those two together are
+ // what installing pardes means, and asking for them one at a time is two
+ // invocations that a person has to remember are two.
+ //
+ // Everything below derives from `platform`, which is the PRIMARY shell:
+ // the one rooted at `root_mod`, the one `unit-test` runs, and the one the
+ // snapshot/harness suites drive. `also_gui` adds the second beside it and
+ // changes nothing about the first.
+ const requested_platform = b.option(Platform, "platform", "which shell to build (tty, gui, web, macos, esp32p4); absent builds the tty cli and the SDL gui together");
+ const platform = requested_platform orelse .tty;
+ const also_gui = requested_platform == null;
+ // WHERE A BARE `zig build` PUTS THE BINARIES: `~/.local/bin`, not
+ // `zig-out/bin`. The default build IS the install — a `pardes` that is not
+ // on PATH afterwards is one more command to remember — and the two shells
+ // it makes are exactly the two a person runs.
+ //
+ // Two conditions, and the second one is not the obvious one:
+ // * no shell was NAMED. `-Dplatform=web` keeps writing `zig-out/web`,
+ // which its docs name, and `-Dplatform=macos` keeps its lib/include
+ // layout.
+ // * nothing else has already said where to install: no DESTDIR, no
+ // `--prefix`, no `--prefix-*dir`. See `prefixIsUntouched`, which also
+ // records the one spelling it cannot detect.
+ //
+ // It does NOT depend on which step was asked for, because build.zig cannot
+ // know that (build_runner keeps the step names in a local and resolves them
+ // after `build()` returns). That is why the dev binaries below install to
+ // `<prefix>/dev` rather than `<prefix>/bin`: `zig build perf` redirects the
+ // prefix too, and a 200 MB Debug benchmark must not land on a PATH.
+ //
+ // `resolveInstallPrefix` is what recomputes the derived lib/bin/include
+ // directories; assigning `install_prefix` alone would leave `exe_dir`
+ // pointing into zig-out.
+ if (also_gui and prefixIsUntouched(b)) {
+ if (b.graph.environ_map.get("HOME")) |home| {
+ b.resolveInstallPrefix(b.pathJoin(&.{ home, ".local" }), .{});
+ // Said out loud, because a build that moves a file somewhere the
+ // command line did not mention should not be silent about it — and
+ // because it is the only signal in the one case this cannot detect,
+ // `--prefix` given as the default path spelled absolutely.
+ std.debug.print("pardes: installing into {s} (override with --prefix)\n", .{b.install_prefix});
+ }
+ }
// 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
@@ -63,7 +106,7 @@ 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 p4_target: std.Target.Query = .{
+ const esp32p4_target: std.Target.Query = .{
.cpu_arch = .riscv32,
.os_tag = .freestanding,
.abi = .none,
@@ -77,7 +120,7 @@ pub fn build(b: *std.Build) void {
.os_tag = .macos,
.os_version_min = .{ .semver = macos_min_version },
} else .{},
- // The P4 firmware target, spelled out here so `-Dplatform=p4` alone is a
+ // The P4 firmware target, spelled out here so `-Dplatform=esp32p4` 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`,
@@ -87,7 +130,7 @@ pub fn build(b: *std.Build) void {
// 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,
+ .esp32p4 => esp32p4_target,
.tty, .gui, .web => .{
.cpu_arch = .x86_64,
.os_tag = .linux,
@@ -97,13 +140,13 @@ pub fn build(b: *std.Build) void {
},
});
// `standardTargetOptions` honours `default_target` ONLY when `-Dtarget` is absent, so the
- // documented `-Dplatform=p4 -Dtarget=riscv32-freestanding` discarded the CPU features above and
+ // documented `-Dplatform=esp32p4 -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 float ABI, and the object is linked into an image whose other halves have it - so esp32p4 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 target = if (platform == .esp32p4) b.resolveTargetQuery(esp32p4_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)");
@@ -114,13 +157,13 @@ pub fn build(b: *std.Build) void {
else
&.{};
const is_web = platform == .web;
- const is_p4 = platform == .p4;
+ const is_esp32p4 = platform == .esp32p4;
// 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;
+ const freestanding_core = is_web or is_esp32p4;
+ const enable_mupdf = b.option(bool, "mupdf", "native PDF rendering with MuPDF (AGPL/commercial; native default on, web/esp32p4 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
@@ -129,11 +172,11 @@ 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;
+ const is_esp32p4_target = target.result.cpu.arch == .riscv32 and target.result.os.tag == .freestanding;
// wasm: size is the budget.
//
- // p4: Debug is not a supported mode, and `-Doptimize` defaulting to it made the naive
- // `zig build -Dplatform=p4` produce an object that cannot run. Debug wraps every tier in
+ // esp32p4: Debug is not a supported mode, and `-Doptimize` defaulting to it made the naive
+ // `zig build -Dplatform=esp32p4` produce an object that cannot run. Debug wraps every tier in
// `allocators.zig` in a `DebugAllocator`, whose metadata is page-granular; the board hands the
// editor a 384 KiB heap and one 4 KiB page per size class does not fit in it, so the image
// links and then dies in `Pardes.init`. ReleaseFast rather than ReleaseSmall because it was
@@ -143,7 +186,7 @@ pub fn build(b: *std.Build) void {
// ReleaseSmall remains one flag away when flash matters more than latency.
const optimize = if (is_web)
.ReleaseSmall
- else if (is_p4_target and requested_optimize == .Debug)
+ else if (is_esp32p4_target and requested_optimize == .Debug)
.ReleaseFast
else
requested_optimize;
@@ -157,7 +200,7 @@ 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 if (is_p4) .disabled else .full;
+ const default_grammars: TreeSitterGrammars = if (is_web) .zig else if (is_esp32p4) .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");
@@ -173,13 +216,21 @@ pub fn build(b: *std.Build) void {
// shaders/prebuilt/, and this flag embeds that pair instead of shelling
// out. `-Dplatform=gui` then builds with nothing but a C toolchain.
//
- // Off by default because it trades a dependency for a freshness problem:
- // with the flag on, the live .glsl sources are NOT build inputs, so editing
- // one changes neither runtime bytecode nor EffectCode until someone runs
- // `zig build shaders` (see below). The default therefore stays the honest
- // one — compile the shaders that are actually in the tree.
- const prebuilt_shaders = b.option(bool, "prebuilt-shaders", "embed the committed shaders/prebuilt/*.spv instead of running glslc (-Dplatform=gui without a Vulkan SDK)") orelse false;
- const gui_shader_sources_prebuilt = platform == .gui and prebuilt_shaders;
+ // Off by default when a shell was NAMED, because it trades a dependency for
+ // a freshness problem: with the flag on, the live .glsl sources are not
+ // build inputs, so editing one changes neither runtime bytecode nor
+ // EffectCode until someone runs `zig build shaders` (see below). Somebody
+ // who typed `-Dplatform=gui` is working on the gui and wants the shaders
+ // that are actually in the tree.
+ //
+ // ON by default for the bare `zig build`, which is a different question
+ // with a different right answer. That build makes the gui BESIDE the cli,
+ // for a person who asked for pardes rather than for a graphics toolchain,
+ // and compiling shaders live would make `zig build` fail on any machine
+ // without a Vulkan SDK — a dependency the cli never needed and that this
+ // build did not have before the gui joined it. The committed SPIR-V exists
+ // exactly so that arrangement is possible.
+ const prebuilt_shaders = b.option(bool, "prebuilt-shaders", "embed the committed shaders/prebuilt/*.spv instead of running glslc (default: on for a bare `zig build`, off when -Dplatform names a shell)") orelse also_gui;
// The browser shell is a freestanding wasm core plus ordinary web files.
// JavaScript owns the loop and IO; HTML/CSS own rendering.
@@ -220,9 +271,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)");
+ if (is_esp32p4 and !is_esp32p4_target) return failBuild(b, web_step, "-Dplatform=esp32p4 requires -Dtarget=riscv32-freestanding (ESP32-P4 firmware)");
+ if (enable_mupdf and is_esp32p4) return failBuild(b, web_step, "-Dmupdf=true is supported only by the native tty/Kitty and gui/SDL backends");
+ if (is_esp32p4 and requested_grammars != null and requested_grammars.? != .disabled) return failBuild(b, web_step, "-Dplatform=esp32p4 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
@@ -238,10 +289,10 @@ 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
+ // esp32p4 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",
+ .esp32p4 => "src/esp32p4.zig",
.tty, .gui => "src/main.zig",
}),
.link_libc = !freestanding_core,
@@ -272,15 +323,40 @@ pub fn build(b: *std.Build) void {
.link_libc = true,
}) else null;
+ // The SECOND shell of a default build: the same src/main.zig, compiled
+ // with `platform = .gui`. A separate module and not a flag on the first
+ // one for the reason pardes-isolate is separate — `pardes.platform` is
+ // comptime, and the gui shell @cImports an SDL the cli must not link.
+ const gui_mod: ?*std.Build.Module = if (also_gui) b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/main.zig"),
+ .link_libc = true,
+ }) else null;
+
// Every module that compiles src/pardes.zig, so its wiring is applied once
- // per dependency instead of once per module per dependency.
- var core_mods_buf: [3]*std.Build.Module = .{ root_mod, hx_core_mod, undefined };
- var core_mods_len: usize = 2;
- if (isolated_mod) |m| {
- core_mods_buf[2] = m;
- core_mods_len = 3;
- }
+ // per dependency instead of once per module per dependency — each paired
+ // with the frontend it IS, because that is the single thing their
+ // `pardes_config` modules are allowed to disagree about.
+ const CoreMod = struct { mod: ?*std.Build.Module, shell: Platform };
+ var core_mods_buf: [4]*std.Build.Module = undefined;
+ var core_shells_buf: [4]Platform = undefined;
+ var core_mods_len: usize = 0;
+ // root_mod FIRST: the module map below is folded out of its import table.
+ for ([_]CoreMod{
+ .{ .mod = root_mod, .shell = platform },
+ // hxdiff's headless core and pardes-isolate are the primary shell's
+ // configuration compiled two more ways, not two more frontends.
+ .{ .mod = hx_core_mod, .shell = platform },
+ .{ .mod = isolated_mod, .shell = platform },
+ .{ .mod = gui_mod, .shell = .gui },
+ }) |entry| if (entry.mod) |m| {
+ core_mods_buf[core_mods_len] = m;
+ core_shells_buf[core_mods_len] = entry.shell;
+ core_mods_len += 1;
+ };
const core_mods = core_mods_buf[0..core_mods_len];
+ const core_shells = core_shells_buf[0..core_mods_len];
// The conditional dependencies, hoisted so one `wireCore` call at the end
// can name them all. The unconditional ones are ordinary consts below.
@@ -339,15 +415,6 @@ pub fn build(b: *std.Build) void {
// would be pure wasm.
const zls_backend = !freestanding_core;
- const opts = b.addOptions();
- opts.addOption(Platform, "platform", platform);
- opts.addOption(bool, "syntax_highlighting", tree_sitter_grammars != .disabled);
- opts.addOption(bool, "syntax_zig_grammar", tree_sitter_grammars != .disabled);
- opts.addOption(bool, "syntax_minimal_grammars", tree_sitter_grammars == .minimal or tree_sitter_grammars == .full);
- opts.addOption(bool, "syntax_full_grammars", tree_sitter_grammars == .full);
- opts.addOption(bool, "enable_tracy", tracy != null);
- opts.addOption(bool, "zls_backend", zls_backend);
- opts.addOption(bool, "mupdf", enable_mupdf);
// THE BOARD'S GRID, a build option because the right size is a measurement rather than a
// constant, and because two independent things limit it.
//
@@ -360,8 +427,21 @@ pub fn build(b: *std.Build) void {
// MEMORY binds much later, and only since vaxis's two unused grids stopped being allocated:
// 140x42 runs, 160x48 links and then traps, and 200x60 does not link at all - `.bss will not fit
// in region l2mem, overflowed by 76036 bytes`, that being the shell's shadow copy of the grid.
- opts.addOption(u16, "p4_cols", b.option(u16, "p4-cols", "board grid width in cells (p4 only)") orelse 56);
- opts.addOption(u16, "p4_rows", b.option(u16, "p4-rows", "board grid height in cells (p4 only)") orelse 14);
+ const esp32p4_cols = b.option(u16, "esp32p4-cols", "the board's grid width in cells (-Dplatform=esp32p4 only)") orelse 56;
+ const esp32p4_rows = b.option(u16, "esp32p4-rows", "the board's grid height in cells (-Dplatform=esp32p4 only)") orelse 14;
+ // THE OTHER THREE BOARD OPTIONS, registered HERE and not inside the
+ // `-Desp32p4-firmware` block that consumes them. Declared in there they
+ // existed only once the flag was already on, so `zig build
+ // -Dplatform=esp32p4 -Desp32p4-port=/dev/ttyACM0` was rejected as an
+ // "invalid option" and told the reader to consult a help menu that did not
+ // list it either. Worse for `-Desp32p4-cpu-mhz`: the ESP-IDF read inside
+ // `esp32p4.firmware()` happens before the block reaches the `b.option`
+ // call, so on a machine with no IDF checkout the build exited before the
+ // option was ever declared. An option is part of this build's interface
+ // whether or not this invocation reaches the code that uses it.
+ const esp32p4_port = b.option([]const u8, "esp32p4-port", "serial port the board is wired to (default /dev/ttyUSB0)") orelse "/dev/ttyUSB0";
+ const esp32p4_prof = b.option(bool, "esp32p4-prof", "make the firmware print per-phase cycle counts for every frame") orelse false;
+ const esp32p4_cpu_mhz = b.option(u16, "esp32p4-cpu-mhz", "board HP CPU clock: 90 (bootloader default), 180 or 360") orelse 90;
// ANIMATED THEME CHANGES, off on the board because there the animation is not an animation.
//
// A theme change moves the anchored chrome palette - taglines, boxes, line numbers, scroll bars -
@@ -387,27 +467,29 @@ pub fn build(b: *std.Build) void {
// A build option rather than a platform test, because "is a frame expensive" is a property of the
// transport and not of the target: a P4 driven over something faster than a UART would want it on,
// and it is off here only as the default that matches the wire this port actually has.
- opts.addOption(bool, "theme_animation", b.option(bool, "theme-animation", "animate chrome colors across a theme change (default: off for p4)") orelse
- (platform != .p4));
- // Meaningful only for the SDL shell. Keeping the platform condition here
- // prevents Config/EffectCode from describing tty/macOS/web as "prebuilt".
- opts.addOption(bool, "gui_shader_sources_prebuilt", gui_shader_sources_prebuilt);
- // Which ZLS is compiled in, for `SPC l i`. Kept next to the dependency it
- // names: the .zon pins a commit, and a status screen that cannot say WHICH
- // analyser answered is not worth opening.
- opts.addOption([]const u8, "zls_version", if (zls_backend) zls_version else "none");
- // Mirrors build.zig.zon's `.version`; kept in sync by hand because a typed
- // @import of the .zon rejects its other fields on this Zig 0.16 build.
- opts.addOption([]const u8, "version", "0.0.1");
- // The stdlib this binary was compiled against, so `gd` on `std.mem.count`
- // can open the same mem.zig the compiler used. ZLS resolves `@import("std")`
- // through `zig_lib_dir` and nothing else; without it every std symbol is a
- // silent miss, and asking the `zig` binary for it is the subprocess this
- // whole backend exists to avoid. ZIG_LIB_DIR overrides it at runtime.
- opts.addOption([]const u8, "zig_lib_dir", b.graph.zig_lib_directory.path orelse "");
- // NOTE: `opts` is attached to the modules at the BOTTOM of this function,
+ const theme_animation = b.option(bool, "theme-animation", "animate chrome colors across a theme change (default: off for esp32p4)");
+ // Everything `pardes_config` carries that is a property of the BUILD
+ // rather than of one frontend, gathered into one value so the two options
+ // modules a default build makes cannot drift apart in any other field.
+ const shell_cfg: ShellConfig = .{
+ .tree_sitter_grammars = tree_sitter_grammars,
+ .tracy = tracy != null,
+ .zls_backend = zls_backend,
+ .mupdf = enable_mupdf,
+ .esp32p4_cols = esp32p4_cols,
+ .esp32p4_rows = esp32p4_rows,
+ .theme_animation = theme_animation,
+ .prebuilt_shaders = prebuilt_shaders,
+ .zig_lib_dir = b.graph.zig_lib_directory.path orelse "",
+ };
+ // ONE `pardes_config` per distinct frontend in this build: two when the
+ // cli and the gui are made together, one otherwise. hxdiff's core and
+ // pardes-isolate share the primary shell's, being the same frontend.
+ const opts = shellOptions(b, shell_cfg, platform);
+ const gui_opts: ?*std.Build.Step.Options = if (also_gui) shellOptions(b, shell_cfg, .gui) else null;
+ // NOTE: these are attached to the modules at the BOTTOM of this function,
// after every addImport — the module-import table below is folded out of
- // root_mod.import_table and would be empty if we attached it here.
+ // root_mod.import_table and would be empty if we attached them here.
if (zls_backend) {
// .target/.optimize are mandatory: createZLSModule bakes them into the
@@ -645,9 +727,13 @@ pub fn build(b: *std.Build) void {
scroll_bench_opts.addOption(bool, "release_fast_core", optimize == .ReleaseFast);
scroll_bench_opts.addOption([]const u8, "core_optimize", @tagName(optimize));
pdf_scroll_bench.root_module.addOptions("pdf_scroll_bench_config", scroll_bench_opts);
- // Installed, unlike the other two: `perf record zig-out/bin/...`
- // needs a stable path, and a cache hash is not one.
- b.installArtifact(pdf_scroll_bench);
+ // Installed, unlike the other two: `perf record` needs a stable
+ // path and a cache hash is not one. By ITS OWN step, and into
+ // `<prefix>/dev` rather than `<prefix>/bin`: a bare `zig build`
+ // installs into the user's own bin directory now, and build.zig
+ // cannot tell which step was asked for, so the only way a 200 MB
+ // Debug benchmark is kept off a PATH is by never targeting `bin`.
+ pdf_scroll_bench_step.dependOn(&b.addInstallArtifact(pdf_scroll_bench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step);
const run_pdf_scroll_bench = b.addRunArtifact(pdf_scroll_bench);
if (b.args) |args| run_pdf_scroll_bench.addArgs(args);
run_pdf_scroll_bench.setCwd(b.path("."));
@@ -720,9 +806,9 @@ pub fn build(b: *std.Build) void {
// 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
+ // a lazyDependency, so an esp32p4 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 });
+ const ghostty_dep = if (is_esp32p4) 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;
@@ -793,20 +879,40 @@ pub fn build(b: *std.Build) void {
}
}
std.debug.assert(module_index == module_count);
- opts.addOption([]const []const u8, "module_names", mod_names);
- opts.addOption([]const []const u8, "module_roots", mod_roots);
+ // Both options modules get the same map: the shells' import tables are
+ // identical at this point, because every import that differs between them
+ // is an anonymous FILE added below rather than a module.
+ for ([_]?*std.Build.Step.Options{ opts, gui_opts }) |maybe| if (maybe) |o| {
+ o.addOption([]const []const u8, "module_names", mod_names);
+ o.addOption([]const []const u8, "module_roots", mod_roots);
+ };
+
+ // The one setting that produces a SECOND executable rather than changing
+ // this one: its own tiny options module, because duplicating `opts` to
+ // flip a single bool would be twenty lines that must then agree forever.
+ // Both are handed out in the loop below rather than module by module:
+ // every compilation of src/pardes.zig imports it unconditionally (:89),
+ // and naming the modules one at a time is exactly what left the second
+ // shell without one.
+ const iso_off = b.addOptions();
+ iso_off.addOption(bool, "isolated", false);
+ const iso_on = b.addOptions();
+ iso_on.addOption(bool, "isolated", true);
- const gui_effect_source_dir = if (gui_shader_sources_prebuilt) "shaders/prebuilt" else "shaders";
+ // Consulted only for a gui shell, so `-Dprebuilt-shaders` alone decides it
+ // here; the platform half of that condition is the branch it sits in.
+ const gui_effect_source_dir = if (prebuilt_shaders) "shaders/prebuilt" else "shaders";
// Attached after the fold above, for the reason `opts` names, and with the
// embedded FILES: those are bytes rather than modules, and adding them
// earlier would list them in the module map as if they were importable.
- for (core_mods) |mod| {
- mod.addOptions("pardes_config", opts);
- if (platform == .gui) for (gui_shaders) |name| mod.addAnonymousImport(
+ for (core_mods, core_shells) |mod, shell| {
+ mod.addOptions("pardes_config", if (also_gui and shell == .gui) gui_opts.? else opts);
+ mod.addOptions("pardes_isolation", if (isolated_mod == mod) iso_on else iso_off);
+ if (shell == .gui) for (gui_shaders) |name| mod.addAnonymousImport(
b.fmt("effect-source-{s}.glsl", .{name}),
.{ .root_source_file = b.path(b.fmt("{s}/{s}.glsl", .{ gui_effect_source_dir, name })) },
);
- if (platform == .macos) mod.addAnonymousImport("effect-source-crt.ci.metal", .{
+ if (shell == .macos) mod.addAnonymousImport("effect-source-crt.ci.metal", .{
.root_source_file = b.path("shaders/crt.ci.metal"),
});
// The virtual filesystem's two repo-root entries: `@embedFile` cannot
@@ -816,23 +922,16 @@ pub fn build(b: *std.Build) void {
mod.addAnonymousImport("root-build.zig.zon", .{ .root_source_file = b.path("build.zig.zon") });
}
- // The one setting that produces a SECOND executable rather than changing
- // this one: its own tiny options module, because duplicating `opts` to
- // flip a single bool would be twenty lines that must then agree forever.
- const iso_off = b.addOptions();
- iso_off.addOption(bool, "isolated", false);
- root_mod.addOptions("pardes_isolation", iso_off);
- hx_core_mod.addOptions("pardes_isolation", iso_off);
- if (isolated_mod) |m| {
- const iso_on = b.addOptions();
- iso_on.addOption(bool, "isolated", true);
- m.addOptions("pardes_isolation", iso_on);
- }
-
- // SDL3 shell wiring (-Dplatform=gui). SDL3 and FreeType are both built
- // from source as static libraries; gui.zig @cImports their headers plus
- // the small FreeType policy shim, and embeds SPIR-V compiled from GLSL.
- if (platform == .gui) {
+ // SDL3 shell wiring. SDL3 and FreeType are both built from source as
+ // static libraries; gui.zig @cImports their headers plus the small
+ // FreeType policy shim, and embeds SPIR-V compiled from GLSL.
+ //
+ // The module it lands on is whichever one IS the SDL shell here: the
+ // second shell of a default build, or the only shell under
+ // `-Dplatform=gui`. Never both, and never the cli.
+ const gui_shell_mod: ?*std.Build.Module = gui_mod orelse
+ if (platform == .gui) root_mod else null;
+ if (gui_shell_mod) |gm| {
// sanitize_c MUST stay off: zig cc's UBSan (on for C in Debug AND
// ReleaseSafe) traps hidapi's mismatched fn-pointer calls the moment
// a HID gamepad is enumerated — SDL_Init SIGILLs on the deck itself
@@ -842,29 +941,29 @@ pub fn build(b: *std.Build) void {
const sdl_dep = b.lazyDependency("sdl", .{ .target = target, .optimize = optimize, .sanitize_c = .off, .preferred_linkage = .static });
if (sdl_dep) |dep| {
const sdl_lib = dep.artifact("SDL3");
- root_mod.linkLibrary(sdl_lib);
- root_mod.addIncludePath(dep.path("include"));
+ gm.linkLibrary(sdl_lib);
+ gm.addIncludePath(dep.path("include"));
}
const freetype_dep = b.dependency("freetype", .{
.target = target,
.optimize = optimize,
.@"enable-libpng" = false,
});
- root_mod.linkLibrary(freetype_dep.artifact("freetype"));
+ gm.linkLibrary(freetype_dep.artifact("freetype"));
// darwin: SDL_GPU only speaks SPIRV here (shaders/*.glsl -> glslc), so
// it must pick its vulkan backend via the Vulkan SDK's loader +
// MoltenVK in /usr/local/lib — a path dyld no longer searches for
// bare dlopen names. The rpath restores that lookup.
if (target.result.os.tag.isDarwin())
- root_mod.addRPath(.{ .cwd_relative = "/usr/local/lib" });
- root_mod.addIncludePath(b.path("src/gui"));
- root_mod.addCSourceFile(.{ .file = b.path("src/gui/font.c") });
+ gm.addRPath(.{ .cwd_relative = "/usr/local/lib" });
+ gm.addIncludePath(b.path("src/gui"));
+ gm.addCSourceFile(.{ .file = b.path("src/gui/font.c") });
// the embedded UI font: provided as a build import since assets/ lives
// outside the src/ module root (@embedFile can't escape it)
- root_mod.addAnonymousImport("AdwaitaMono-Regular.ttf", .{
+ gm.addAnonymousImport("AdwaitaMono-Regular.ttf", .{
.root_source_file = b.path("assets/AdwaitaMono-Regular.ttf"),
});
- for (gui_shaders) |name| root_mod.addAnonymousImport(b.fmt("{s}.spv", .{name}), .{
+ for (gui_shaders) |name| gm.addAnonymousImport(b.fmt("{s}.spv", .{name}), .{
.root_source_file = if (prebuilt_shaders)
b.path(b.fmt("shaders/prebuilt/{s}.spv", .{name}))
else
@@ -902,27 +1001,216 @@ 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
+ } else if (is_esp32p4) {
+ // ONE freestanding object, exporting the C ABI in src/esp32p4.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.
+ // A dependency in the OTHER DIRECTION 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.
//
- // The object is also the compile probe: rooted at src/p4.zig it drags the whole core through
+ // THIS direction is free, and that is why build.zig.zon now names `.zig_p4`: that package
+ // declares no dependencies at all, so it enlarges nothing here, and its `build()`
+ // early-returns when it is not the root. What did NOT change is the seam. The editor still
+ // crosses to the firmware as this one object over eight C functions, because bytes are the
+ // right seam for a serial line and because that is the arrangement the board was measured
+ // through — see the `-Desp32p4-firmware` block below, which links this exact object.
+ //
+ // The object is also the compile probe: rooted at src/esp32p4.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);
+ const obj = b.addObject(.{ .name = "pardes-esp32p4", .root_module = root_mod });
+ b.getInstallStep().dependOn(&b.addInstallFile(obj.getEmittedBin(), "pardes-esp32p4.o").step);
+
+ // ------------------------------------------------- the whole board, from this build tree
+ //
+ // `-Dplatform=esp32p4` alone still emits nothing but the object above, which is what the
+ // toolchain repo's own `-Dpardes -Dpardes-obj=` path links and what every measurement in
+ // its `experiments/` was taken through. `-Desp32p4-firmware` adds the other half here: the
+ // firmware executable rooted at src/esp32p4/app.zig, the flashable image, and the steps that
+ // write it to a board and talk to it.
+ //
+ // OPT-IN, and not out of timidity: `esp32p4.firmware()` reads ESP-IDF's register headers at
+ // CONFIGURE time and exits(1) when there is no checkout. Merely DECLARING the dependency
+ // costs nothing — that package has no dependencies and its `build()` early-returns when it
+ // is not the root — but calling this acquires an ESP-IDF requirement, and a bare
+ // `-Dplatform=esp32p4` must not.
+ //
+ // The seam stays the object. `src/esp32p4/app.zig` reaches the editor through the same eight
+ // `extern` C functions whichever repository drives the build, so neither path is the
+ // better-tested one: the image this produces is byte-identical to the one
+ // `05-zig-p4 -Dpardes -Dapp=…/src/esp32p4/app.zig` produces.
+ if (b.option(bool, "esp32p4-firmware", "also build the flashable ESP32-P4 firmware image and its board steps (needs an ESP-IDF checkout for the register headers)") orelse false) {
+ const esp32p4 = @import("zig_p4");
+ // Every entry point in that package takes ITS OWN builder: it resolves source paths and
+ // the image step's cache inputs against its own build root, and its `build()` never
+ // runs when consumed, so `dep.module`/`dep.artifact` deliberately find nothing.
+ const pb = b.dependency("zig_p4", .{}).builder;
+ // One spelling of the ISA, asked of the package that owns the linker script and the
+ // other half of the image, so the two cannot drift. It is the same set `esp32p4_target`
+ // above names, and `f` is the member that decides the float ABI.
+ const fw_target = esp32p4.chipTarget(pb);
+ const fw = esp32p4.firmware(pb, .{
+ .target = fw_target,
+ // ReleaseSmall for the PLATFORM half — a heap, a clock, a UART and a trap vector,
+ // none of it hot — while `obj` keeps the ReleaseFast that was measured on the die.
+ // Two objects, two modes, one link.
+ .optimize = .ReleaseSmall,
+ // 32768 and not that package's own 8192 default: the core recurses through layout
+ // and 8 KiB is not enough for it. It is baked into the generated script's `.stack`.
+ .stack_size = 32768,
+ .prof = esp32p4_prof,
+ .cpu_mhz = esp32p4_cpu_mhz,
+ });
+ const fw_exe = b.addExecutable(.{
+ .name = "pardes-esp32p4-firmware",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/esp32p4/app.zig"),
+ .target = fw_target,
+ .optimize = .ReleaseSmall,
+ .strip = true,
+ .single_threaded = true,
+ .unwind_tables = .none,
+ .omit_frame_pointer = true,
+ .error_tracing = false,
+ // The four modules src/esp32p4/app.zig imports, and no more. `uart.zig` and
+ // `input_rescue.zig` are sibling FILES of that root, so they need no module.
+ .imports = &.{
+ .{ .name = "config", .module = fw.config },
+ .{ .name = "soc", .module = fw.soc },
+ .{ .name = "hal", .module = fw.hal },
+ .{ .name = "heap", .module = fw.heap },
+ },
+ }),
+ });
+ fw_exe.root_module.addObject(obj);
+ // The linker script, `_start` as the entry, the app descriptor the bootloader reads at
+ // image offset 0x20, and the register census that gates compiling against `regs` — in
+ // one call. It deliberately leaves `--gc-sections` to the caller, because which
+ // sections an image keeps belongs to the image.
+ fw.attach(fw_exe);
+ fw_exe.link_gc_sections = true;
+ b.getInstallStep().dependOn(&b.addInstallArtifact(fw_exe, .{}).step);
+
+ // ELF -> flashable image, and the four things one does with a board. The image's own
+ // defaults come from that package (`0x10000`, 16 MB, chip revision window 1.00-1.99).
+ const img = esp32p4.ImageStep.create(pb, fw_exe, .{
+ .chip = .esp32p4,
+ .min_rev_full = 100,
+ .max_rev_full = 199,
+ .flash_size = .@"16MB",
+ .flash_offset = 0x10000,
+ });
+ b.getInstallStep().dependOn(&b.addInstallBinFile(img.getOutput(), "pardes-esp32p4.bin").step);
+ const flash = esp32p4.FlashStep.create(pb, img, .{
+ .port = esp32p4_port,
+ // The FLASHER's rate, negotiated by the ROM loader's SYNC auto-detect. Measured
+ // reliable on this board at 921600; 2000000 is not.
+ .baud = .b921600,
+ .verify = true,
+ .opts = img.opts,
+ });
+ b.step("esp32p4-flash", "write pardes into the board's flash and start it (needs an ESP32-P4 on -Desp32p4-port)").dependOn(&flash.step);
+
+ // INTERACT. The console is a host binary, not a step: it puts the terminal in raw mode
+ // and passes the board's bytes and the user's keystrokes through untouched, which is
+ // what makes the editor on the far end see a real terminal answering its capability
+ // queries. Inherited stdio for the same reason. It lives in the toolchain package
+ // because a serial console is useful to anything on this chip; the RUNNER is here
+ // because attaching to pardes is a pardes command.
+ //
+ // 115200 and not the flasher's rate: this one is whatever the running firmware
+ // programmed into UART0, and the bootloader leaves it at 115200.
+ const tools = esp32p4.hostTools(pb);
+ const console_args: []const []const u8 = &.{ "--port", esp32p4_port, "--baud", "115200" };
+ const con = b.addRunArtifact(tools.console);
+ con.addArgs(console_args);
+ con.stdio = .inherit;
+ con.step.dependOn(&tools.console_install.step);
+ b.step("esp32p4-attach", "attach this terminal to the pardes already running on the board").dependOn(&con.step);
+ // Ordered on purpose: two independent steps let the console reset the board out from
+ // under the writer, and in practice the console wins and drives the OLD firmware.
+ const interact = b.addRunArtifact(tools.console);
+ interact.addArgs(console_args);
+ interact.stdio = .inherit;
+ interact.step.dependOn(&tools.console_install.step);
+ interact.step.dependOn(&flash.step);
+ b.step("esp32p4-run", "flash pardes into the board, then attach this terminal to it").dependOn(&interact.step);
+
+ b.step("esp32p4-image-size", "print how much flash the firmware image uses, segment by segment (no board needed)")
+ .dependOn(&esp32p4.SizeStep.create(pb, img).step);
+ b.step("esp32p4-image-check", "print the firmware image's segments and say whether the bootloader would accept it (no board needed)")
+ .dependOn(&esp32p4.LayoutStep.create(pb, fw_exe, img.opts).step);
+ b.step("esp32p4-reset", "restart the board so the pardes already in its flash runs from the top")
+ .dependOn(&esp32p4.ResetStep.create(pb, esp32p4_port).step);
+
+ // `zig build esp32p4-test` — the on-die suite, its OWN image and not this firmware. It
+ // deliberately links NO `pardes-esp32p4` object: it is not the editor, it is the set of
+ // claims about this board that only the board can answer — byte-at-a-time
+ // `std.mem.eql`, the lone-ESC decode, the transmit FIFO going full, the heap span the
+ // grid is cut from — and linking 1.5 MiB of editor into it would only make those
+ // claims slower to flash.
+ const st_exe = b.addExecutable(.{
+ .name = "pardes-esp32p4-selftest",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/esp32p4/selftest.zig"),
+ .target = fw_target,
+ .optimize = .ReleaseSmall,
+ .strip = true,
+ .single_threaded = true,
+ .unwind_tables = .none,
+ .omit_frame_pointer = true,
+ .error_tracing = false,
+ // The four package modules the suite names, and `input_rescue`, which is a
+ // sibling FILE here and stays a MODULE anyway: the root says
+ // `@import("input_rescue")`, and it must, because `FakePort`'s methods are
+ // `pub` precisely so the policy can reach them by duck typing ACROSS a module
+ // boundary. The toolchain repo compiles this same root with the same wiring,
+ // so one file serves both builds and neither is the tested one.
+ .imports = &.{
+ .{ .name = "config", .module = fw.config },
+ .{ .name = "soc", .module = fw.soc },
+ .{ .name = "hal", .module = fw.hal },
+ .{ .name = "heap", .module = fw.heap },
+ .{ .name = "input_rescue", .module = b.createModule(.{
+ .root_source_file = b.path("src/esp32p4/input_rescue.zig"),
+ .target = fw_target,
+ .optimize = .ReleaseSmall,
+ .single_threaded = true,
+ }) },
+ },
+ }),
+ });
+ // Script, `ENTRY(_start)`, descriptor, register census — and deliberately NO
+ // `link_gc_sections`, unlike `fw_exe` above: the toolchain's own selftest image is
+ // built without it, and switching it on would change the bytes of the image every
+ // on-die measurement was taken against.
+ fw.attach(st_exe);
+ // Same image rules as the firmware's, by reusing `img.opts` rather than restating
+ // them: one flash offset, one revision window, one fitted size.
+ const st_img = esp32p4.ImageStep.create(pb, st_exe, img.opts);
+ const st_flash = esp32p4.FlashStep.create(pb, st_img, .{
+ .port = esp32p4_port,
+ .baud = .b921600,
+ .verify = true,
+ .opts = img.opts,
+ });
+ // Ordered flash-then-run rather than two independent steps: the suite must be the
+ // image that is running when the reader attaches, or the verdict belongs to whatever
+ // was on the board before. An absent `MARK SELFTEST DONE` inside the 20-second window
+ // is itself a failure, which is what makes a board that never got there fail loudly
+ // instead of passing quietly.
+ const st_run = esp32p4.SelftestStep.create(pb, esp32p4_port, 20);
+ st_run.step.dependOn(&st_flash.step);
+ b.step("esp32p4-test", "run pardes's on-die test suite on the board; any failed check fails the build")
+ .dependOn(&st_run.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
@@ -1182,6 +1470,21 @@ pub fn build(b: *std.Build) void {
.root_module = root_mod,
});
b.installArtifact(exe);
+ // The gui beside the cli when neither was named. Same source, its own
+ // module, its own binary name — `pardes-gui`, which is the spelling
+ // nested.zig's executable-family test already expects to find next to
+ // `pardes` (see samePardesExecutable).
+ if (gui_mod) |gm| {
+ var gui_name: []const u8 = "pardes-gui";
+ if (target.result.os.tag != .linux or target.result.cpu.arch != .x86_64)
+ gui_name = b.fmt("{s}-{s}-{s}", .{ gui_name, @tagName(target.result.os.tag), @tagName(target.result.cpu.arch) });
+ const gui_exe = b.addExecutable(.{
+ .name = gui_name,
+ .linkage = if (static) .static else null,
+ .root_module = gm,
+ });
+ b.installArtifact(gui_exe);
+ }
const run = b.addRunArtifact(exe);
b.step("run", "descend to the pardes").dependOn(&run.step);
// `pardes-isolate`: the SAME source on the core's own defaults — the
@@ -1195,12 +1498,14 @@ pub fn build(b: *std.Build) void {
.linkage = if (static) .static else null,
.root_module = iso,
});
- b.installArtifact(iso_exe);
const run_iso = b.addRunArtifact(iso_exe);
// Something to look at on arrival, read out of the binary itself.
if (b.args) |args| run_iso.addArgs(args) else run_iso.addArg("src/pardes.zig");
- b.step("run-isolated", "descend to a pardes with no host but the terminal")
- .dependOn(&run_iso.step);
+ // Installed by its own step, like every other binary here that is
+ // not one of the two shells `zig build` exists to produce.
+ const iso_step = b.step("run-isolated", "descend to a pardes with no host but the terminal");
+ iso_step.dependOn(&b.addInstallArtifact(iso_exe, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step);
+ iso_step.dependOn(&run_iso.step);
}
web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=<dump.zon>").step);
@@ -1269,7 +1574,9 @@ pub fn build(b: *std.Build) void {
}),
});
hxdiff.root_module.addImport("pardes", hx_core_mod);
- b.installArtifact(hxdiff);
+ // Installed by the steps that run it, not by the default install: the
+ // two shells are what `zig build` puts in the user's bin directory.
+ const install_hxdiff = &b.addInstallArtifact(hxdiff, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step;
const run_hxdiff = b.addRunArtifact(hxdiff);
if (b.args) |args| run_hxdiff.addArgs(args) else {
// no args: run the checked-in differential suite offline —
@@ -1282,7 +1589,9 @@ pub fn build(b: *std.Build) void {
});
run_hxdiff.setCwd(b.path("."));
}
- b.step("hxdiff", "run the helix differential suite (-- <cases.jsonl> [goldens.jsonl [waivers.jsonl]])").dependOn(&run_hxdiff.step);
+ const hxdiff_step = b.step("hxdiff", "run the helix differential suite (-- <cases.jsonl> [goldens.jsonl [waivers.jsonl]])");
+ hxdiff_step.dependOn(install_hxdiff);
+ hxdiff_step.dependOn(&run_hxdiff.step);
// file-vs-pty parity: the SAME harness binary, run in --parity mode.
// Each case runs twice over the same text and keys, once in a file
@@ -1303,7 +1612,9 @@ pub fn build(b: *std.Build) void {
});
run_hxparity.setCwd(b.path("."));
}
- b.step("hxparity", "run the file-vs-pty editing parity suite (-- [--waivers w.jsonl] <cases.jsonl>...)").dependOn(&run_hxparity.step);
+ const hxparity_step = b.step("hxparity", "run the file-vs-pty editing parity suite (-- [--waivers w.jsonl] <cases.jsonl>...)");
+ hxparity_step.dependOn(install_hxdiff);
+ hxparity_step.dependOn(&run_hxparity.step);
// the language-backend scoreboard. ReleaseFast on purpose: the point
// is to compare backends' real cost, and a Debug build measures the
@@ -1320,11 +1631,13 @@ pub fn build(b: *std.Build) void {
}),
});
lspbench.root_module.addImport("pardes", hx_core_mod);
- b.installArtifact(lspbench);
+ const install_lspbench = &b.addInstallArtifact(lspbench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step;
const run_lspbench = b.addRunArtifact(lspbench);
if (b.args) |args| run_lspbench.addArgs(args);
run_lspbench.setCwd(b.path("."));
- b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [repo-root])").dependOn(&run_lspbench.step);
+ const lspbench_step = b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [repo-root])");
+ lspbench_step.dependOn(install_lspbench);
+ lspbench_step.dependOn(&run_lspbench.step);
// the editing scoreboard: one gesture, one file size, one number.
// ReleaseFast for the same reason lspbench is — a Debug build measures
@@ -1340,11 +1653,13 @@ pub fn build(b: *std.Build) void {
}),
});
perf.root_module.addImport("pardes", hx_core_mod);
- b.installArtifact(perf);
+ const install_perf = &b.addInstallArtifact(perf, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step;
const run_perf = b.addRunArtifact(perf);
if (b.args) |args| run_perf.addArgs(args);
run_perf.setCwd(b.path("."));
- b.step("perf", "large-file / long-line latency table (-- [--json] [--reps N] [--base old.json])").dependOn(&run_perf.step);
+ const perf_step = b.step("perf", "large-file / long-line latency table (-- [--json] [--reps N] [--base old.json])");
+ perf_step.dependOn(install_perf);
+ perf_step.dependOn(&run_perf.step);
// The filesystem scoreboard. Same shape as `perf` and for the same
// reason: it drives `acmefs.handle` through the real core, so it wants
@@ -1363,11 +1678,13 @@ pub fn build(b: *std.Build) void {
}),
});
fs_bench.root_module.addImport("pardes", hx_core_mod);
- b.installArtifact(fs_bench);
+ const install_fs_bench = &b.addInstallArtifact(fs_bench, .{ .dest_dir = .{ .override = .{ .custom = "dev" } } }).step;
const run_fs_bench = b.addRunArtifact(fs_bench);
if (b.args) |args| run_fs_bench.addArgs(args);
run_fs_bench.setCwd(b.path("."));
- b.step("fs-bench", "acme-fs per-request cost and allocation count (-- [--json] [--reps N])").dependOn(&run_fs_bench.step);
+ const fs_bench_step = b.step("fs-bench", "acme-fs per-request cost and allocation count (-- [--json] [--reps N])");
+ fs_bench_step.dependOn(install_fs_bench);
+ fs_bench_step.dependOn(&run_fs_bench.step);
const unit_step = b.step("unit-test", "run native shell and module unit tests");
// Secure tempfile creation is native-shell IO, isolated from the core
@@ -1399,6 +1716,20 @@ pub fn build(b: *std.Build) void {
.link_libc = true,
}) });
unit_step.dependOn(&b.addRunArtifact(nested_test).step);
+ // The board's input-rescue policy: drain the receiver while spinning on a full transmitter.
+ // A measured bug — a 200-byte burst typed into a long frame lost 88 bytes on the die — so
+ // it gets a test that fails without the fix, and it runs HERE rather than only on hardware.
+ // Its own module for the reason spelled out above rather than as a convention: nothing
+ // `unit-test` compiles reaches src/esp32p4/, because the firmware root is its own module graph,
+ // so these five tests would otherwise silently not exist. No `link_libc`, unlike its
+ // neighbours: input_rescue.zig imports `std` and nothing else, which is exactly what lets
+ // the same source run against a fake FIFO here and against UART0 on the board.
+ const esp32p4_rescue_test = b.addTest(.{ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("src/esp32p4/input_rescue.zig"),
+ }) });
+ unit_step.dependOn(&b.addRunArtifact(esp32p4_rescue_test).step);
// fonts.zig is the same shape once more, and it needs its own module
// for the reason spelled out above rather than as a convention: the
// core imports it behind `platform == .gui or .macos`, so on this build
@@ -1450,6 +1781,89 @@ pub fn build(b: *std.Build) void {
}
}
+/// Is this build free to choose where it installs? Only when nothing else has
+/// said: no DESTDIR, no `--prefix`, and no `--prefix-lib-dir`/`--prefix-exe-dir`
+/// /`--prefix-include-dir`.
+///
+/// Answered by INSPECTING WHAT THE RUNNER RESOLVED, because the runner records
+/// nothing else: `lib/build_runner.zig` keeps the requested step names in a
+/// local, and calls `resolveInstallPrefix(install_prefix, dir_list)` (its
+/// line 459) before `runBuild`, which substitutes the `zig-out` default for a
+/// null prefix and folds the three directory overrides into `lib_dir`/
+/// `exe_dir`/`h_dir`. So by the time `build()` runs, "was it given" survives
+/// only as "does it still look exactly like the default".
+///
+/// KNOWN LIMIT, stated because it cannot be closed from here: `--prefix` given
+/// as the default path spelled absolutely (`--prefix "$PWD/zig-out"`) is
+/// indistinguishable from no prefix at all. `announceInstallPrefix` below is
+/// the mitigation — the redirect says out loud where it put things, so the case
+/// is visible rather than silent.
+fn prefixIsUntouched(b: *std.Build) bool {
+ if (b.dest_dir != null) return false;
+ const zig_out = b.build_root.join(b.allocator, &.{"zig-out"}) catch @panic("OOM");
+ if (!std.mem.eql(u8, b.install_prefix, zig_out)) return false;
+ // The three directory overrides do NOT touch `install_prefix`, so without
+ // this they would pass the test above and then be discarded by the
+ // `resolveInstallPrefix` call that follows it.
+ return std.mem.eql(u8, b.exe_dir, b.pathJoin(&.{ b.install_path, "bin" })) and
+ std.mem.eql(u8, b.lib_dir, b.pathJoin(&.{ b.install_path, "lib" })) and
+ std.mem.eql(u8, b.h_dir, b.pathJoin(&.{ b.install_path, "include" }));
+}
+
+/// Everything `pardes_config` says that does NOT depend on which frontend is
+/// being built. Gathered into one value so the two options modules a default
+/// build makes cannot drift apart in any field but the one that is supposed to
+/// differ. Adding a field here is additive for both shells at once, which is
+/// the property that makes two shells in one build cheap to keep honest.
+const ShellConfig = struct {
+ tree_sitter_grammars: TreeSitterGrammars,
+ tracy: bool,
+ zls_backend: bool,
+ mupdf: bool,
+ esp32p4_cols: u16,
+ esp32p4_rows: u16,
+ /// null = "the platform's own default", resolved per shell below, because
+ /// the default is off for the board and on everywhere else.
+ theme_animation: ?bool,
+ prebuilt_shaders: bool,
+ zig_lib_dir: []const u8,
+};
+
+/// One `pardes_config` options module, for one frontend. `module_names` and
+/// `module_roots` are NOT here: they are folded out of the module import table
+/// long after this runs, and are added to every returned module then.
+fn shellOptions(b: *std.Build, cfg: ShellConfig, platform: Platform) *std.Build.Step.Options {
+ const o = b.addOptions();
+ o.addOption(Platform, "platform", platform);
+ o.addOption(bool, "syntax_highlighting", cfg.tree_sitter_grammars != .disabled);
+ o.addOption(bool, "syntax_zig_grammar", cfg.tree_sitter_grammars != .disabled);
+ o.addOption(bool, "syntax_minimal_grammars", cfg.tree_sitter_grammars == .minimal or cfg.tree_sitter_grammars == .full);
+ o.addOption(bool, "syntax_full_grammars", cfg.tree_sitter_grammars == .full);
+ o.addOption(bool, "enable_tracy", cfg.tracy);
+ o.addOption(bool, "zls_backend", cfg.zls_backend);
+ o.addOption(bool, "mupdf", cfg.mupdf);
+ o.addOption(u16, "esp32p4_cols", cfg.esp32p4_cols);
+ o.addOption(u16, "esp32p4_rows", cfg.esp32p4_rows);
+ o.addOption(bool, "theme_animation", cfg.theme_animation orelse (platform != .esp32p4));
+ // Meaningful only for the SDL shell. Keeping the platform condition here
+ // prevents Config/EffectCode from describing tty/macOS/web as "prebuilt".
+ o.addOption(bool, "gui_shader_sources_prebuilt", platform == .gui and cfg.prebuilt_shaders);
+ // Which ZLS is compiled in, for `SPC l i`. Kept next to the dependency it
+ // names: the .zon pins a commit, and a status screen that cannot say WHICH
+ // analyser answered is not worth opening.
+ o.addOption([]const u8, "zls_version", if (cfg.zls_backend) zls_version else "none");
+ // Mirrors build.zig.zon's `.version`; kept in sync by hand because a typed
+ // @import of the .zon rejects its other fields on this Zig 0.16 build.
+ o.addOption([]const u8, "version", "0.0.1");
+ // The stdlib this binary was compiled against, so `gd` on `std.mem.count`
+ // can open the same mem.zig the compiler used. ZLS resolves `@import("std")`
+ // through `zig_lib_dir` and nothing else; without it every std symbol is a
+ // silent miss, and asking the `zig` binary for it is the subprocess this
+ // whole backend exists to avoid. ZIG_LIB_DIR overrides it at runtime.
+ o.addOption([]const u8, "zig_lib_dir", cfg.zig_lib_dir);
+ return o;
+}
+
// a bad -D combination: fail both `zig build` and `zig build web` with the why
fn failBuild(b: *std.Build, web_step: *std.Build.Step, msg: []const u8) void {
const fail = &b.addFail(msg).step;