const std = @import("std"); const builtin = @import("builtin"); // The danos build API (docs/build-packages-plan.md): the shared user-binary // recipe lives in the build-support package; this root build orchestrates. const build_support = @import("build-support"); /// danos is developed against Zig 0.16.x. Pre-1.0 Zig makes breaking API changes /// between minor releases, and the .zon's `minimum_zig_version` only enforces a /// floor — so reject anything off the 0.16 line to keep the build reproducible. fn ensureZigVersion() void { const v = builtin.zig_version; if (v.major != 0 or v.minor != 16) { std.debug.print( "danos requires Zig 0.16.x, but this is {d}.{d}.{d}. " ++ "Zig makes breaking changes between minor releases pre-1.0.\n", .{ v.major, v.minor, v.patch }, ); std.process.exit(1); } } /// Return the first path in `candidates` that exists on the build host, else the /// first candidate as a fallback so a missing-firmware error still names a /// concrete (and, by convention, the primary) path. Used to locate OVMF firmware /// across distro/OS layouts without configuration. fn firstExisting(io: std.Io, candidates: []const []const u8) []const u8 { for (candidates) |path| { std.Io.Dir.accessAbsolute(io, path, .{}) catch continue; return path; } return candidates[0]; } /// A UTC timestamp like "20260708-153045", for naming a per-run artifact so /// repeated runs don't clobber each other's logs. Resolved when `build.zig` runs /// (i.e. at `zig build` invocation), which is moments before QEMU launches. fn timestamp(b: *std.Build) []const u8 { const ns = std.Io.Clock.now(.real, b.graph.io).nanoseconds; const secs: u64 = @intCast(@divFloor(ns, std.time.ns_per_s)); const es = std.time.epoch.EpochSeconds{ .secs = secs }; const yd = es.getEpochDay().calculateYearDay(); const md = yd.calculateMonthDay(); const ds = es.getDaySeconds(); return b.fmt("{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}", .{ yd.year, md.month.numeric(), @as(u32, md.day_index) + 1, ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(), }); } /// The modules the kernel imports, gathered once so both kernel variants (the /// installed one and the serial-enabled one `run-x86-64` boots) are built from /// the same set. `build_options` is *not* here — it carries `serial`/`test_case`, /// which differ per variant, so `addKernel` builds it fresh each time. const KernelModules = struct { boot_handoff: *std.Build.Module, abi: *std.Build.Module, device_abi: *std.Build.Module, architecture: *std.Build.Module, platform: *std.Build.Module, parameters: *std.Build.Module, initial_ramdisk: *std.Build.Module, }; /// Build the freestanding x86_64 kernel ELF. Factored so we can build it twice /// from one recipe: the installed/flashable image (serial off by default) and the /// serial-enabled variant `run-x86-64` boots — they differ only in the `serial` /// build option baked into `build_options`. fn addKernel( b: *std.Build, kernel_target: std.Build.ResolvedTarget, optimize: std.builtin.OptimizeMode, modules: KernelModules, test_case: ?[]const u8, serial: bool, ) *std.Build.Step.Compile { // Compile-time configuration the kernel reads as `@import("build_options")`: // the QEMU harness's -Dtest-case, and whether the serial log sink is compiled // in (see the -Dserial option). Built per variant since `serial` differs. const build_options = b.addOptions(); build_options.addOption(?[]const u8, "test_case", test_case); build_options.addOption(bool, "serial", serial); const build_options_module = build_options.createModule(); const exe = b.addExecutable(.{ .name = "kernel", .root_module = b.createModule(.{ .root_source_file = b.path("system/kernel/kernel.zig"), .target = kernel_target, .optimize = optimize, .code_model = .kernel, // kernel runs in the top 2 GiB (higher half) .red_zone = false, // interrupts would corrupt the SystemV red zone .single_threaded = false, // SMP: the big kernel lock's atomics must be real across cores .sanitize_c = .off, // the UBSan runtime needs f128/SSE support we don't provide .stack_check = false, // stack-probe calls have no runtime to land in .stack_protector = false, .strip = optimize != .Debug, // DWARF info doubles the flashable image; keep it only for debug builds .imports = &.{ .{ .name = "boot-handoff", .module = modules.boot_handoff }, .{ .name = "abi", .module = modules.abi }, .{ .name = "device-abi", .module = modules.device_abi }, .{ .name = "architecture", .module = modules.architecture }, .{ .name = "platform", .module = modules.platform }, .{ .name = "parameters", .module = modules.parameters }, .{ .name = "build_options", .module = build_options_module }, .{ .name = "initial-ramdisk", .module = modules.initial_ramdisk }, }, }), }); exe.setLinkerScript(b.path("system/kernel/architecture/x86_64/linker.ld")); exe.entry = .{ .symbol_name = "_start" }; // The self-hosted linker ignores parts of the linker script (PHDRS, // /DISCARD/, AT(), section order); the higher-half layout depends on the // script being authoritative, so pin the kernel to LLVM + LLD. exe.use_llvm = true; exe.use_lld = true; // Higher-half virtual base (matches KERNEL_VIRT_BASE in linker.ld); the // linker's AT() clauses give each segment a low physical load address // (.text at 1 MiB), which the loader allocates and copies into. exe.image_base = 0xFFFFFFFF80100000; return exe; } /// One user binary and its FHS home on the boot volume (and in zig-out). const BundledBinary = struct { path: []const u8, binary: std.Build.LazyPath }; /// Assemble the bootable FAT32 image (the in-repo Python builder) holding the /// EFI stub, the kernel, and every user binary at its FHS path — the volume's /// /system tree IS the system image; the EFI loader walks it at boot and builds /// the in-RAM initial_ramdisk from it. Factored so the serial-enabled /// `run-x86-64` variant can bundle its own serial kernel while sharing the /// loader and user tree (the loader's boot breadcrumbs and init's heartbeat both /// follow the top-level -Dserial). Returns the image's LazyPath. fn addBootImage( b: *std.Build, kernel_bin: std.Build.LazyPath, efi_bin: std.Build.LazyPath, manifest: std.Build.LazyPath, capsule: std.Build.LazyPath, bundled: []const BundledBinary, ) std.Build.LazyPath { const mk_fat = b.addSystemCommand(&.{"python3"}); mk_fat.addFileArg(b.path("tools/make-fat-image.py")); const fat_image = mk_fat.addOutputFileArg("danos-usb.img"); mk_fat.addArg("64"); // MiB mk_fat.addArg("EFI/BOOT/BOOTX64.efi"); mk_fat.addFileArg(efi_bin); mk_fat.addArg("system/kernel"); mk_fat.addFileArg(kernel_bin); mk_fat.addArg("system/manifest"); mk_fat.addFileArg(manifest); mk_fat.addArg("boot/system.img"); mk_fat.addFileArg(capsule); for (bundled) |item| { mk_fat.addArg(item.path); mk_fat.addFileArg(item.binary); } return fat_image; } pub fn build(b: *std.Build) void { ensureZigVersion(); const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); // The library domain packages (docs/build-packages-plan.md, phase 1): each // domain owns a build.zig/zon that wires and exports its modules, and this // root build is a consumer — a library interface change now happens in the // domain's own build file, not here. The per-module commentary lives with // each domain's build.zig. const kernel_library = b.dependency("kernel", .{}); const device_library = b.dependency("device", .{}); const client_library = b.dependency("client", .{}); const protocol_library = b.dependency("protocol", .{}); const csv_library = b.dependency("csv", .{}); const xkeyboard_config_library = b.dependency("xkeyboard-config", .{}); // The three shared contracts, each with its own audience so every import // declares which one it speaks (no target is set, so each inherits the target of // whichever binary imports it). See docs/coding-standards.md. // boot-handoff : loader <-> kernel (BootInformation, framebuffer, VM layout) // abi : kernel <-> runtime, core (SystemCall, mmap prot flags, page_size) // device-abi : kernel <-> user, devices (DeviceDescriptor, DeviceClass, ...) // boot-handoff stays a root module (a system/ source the loader <-> kernel // pair speaks); abi is exported by the kernel library package (its source // also lives in system/), device-abi by the device package. const boot_handoff_module = b.addModule("boot-handoff", .{ .root_source_file = b.path("system/boot-handoff.zig"), }); const abi_module = kernel_library.module("abi"); const device_abi_module = device_library.module("device-abi"); // Kernel tunables (maximum_cpus, stack sizes, tick rate). A dependency-free module of // compile-time constants, imported wherever a knob is read; keeps the trade-offs // in one place instead of scattered across the tree. See system/parameters.zig. const parameters_module = b.addModule("parameters", .{ .root_source_file = b.path("system/parameters.zig"), }); // Architecture-specific kernel code (CPU ops, entry, later GDT/IDT/paging). // The generic kernel imports this as "architecture" and never names x86_64, so a new // architecture is a matter of pointing this module at a different directory. const architecture_module = b.addModule("architecture", .{ .root_source_file = b.path("system/kernel/architecture/x86_64/cpu.zig"), .imports = &.{ .{ .name = "boot-handoff", .module = boot_handoff_module }, // paging uses BootInformation/memory-map + physicalToVirtual .{ .name = "abi", .module = abi_module }, // paging works in page_size units .{ .name = "parameters", .module = parameters_module }, // maximum_cpus, ist_stack_size, timer_hz }, }); // CPU-exception stubs — real assembly, since they need cross-symbol // jumps/calls that Zig inline asm can't express (see the file's header). architecture_module.addAssemblyFile(b.path("system/kernel/architecture/x86_64/isr.s")); // The AP bring-up trampoline: 16-/32-/64-bit mode-switch code that can't be // inline asm (it runs relocated to a low page, not at its link address). architecture_module.addAssemblyFile(b.path("system/kernel/architecture/x86_64/trampoline.s")); // Firmware-agnostic device discovery. The generic kernel imports this as // "platform" and asks it to enumerate hardware into a backend-neutral device // tree, never naming ACPI (or, later, device-tree) — the same discipline the // architecture module applies to CPU code. The backend is selected at runtime from // the boot handoff (see system/kernel/platform.zig). const platform_module = b.addModule("platform", .{ .root_source_file = b.path("system/kernel/platform.zig"), .imports = &.{ .{ .name = "boot-handoff", .module = boot_handoff_module }, // BootInformation (carries the ACPI RSDP), physicalToVirtual .{ .name = "abi", .module = abi_module }, // acpi.zig works in page_size units .{ .name = "device-abi", .module = device_abi_module }, // device-model's DeviceClass/ResourceKind live here .{ .name = "parameters", .module = parameters_module }, // maximum_cpus (the discovery pool) }, }); // The initial_ramdisk container format, shared by the kernel (unpacks it) and // the EFI loader (packs it in RAM from the boot volume's /system tree). No // dependencies. const initial_ramdisk_module = b.addModule("initial-ramdisk", .{ .root_source_file = b.path("system/initial-ramdisk.zig"), }); // Compile-time configuration the kernel reads as `@import("build_options")`. The // QEMU test harness sets -Dtest-case= to run one self-test at boot. const test_case = b.option([]const u8, "test-case", "Kernel self-test case to run at boot (see system/kernel/tests.zig)"); // The serial-console log sink. Off by default: a real machine often has no // working legacy COM1, and the boot log is kept in RAM (klog) and flushed to // disk instead — serial is now only a QEMU convenience. `run-x86-64` and the // QEMU test harness (test/qemu_test.py, which asserts on serial markers) turn // it on; a flashable `zig build` image leaves it out. See serial.zig. const serial = b.option(bool, "serial", "Compile the serial-console log sink into the kernel (default: off; run-x86-64 and the test harness enable it)") orelse false; // The diagnose boot: init skips the display service (and demo), so the // on-screen boot transcript is never suppressed — the full timestamped // timeline stays on the screen for real-hardware debugging by eye. const diagnose = b.option(bool, "diagnose", "Boot without the display service so the timestamped boot transcript stays on screen (real-hardware debugging)") orelse false; // --- Kernel: freestanding x86_64 ELF, jumped to by the bootloader --- // (See build-support/build.zig for why SSE2 stays enabled.) const kernel_target = build_support.freestandingTarget(b); const kernel_modules = KernelModules{ .boot_handoff = boot_handoff_module, .abi = abi_module, .device_abi = device_abi_module, .architecture = architecture_module, .platform = platform_module, .parameters = parameters_module, .initial_ramdisk = initial_ramdisk_module, }; // The installed/flashable kernel: serial follows -Dserial (off by default). const exe = addKernel(b, kernel_target, optimize, kernel_modules, test_case, serial); // Everything installs into a FHS-shaped zig-out: it IS the danos filesystem *and* // the boot volume. Each binary lands at its addressed, leaf-collapsed path — the // kernel at zig-out/system/kernel (from system/kernel/kernel.zig), init at // zig-out/system/services/init, and so on (see docs/README.md). The bootloader // then loads these FHS paths off the volume. const kernel_install = b.addInstallArtifact(exe, .{ .dest_dir = .{ .override = .{ .custom = "system" } } }); b.getInstallStep().dependOn(&kernel_install.step); // --- the user-space binaries, every one of them a package --- // Binary packages (docs/build-packages-plan.md, phase 2): each binary // builds itself against the domain packages via build-support's shared // recipe, started in ring 3 by the kernel's user-ELF loader like always; // the root build just takes artifacts for the boot image. init receives // the root's -Dserial as a dependency option (its liveness heartbeat is a // serial/test-build diagnostic the QEMU harness asserts on; a flashable // image leaves it out). const init_exe = b.dependency("init", .{ .serial = serial }).artifact("init"); // --- the rest of the boot tree: /system services and drivers, /test fixtures --- // Each is built by the same user-binary recipe and laid out at its FHS path on // the boot volume (see `bundled` below). The EFI loader walks the tree at boot // and hands the kernel an in-RAM initial_ramdisk of it (system/initial-ramdisk.zig). const vfstest_exe = b.dependency("vfs-test", .{}).artifact("vfs-test"); // The drivers, each directory its own package: the PS/2 bus family (bus + // keyboard + mouse from one package), the xHCI bus driver, the USB HID // class drivers, and USB mass storage. Their unit tests ride along. const ps2_bus_package = b.dependency("ps2-bus", .{}); const ps2_bus_exe = ps2_bus_package.artifact("ps2-bus"); const ps2_keyboard_exe = ps2_bus_package.artifact("ps2-keyboard"); const ps2_mouse_exe = ps2_bus_package.artifact("ps2-mouse"); const usb_xhci_bus_exe = b.dependency("usb-xhci-bus", .{}).artifact("usb-xhci-bus"); const usb_hid_package = b.dependency("usb-hid", .{}); const usb_hid_keyboard_exe = usb_hid_package.artifact("usb-hid-keyboard"); const usb_hid_mouse_exe = usb_hid_package.artifact("usb-hid-mouse"); const usb_storage_package = b.dependency("usb-storage", .{}); const usb_storage_exe = usb_storage_package.artifact("usb-storage"); // The FAT filesystem server and the display stack, each its own package // (fat's and display's unit tests ride along in their packages). const fat_package = b.dependency("fat", .{}); const fat_exe = fat_package.artifact("fat"); const display_package = b.dependency("display", .{}); const display_exe = display_package.artifact("display"); const display_demo_exe = b.dependency("display-demo", .{}).artifact("display-demo"); const virtio_gpu_package = b.dependency("virtio-gpu", .{}); const virtio_gpu_exe = virtio_gpu_package.artifact("virtio-gpu"); const shared_memory_server_exe = b.dependency("shared-memory-server", .{}).artifact("shared-memory-server"); const shared_memory_client_exe = b.dependency("shared-memory-client", .{}).artifact("shared-memory-client"); const fat_test_exe = b.dependency("fat-test", .{}).artifact("fat-test"); // The first binary package (docs/build-packages-plan.md, phase 2): pci-bus // builds itself against the domain packages; the root build just takes the // artifact for the boot image. const pci_bus_exe = b.dependency("pci-bus", .{}).artifact("pci-bus"); // crash-test is a fixture, not a real driver: it hellos to the device // manager, then faults — what the driver-restart scenario drives the // crash-loop cap with. pci-cap-test and iommu-fault-test exercise the // driver-side PCI library surface and the VT-d rogue-DMA negative proof. const crash_test_exe = b.dependency("crash-test", .{}).artifact("crash-test"); const device_list_exe = b.dependency("device-list", .{}).artifact("device-list"); const pci_cap_test_exe = b.dependency("pci-cap-test", .{}).artifact("pci-cap-test"); const iommu_fault_test_exe = b.dependency("iommu-fault-test", .{}).artifact("iommu-fault-test"); // The discovery service: one swappable process per firmware // (docs/discovery.md), bundled under the neutral ramdisk name // "discovery" so the device manager never learns which firmware it is on. // x86 boots describe hardware with ACPI; the Raspberry Pis hand over a // flattened device tree — the aarch64 target flips the default when it // lands (docs/arm.md). Each firmware's service is its own package; both // export an artifact named "discovery", and this option picks which one // ships (only the chosen one is compiled). const Discovery = enum { acpi, fdt }; const discovery = b.option(Discovery, "discovery", "Which discovery service fills the ramdisk's 'discovery' slot (default: acpi)") orelse Discovery.acpi; const discovery_exe = switch (discovery) { .acpi => b.dependency("acpi", .{}).artifact("discovery"), .fdt => b.dependency("fdt", .{}).artifact("discovery"), }; const device_manager_exe = b.dependency("device-manager", .{}).artifact("device-manager"); // The input service and its exercisers: the fan-out server, a hardware-free synthetic // source, and a subscriber that doubles as the `input` test's oracle. See docs/input.md. const input_exe = b.dependency("input", .{}).artifact("input"); const input_source_exe = b.dependency("input-source", .{}).artifact("input-source"); const input_test_exe = b.dependency("input-test", .{}).artifact("input-test"); const args_echo_exe = b.dependency("args-echo", .{}).artifact("args-echo"); const process_test_exe = b.dependency("process-test", .{}).artifact("process-test"); const logger_exe = b.dependency("logger", .{}).artifact("logger"); // The first multi-threaded binary: exercises runtime.Thread over the thread ABI // (docs/threading.md). Its package opts into threading (real atomics/TLS). const thread_test_exe = b.dependency("thread-test", .{}).artifact("thread-test"); // Every user binary and its FHS home on the boot volume. There is no packed // ramdisk artifact any more: make-fat-image.py lays each binary out at this // path on the image, and the EFI loader walks /system and /test at boot and // builds the in-RAM initial_ramdisk table from the trees — the volume's file // structure is the single source of truth. Entry names (and hence argv[0] and // task names) are these paths with a leading slash. Test fixtures mirror their // repo home: test/system/services/ in the source tree IS the boot path. // init's boot service list is data (/etc/init.csv). -Ddiagnose selects the // variant that omits the display stack (so the kernel's boot transcript stays // on screen); both are bundled at the same /etc/init.csv path. const init_csv_source = if (diagnose) "etc/init-diagnose.csv" else "etc/init.csv"; const production_bundled = [_]BundledBinary{ .{ .path = "system/services/init", .binary = init_exe.getEmittedBin() }, .{ .path = "system/services/fat", .binary = fat_exe.getEmittedBin() }, .{ .path = "system/services/display", .binary = display_exe.getEmittedBin() }, .{ .path = "system/services/display-demo", .binary = display_demo_exe.getEmittedBin() }, .{ .path = "system/services/device-manager", .binary = device_manager_exe.getEmittedBin() }, .{ .path = "system/services/input", .binary = input_exe.getEmittedBin() }, .{ .path = "system/services/discovery", .binary = discovery_exe.getEmittedBin() }, .{ .path = "system/services/logger", .binary = logger_exe.getEmittedBin() }, // A data file, not a binary: the device registry the manager reads at boot. // Packing it under /etc makes the kernel auto-mount /etc as a read-only // initrd tree (system/kernel/vfs.zig setInitialRamdisk), so the manager can // fs.open("/etc/devices.csv") with no filesystem service running. .{ .path = "etc/devices.csv", .binary = b.path("etc/devices.csv") }, // init's service list, likewise read from the kernel-served initrd /etc. .{ .path = "etc/init.csv", .binary = b.path(init_csv_source) }, .{ .path = "system/drivers/ps2-bus", .binary = ps2_bus_exe.getEmittedBin() }, .{ .path = "system/drivers/ps2-keyboard", .binary = ps2_keyboard_exe.getEmittedBin() }, .{ .path = "system/drivers/ps2-mouse", .binary = ps2_mouse_exe.getEmittedBin() }, .{ .path = "system/drivers/usb-xhci-bus", .binary = usb_xhci_bus_exe.getEmittedBin() }, .{ .path = "system/drivers/usb-hid-keyboard", .binary = usb_hid_keyboard_exe.getEmittedBin() }, .{ .path = "system/drivers/usb-hid-mouse", .binary = usb_hid_mouse_exe.getEmittedBin() }, .{ .path = "system/drivers/usb-storage", .binary = usb_storage_exe.getEmittedBin() }, .{ .path = "system/drivers/virtio-gpu", .binary = virtio_gpu_exe.getEmittedBin() }, .{ .path = "system/drivers/pci-bus", .binary = pci_bus_exe.getEmittedBin() }, }; // The userspace test fixtures under /test. A plain `zig build` produces a clean // image WITHOUT them; they are bundled only for a test build — which the QEMU // harness signals by passing -Dtest-case= for every scenario, exactly when // these fixtures must be on the boot volume. Merely building this array never // forces a compile: the fixture exes build only if `bundled` (below) includes them. const test_bundled = [_]BundledBinary{ .{ .path = "test/system/services/vfs-test", .binary = vfstest_exe.getEmittedBin() }, .{ .path = "test/system/services/fat-test", .binary = fat_test_exe.getEmittedBin() }, .{ .path = "test/system/services/shared-memory-server", .binary = shared_memory_server_exe.getEmittedBin() }, .{ .path = "test/system/services/shared-memory-client", .binary = shared_memory_client_exe.getEmittedBin() }, .{ .path = "test/system/services/crash-test", .binary = crash_test_exe.getEmittedBin() }, .{ .path = "test/system/services/device-list", .binary = device_list_exe.getEmittedBin() }, .{ .path = "test/system/services/pci-cap-test", .binary = pci_cap_test_exe.getEmittedBin() }, .{ .path = "test/system/services/iommu-fault-test", .binary = iommu_fault_test_exe.getEmittedBin() }, .{ .path = "test/system/services/input-source", .binary = input_source_exe.getEmittedBin() }, .{ .path = "test/system/services/input-test", .binary = input_test_exe.getEmittedBin() }, .{ .path = "test/system/services/args-echo", .binary = args_echo_exe.getEmittedBin() }, .{ .path = "test/system/services/process-test", .binary = process_test_exe.getEmittedBin() }, .{ .path = "test/system/services/thread-test", .binary = thread_test_exe.getEmittedBin() }, }; // A no-option build assumes neither -Dtest-case nor -Ddiagnose: it ships the // production set only. Test fixtures join in only under -Dtest-case; the // diagnose display-omission is already handled by init_csv_source above. var bundled_list: std.ArrayListUnmanaged(BundledBinary) = .empty; bundled_list.appendSlice(b.allocator, &production_bundled) catch @panic("OOM"); if (test_case != null) bundled_list.appendSlice(b.allocator, &test_bundled) catch @panic("OOM"); const bundled = bundled_list.items; // The boot manifest: the FHS path of every bundled binary, one per line. The // EFI loader reads THIS by name and opens each listed path by name — FAT // name lookup is case-insensitive and firmware-portable, unlike directory // ENUMERATION, whose returned names vary by firmware (bare 8.3 entries come // back uppercase on some FAT drivers). The tree walk remains only as the // loader's fallback for hand-assembled sticks without a manifest. var manifest_text: std.ArrayListUnmanaged(u8) = .empty; for (bundled) |item| { manifest_text.append(b.allocator, '/') catch @panic("OOM"); manifest_text.appendSlice(b.allocator, item.path) catch @panic("OOM"); manifest_text.append(b.allocator, '\n') catch @panic("OOM"); } const manifest_files = b.addWriteFiles(); const manifest_file = manifest_files.add("manifest", manifest_text.items); const manifest_install = b.addInstallFileWithDir(manifest_file, .prefix, "system/manifest"); b.getInstallStep().dependOn(&manifest_install.step); // The boot capsule: the same bundled list packed into ONE file (v2 // initial_ramdisk format), because a single open + sequential read is the // only firmware file I/O shape that is fast everywhere — a per-file tree // walk measured MINUTES on real firmware. The loader tries this first, // then the manifest, then the walk; the running system cannot tell the // difference (it always receives the same in-RAM table). Derived from the // tree in the same build graph, so the two cannot drift. const mk_capsule = b.addSystemCommand(&.{"python3"}); mk_capsule.addFileArg(b.path("tools/pack-system-image.py")); const capsule_img = mk_capsule.addOutputFileArg("system.img"); for (bundled) |item| { mk_capsule.addArg(item.path); mk_capsule.addFileArg(item.binary); } const capsule_install = b.addInstallFile(capsule_img, "boot/system.img"); b.getInstallStep().dependOn(&capsule_install.step); // Install every bundled binary to its FHS home, so zig-out is a true image of // the filesystem — the same tree make-fat-image.py lays out on the boot volume. for (bundled) |item| { const install = b.addInstallFileWithDir(item.binary, .prefix, item.path); b.getInstallStep().dependOn(&install.step); } // Boot methods live in boot/, one per way of getting the kernel running. // Each is its own binary/entry (a loader is built for its own target); today // that's UEFI for x86-64, with room for e.g. a device-tree path for the Pis. // The loader reads -Dserial too, so its boot-progress breadcrumbs (con_out, // which firmware may mirror to a serial console) are silenced by default — a // real-hardware boot stays quiet. Fatal-error messages ignore this and always // show, so a failed boot still explains itself on screen. See boot/efi.zig. const loader_options = b.addOptions(); loader_options.addOption(bool, "serial", serial); const loader_options_module = loader_options.createModule(); const efiexe = b.addExecutable(.{ .name = "BOOTX64", .root_module = b.createModule(.{ .root_source_file = b.path("boot/efi.zig"), .target = b.resolveTargetQuery(.{ .cpu_arch = .x86_64, .os_tag = .uefi, }), .optimize = optimize, .imports = &.{ // The bootloader speaks the handoff contract and the ramdisk // container it packs the /system tree into — never the user ABI. .{ .name = "boot-handoff", .module = boot_handoff_module }, .{ .name = "initial-ramdisk", .module = initial_ramdisk_module }, .{ .name = "build_options", .module = loader_options_module }, }, }), }); // UEFI firmware requires the removable-media loader at exactly \EFI\BOOT\BOOTX64.efi, // so that path is fixed by the firmware (it is /boot's EFI stub, conceptually). const efi_install = b.addInstallArtifact(efiexe, .{ .dest_dir = .{ .override = .{ .custom = "EFI/BOOT" } } }); b.getInstallStep().dependOn(&efi_install.step); // --- danos-usb.img: the bootable FAT32 USB image --- // Format a real FAT32 image (the in-repo Python builder, no external tools) // holding the EFI stub, the kernel, and the whole /system tree of user // binaries at their FHS paths. QEMU presents this image as a USB mass-storage // device the guest boots from (see run-x86-64 and the test harness), and the // danos fat driver mounts the same image at /mnt/usb. const fat_image = addBootImage(b, exe.getEmittedBin(), efiexe.getEmittedBin(), manifest_file, capsule_img, bundled); const fat_image_install = b.addInstallFile(fat_image, "danos-usb.img"); b.getInstallStep().dependOn(&fat_image_install.step); // The image `run-x86-64` boots: identical to the flashable one but with the // serial log sink compiled in, so a developer always gets the machine-readable // log captured to serial0 — without baking serial into the image users flash. // Built lazily (only when `run-x86-64` is requested), and never installed. const exe_serial = addKernel(b, kernel_target, optimize, kernel_modules, test_case, true); const fat_image_serial = addBootImage(b, exe_serial.getEmittedBin(), efiexe.getEmittedBin(), manifest_file, capsule_img, bundled); // `zig build check-fat-image` — validate the produced image is a real FAT32 // with the EFI stub present (the builder's own --verify, no external tools). const check_fat = b.addSystemCommand(&.{"python3"}); check_fat.addFileArg(b.path("tools/make-fat-image.py")); check_fat.addArg("--verify"); check_fat.addFileArg(fat_image); const check_fat_step = b.step("check-fat-image", "Verify the FAT32 USB image is valid and bootable"); check_fat_step.dependOn(&check_fat.step); // --- release-x86-64: danos-x86-64.iso, the flashable release image --- // Wrap the FAT32 boot volume in a hybrid ISO (the in-repo Python builder // again, no xorriso/isohybrid): an ISO9660 whose El Torito EFI boot entry // and MBR ESP partition entry both point at the embedded FAT image. One // file then boots every way release media is consumed — flashed raw to a // USB stick with Etcher or dd, or burned to optical media — while // danos-usb.img stays the raw superfloppy QEMU and the test harness boot. const mk_iso = b.addSystemCommand(&.{"python3"}); mk_iso.addFileArg(b.path("tools/make-iso-image.py")); const iso_image = mk_iso.addOutputFileArg("danos-x86-64.iso"); mk_iso.addFileArg(fat_image); const iso_install = b.addInstallFile(iso_image, "danos-x86-64.iso"); const release_step = b.step("release-x86-64", "Build the flashable x86-64 release ISO (zig-out/danos-x86-64.iso; flash with Etcher or dd)"); release_step.dependOn(&iso_install.step); // `zig build check-iso-image` — the ISO builder's own --verify (mirroring // check-fat-image): the MBR partition, the El Torito catalog, and the // embedded FAT32 image must all agree. const check_iso = b.addSystemCommand(&.{"python3"}); check_iso.addFileArg(b.path("tools/make-iso-image.py")); check_iso.addArg("--verify"); check_iso.addFileArg(iso_image); const check_iso_step = b.step("check-iso-image", "Verify the release ISO is a valid hybrid (MBR ESP partition + El Torito EFI entry)"); check_iso_step.dependOn(&check_iso.step); // --- run-x86-64: boot the x86-64 kernel in QEMU via UEFI/OVMF --- // Firmware lives in different places per OS/distro, so probe the known // layouts (Architecture, Debian/Ubuntu, Fedora, macOS Homebrew) and use the first // that exists. Override with -Dovmf-code / -Dovmf-vars if yours is elsewhere. const ovmf_code = b.option( []const u8, "ovmf-code", "Path to the OVMF_CODE firmware image", ) orelse firstExisting(b.graph.io, &.{ "/usr/share/edk2/x64/OVMF_CODE.4m.fd", // Architecture "/usr/share/OVMF/OVMF_CODE_4M.fd", // Debian/Ubuntu "/usr/share/OVMF/OVMF_CODE.fd", // older Debian/Ubuntu "/usr/share/edk2-ovmf/x64/OVMF_CODE.fd", // Fedora "/opt/homebrew/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Apple Silicon) "/usr/local/share/qemu/edk2-x86_64-code.fd", // macOS Homebrew (Intel) }); const ovmf_vars = b.option( []const u8, "ovmf-vars", "Path to the OVMF_VARS firmware image (a writable copy is made)", ) orelse firstExisting(b.graph.io, &.{ "/usr/share/edk2/x64/OVMF_VARS.4m.fd", // Architecture "/usr/share/OVMF/OVMF_VARS_4M.fd", // Debian/Ubuntu "/usr/share/OVMF/OVMF_VARS.fd", // older Debian/Ubuntu "/usr/share/edk2-ovmf/x64/OVMF_VARS.fd", // Fedora "/opt/homebrew/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Apple Silicon) "/usr/local/share/qemu/edk2-i386-vars.fd", // macOS Homebrew (Intel) }); // The guest boots the self-contained FAT image (attached as USB storage below), // not the installed FHS zig-out — see the run step's drive/device flags. // The firmware needs to write NVRAM, so give it a writable copy of the vars. const vars_copy = b.addSystemCommand(&.{ "cp", "-f", ovmf_vars }); const vars_out = vars_copy.addOutputFileArg("OVMF_VARS.4m.fd"); const run_efi = b.addSystemCommand(&.{ "qemu-system-x86_64", "-device", "qemu-xhci,id=xhci", "-device", "usb-mouse,bus=xhci.0", "-device", "usb-kbd,bus=xhci.0", // "-usb", // "-device", // "usb-ehci,id=ehci", // "-device", // "usb-tablet,bus=usb-bus.0", // "-device", // "usb-mouse,bus=ehci.0", "-machine", "q35", "-m", "128M", "-drive", b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}), }); run_efi.addArg("-drive"); run_efi.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out); // Boot off the FAT32 USB image: a mass-storage device on the same xHCI bus as // the keyboard and mouse. OVMF finds \EFI\BOOT\BOOTX64.efi on it and boots. // The serial-enabled variant, so serial0 carries the log for this dev boot. run_efi.addArg("-drive"); run_efi.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial); run_efi.addArgs(&.{ "-device", "usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0", "-net", "none", // Emulated display advertising 1280x720 as its native (EDID preferred) // resolution, so the kernel's native-resolution switch has something to // find. `-vga none` avoids a second, default adapter. "-vga", "none", "-device", "VGA,edid=on,xres=1280,yres=720", }); // Capture the guest's serial0 (danos's machine-readable log) to the qemu-test // scratch area — a dev/host artifact, kept out of the FHS boot volume we mount. // (/var/log/system is reserved for the kernel's own logging system later.) One // timestamped file per run. const log_dir = b.fmt("{s}/qemu-test", .{b.install_path}); const make_log_dir = b.addSystemCommand(&.{ "mkdir", "-p", log_dir }); const serial_log = b.fmt("{s}/run-x86-64-serial0-{s}.log", .{ log_dir, timestamp(b) }); run_efi.addArgs(&.{ "-serial", b.fmt("file:{s}", .{serial_log}) }); // We boot the self-contained `fat_image_serial` (added as a file arg above, so // it's already a dependency) — not the installed FHS zig-out — so `run-x86-64` // builds only the serial kernel, never the flashable one. Just make the serial // scratch dir first. run_efi.step.dependOn(&make_log_dir.step); const run_efi_step = b.step("run-x86-64", "Boot the x86-64 kernel in QEMU (UEFI/OVMF); serial0 is logged to zig-out/qemu-test/run-x86-64-serial0-.log"); run_efi_step.dependOn(&run_efi.step); // --- run-x86-64-gpu: the same boot plus a virtio-gpu adapter --- // The VGA device still supplies the boot (GOP) framebuffer the compositor starts // on; the virtio-gpu function is discovered by the device-manager stack, its // driver announces a shared scanout, and the compositor upgrades off the GOP // floor to fenced, tear-free native presents (docs/display-v2.md). // This is the interactive twin of the `display-native` test case, and 512M // matches it (the whole driver stack + the compositor's surfaces at once). // QEMU shows one head per adapter: pick the virtio-gpu head in the View menu // to watch the native output. const run_gpu = b.addSystemCommand(&.{ "qemu-system-x86_64", "-device", "qemu-xhci,id=xhci", "-device", "usb-mouse,bus=xhci.0", "-device", "usb-kbd,bus=xhci.0", "-machine", "q35", "-m", "512M", "-drive", b.fmt("if=pflash,format=raw,readonly=on,file={s}", .{ovmf_code}), }); run_gpu.addArg("-drive"); run_gpu.addPrefixedFileArg("if=pflash,format=raw,file=", vars_out); run_gpu.addArg("-drive"); run_gpu.addPrefixedFileArg("if=none,id=bootusb,format=raw,file=", fat_image_serial); run_gpu.addArgs(&.{ "-device", "usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0", "-net", "none", "-vga", "none", "-device", "VGA,edid=on,xres=1280,yres=720", "-device", "virtio-gpu-pci", }); const gpu_serial_log = b.fmt("{s}/run-x86-64-gpu-serial0-{s}.log", .{ log_dir, timestamp(b) }); run_gpu.addArgs(&.{ "-serial", b.fmt("file:{s}", .{gpu_serial_log}) }); run_gpu.step.dependOn(&make_log_dir.step); const run_gpu_step = b.step("run-x86-64-gpu", "Boot in QEMU with a virtio-gpu adapter: the compositor upgrades to fenced (tear-free) native presents; watch the virtio-gpu head in QEMU's View menu"); run_gpu_step.dependOn(&run_gpu.step); // const run_cmd = b.addRunArtifact(exe); // const run_step = b.step("run", "Run the app"); // run_step.dependOn(&run_cmd.step); // run_cmd.step.dependOn(b.getInstallStep()); // // if (b.args) |args| { // run_cmd.addArgs(args); // } // Tests run on the host. The kernel and bootloader target freestanding/UEFI // and can't be executed natively, so only the shared contracts are unit-tested // here (compiled for the host rather than inheriting a freestanding target) — // which also compile-checks that the three-way split stays self-consistent. const test_step = b.step("test", "Run tests"); for ([_][]const u8{ "system/boot-handoff.zig", "system/abi.zig", "system/initial-ramdisk.zig", // v2 path-named entries: find/basename/magic }) |root| { const mod_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path(root), .target = target, .optimize = optimize, }), }); test_step.dependOn(&b.addRunArtifact(mod_tests).step); } // The library domains and the binary packages own their unit tests (each // package's standalone `zig build test` step); the root aggregate // delegates to those steps so one command still runs everything and a // test added inside a package can never be silently skipped here. Package // tests are host-only, so root's -Dtarget/-Doptimize deliberately do not // reach them. for ([_]*std.Build.Dependency{ kernel_library, device_library, client_library, protocol_library, csv_library, xkeyboard_config_library, fat_package, display_package, ps2_bus_package, usb_hid_package, usb_storage_package, virtio_gpu_package, }) |package| { test_step.dependOn(&package.builder.top_level_steps.get("test").?.step); } // The tagged kernel log ring: append/wrap/reclaim/sequence-gap behavior over // a RAM buffer. Needs the `abi` module (record header layout), so it doesn't // fit the plain loop above. const log_ring_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("system/kernel/log-ring.zig"), .target = target, .optimize = optimize, .imports = &.{ .{ .name = "abi", .module = abi_module }, }, }), }); test_step.dependOn(&b.addRunArtifact(log_ring_tests).step); // Convenience: `zig build gen-xkeyboard-config` regenerates the layout tables from the // vendored data (offline). `fetch` (the network step) stays a manual script run. const gen_xkb = b.addSystemCommand(&.{ "python3", "tools/make-xkeyboard-config.py", "generate" }); const gen_xkb_step = b.step("gen-xkeyboard-config", "Regenerate library/xkeyboard-config/generated from the vendored data"); gen_xkb_step.dependOn(&gen_xkb.step); }