summaryrefslogtreecommitdiff
path: root/build.zig
diff options
context:
space:
mode:
Diffstat (limited to 'build.zig')
-rw-r--r--build.zig1423
1 files changed, 1423 insertions, 0 deletions
diff --git a/build.zig b/build.zig
new file mode 100644
index 0000000..3a22d4f
--- /dev/null
+++ b/build.zig
@@ -0,0 +1,1423 @@
+//! A complete ESP32-P4 toolchain in one build graph.
+//!
+//! zig build compile, link, and emit a flashable image
+//! zig build flash the above, then write it to the chip over the serial port
+//! zig build monitor open the console
+//! zig build size print where every byte of the image went
+//!
+//! There is no CMake, no ninja, no idf.py, no esptool and no external linker: Zig's own LLD does
+//! the link, and the image builder and flasher are ordinary Zig code (tools/) imported straight
+//! into this file, so they produce no artefacts of their own. What lands in zig-out is the ELF and
+//! the image, and nothing else.
+
+const std = @import("std");
+const image = @import("tools/image.zig");
+const serial = @import("tools/serial.zig");
+const rom = @import("tools/rom.zig");
+
+pub fn build(b: *std.Build) void {
+ // ---------------------------------------------------------------- board and target knobs
+ const port_path = b.option([]const u8, "port", "serial port (default /dev/ttyUSB0)") orelse "/dev/ttyUSB0";
+ const baud = b.option(serial.Baud, "baud", "flashing baud rate (default 921600, measured reliable on this board; 2000000 is not)") orelse .b921600;
+ const led_pin = b.option(u8, "led", "GPIO to blink, 0-31 (default 20 = JP1 pin 17)") orelse 20;
+ if (led_pin > 31) {
+ std.log.err(
+ "-Dled={d}: src/soc.zig models GPIO0-31; GPIO32-56 need the OUT1/ENABLE1/IN1 bank",
+ .{led_pin},
+ );
+ std.process.exit(1);
+ }
+ const app_offset = b.option(u32, "offset", "flash offset of the app partition (default 0x10000)") orelse 0x10000;
+ const flash_size = b.option(image.FlashSize, "flash-size", "fitted flash (default 16MB)") orelse .@"16MB";
+ const min_rev = b.option(u16, "min-rev", "minimum silicon revision, major*100+minor (default 100)") orelse 100;
+ const max_rev = b.option(u16, "max-rev", "maximum silicon revision (default 199)") orelse 199;
+ const descriptor = b.option(DescriptorKind, "descriptor", "app descriptor: minimal (184 B) or full (256 B)") orelse .minimal;
+ const stack_size = b.option(u32, "stack", "stack size in bytes (default 8192)") orelse 8192;
+ // ReleaseSmall by default: this is firmware, and `standardOptimizeOption` would otherwise
+ // hand out Debug builds - which for this target means panic machinery and formatting code
+ // linked into a 500-byte image.
+ const optimize = b.option(std.builtin.OptimizeMode, "optimize", "optimize mode (default ReleaseSmall)") orelse .ReleaseSmall;
+
+ const target = b.resolveTargetQuery(.{
+ .cpu_arch = .riscv32,
+ .os_tag = .freestanding,
+ .abi = .none,
+ // rv32imafc with the CSR/fence extensions the ESP32-P4 implements. Espressif's own GCC
+ // adds the vendor extensions xesploop and xespv2p1 on top; upstream LLVM has neither, and
+ // ordinary code never emits them, so this matches the base ISA exactly.
+ .cpu_model = .{ .explicit = &std.Target.riscv.cpu.generic_rv32 },
+ .cpu_features_add = featureSet(&.{ .m, .a, .f, .c, .zicsr, .zifencei }),
+ });
+
+ // ---------------------------------------------------------------- the chip's registers
+ // Every peripheral register of the P4, taken from ESP-IDF's own `*_reg.h` headers by
+ // `zig translate-c`. There is no generator and no checked-in generated file: the C front end
+ // does the work, so the addresses, shifts and masks in Zig are not a re-derivation of IDF's
+ // numbers, they *are* IDF's numbers. 94 headers -> 86,253 constants in about 0.26 s, and
+ // importing the module costs ~0.16 s because Zig only analyses the handful of decls used.
+ //
+ // `*_struct.h` is deliberately not used: translate-c demotes every one of those register
+ // structs to `opaque {}` ("has bitfield"), so the C bitfields buy nothing. src/mmio.zig builds
+ // the typed layer on top of the flat constants instead.
+ const registers = idfRegisters(b, target, optimize);
+ const regs_mod = registers.mod;
+ // ---------------------------------------------------------------- generated linker script
+ // The layout is a build input, not a checked-in file: change -Dstack or the descriptor size
+ // and the script follows. Both flash-mapped sections sit in one 64 KiB MMU window, which is
+ // what keeps the image ~1 KB instead of ~66 KB (see tools/image.zig).
+ const ld = b.addWriteFiles();
+ // The oracle links ESP-IDF's own LL functions in beside ours as the differential reference.
+ // Off by default: it is a test rig, it needs an IDF checkout with the C headers, and it has no
+ // business in a shipping image.
+ const oracle = b.option(bool, "oracle", "link ESP-IDF's LL functions in as the differential reference") orelse false;
+ // `-Dhosted` links ESP-Hosted's transport C into the image, on top of this project's Zig
+ // runtime and SDIO driver. The P4 has no radio: the ESP32-C6 beside it does, and it speaks a
+ // protocol whose host half is 13,000 lines of already-debugged C. Reimplementing that before
+ // anything can reach the network would be the wrong order, so it is linked in and replaced from
+ // the bottom up - exactly as -Doracle links IDF's LL beside the HAL.
+ //
+ // Off by default: it needs an IDF checkout, and a managed_components tree from the sibling
+ // 02-esp32p4-m3-radio project.
+ const hosted = b.option(bool, "hosted", "link ESP-Hosted's SDIO transport C in, for the radio path") orelse false;
+ // Wi-Fi credentials arrive as build options, never as source. This keeps the passphrase out of
+ // the tree and out of git. It does end up in the image - unavoidable for a device that has to
+ // join a network - but nothing here writes it to a file or prints it.
+ const wifi_ssid = b.option([]const u8, "ssid", "Wi-Fi SSID to join (hosted builds)") orelse "";
+ const wifi_psk_opt = b.option([]const u8, "psk", "Wi-Fi passphrase; prefer -Dpsk-file") orelse "";
+ // A file is the better route: the passphrase never appears in a command line, so it stays out of
+ // the shell history and out of the process table where `ps` can see it.
+ const psk_file = b.option([]const u8, "psk-file", "read the passphrase from this file instead of -Dpsk");
+ const ld_script = ld.add("app.ld", linkerScript(
+ b,
+ stack_size,
+ if (oracle) readPeripheralsLd(b, registers.idf_path) else null,
+ ));
+
+
+ // ---------------------------------------------------------------- the application
+ const options = b.addOptions();
+ options.addOption(u8, "led_pin", led_pin);
+ options.addOption(u32, "stack_size", stack_size);
+ options.addOption([]const u8, "wifi_ssid", wifi_ssid);
+ options.addOption([]const u8, "wifi_psk", if (psk_file) |path| blk: {
+ const raw = std.Io.Dir.cwd().readFileAlloc(b.graph.io, path, b.allocator, .limited(256)) catch
+ @panic("cannot read the file named by -Dpsk-file");
+ break :blk std.mem.trim(u8, raw, " \t\r\n");
+ } else wifi_psk_opt);
+ options.addOption(bool, "full_descriptor", descriptor == .full);
+ options.addOption(u16, "min_rev_full", min_rev);
+ options.addOption(u16, "max_rev_full", max_rev);
+ const config_mod = options.createModule();
+
+ // The board-support modules are real modules, so an app can live anywhere and still
+ // `@import("soc")`. src/ holds one copy of each; examples/ holds none.
+ const soc_mod = b.createModule(.{
+ .root_source_file = b.path("src/soc.zig"),
+ .target = target,
+ .optimize = optimize,
+ });
+ // The whole chip's registers, and the typed layer over them. `hal` is what applications and
+ // drivers use; `regs` is the raw translate-c output, exposed so a driver can reach a register
+ // the HAL does not model yet without waiting for one to be written.
+ const mmio_mod = b.createModule(.{
+ .root_source_file = b.path("src/mmio.zig"),
+ .target = target,
+ .optimize = optimize,
+ .imports = &.{.{ .name = "regs", .module = regs_mod }},
+ });
+ const hal_mod = b.createModule(.{
+ .root_source_file = b.path("src/hal.zig"),
+ .target = target,
+ .optimize = optimize,
+ .imports = &.{
+ .{ .name = "regs", .module = regs_mod },
+ .{ .name = "mmio", .module = mmio_mod },
+ },
+ });
+ soc_mod.addImport("hal", hal_mod);
+ // The app descriptor is its own translation unit, linked in unconditionally. An application
+ // that merely `@import`s it would not do: under ReleaseSmall the import is analysed lazily,
+ // nothing forces the constant to be emitted, `.flash.rodata` disappears, and the image ends up
+ // with a single mapped segment at the wrong offset. As a separate object with an exported
+ // symbol it always exists, and no application has to remember anything.
+ const appdesc_obj = b.addObject(.{
+ .name = "appdesc",
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/appdesc.zig"),
+ .target = target,
+ .optimize = optimize,
+ .imports = &.{.{ .name = "config", .module = config_mod }},
+ }),
+ });
+
+ const app_source = b.option([]const u8, "app", "root source file (default src/main.zig)") orelse "src/main.zig";
+ const app = b.addExecutable(.{
+ .name = "app",
+ .root_module = b.createModule(.{
+ .root_source_file = if (std.fs.path.isAbsolute(app_source))
+ .{ .cwd_relative = app_source }
+ else
+ b.path(app_source),
+ .target = target,
+ .optimize = optimize,
+ .strip = true,
+ .single_threaded = true,
+ .unwind_tables = .none,
+ .omit_frame_pointer = true,
+ .error_tracing = false,
+ .imports = &.{
+ .{ .name = "config", .module = config_mod },
+ .{ .name = "soc", .module = soc_mod },
+ .{ .name = "hal", .module = hal_mod },
+ .{ .name = "mmio", .module = mmio_mod },
+ .{ .name = "regs", .module = regs_mod },
+ },
+ }),
+ });
+ // The census is a gate, not a side effect: nothing may compile against the register module
+ // without it having been counted.
+ app.step.dependOn(registers.census);
+ if (oracle) {
+ // IDF's LL compiled into this very image, as the reference half of the differential.
+ idfReference(b, app.root_module, registers.idf_path, registers.hw_ver);
+ // The suites: one module listing every peripheral registered with the harness, so the
+ // harness itself does not grow as peripherals are added.
+ const oracle_mod = b.createModule(.{
+ .root_source_file = b.path("src/oracle/all.zig"),
+ .target = target,
+ .optimize = optimize,
+ .imports = &.{
+ .{ .name = "hal", .module = hal_mod },
+ .{ .name = "regs", .module = regs_mod },
+ .{ .name = "mmio", .module = mmio_mod },
+ },
+ });
+ app.root_module.addImport("oracle", oracle_mod);
+ }
+ // `std.Io` implemented for this chip: a cooperative scheduler, timers off the systimer, and
+ // futexes. Its own module rather than a file inside `net`, because Zig confines a module's
+ // imports to its root directory - src/net/ cannot reach ../io/ - and because it is useful
+ // without the radio: any application wanting tasks and timeouts can import it alone.
+ const io_mod = b.createModule(.{
+ .root_source_file = b.path("src/io/p4.zig"),
+ .target = target,
+ .optimize = optimize,
+ .single_threaded = true,
+ .imports = &.{
+ .{ .name = "soc", .module = soc_mod },
+ .{ .name = "hal", .module = hal_mod },
+ .{ .name = "mmio", .module = mmio_mod },
+ .{ .name = "regs", .module = regs_mod },
+ },
+ });
+ app.root_module.addImport("io", io_mod);
+
+ if (hosted) {
+ // The Zig half: the port table, the libc surface and the IP stack.
+ //
+ // A plain module, NOT an `addObject` like appdesc. The object route looks tempting - these
+ // files exist to define exported C symbols, and appdesc is an object for exactly that
+ // reason - but it is wrong here and fails loudly: an object gets its own copy of every
+ // module it imports, so `hal` and `io` end up compiled into both net.o and the executable,
+ // and the link dies on duplicate `trapEntry`, `intrDispatch`, `intrFault`, `g_h` and
+ // `g_hosted_osi_funcs`.
+ //
+ // A module is safe here for a reason appdesc could not rely on: a hosted application has to
+ // call `net.init(io, gpa)` to bring the radio up, so the module is genuinely referenced and
+ // its exports are emitted. appdesc had nothing referencing it at all.
+ const net_mod = b.createModule(.{
+ .root_source_file = b.path("src/net/all.zig"),
+ .target = target,
+ .optimize = optimize,
+ .single_threaded = true,
+ .imports = &.{
+ .{ .name = "config", .module = config_mod },
+ .{ .name = "soc", .module = soc_mod },
+ .{ .name = "hal", .module = hal_mod },
+ .{ .name = "mmio", .module = mmio_mod },
+ .{ .name = "regs", .module = regs_mod },
+ .{ .name = "io", .module = io_mod },
+ },
+ });
+ app.root_module.addImport("net", net_mod);
+ // The C half, compiled against this project's Kconfig surface. It attaches to the
+ // executable's own module rather than net's, because the C is linked, not imported.
+ hostedC(b, app.root_module, registers.idf_path);
+ }
+ app.setLinkerScript(ld_script);
+ app.entry = .{ .symbol_name = "_start" };
+ // The app descriptor must survive --gc-sections even though no code reads it: the bootloader
+ // does, at image offset 0x20. Asking the linker for the symbol is what keeps the module alive,
+ // regardless of whether the application source happens to mention it.
+ app.root_module.addObject(appdesc_obj);
+ app.link_gc_sections = true;
+ app.link_function_sections = true;
+ app.link_data_sections = true;
+
+
+ // One install step for the ELF, reachable two ways: `zig build elf` on its own (handy when
+ // debugging the image builder) and `-Delf` to get it alongside the image.
+ const elf_only = b.addInstallArtifact(app, .{});
+ b.step("elf", "build and install just the ELF, skipping the image").dependOn(&elf_only.step);
+ if (b.option(bool, "elf", "also install the ELF (default false)") orelse false)
+ b.getInstallStep().dependOn(&elf_only.step);
+
+ // ---------------------------------------------------------------- ELF -> image
+ const img = ImageStep.create(b, app, .{
+ .chip = .esp32p4,
+ .min_rev_full = min_rev,
+ .max_rev_full = max_rev,
+ .flash_size = flash_size,
+ .flash_offset = app_offset,
+ });
+ b.getInstallStep().dependOn(&b.addInstallBinFile(img.getOutput(), "app.bin").step);
+
+ // ---------------------------------------------------------------- flash / monitor / size
+ const flash = FlashStep.create(b, img, .{
+ .port = port_path,
+ .baud = baud,
+ .verify = b.option(bool, "verify", "ask the ROM for an MD5 of what it stored (default true)") orelse true,
+ .opts = img.opts,
+ });
+ b.step("flash", "write the image to the chip and run it").dependOn(&flash.step);
+
+ const monitor_seconds = b.option(u32, "seconds", "monitor duration (default 5)") orelse 5;
+ const mon = MonitorStep.create(b, port_path, monitor_seconds);
+ b.step("monitor", "reset the board and print its console output").dependOn(&mon.step);
+
+ // `zig build flash monitor` names two independent steps, and the runner may start either
+ // first - in practice monitor wins and prints the *old* firmware. This one is ordered.
+ const run_mon = MonitorStep.create(b, port_path, monitor_seconds);
+ run_mon.step.dependOn(&flash.step);
+ b.step("run", "flash the image, then print its console output").dependOn(&run_mon.step);
+
+ const reset = ResetStep.create(b, port_path);
+ b.step("reset", "reset the board and let the flashed application run").dependOn(&reset.step);
+
+ const size = SizeStep.create(b, img);
+ b.step("size", "print the image layout byte by byte").dependOn(&size.step);
+
+ // `zig build diff` - the hardware oracle. Builds the differential harness with ESP-IDF's own LL
+ // functions linked in beside ours, flashes it, and prints the comparison. This is the project's
+ // real correctness argument for the HAL: not "the tests pass" but "the registers this leaves
+ // behind are the registers ESP-IDF leaves behind, measured on the die".
+ //
+ // It is a separate step rather than part of `test` because it needs the board, and because it
+ // needs an ESP-IDF checkout to compile the reference against.
+ if (oracle) {
+ const diff_mon = MonitorStep.create(b, port_path, monitor_seconds);
+ diff_mon.step.dependOn(&flash.step);
+ b.step("diff", "flash the differential harness and compare against ESP-IDF's LL on the die")
+ .dependOn(&diff_mon.step);
+ } else {
+ const hint = b.step("diff", "flash the differential harness and compare against ESP-IDF's LL on the die");
+ hint.dependOn(&NeedsOracle.create(b).step);
+ }
+
+ // Host tests for the image builder: every case is a rule the ROM bootloader enforces.
+ const tests = b.addTest(.{
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("tools/image_test.zig"),
+ .target = b.graph.host,
+ .optimize = .Debug,
+ }),
+ });
+ const test_step = b.step("test", "run the host tests: image builder, and the register layer's field arithmetic");
+ test_step.dependOn(&b.addRunArtifact(tests).step);
+
+ // The typed register layer's arithmetic - masks, shifts, bank splits - is host-testable and
+ // worth testing there: a wrong shift is otherwise a silent misconfiguration on the die. These
+ // run against the host target, so they exercise mmio.zig without needing the chip's registers.
+ const mmio_tests = b.addTest(.{
+ .root_module = b.createModule(.{
+ .root_source_file = b.path("src/mmio.zig"),
+ .target = b.graph.host,
+ .optimize = .Debug,
+ }),
+ });
+ test_step.dependOn(&b.addRunArtifact(mmio_tests).step);
+
+ // The radio path's host-testable parts. A wrong checksum, a wrong snprintf, or a scheduler that
+ // loses a task is far cheaper to find here than on a board whose only output is a serial line.
+ //
+ // These need the board-support modules built for the *host*, not for the chip: handing a test
+ // the riscv-targeted `hal` crashes the compiler outright rather than reporting a target
+ // mismatch. The modules themselves are happy to be built either way - src/mmio.zig computes
+ // addresses with @ptrFromInt and never dereferences one at comptime - so a second instance of
+ // the same source files is all it takes. Nothing here touches a real register; the tests that
+ // must do that run on the die, through examples/halcheck.zig.
+ const host_mmio = b.createModule(.{
+ .root_source_file = b.path("src/mmio.zig"),
+ .target = b.graph.host,
+ .optimize = .Debug,
+ .imports = &.{.{ .name = "regs", .module = regs_mod }},
+ });
+ const host_hal = b.createModule(.{
+ .root_source_file = b.path("src/hal.zig"),
+ .target = b.graph.host,
+ .optimize = .Debug,
+ .imports = &.{
+ .{ .name = "regs", .module = regs_mod },
+ .{ .name = "mmio", .module = host_mmio },
+ },
+ });
+ const host_soc = b.createModule(.{
+ .root_source_file = b.path("src/soc.zig"),
+ .target = b.graph.host,
+ .optimize = .Debug,
+ });
+ host_soc.addImport("hal", host_hal);
+
+ for ([_][]const u8{
+ "src/net/libc.zig", // the malloc header trick, and snprintf's conversions
+ "src/net/ip.zig", // checksums, ARP, DHCP, the TCP state machine
+ "src/net/heap.zig", // the allocator behind the port table
+ "src/net/hosted_os.zig", // the port table's sync and queue wrappers
+ "src/io/p4.zig", // the scheduler, futexes and timers
+ }) |path| {
+ // Skip quietly if a file has not landed: these are written in parallel, and one missing
+ // file should not stop the rest of the suite from running.
+ std.Io.Dir.cwd().access(b.graph.io, b.pathFromRoot(path), .{}) catch continue;
+ const t = b.addTest(.{
+ .root_module = b.createModule(.{
+ .root_source_file = b.path(path),
+ .target = b.graph.host,
+ .optimize = .Debug,
+ .imports = &.{
+ .{ .name = "soc", .module = host_soc },
+ .{ .name = "hal", .module = host_hal },
+ .{ .name = "mmio", .module = host_mmio },
+ .{ .name = "regs", .module = regs_mod },
+ },
+ }),
+ });
+ test_step.dependOn(&b.addRunArtifact(t).step);
+ }
+}
+
+/// The ESP32-P4's entire register map as a Zig module, via `zig translate-c` over ESP-IDF's own
+/// register headers.
+///
+/// This is the one place the toolchain reads ESP-IDF, and it reads it as *data*: header files, at
+/// build time, through the C front end Zig already ships. No IDF program runs, nothing from IDF is
+/// linked, and no generated file is committed. What it needs is a checkout to point at.
+///
+/// `hw_ver1` is the correct register set for a pre-v3 die, which is what this board has (silicon
+/// rev v1.3). ESP-IDF makes the same choice the same way: soc/CMakeLists.txt:37-41 selects
+/// `register/hw_ver1` when CONFIG_ESP32P4_SELECTS_REV_LESS_V3 is set, and this project's own IDF
+/// build resolved to that directory.
+fn idfRegisters(
+ b: *std.Build,
+ target: std.Build.ResolvedTarget,
+ optimize: std.builtin.OptimizeMode,
+) struct { mod: *std.Build.Module, census: *std.Build.Step, idf_path: []const u8, hw_ver: u8 } {
+ const idf = b.option([]const u8, "idf", "ESP-IDF checkout, for the register headers (default $IDF_PATH or ~/esp/esp-idf)") orelse
+ b.graph.environ_map.get("IDF_PATH") orelse
+ b.pathJoin(&.{ b.graph.environ_map.get("HOME") orelse "", "esp", "esp-idf" });
+ const hw_ver = b.option(u8, "idf-hw-ver", "register header set: 1 for pre-v3 silicon (default), 3 for v3+") orelse 1;
+
+ const reg_dir = b.pathJoin(&.{ idf, "components", "soc", "esp32p4", "register", b.fmt("hw_ver{d}", .{hw_ver}), "soc" });
+
+ // One umbrella translation unit including every peripheral header, listed by reading the
+ // directory: a new IDF release that adds a peripheral is picked up without editing this file.
+ var names: std.ArrayList([]const u8) = .empty;
+ const io = b.graph.io;
+ var dir = std.Io.Dir.cwd().openDir(io, reg_dir, .{ .iterate = true }) catch {
+ std.log.err(
+ \\cannot read the ESP-IDF register headers at
+ \\ {s}
+ \\This is the only thing the build needs ESP-IDF for - the headers are read as data at
+ \\build time; no IDF program runs and nothing from IDF is linked. Point at a checkout:
+ \\ zig build -Didf=/path/to/esp-idf
+ \\or set IDF_PATH. ESP-IDF v6.0.2 is what this was developed against.
+ , .{reg_dir});
+ std.process.exit(1);
+ };
+ defer dir.close(io);
+ var it = dir.iterate();
+ while (it.next(io) catch null) |entry| {
+ // Deliberately not testing `entry.kind`: a `*_reg.h` that is a symlink - ordinary in Nix
+ // store paths, Bazel sandboxes and `cp -as` mirrors - was silently dropped from the
+ // umbrella. Replacing one header with a symlink to its own content removed all three I2S
+ // controllers (600 constants) while the build stayed green and the poison census stayed at
+ // exactly 524, because a clean header contributes no poison. The name suffix decides.
+ if (entry.kind == .directory or !std.mem.endsWith(u8, entry.name, "_reg.h")) continue;
+ names.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM");
+ }
+ // Directory order is filesystem order. Sorting keeps the generated C - and therefore the
+ // translate-c cache key and the module's decl order - stable across machines.
+ std.mem.sort([]const u8, names.items, {}, struct {
+ fn lt(_: void, x: []const u8, y: []const u8) bool {
+ return std.mem.lessThan(u8, x, y);
+ }
+ }.lt);
+
+ var umbrella: std.ArrayList(u8) = .empty;
+ umbrella.appendSlice(b.allocator,
+ \\/* GENERATED by build.zig: every ESP32-P4 peripheral register header, in one translation
+ \\ unit, for zig translate-c. */
+ \\
+ ) catch @panic("OOM");
+ for (names.items) |n| {
+ umbrella.print(b.allocator, "#include \"soc/{s}\"\n", .{n}) catch @panic("OOM");
+ }
+ // Not a register header, but the other half of the GPIO matrix: 474 peripheral signal indices,
+ // which is what a driver routes to a pad. Taking SIG_GPIO_OUT_IDX from here rather than writing
+ // 256 in Zig is not pedantry - it is 128 on the ESP32-S3, and the wrong one leaves a pad
+ // undriven with no error anywhere.
+ umbrella.appendSlice(b.allocator, "#include \"soc/gpio_sig_map.h\"\n") catch @panic("OOM");
+ // Six more register headers live in include/soc/ rather than the generated register directory,
+ // and they are not minor ones: clic_reg.h is the interrupt controller this chip actually has
+ // (the P4 has a CLIC, not a PLIC - soc_caps.h:191 SOC_INT_CLIC_SUPPORTED), interrupt_reg.h
+ // carries the per-die threshold selection, and spi_mem/system/hwcrypto are the flash and system
+ // blocks. Globbing only the register directory silently omits all of them.
+ //
+ // They use an older macro-comment form than the generated headers, which matters not at all
+ // here: nothing in this toolchain parses those comments, clang does the reading.
+ for ([_][]const u8{
+ "clic_reg.h",
+ "interrupt_reg.h",
+ "spi_mem_reg.h",
+ "system_reg.h",
+ "hwcrypto_reg.h",
+ "regi2c_dig_reg.h",
+ }) |extra| {
+ umbrella.print(b.allocator, "#include \"soc/{s}\"\n", .{extra}) catch @panic("OOM");
+ }
+
+ const wf = b.addWriteFiles();
+ const umbrella_c = wf.add("esp32p4_regs.c", umbrella.items);
+
+ const tc = b.addTranslateC(.{
+ .root_source_file = umbrella_c,
+ .target = target,
+ .optimize = optimize,
+ // Header text only: nothing in the register macros calls a libc function, and this target
+ // has no libc to link (`unable to provide libc for target riscv32-freestanding-none`).
+ .link_libc = false,
+ });
+
+ // The hw_ver the headers came from, as a constant inside the module. It is load-bearing and
+ // otherwise invisible: 61 macros keep their name and change their value between hw_ver1 and
+ // hw_ver3 (AHB_DMA_INFIFO_CNT_CH0 is bits [7:2] on v1 and [14:8] on v3), so a module built from
+ // the wrong header set is silently wrong rather than absent. hal.zig comptime-asserts it.
+ tc.defineCMacroRaw(b.fmt("ZIG_P4_HW_VER={d}", .{hw_ver}));
+ // soc.h pulls in a few headers that only exist for a hosted target. The register headers
+ // themselves need nothing from them, so empty stand-ins are enough and keep the include set to
+ // ESP-IDF proper.
+ const shims = b.addWriteFiles();
+ _ = shims.add("stdlib.h", "#pragma once\n");
+ _ = shims.add("string.h", "#pragma once\n");
+ _ = shims.add("assert.h", "#pragma once\n#define assert(x) ((void)0)\n");
+ tc.addIncludePath(shims.getDirectory());
+ for ([_][]const u8{
+ "components/soc/include",
+ "components/soc/esp32p4/include",
+ b.fmt("components/soc/esp32p4/register/hw_ver{d}", .{hw_ver}),
+ "components/esp_common/include",
+ "components/esp_rom/include",
+ }) |rel| {
+ tc.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ idf, rel }) });
+ }
+
+ // Six base macros are referenced by IDF's own register headers and defined nowhere in IDF:
+ // DMAC, MB, TSENS, RTCLOCKCALI, H264 and H264_DMA. Those 507 register macros are dead in C too
+ // - nothing expands them, so nothing notices - and translate-c makes them visible by turning
+ // each into a poisoned decl that fails only if a driver names it.
+ //
+ // IDF reaches those peripherals a different way: through the linker, not a macro.
+ // soc/esp32p4/ld/esp32p4.peripherals.ld PROVIDEs 111 instance addresses, and for the 65
+ // peripherals that appear in both that file and reg_base.h the two agree on all 65. That makes
+ // it a sound source for the missing three that are in scope here.
+ //
+ // H264 and H264_DMA stay unavailable on purpose (video encode is out of scope) and
+ // RTCLOCKCALI is absent from both files, so its 21 registers stay unreachable rather than
+ // reachable at a guessed address.
+ for ([_][]const u8{
+ "DR_REG_DMAC_BASE=0x50081000", // ld: DW_GDMA - the AXI general-purpose DMA
+ "DR_REG_MB_BASE=0x50118000", // ld: LP_MAILBOX - LP<->HP mailbox
+ "DR_REG_TSENS_BASE=0x5012f000", // ld: LP_TSENS - temperature sensor
+ }) |def| {
+ tc.defineCMacroRaw(def);
+ }
+
+ // translate-c exits 0 with empty stderr and still emits `pub const X = @compileError(...)` for
+ // every macro it could not translate. Those are invisible until a driver names one, months
+ // later, with a message that blames Zig for an ESP-IDF header bug. So count them and fail the
+ // build if the number grows: the current 524 are accounted for, and a 525th means either a new
+ // IDF release broke something or an include path was lost.
+ const census = PoisonCensus.create(b, tc.getOutput(), 524);
+ return .{ .mod = tc.createModule(), .census = &census.step, .idf_path = idf, .hw_ver = hw_ver };
+}
+
+/// Counts the poisoned declarations in the translated register module and fails if there are more
+/// than expected.
+///
+/// Composition of the 524 expected today, all of them harmless *here* and none of them ours:
+/// * 333 `*_REG` addresses whose `DR_REG_*_BASE` ESP-IDF references but never defines. 308 are
+/// H264 and H264_DMA (video encode, out of scope) and 21 are RTCLOCKCALI, which is absent from
+/// both reg_base.h and peripherals.ld, so there is no honest address to supply.
+/// * 153 `_M` pre-shifted masks that are broken C inside ESP-IDF: interrupt_core0_reg.h writes
+/// `(CORE0_x_V << CORE0_x_S)` for `INTERRUPT_CORE0_x_M`, dropping the prefix, and spi_mem_c
+/// does the same with `SPI_XTS_PLAIN_V`. Nothing in C expands them, so nobody noticed. This
+/// toolchain builds every field from the `_S`/`_V` pair and never from `_M`, which is why all
+/// 153 sit in code it cannot reach - and mmio.Field.of rejects a pre-shifted mask at comptime
+/// if one is ever passed by hand.
+/// * 38 function-like or compiler-predefined macros (`REG_WRITE`, `ESP_STATIC_ASSERT`,
+/// `__UINT32_C_SUFFIX__`), which have Zig equivalents in mmio.zig.
+const PoisonCensus = struct {
+ step: std.Build.Step,
+ zig_file: std.Build.LazyPath,
+ budget: usize,
+
+ fn create(b: *std.Build, zig_file: std.Build.LazyPath, budget: usize) *PoisonCensus {
+ const self = b.allocator.create(PoisonCensus) catch @panic("OOM");
+ self.* = .{
+ .step = std.Build.Step.init(.{
+ .id = .custom,
+ .name = "register poison census",
+ .owner = b,
+ .makeFn = make,
+ }),
+ .zig_file = zig_file,
+ .budget = budget,
+ };
+ zig_file.addStepDependencies(&self.step);
+ return self;
+ }
+
+ fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) anyerror!void {
+ const self: *PoisonCensus = @fieldParentPtr("step", step);
+ const b = step.owner;
+ const io = b.graph.io;
+ const gpa = options.gpa;
+
+ const path = self.zig_file.getPath2(b, step);
+ const text = std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 << 20)) catch |err|
+ return step.fail("unable to read the translated registers at {s}: {s}", .{ path, @errorName(err) });
+ defer gpa.free(text);
+
+ const found = std.mem.count(u8, text, "@compileError");
+ if (found > self.budget) {
+ return step.fail(
+ \\the translated register module has {d} untranslatable macros, expected at most {d}.
+ \\Each one is a `pub const X = @compileError(...)` that compiles fine until a driver
+ \\names it. Find the new ones with:
+ \\ grep -n '@compileError' {s}
+ \\A likely cause is a lost include path or an ESP-IDF version whose headers moved.
+ , .{ found, self.budget, path });
+ }
+ if (found < self.budget) {
+ std.log.info(
+ "register poison census: {d} untranslatable macros, below the expected {d} - tighten the budget in build.zig",
+ .{ found, self.budget },
+ );
+ }
+ }
+};
+
+/// ESP-Hosted's SDIO transport C, compiled into this image on top of this project's Zig runtime.
+///
+/// The P4 has no radio. The ESP32-C6 on this board does, and the host half of the protocol between
+/// them is ~13,000 lines of C that already works. This function compiles the SDIO path of that C
+/// with Zig's clang, for this target, so the Zig side can be built underneath it and the C replaced
+/// layer by layer - with the C still linked in as the differential reference, the same way
+/// `idfReference` above keeps IDF's LL beside the HAL.
+///
+/// Every decision here was measured, not guessed. 31 of ESP-Hosted's 43 host sources compile under
+/// these flags (the 12 that do not are the SPI and UART transports, which this board does not use,
+/// plus protobuf-c's own test files). Linking just the SDIO set leaves 26 undefined symbols, which
+/// is the entire cost of the reuse: src/net/libc.zig covers the libc ones, src/net/port.zig
+/// defines `g_h`, and src/net/hosted_glue.zig covers logging and the upper-layer hooks.
+fn hostedC(b: *std.Build, mod: *std.Build.Module, idf: []const u8) void {
+ const io = b.graph.io;
+
+ // The managed_components tree lives in the sibling project that proved this C on this board.
+ // Not vendored: it is 19,000 lines of someone else's code, it is pinned by that project's
+ // dependency lock, and copying it would make the provenance of the sdkconfig checked in here
+ // a lie.
+ const mc_root = b.pathJoin(&.{ b.pathFromRoot(".."), "02-esp32p4-m3-radio" });
+ {
+ var probe = std.Io.Dir.cwd().openDir(io, b.pathJoin(&.{ mc_root, "managed_components" }), .{}) catch {
+ std.debug.print(
+ \\-Dhosted needs the ESP-Hosted component from the sibling radio project:
+ \\ {s}/managed_components/espressif__esp_hosted
+ \\That tree is created by `idf.py build` in 02-esp32p4-m3-radio, which is also where
+ \\the checked-in src/net/hosted/sdkconfig.h came from.
+ \\
+ , .{mc_root});
+ @panic("ESP-Hosted component not found");
+ };
+ probe.close(io);
+ }
+ const hosted_root = b.pathJoin(&.{ mc_root, "managed_components", "espressif__esp_hosted" });
+
+ // Include order is ESP-IDF's own, taken from the compile_commands.json of the build that
+ // worked, and it matters: esp_wifi_remote's `injected` directory has to precede
+ // components/esp_wifi/include, because a host with no radio needs the injected Wi-Fi types
+ // rather than the real ones. See src/net/hosted/include_dirs.txt for the provenance note.
+ {
+ const list = std.Io.Dir.cwd().readFileAlloc(
+ io,
+ b.pathFromRoot("src/net/hosted/include_dirs.txt"),
+ b.allocator,
+ .limited(1 << 20),
+ ) catch @panic("src/net/hosted/include_dirs.txt is missing");
+ var lines = std.mem.tokenizeScalar(u8, list, '\n');
+ while (lines.next()) |raw| {
+ const line = std.mem.trim(u8, raw, " \t\r");
+ if (line.len == 0 or line[0] == '#') continue;
+ const abs = if (std.mem.startsWith(u8, line, "IDF/"))
+ b.pathJoin(&.{ idf, line["IDF/".len..] })
+ else if (std.mem.startsWith(u8, line, "MC/"))
+ b.pathJoin(&.{ mc_root, line["MC/".len..] })
+ else
+ @panic("include_dirs.txt: every line must start with IDF/ or MC/");
+ mod.addIncludePath(.{ .cwd_relative = abs });
+ }
+ }
+
+ // Our own Kconfig surface and the two compile-time assertion files.
+ mod.addIncludePath(b.path("src/net/hosted"));
+
+ // libc *declarations* from Zig's own bundled musl headers. This is the alternative to a pile of
+ // hand-written stubs: real prototypes, real errno values, real sys/queue.h, and no drift. The
+ // few symbols actually referenced come from the P4 mask ROM, compiler_rt and src/net/libc.zig -
+ // nothing links a libc. Resolved through the Zig installation rather than hardcoded, so this
+ // survives a toolchain move.
+ const zig_lib = b.graph.zig_lib_directory.path orelse
+ @panic("cannot locate the Zig lib directory for the bundled libc headers");
+ for ([_][]const u8{ "riscv32-linux-musl", "generic-musl", "any-linux-any" }) |flavour| {
+ mod.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ zig_lib, "libc", "include", flavour }) });
+ }
+
+ // Four headers musl does not have and IDF or ESP-Hosted expects. Each is here for one named
+ // reason, and none of them invents a declaration.
+ const shims = b.addWriteFiles();
+ // `sys/queue.h` is the BSD linked-list macros. musl has no such header; ESP-Hosted's
+ // common/mempool/include/mempool.h:12 includes it, and every transport source reaches it
+ // through that. Zig does bundle a copy for glibc targets, so this forwards to exactly that one
+ // file by absolute path. Putting glibc's whole include directory on the path would work too and
+ // would be a trap: it would shadow musl's headers wholesale and mix two libcs' declarations.
+ _ = shims.add("sys/queue.h", b.fmt(
+ \\#pragma once
+ \\/* Generated by build.zig. The BSD queue macros, from Zig's bundled glibc headers -
+ \\ musl has no sys/queue.h, and mempool.h needs one. Pure macros: nothing is linked. */
+ \\#include "{s}"
+ \\
+ , .{b.pathJoin(&.{ zig_lib, "libc", "include", "generic-glibc", "sys", "queue.h" })}));
+ _ = shims.add("esp_newlib.h",
+ \\#pragma once
+ \\/* FreeRTOS's riscv portmacro.h:76 includes this for newlib re-entrancy hooks. This image
+ \\ has no newlib runtime, so nothing is declared. */
+ \\
+ );
+ _ = shims.add("sys/features.h",
+ \\#pragma once
+ \\/* A newlib feature-test header. IDF's platform_include wrappers include it by name; the
+ \\ musl headers underneath supply the real declarations. */
+ \\
+ );
+ _ = shims.add("machine/endian.h",
+ \\#pragma once
+ \\/* newlib puts the byte-order macros here and esp_netif_ip_addr.h:11 includes that path.
+ \\ riscv32 is little-endian. */
+ \\#define _LITTLE_ENDIAN 1234
+ \\#define _BIG_ENDIAN 4321
+ \\#define _PDP_ENDIAN 3412
+ \\#define _BYTE_ORDER _LITTLE_ENDIAN
+ \\#define LITTLE_ENDIAN _LITTLE_ENDIAN
+ \\#define BIG_ENDIAN _BIG_ENDIAN
+ \\#define BYTE_ORDER _BYTE_ORDER
+ \\
+ );
+ mod.addIncludePath(shims.getDirectory());
+
+ // The SDIO path, and nothing else. Named one by one rather than globbed: the SPI, SPI-half-
+ // duplex and UART transports are in the same tree and compile, and pulling them in would link
+ // three unused transports and their symbol demands into a 128 KB image.
+ const sources = [_][]const u8{
+ // The transport state machine and its helpers.
+ "host/drivers/transport/transport_drv.c",
+ "host/drivers/transport/transport_util.c",
+ "host/drivers/transport/sdio/sdio_drv.c",
+ // common/mempool/mempool.c is deliberately NOT here. Its backend is NimBLE's os_mempool -
+ // MYNEWT_VAL and struct os_mempool - so the file only compiles in a build that carries the
+ // NimBLE host, which is why the IDF project this came from had NimBLE enabled. Bluetooth and
+ // the pool are both off (src/net/hosted/sdkconfig.h overrides 2 and 3), and every mempool
+ // call site in sdio_drv.c is behind `#if H_USE_MEMPOOL` (:109, :177, :244, :264), so with the
+ // pool off there is nothing to link. Buffers come straight from _h_malloc instead.
+ // The RPC layer: what carries Wi-Fi association, scanning and everything else the
+ // coprocessor does on our behalf. The transport alone gets the two chips talking; this is
+ // what gives that conversation vocabulary. Reached at transport_delayed_init -> rpc_start.
+ // transport_drv.c:807 calls create_debugging_tasks() unconditionally. This is the vendor's
+ // own implementation; with the stats Kconfig options off it spawns nothing, which is the
+ // answer we want and one we do not have to write.
+ "host/utils/stats.c",
+ // The control endpoint the RPC layer talks over: a virtual serial device multiplexed onto
+ // the same SDIO transport. transport_drv.c dispatches into serial_ll_rx_handler.
+ "host/drivers/serial/serial_ll_if.c",
+ "host/drivers/serial/serial_drv.c",
+ "host/drivers/virtual_serial_if/serial_if.c",
+ "host/drivers/rpc/core/rpc_core.c",
+ "host/drivers/rpc/core/rpc_req.c",
+ "host/drivers/rpc/core/rpc_rsp.c",
+ "host/drivers/rpc/core/rpc_evt.c",
+ "host/drivers/rpc/core/rpc_utils.c",
+ "host/drivers/rpc/slaveif/rpc_slave_if.c",
+ "host/drivers/rpc/wrap/rpc_wrap.c",
+ // The wire format underneath it. esp_hosted_rpc.pb-c.c is 33,000 generated lines of
+ // protobuf descriptors - almost entirely .rodata, so it costs flash rather than the L2MEM
+ // this image is actually short of.
+ "common/protobuf-c/protobuf-c/protobuf-c.c",
+ "common/proto/esp_hosted_rpc.pb-c.c",
+ // Bluetooth, declined. transport_drv.c:126 calls hci_drv_init() unconditionally, and this
+ // is ESP-Hosted's own no-op for a host without BT - a real answer from the vendor rather
+ // than a stub of ours. Bluetooth is switched off in src/net/hosted/sdkconfig.h, which is
+ // what keeps this file from pulling NimBLE in.
+ "host/drivers/bt/hci_stub_drv.c",
+ // The transport configuration, derived from Kconfig. Compiled rather than transcribed into
+ // Zig: the real struct interleaves `gpio_pin_t {void *port; int pin;}` pairs, and a
+ // hand-copy of that layout is a silent wrong-pin bug. src/net/hosted/pin_assert.c checks
+ // the values these produce against the board.
+ "host/api/src/esp_hosted_transport_config.c",
+ "host/port/esp/freertos/src/port_esp_hosted_host_transport_defaults.c",
+ };
+
+ // The force-include is load-bearing, and this is the single most expensive thing to get wrong
+ // in this whole file.
+ //
+ // `hosted_osi_funcs_t` guards four mempool entries with `#ifdef H_USE_MEMPOOL`
+ // (host/esp_hosted_os_abstraction.h:64-69). H_USE_MEMPOOL is *defined* - to 1 or to 0, but
+ // always defined - by port_esp_hosted_host_config.h:127-131, and `#ifdef` does not care which.
+ // So the struct is four pointers longer in any translation unit that saw that header first.
+ // Both host/esp_hosted.h:14 and host/drivers/transport/transport_util.h:10 include
+ // esp_hosted_os_abstraction.h as their very first include, so reaching the short layout is
+ // easy. Measured with these exact flags:
+ //
+ // sizeof _h_config_gpio _h_event_post
+ // without this -include 268 132 264
+ // with this -include 284 148 280
+ //
+ // A TU with the short layout calling _h_config_gpio jumps through a mempool pointer instead.
+ // On a board with no debugger that is a hang whose symptom is indistinguishable from the SDIO
+ // bus failing to come up. src/net/hosted/abi_assert.c asserts the long layout so this cannot
+ // regress silently.
+ const force_include = b.fmt("-include{s}", .{
+ b.pathJoin(&.{ hosted_root, "host/port/esp/freertos/include/port_esp_hosted_host_config.h" }),
+ });
+
+ const flags = [_][]const u8{
+ "-std=gnu17",
+ // Same reason as the oracle: IDF's code otherwise pulls in six __ubsan_handle_* symbols
+ // that do not exist in a freestanding image.
+ "-fno-sanitize=undefined",
+ // The reference should be the code as shipped, not a debug build of it.
+ "-O2",
+ // IDF's build defines this, and ESP-Hosted's config header uses it to decide whether to
+ // derive the slave target from Kconfig or from a hand-edited block of #defines. Without it
+ // the build fails with "No Slave Target or more than one Slave Target was defined".
+ "-DESP_PLATFORM",
+ "-DIDF_VER=\"v6.0.2\"",
+ // esp_libc/platform_include/stdio.h:46 uses newlib's spelling of off_t for its
+ // fopencookie typedefs. musl spells it off_t.
+ "-D__off_t=off_t",
+ // esp_private/interrupt_clic.h:34 uses newlib's assert.h internals.
+ "-D__ASSERT_FUNC=__func__",
+ // ESP-Hosted's own code is not this project's to clean up.
+ "-Wno-everything",
+ force_include,
+ };
+
+ for (sources) |rel| mod.addCSourceFile(.{
+ .file = .{ .cwd_relative = b.pathJoin(&.{ hosted_root, rel }) },
+ .flags = &flags,
+ });
+
+ // The Wi-Fi shim: a narrow C surface over the RPC layer, so Zig never transcribes an IDF
+ // struct. Compiled with the ESP-Hosted flags because it includes their headers.
+ mod.addCSourceFile(.{
+ .file = b.path("src/net/hosted/wifi_shim.c"),
+ .flags = &flags,
+ });
+
+ // The two assertion files. They define nothing anyone calls; they exist so that a Kconfig
+ // value drifting from the board, or an include order shifting a struct, fails the build with a
+ // sentence instead of hanging the radio.
+ for ([_][]const u8{ "pin_assert.c", "abi_assert.c" }) |name| mod.addCSourceFile(.{
+ .file = b.path(b.pathJoin(&.{ "src/net/hosted", name })),
+ .flags = &flags,
+ });
+}
+
+/// ESP-IDF's own `*_ll.h` functions, compiled into this image as the differential reference.
+///
+/// The whole point of the oracle is that both implementations run on the same die, in the same
+/// boot, against the same clocks - so IDF's headers have to be *compiled*, by Zig's clang, for the
+/// same target. They are, with a 10-file shim and one flag; the details below each cost a debugging
+/// round to find, so they are recorded rather than summarised.
+fn idfReference(
+ b: *std.Build,
+ mod: *std.Build.Module,
+ idf: []const u8,
+ hw_ver: u8,
+) void {
+
+ // IDF's LL headers include a handful of hosted-libc headers for types they mostly do not use.
+ // Empty stand-ins are enough for all but one line: `assert.h` must define `static_assert`,
+ // because esp_assert.h defines ESP_STATIC_ASSERT in terms of it and IDF sprinkles those through
+ // the `*_types.h` headers. Without that single line, 16 of the 96 LL headers fail to compile.
+ const shims = b.addWriteFiles();
+ _ = shims.add("stdlib.h", "#pragma once\ntypedef unsigned int size_t;\n#define NULL ((void *)0)\n");
+ _ = shims.add("string.h", "#pragma once\n");
+ _ = shims.add("stdio.h", "#pragma once\n");
+ _ = shims.add("math.h", "#pragma once\n");
+ _ = shims.add("inttypes.h", "#pragma once\n");
+ _ = shims.add("sys/param.h", "#pragma once\n");
+ _ = shims.add("sys/cdefs.h", "#pragma once\n");
+ _ = shims.add("assert.h",
+ \\#pragma once
+ \\#define assert(x) ((void)0)
+ \\/* the load-bearing line: ESP_STATIC_ASSERT expands to this, in 16 headers */
+ \\#define static_assert _Static_assert
+ \\
+ );
+ // IDF's headers include "sdkconfig.h" by that exact name. The real content is checked in as
+ // src/oracle/oracle_sdkconfig.h, where the name says what it is; this is the bridge.
+ _ = shims.add("sdkconfig.h", "#pragma once\n#include \"oracle_sdkconfig.h\"\n");
+ mod.addIncludePath(shims.getDirectory());
+ // Our own Kconfig surface, checked in next to the reference so it is part of the experiment.
+ mod.addIncludePath(b.path("src/oracle"));
+
+ // ESP-IDF v6 split the HAL into one component per peripheral, so a reference for peripheral X
+ // needs components/esp_hal_X/{esp32p4/include,include} on the path. Rather than listing them -
+ // and editing this file every time the oracle grows - take all of them: there are ~30, they are
+ // header-only, and an unused include path costs nothing.
+ {
+ const io = b.graph.io;
+ const comp_path = b.pathJoin(&.{ idf, "components" });
+ var dir = std.Io.Dir.cwd().openDir(io, comp_path, .{ .iterate = true }) catch
+ @panic("cannot read ESP-IDF components/");
+ defer dir.close(io);
+ var names: std.ArrayList([]const u8) = .empty;
+ var it = dir.iterate();
+ while (it.next(io) catch null) |entry| {
+ if (entry.kind != .directory or !std.mem.startsWith(u8, entry.name, "esp_hal_")) continue;
+ names.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM");
+ }
+ std.mem.sort([]const u8, names.items, {}, struct {
+ fn lt(_: void, x: []const u8, y: []const u8) bool {
+ return std.mem.lessThan(u8, x, y);
+ }
+ }.lt);
+ for (names.items) |n| {
+ mod.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ comp_path, n, "esp32p4", "include" }) });
+ mod.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ comp_path, n, "include" }) });
+ }
+ }
+
+ for ([_][]const u8{
+ "components/hal/esp32p4/include",
+ "components/hal/include",
+ "components/hal/platform_port/include",
+ "components/soc/include",
+ "components/soc/esp32p4/include",
+ b.fmt("components/soc/esp32p4/register/hw_ver{d}", .{hw_ver}),
+ "components/esp_common/include",
+ "components/esp_rom/include",
+ "components/esp_rom/esp32p4/include",
+ "components/esp_rom/esp32p4/include/esp32p4",
+ "components/riscv/include",
+ "components/esp_hw_support/include",
+ }) |rel| {
+ mod.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ idf, rel }) });
+ }
+
+ // Every reference translation unit in src/oracle. Adding a peripheral to the oracle means
+ // adding one `<name>_ref.c` there; this file does not need to know about it.
+ var refs: std.ArrayList([]const u8) = .empty;
+ {
+ const io = b.graph.io;
+ var dir = std.Io.Dir.cwd().openDir(io, b.pathFromRoot("src/oracle"), .{ .iterate = true }) catch
+ @panic("src/oracle is missing");
+ defer dir.close(io);
+ var it = dir.iterate();
+ while (it.next(io) catch null) |entry| {
+ if (entry.kind != .file or !std.mem.endsWith(u8, entry.name, "_ref.c")) continue;
+ refs.append(b.allocator, b.dupe(entry.name)) catch @panic("OOM");
+ }
+ std.mem.sort([]const u8, refs.items, {}, struct {
+ fn lt(_: void, x: []const u8, y: []const u8) bool {
+ return std.mem.lessThan(u8, x, y);
+ }
+ }.lt);
+ }
+ for (refs.items) |name| mod.addCSourceFile(.{
+ .file = b.path(b.pathJoin(&.{ "src/oracle", name })),
+ .flags = &.{
+ "-std=gnu17",
+ // Without this, IDF's LL code pulls in six __ubsan_handle_* symbols that have nothing
+ // to do with the peripheral and do not exist in a freestanding image.
+ "-fno-sanitize=undefined",
+ // The reference should be the code IDF ships, not a debug build of it.
+ "-O2",
+ // gpio_ll.h:588 (`gpio_ll_is_digital_io_hold`) has a path that returns nothing under
+ // this chip's `#if`s. It is IDF's bug, in a function nothing here calls, but clang
+ // analyses every static inline in a header it parses. Downgrading it is the only way to
+ // compile the reference at all, and pretending we could fix IDF here would be worse.
+ "-Wno-return-type",
+ // Force the Kconfig surface into every reference translation unit. Some IDF headers read
+ // CONFIG_* macros without including sdkconfig.h themselves - soc/interrupt_reg.h keys
+ // the CLIC threshold mechanism off CONFIG_ESP32P4_SELECTS_REV_LESS_V3 and assumes the
+ // includer already has it - so relying on each file to remember is a silent
+ // misconfiguration waiting to happen. On this die that particular one decides whether
+ // the interrupt threshold is a memory-mapped register or a CSR that does nothing.
+ // Absolute path to the checked-in file, not `-include sdkconfig.h`: the bridging header
+ // lives in a WriteFiles directory whose name changes with its hash, and a C object cached
+ // against the old path fails the build with CacheCheckFailed on the next run.
+ b.fmt("-include{s}", .{b.pathFromRoot("src/oracle/oracle_sdkconfig.h")}),
+ },
+ });
+}
+
+/// `zig build diff` without -Doracle would silently flash whatever app is default and compare
+/// nothing, so it fails with the command to run instead.
+const NeedsOracle = struct {
+ step: std.Build.Step,
+
+ fn create(b: *std.Build) *NeedsOracle {
+ const self = b.allocator.create(NeedsOracle) catch @panic("OOM");
+ self.* = .{ .step = std.Build.Step.init(.{
+ .id = .custom,
+ .name = "diff needs -Doracle",
+ .owner = b,
+ .makeFn = make,
+ }) };
+ return self;
+ }
+
+ fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void {
+ return step.fail(
+ \\the differential harness needs ESP-IDF's LL functions linked in as the reference:
+ \\ zig build diff -Doracle -Dapp=examples/differ.zig
+ \\That compiles ESP-IDF's own *_ll.h into this image with zig cc, so both go on the die
+ \\in one boot. It needs an ESP-IDF checkout, via -Didf=<path> or $IDF_PATH.
+ , .{});
+ }
+};
+
+/// ESP-IDF's peripheral instance addresses, as text to splice into the generated linker script.
+///
+/// IDF's LL code addresses peripherals through struct instances (`GPIO`, `IO_MUX`, `HP_SYS_CLKRST`)
+/// which are not C objects at all: esp32p4.peripherals.ld PROVIDEs 111 of them as linker symbols.
+fn readPeripheralsLd(b: *std.Build, idf: []const u8) []const u8 {
+ const path = b.pathJoin(&.{ idf, "components/soc/esp32p4/ld/esp32p4.peripherals.ld" });
+ const text = std.Io.Dir.cwd().readFileAlloc(b.graph.io, path, b.allocator, .limited(256 << 10)) catch {
+ std.log.err("cannot read {s}, which the oracle needs for ESP-IDF's peripheral symbols", .{path});
+ std.process.exit(1);
+ };
+ return b.fmt("/* spliced from {s} */\n{s}", .{ path, text });
+}
+
+const DescriptorKind = enum { minimal, full };
+
+fn featureSet(features: []const std.Target.riscv.Feature) std.Target.Cpu.Feature.Set {
+ var set = std.Target.Cpu.Feature.Set.empty;
+ for (features) |f| set.addFeature(@intFromEnum(f));
+ return set;
+}
+
+/// The eleven ESP-IDF linker scripts, replaced by these twenty lines.
+fn linkerScript(b: *std.Build, stack_size: u32, peripherals_ld: ?[]const u8) []const u8 {
+ // `peripherals_ld` is the *text* of ESP-IDF's peripherals.ld, not a path, and that is the whole
+ // point. An earlier version emitted `INCLUDE "<path>"`: LLD read the file at link time, but only
+ // the generated app.ld was a cache input, so the 111 peripheral base addresses the reference
+ // half is linked against were untracked. Perturbing GPIO's base in that file and rebuilding
+ // produced a byte-identical image - no relink at all - and the reverse was worse: a relink
+ // forced for an unrelated reason baked the perturbed address in, and restoring the file left it
+ // there under a green build. Splicing the text in makes the content hash the link's cache key,
+ // and drops an absolute host path out of a generated artefact.
+ return b.fmt(
+ \\/* generated by build.zig - do not edit */
+ \\ENTRY(_start)
+ \\{s}
+ \\
+ \\/* ESP32-P4 mask ROM entry points, the only "library" this image links against */
+ \\ets_printf = 0x4fc00024;
+ \\ets_delay_us = 0x4fc0003c;
+ \\
+ \\MEMORY {{
+ \\ /* Flash-mapped code and rodata.
+ \\
+ \\ This was 0xFFE0 - one 64 KiB MMU window - for as long as every application here was a
+ \\ few KB, and the comment said the bootloader demands two mapped segments and nothing
+ \\ says they may not share a page. Both halves of that are still true; what changed is
+ \\ that -Dhosted links ESP-Hosted's transport and RPC layers, and the generated protobuf
+ \\ descriptors alone are 33,000 lines of .rodata. One window overflowed by 14,744 bytes.
+ \\
+ \\ 1 MiB now. It is a *region*, not a reservation: the image contains only the sections
+ \\ actually emitted, so a blink app is still ~1.7 KB. The ceiling that matters is the
+ \\ factory partition, 0x177000 = 1.5 MiB, and this stays comfortably inside it. Segments
+ \\ still come in the two the loader wants; they simply span more than one MMU page now,
+ \\ which the bootloader maps without complaint. */
+ \\ flash (rx) : ORIGIN = 0x40000020, LENGTH = 0xFFFE0
+ \\ l2mem (rw) : ORIGIN = 0x4FF00000, LENGTH = 0x20000
+ \\}}
+ \\
+ \\SECTIONS {{
+ \\ .flash.rodata : ALIGN(16) {{
+ \\ KEEP(*(.rodata.appdesc)) /* the loader reads esp_app_desc_t at image offset 0x20 */
+ \\ *(.rodata .rodata.* .srodata .srodata.*)
+ \\ /* Leave exactly the 8 bytes the image builder needs for the next segment's header
+ \\ before the 64-byte boundary that .flash.text starts on. ALIGN(64) alone does not
+ \\ guarantee any hole: for one rodata length in eight the gap is 0 or 4 bytes and the
+ \\ build dies with MappedSegmentsTooClose. */
+ \\ . = ALIGN(. + 8, 64) - 8;
+ \\ }} > flash
+ \\
+ \\ .flash.text : ALIGN(64) {{
+ \\ *(.text.entry)
+ \\ *(.text .text.*)
+ \\ }} > flash
+ \\
+ \\ .data : ALIGN(4) {{ *(.data .data.* .sdata .sdata.*) }} > l2mem
+ \\ .bss (NOLOAD) : ALIGN(4) {{
+ \\ __bss_start = .;
+ \\ *(.bss .bss.* .sbss .sbss.* COMMON)
+ \\ __bss_end = .;
+ \\ }} > l2mem
+ \\ /* ALIGN(16) aligns the section start; __stack_top is start + size, so round that too -
+ \\ the RISC-V ABI wants sp 16-byte aligned and -Dstack takes any integer. */
+ \\ .stack (NOLOAD) : ALIGN(16) {{ . = ALIGN(. + {d}, 16); __stack_top = .; }} > l2mem
+ \\
+ \\ /DISCARD/ : {{ *(.eh_frame) *(.eh_frame_hdr) *(.comment) *(.riscv.attributes) }}
+ \\}}
+ \\
+ , .{
+ // ESP-IDF's LL code addresses peripherals through struct instances (`GPIO`, `IO_MUX`,
+ // `HP_SYS_CLKRST`), which are not C objects at all: soc/esp32p4/ld/esp32p4.peripherals.ld
+ // PROVIDEs 111 of them as linker symbols. Pulling that file in with INCLUDE rather than a
+ // second -T matters - Zig's driver honours only the last -T given, a limitation this project
+ // already ran into once.
+ if (peripherals_ld) |text| text else "",
+ stack_size,
+ });
+}
+
+// ---------------------------------------------------------------------------- custom steps
+//
+// Three steps, each following the same shape the standard steps use (see std/Build/Step/ObjCopy.zig):
+// hash the inputs into a cache manifest, skip the work on a hit, publish the result as a LazyPath.
+// Nothing is passed between steps through private fields, so `zig build flash` works whether or not
+// the image step ran in this process.
+
+/// The serial port is one device but `flash` and `monitor` are unordered top-level steps, and the
+/// build runner executes independent steps concurrently. Serializing them here turns
+/// `zig build flash monitor` from a race into a sequence.
+var port_lock: std.Io.Mutex = .init;
+
+const ImageStep = struct {
+ step: std.Build.Step,
+ elf: std.Build.LazyPath,
+ opts: image.Options,
+ basename: []const u8,
+ generated: std.Build.GeneratedFile,
+
+ fn create(b: *std.Build, app: *std.Build.Step.Compile, opts: image.Options) *ImageStep {
+ const self = b.allocator.create(ImageStep) catch @panic("OOM");
+ self.* = .{
+ .step = std.Build.Step.init(.{
+ .id = .custom,
+ .name = "image",
+ .owner = b,
+ .makeFn = make,
+ }),
+ .elf = app.getEmittedBin(),
+ .opts = opts,
+ .basename = "app.bin",
+ .generated = .{ .step = undefined },
+ };
+ self.generated.step = &self.step;
+ self.elf.addStepDependencies(&self.step);
+ return self;
+ }
+
+ fn getOutput(self: *ImageStep) std.Build.LazyPath {
+ return .{ .generated = .{ .file = &self.generated } };
+ }
+
+ fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) anyerror!void {
+ const self: *ImageStep = @fieldParentPtr("step", step);
+ const b = step.owner;
+ const io = b.graph.io;
+ const gpa = options.gpa;
+
+ var man = b.graph.cache.obtain();
+ defer man.deinit();
+
+ const elf_path = self.elf.getPath2(b, step);
+ _ = try man.addFile(elf_path, null);
+ // The image builder is part of the input: without these two, editing tools/image.zig gives
+ // a cache hit and ships the previous bytes, and `--watch` never notices the edit at all.
+ _ = try man.addFile(b.pathFromRoot("tools/image.zig"), null);
+ _ = try man.addFile(b.pathFromRoot("build.zig"), null);
+ inline for (@typeInfo(image.Options).@"struct".fields) |f| {
+ const v = @field(self.opts, f.name);
+ man.hash.add(switch (@typeInfo(@TypeOf(v))) {
+ .@"enum" => @as(u32, @intFromEnum(v)),
+ else => @as(u32, v),
+ });
+ }
+
+ if (try step.cacheHitAndWatch(&man)) {
+ const digest = man.final();
+ self.generated.path = try b.cache_root.join(b.allocator, &.{ "o", &digest, self.basename });
+ return;
+ }
+
+ const digest = man.final();
+ const cache_dir = "o" ++ std.fs.path.sep_str ++ digest;
+ b.cache_root.handle.createDirPath(io, cache_dir) catch |err|
+ return step.fail("unable to make {s}: {s}", .{ cache_dir, @errorName(err) });
+
+ const elf_bytes = std.Io.Dir.cwd().readFileAlloc(io, elf_path, gpa, .limited(8 << 20)) catch |err|
+ return step.fail("unable to read {s}: {s}", .{ elf_path, @errorName(err) });
+ defer gpa.free(elf_bytes);
+
+ var layout = image.fromElf(gpa, elf_bytes, self.opts) catch |err|
+ return step.fail("image build failed: {s}", .{@errorName(err)});
+ defer layout.deinit(gpa);
+
+ // Validate before anything is written: a rejected image must never exist on disk under a
+ // name the flash step - or a human with esptool - would pick up.
+ layout.validate(self.opts) catch |err|
+ return step.fail("image violates a loader rule: {s}", .{@errorName(err)});
+
+ const out_path = try b.cache_root.join(b.allocator, &.{ cache_dir, self.basename });
+ std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = layout.bytes }) catch |err|
+ return step.fail("unable to write {s}: {s}", .{ out_path, @errorName(err) });
+
+ self.generated.path = out_path;
+ try step.writeManifestAndWatch(&man);
+ }
+};
+
+/// Read a built image back off disk. Both the flash and size steps do exactly this, which is what
+/// lets them work on a cache hit, in any order, or on an image from a previous build.
+fn readImage(step: *std.Build.Step, gpa: std.mem.Allocator, path: std.Build.LazyPath, opts: image.Options) !image.Layout {
+ const b = step.owner;
+ const io = b.graph.io;
+ const p = path.getPath2(b, step);
+ const bytes = std.Io.Dir.cwd().readFileAlloc(io, p, gpa, .limited(4 << 20)) catch |err|
+ return step.fail("unable to read {s}: {s}", .{ p, @errorName(err) });
+ defer gpa.free(bytes);
+ return image.parse(gpa, bytes, opts) catch |err|
+ return step.fail("{s} is not a usable image: {s}", .{ p, @errorName(err) });
+}
+
+const FlashStep = struct {
+ step: std.Build.Step,
+ bin: std.Build.LazyPath,
+ opts: image.Options,
+ port: []const u8,
+ baud: serial.Baud,
+ verify: bool,
+
+ const Args = struct {
+ port: []const u8,
+ baud: serial.Baud,
+ verify: bool,
+ opts: image.Options,
+ };
+
+ fn create(b: *std.Build, img: *ImageStep, args: Args) *FlashStep {
+ const self = b.allocator.create(FlashStep) catch @panic("OOM");
+ self.* = .{
+ .step = std.Build.Step.init(.{ .id = .custom, .name = "flash", .owner = b, .makeFn = make }),
+ .bin = img.getOutput(),
+ .opts = args.opts,
+ .port = args.port,
+ .baud = args.baud,
+ .verify = args.verify,
+ };
+ self.bin.addStepDependencies(&self.step);
+ return self;
+ }
+
+ fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) anyerror!void {
+ const self: *FlashStep = @fieldParentPtr("step", step);
+ const b = step.owner;
+ const gpa = options.gpa;
+
+ var layout = try readImage(step, gpa, self.bin, self.opts);
+ defer layout.deinit(gpa);
+
+ port_lock.lockUncancelable(b.graph.io);
+ defer port_lock.unlock(b.graph.io);
+
+ var port = serial.Port.open(self.port, self.baud) catch |err|
+ return step.fail("cannot open {s}: {s}", .{ self.port, @errorName(err) });
+ defer port.close();
+
+ const t_open = port.nowMs();
+ port.resetToDownload(.{}) catch |err| return step.fail("reset failed: {s}", .{@errorName(err)});
+
+ var loader = rom.Loader{ .port = &port };
+ loader.sync(gpa) catch |err| return step.fail("ROM loader did not answer: {s}", .{@errorName(err)});
+ const t_sync = port.nowMs();
+ loader.attachFlash(gpa) catch |err| return step.fail("SPI attach failed: {s}", .{@errorName(err)});
+ loader.setFlashParams(gpa, self.opts.flash_size.bytes()) catch |err|
+ return step.fail("SPI params failed: {s}", .{@errorName(err)});
+ const t_setup = port.nowMs();
+
+ loader.writeFlash(gpa, self.opts.flash_offset, layout.bytes) catch |err|
+ return step.fail("write failed: {s}", .{@errorName(err)});
+ const t_write = port.nowMs();
+
+ // The ROM hashes what it actually stored; compare that with our own digest of what we sent.
+ var expect: [16]u8 = undefined;
+ std.crypto.hash.Md5.hash(layout.bytes, &expect, .{});
+ var expect_hex: [32]u8 = undefined;
+ _ = std.fmt.bufPrint(&expect_hex, "{x}", .{&expect}) catch unreachable;
+ if (self.verify) {
+ var rom_md5: [32]u8 = undefined;
+ if (loader.flashMd5(gpa, self.opts.flash_offset, @intCast(layout.bytes.len), &rom_md5)) |_| {
+ if (!std.mem.eql(u8, &rom_md5, &expect_hex)) return step.fail(
+ "flash verify failed: the ROM reports {s}, the image is {s}",
+ .{ &rom_md5, &expect_hex },
+ );
+ } else |err| return step.fail("flash verify failed: {s}", .{@errorName(err)});
+ }
+ const t_verify = port.nowMs();
+
+ port.resetToRun(.{}) catch {};
+ const t_run = port.nowMs();
+
+ var buf: [256]u8 = undefined;
+ var stdout = std.Io.File.stdout().writer(b.graph.io, &buf);
+ try stdout.interface.print(
+ "flashed {d} B at 0x{x} in {d} ms: reset+sync {d}, setup {d}, write {d}, verify {d}, run {d} (md5 {s})\n",
+ .{
+ layout.bytes.len, self.opts.flash_offset, t_run - t_open,
+ t_sync - t_open, t_setup - t_sync, t_write - t_setup,
+ t_verify - t_write, t_run - t_verify, expect_hex[0..16],
+ },
+ );
+ try stdout.interface.flush();
+ }
+};
+
+const MonitorStep = struct {
+ step: std.Build.Step,
+ port: []const u8,
+ seconds: u32,
+
+ fn create(b: *std.Build, port: []const u8, seconds: u32) *MonitorStep {
+ const self = b.allocator.create(MonitorStep) catch @panic("OOM");
+ self.* = .{
+ .step = std.Build.Step.init(.{ .id = .custom, .name = "monitor", .owner = b, .makeFn = make }),
+ .port = port,
+ .seconds = seconds,
+ };
+ return self;
+ }
+
+ fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void {
+ const self: *MonitorStep = @fieldParentPtr("step", step);
+ const b = step.owner;
+
+ port_lock.lockUncancelable(b.graph.io);
+ defer port_lock.unlock(b.graph.io);
+
+ var port = serial.Port.open(self.port, .b115200) catch |err|
+ return step.fail("cannot open {s}: {s}", .{ self.port, @errorName(err) });
+ defer port.close();
+ port.resetToRun(.{}) catch {};
+
+ var out_buf: [4096]u8 = undefined;
+ var stdout = std.Io.File.stdout().writer(b.graph.io, &out_buf);
+ var buf: [1024]u8 = undefined;
+ const deadline = port.nowMs() + @as(i64, self.seconds) * 1000;
+ while (port.nowMs() < deadline) {
+ const n = port.readTimeout(&buf, 200) catch break;
+ if (n > 0) {
+ try stdout.interface.writeAll(buf[0..n]);
+ try stdout.interface.flush();
+ }
+ }
+ }
+};
+
+/// Pulse the reset line and leave. One ioctl pair, but it is the difference between "did my app
+/// hang or did I forget to reset it" during development.
+const ResetStep = struct {
+ step: std.Build.Step,
+ port: []const u8,
+
+ fn create(b: *std.Build, port: []const u8) *ResetStep {
+ const self = b.allocator.create(ResetStep) catch @panic("OOM");
+ self.* = .{
+ .step = std.Build.Step.init(.{ .id = .custom, .name = "reset", .owner = b, .makeFn = make }),
+ .port = port,
+ };
+ return self;
+ }
+
+ fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) anyerror!void {
+ const self: *ResetStep = @fieldParentPtr("step", step);
+ const b = step.owner;
+ port_lock.lockUncancelable(b.graph.io);
+ defer port_lock.unlock(b.graph.io);
+ var port = serial.Port.open(self.port, .b115200) catch |err|
+ return step.fail("cannot open {s}: {s}", .{ self.port, @errorName(err) });
+ defer port.close();
+ port.resetToRun(.{}) catch |err| return step.fail("reset failed: {s}", .{@errorName(err)});
+ }
+};
+
+const SizeStep = struct {
+ step: std.Build.Step,
+ bin: std.Build.LazyPath,
+ opts: image.Options,
+
+ fn create(b: *std.Build, img: *ImageStep) *SizeStep {
+ const self = b.allocator.create(SizeStep) catch @panic("OOM");
+ self.* = .{
+ .step = std.Build.Step.init(.{ .id = .custom, .name = "size", .owner = b, .makeFn = make }),
+ .bin = img.getOutput(),
+ .opts = img.opts,
+ };
+ self.bin.addStepDependencies(&self.step);
+ return self;
+ }
+
+ fn make(step: *std.Build.Step, options: std.Build.Step.MakeOptions) anyerror!void {
+ const self: *SizeStep = @fieldParentPtr("step", step);
+ const b = step.owner;
+ const gpa = options.gpa;
+
+ var layout = try readImage(step, gpa, self.bin, self.opts);
+ defer layout.deinit(gpa);
+
+ var buf: [4096]u8 = undefined;
+ var stdout = std.Io.File.stdout().writer(b.graph.io, &buf);
+ const w = &stdout.interface;
+ try w.print("image {d} B = {d} B segments + {d} B overhead, entry 0x{x}\n", .{
+ layout.bytes.len, layout.payload, layout.overhead, layout.entry,
+ });
+ var off: usize = 24;
+ for (layout.segments) |s| {
+ const flash = self.opts.flash_offset + off + 8;
+ try w.print(" {s:<6} vaddr=0x{x:0>8} len={d:>6} flash=0x{x:0>6}{s}\n", .{
+ @tagName(s.kind), s.addr, s.len, flash,
+ if (s.kind == .mapped and flash % self.opts.mmu_page == s.addr % self.opts.mmu_page)
+ " congruent"
+ else
+ "",
+ });
+ off += 8 + s.len;
+ }
+ try w.print(" 24 B header + {d} B segment headers + checksum pad + 32 B sha256\n", .{layout.segments.len * 8});
+ try w.flush();
+ }
+};