diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-26 13:27:46 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-27 09:47:39 -0300 |
| commit | 11f380f6d7222f2cad93c2cdf13701ea1f903d47 (patch) | |
| tree | 803194ee5853a6b4cda93f90a95e28d1f02e69ae /build.zig | |
| parent | fbc194068687e49a8490c85c9f1257a2f2bb9079 (diff) | |
| download | pardes-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.zig | 676 |
1 files changed, 545 insertions, 131 deletions
@@ -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; |
