From 98b62629795e24de19f535f1072b8a25dcf9f018 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 27 Aug 2026 16:42:15 -0300 Subject: build: pardes builds without the ESP32-P4 toolchain checkout beside it `build.zig.zon` named `../05-zig-p4` as a path dependency, and `build.zig` `@import`ed it inside `if (-Desp32p4-firmware)`. But `@import` in a build script is resolved when the SCRIPT is compiled, not when the branch that needs it is taken -- so naming the package at all meant anyone without that sibling checkout could not build pardes AT ALL. Not the firmware: the terminal shell, the SDL shell, the tests. `zig build` failed with build.zig:1073: error: no module named 'zig_p4' available within module 'root.@build' from a line inside an `if` that was false. Neither escape hatch works for a PATH dependency, and both were tried rather than assumed. `.lazy = true` is about FETCHING; a path dep whose directory is absent is generated as a package with no `build.zig` rather than one marked unavailable, so `b.lazyImport` -- which exists for exactly this and is what the standard library says is to `@import` what `lazyDependency` is to `dependency` -- reaches a `@compileError` instead of returning null. Making it a fetched dependency instead is not available either: the toolchain has no remote. So the duplicate goes. That build tree's firmware block linked an image the toolchain repository already knows how to link -- its own build.zig has `-Dpardes`, `-Dapp=` and `-Dpardes-obj=`, and its comments record having learned this same lesson from the other direction, where nesting pardes's ~30-package graph under it broke every build there. The object is the seam: it crosses by PATH and never by package, and each repository builds what it owns the pieces of. zig build -Dplatform=esp32p4 # here, no toolchain needed zig build -Dpardes # there, the console image zig build -Dpardes -Dapp=/src/esp32p4_9p.zig # there, the 9P image For the second and third to work with no module map, `src/board9p.zig` and the 9P firmware root now reach the codec by PATH instead of through a named `ninep` module that only pardes's own build.zig knew to inject -- which is also why the root moved from `src/esp32p4/nine.zig` up to `src/esp32p4_9p.zig`, beside `src/esp32p4.zig`: a path import may not escape its module's own directory. Both files are now self-contained, and `zig test src/board9p.zig` works with no flags. `-Desp32p4-port`, `-Desp32p4-prof` and `-Desp32p4-cpu-mhz` go with the block. An option this build cannot honour is worse than no option, because it accepts the flag and then ignores it; all three are spelled the same way in the toolchain. Verified by moving ../05-zig-p4 out of the way: `zig build`, `zig build -Dplatform=esp32p4` and `zig build unit-test` all pass without it. With it back, the toolchain still links both images -- console 812,720 B, 9P 88,096 B. next-steps.txt gains the six features the 9P chain shipped. --- build.zig | 245 +++++++++++++------------------------------------------------- 1 file changed, 52 insertions(+), 193 deletions(-) (limited to 'build.zig') diff --git a/build.zig b/build.zig index f8dc3e82..f27e47aa 100644 --- a/build.zig +++ b/build.zig @@ -451,19 +451,12 @@ pub fn build(b: *std.Build) void { // in region l2mem, overflowed by 76036 bytes`, that being the shell's shadow copy of the grid. 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; + // `-Desp32p4-port`, `-Desp32p4-prof` and `-Desp32p4-cpu-mhz` used to be + // declared here, for the block that linked and flashed the image. That + // block is gone — see "where the FLASHABLE image comes from" below — and so + // are they: an option this build cannot honour is worse than no option, + // because it accepts the flag and then ignores it. All three belong to the + // toolchain repository and are spelled the same way there. // 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 - @@ -1051,189 +1044,37 @@ pub fn build(b: *std.Build) void { 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 + // ------------------------------------------------- and where the FLASHABLE image comes from // - // `-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. + // Not from here, and this is the one deliberate asymmetry in the board build. + // `-Dplatform=esp32p4` emits the object above and needs no toolchain at all. Linking that + // object into an image needs the linker script, the app descriptor, the heap, the HAL and + // the flash tooling, all of which live in the `05-zig-p4` checkout — so the image is built + // THERE, by the repository that owns them: // - // 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. + // zig build -Dpardes # the console firmware + // zig build -Dpardes -Dapp=/src/esp32p4_9p.zig # the 9P server firmware // - // 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); - } + // both taking `-Dpardes-obj=/zig-out/pardes-esp32p4.o`, which is exactly the file + // installed above. + // + // This build tree used to do it too, under `-Desp32p4-firmware`, by declaring `.zig_p4` as + // a path dependency on that sibling checkout. That cost far more than the duplication was + // worth: `@import` in a build script is resolved when the SCRIPT is compiled and not when + // the branch that needs it is taken, so naming the package at all meant that anyone + // without a `../05-zig-p4` beside their pardes could not build pardes AT ALL — not the + // firmware, not the SDL shell, not the terminal one. `zig build` failed with + // `no module named 'zig_p4'` from a line inside an `if` that was false. + // + // Neither `.lazy = true` nor `b.lazyImport` rescues it: laziness is about FETCHING, and a + // path dependency whose directory is absent is generated as a package with no `build.zig` + // rather than as one marked unavailable, so `lazyImport` reaches a `@compileError` instead + // of returning null. Both were tried. The toolchain has no remote to make it a fetched + // dependency instead. + // + // So the arrangement is the one that repository had already arrived at from its own side, + // where its build.zig records the same lesson: the object is the seam, it crosses by PATH + // and never by package, and each repository builds what it owns the pieces of. web_step.dependOn(&b.addFail("web needs -Dplatform=web -Dtarget=wasm32-freestanding -Ddump=").step); } else if (platform == .macos) { // The native macOS shell is a static library plus a Swift app: Zig @@ -1755,6 +1596,24 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/9p.zig"), }) }); unit_step.dependOn(&b.addRunArtifact(ninep_test).step); + // The board's own 9P tree, and the comptime table that generates it. Its own module for the + // reason its neighbours have: nothing `unit-test` compiles reaches src/board9p.zig — the + // core does not import it, because the whole point of it is that it does NOT import the + // core — so its eight tests would otherwise silently not exist. No `link_libc`: it imports + // `std`, `src/board_pins.zig` and, in its tests only, `ninep`, which is what lets the same + // source serve a real client here and drive `hal.gpio` on the die. + // + // `ninep` used to be injected here as a named module over src/9p.zig. + // It is a plain path import now (`src/board9p.zig` says why), so this + // test needs no module map at all — which is the same property that + // lets the toolchain repository root a firmware image at + // src/esp32p4_9p.zig without being told what to inject. + const board9p_test = b.addTest(.{ .root_module = b.createModule(.{ + .target = target, + .optimize = optimize, + .root_source_file = b.path("src/board9p.zig"), + }) }); + unit_step.dependOn(&b.addRunArtifact(board9p_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. -- cgit v1.3