const std = @import("std");
const builtin = @import("builtin");

/// 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(),
    });
}

/// Build one user-space binary the same way for every program (init, and later
/// the VFS server + drivers): freestanding, ReleaseSmall, `.large` code model
/// (the image base is above 4 GiB — smaller models emit 32-bit relocations that
/// can't reach), linked against the `runtime` runtime library with the shared user
/// link script. Pinned to LLVM + LLD so the script's PHDRS (segment permissions)
/// are authoritative — the kernel's W^X user-ELF loader requires exact perms.
///
/// The compilation root is not the program's own file but the shared shim
/// library/runtime/root.zig, which supplies the root declarations (`main`
/// re-export, panic handler, `_start` pull) so a program only defines
/// `pub fn main`. The program's file becomes the `program` module the shim
/// imports; reach it through `programModule` to add per-binary imports.
fn addUserBinary(
    b: *std.Build,
    target: std.Build.ResolvedTarget,
    runtime_module: *std.Build.Module,
    mmio_module: *std.Build.Module,
    xkeyboard_config_module: *std.Build.Module,
    acpi_ids_module: *std.Build.Module,
    name: []const u8,
    root: []const u8,
) *std.Build.Step.Compile {
    return addUserBinaryImpl(b, target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, name, root, false);
}

/// As `addUserBinary`, but built multi-threaded (`single_threaded = false`) so real
/// atomics/TLS work — required before a binary may call `runtime.Thread.spawn`
/// (docs/threading.md). Threads are a deliberate per-binary opt-in.
fn addThreadedUserBinary(
    b: *std.Build,
    target: std.Build.ResolvedTarget,
    runtime_module: *std.Build.Module,
    mmio_module: *std.Build.Module,
    xkeyboard_config_module: *std.Build.Module,
    acpi_ids_module: *std.Build.Module,
    name: []const u8,
    root: []const u8,
) *std.Build.Step.Compile {
    return addUserBinaryImpl(b, target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, name, root, true);
}

fn addUserBinaryImpl(
    b: *std.Build,
    target: std.Build.ResolvedTarget,
    runtime_module: *std.Build.Module,
    mmio_module: *std.Build.Module,
    xkeyboard_config_module: *std.Build.Module,
    acpi_ids_module: *std.Build.Module,
    name: []const u8,
    root: []const u8,
    threaded: bool,
) *std.Build.Step.Compile {
    // Settings (target, optimize, code model, ...) live on the root module only;
    // the program and runtime modules leave theirs null and inherit them.
    const program_module = b.createModule(.{
        .root_source_file = b.path(root),
        .imports = &.{
            .{ .name = "runtime", .module = runtime_module },
            // Typed volatile MMIO + memory barriers, for drivers. See library/mmio/.
            .{ .name = "mmio", .module = mmio_module },
            // Keyboard layouts (keycode + modifiers -> keysym/character), available
            // to any program that wants it. See library/xkeyboard-config/.
            .{ .name = "xkeyboard-config", .module = xkeyboard_config_module },
            // ACPI/PnP hardware-ID registry, so drivers name devices
            // (HardwareId.ps2_keyboard) instead of magic "_HID" strings.
            .{ .name = "acpi-ids", .module = acpi_ids_module },
        },
    });
    const exe = b.addExecutable(.{
        .name = name,
        .root_module = b.createModule(.{
            .root_source_file = b.path("library/runtime/root.zig"),
            .target = target,
            .optimize = .ReleaseSmall,
            .code_model = .large,
            .single_threaded = !threaded, // a threaded binary needs real atomics/TLS
            .sanitize_c = .off,
            .stack_check = false,
            .stack_protector = false,
            .imports = &.{
                .{ .name = "runtime", .module = runtime_module },
                .{ .name = "program", .module = program_module },
            },
        }),
    });
    exe.setLinkerScript(b.path("library/runtime/user.ld"));
    exe.entry = .{ .symbol_name = "_start" };
    exe.image_base = 0x7000_0000_0000;
    exe.use_llvm = true;
    exe.use_lld = true;
    return exe;
}

/// The `program` module of a binary built by `addUserBinary` — the module rooted
/// at the program's own source file. Per-binary imports (protocol modules, bus
/// ABIs) go here, not on the root shim: module imports are not transitive, so an
/// import added to the root would be invisible to the program's code.
fn programModule(exe: *std.Build.Step.Compile) *std.Build.Module {
    return exe.root_module.import_table.get("program").?;
}

/// 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,
            .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 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, ...)
    const boot_handoff_module = b.addModule("boot-handoff", .{
        .root_source_file = b.path("system/boot-handoff.zig"),
    });
    const abi_module = b.addModule("abi", .{
        .root_source_file = b.path("system/abi.zig"),
    });
    // The devices sub-project's public interface (the flat wire types), exposed as
    // its own module like vfs-protocol — importable by user space, unlike the
    // kernel-internal device model it also feeds (system/devices/device-model.zig).
    const device_abi_module = b.addModule("device-abi", .{
        .root_source_file = b.path("system/devices/device-abi.zig"),
    });
    // PCI class-code decoding (class/subclass/prog-IF -> names). Pure reference data,
    // shared by kernel discovery (the device-tree dump) and any user-space PCI tool.
    const pci_class_module = b.addModule("pci-class", .{
        .root_source_file = b.path("system/devices/pci-class.zig"),
    });
    // ACPI/PnP hardware-ID (_HID) names — the flat analog of pci-class for acpi_device
    // nodes. Also shared reference data.
    // The AML interpreter, a build module so the ring-3 acpi service can run the
    // same parser the kernel does (docs/discovery.md — the shared AML module).
    // Pure Zig, no kernel imports — one source, two builds.
    const aml_module = b.addModule("aml", .{
        .root_source_file = b.path("system/devices/aml/aml.zig"),
    });

    const acpi_ids_module = b.addModule("acpi-ids", .{
        .root_source_file = b.path("system/devices/acpi-ids.zig"),
    });

    // The USB device-framework wire ABI (chapter-9 set-up packets, standard +
    // class requests, descriptors) and the USB class-code taxonomy — the flat
    // reference the xHCI bus driver, the USB class drivers, and the device
    // manager's identity matcher all share. Pure data, like pci-class/acpi-ids.
    const usb_abi_module = b.addModule("usb-abi", .{
        .root_source_file = b.path("system/devices/usb-abi.zig"),
    });
    const usb_ids_module = b.addModule("usb-ids", .{
        .root_source_file = b.path("system/devices/usb-ids.zig"),
    });
    // The USB transfer protocol: what a USB class driver says to the xHCI bus
    // driver to drive its device (open / control / interrupt / bulk). A protocol
    // module like vfs-protocol, shared by the bus driver and every class driver.
    const usb_transfer_protocol_module = b.addModule("usb-transfer-protocol", .{
        .root_source_file = b.path("system/drivers/usb-xhci-bus/usb-transfer-protocol.zig"),
    });
    // The block-device protocol: read/write of fixed-size blocks, spoken between a
    // filesystem and a block driver (usb-storage). A protocol module like the rest.
    const block_protocol_module = b.addModule("block-protocol", .{
        .root_source_file = b.path("system/services/block/protocol.zig"),
    });

    // 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/devices/platform.zig).
    const platform_module = b.addModule("platform", .{
        .root_source_file = b.path("system/devices/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 = "pci-class", .module = pci_class_module }, // decode PCI class codes in the device dump
            .{ .name = "acpi-ids", .module = acpi_ids_module }, // decode ACPI _HID names in the device dump
            .{ .name = "parameters", .module = parameters_module }, // maximum_cpus (the discovery pool)
        },
    });

    // The VFS wire protocol: the vfs sub-project's public interface, exposed as its
    // own module. Both the vfs server and the runtime's file layer (unistd/stdio)
    // depend on this contract by name — neither reaches into the other's files. This
    // is the first "protocol module" (see docs/driver-model.md); usb/block will
    // expose theirs the same way.
    const vfs_protocol_module = b.addModule("vfs-protocol", .{
        .root_source_file = b.path("system/vfs-protocol.zig"),
    });

    // The input wire protocol: the input service's public interface, exposed as its own
    // module the same way vfs-protocol is. Shared by the input service, the runtime's
    // `input` helper (subscribe/publish), and every source and subscriber.
    const input_protocol_module = b.addModule("input-protocol", .{
        .root_source_file = b.path("system/services/input/protocol.zig"),
    });

    // The danos-native user-space runtime: system_call wrappers, the C-convention
    // heap, IPC helpers, the process start shim, device access. This is the stable
    // application ABI; POSIX compatibility is a separate library on top (see below).
    // Compiled into every user binary (see addUserBinary), so it inherits each exe's
    // `.large` code model — do NOT set a target/code_model here. It imports `abi`
    // for the shared SystemCall numbers / mmap flags, `device-abi` for the device
    // types its `device` helper wraps, and re-exports `vfs-protocol` for the VFS
    // server. It never touches `boot-handoff` — user space has no business with the
    // loader↔kernel handoff.
    const runtime_module = b.addModule("runtime", .{
        .root_source_file = b.path("library/runtime/runtime.zig"),
        .imports = &.{
            .{ .name = "abi", .module = abi_module },
            .{ .name = "device-abi", .module = device_abi_module },
            .{ .name = "vfs-protocol", .module = vfs_protocol_module },
            .{ .name = "input-protocol", .module = input_protocol_module },
        },
    });

    // The device-manager protocol: hello + (M18.2) tree reports, exposed as its
    // own module like the other protocol modules. Imported through the runtime.
    const device_manager_protocol_module = b.addModule("device-manager-protocol", .{
        .root_source_file = b.path("system/services/device-manager/device-manager-protocol.zig"),
    });
    runtime_module.addImport("device-manager-protocol", device_manager_protocol_module);
    // The USB transfer protocol, so runtime.usb (the class-driver client) can speak
    // it, the way runtime.input speaks the input protocol.
    runtime_module.addImport("usb-transfer-protocol", usb_transfer_protocol_module);
    // The block protocol, so runtime.block (the block-device client) can speak it.
    runtime_module.addImport("block-protocol", block_protocol_module);

    // The display protocol, so runtime.display (the compositor client) and the display
    // service both speak it through the runtime, like the other protocol modules.
    const display_protocol_module = b.addModule("display-protocol", .{
        .root_source_file = b.path("system/services/display/protocol.zig"),
    });
    runtime_module.addImport("display-protocol", display_protocol_module);

    // The scanout protocol: the compositor's outbound present channel to a native scanout
    // driver (virtio-gpu), separate from the client-facing display protocol (docs/display-v2.md).
    const scanout_protocol_module = b.addModule("scanout-protocol", .{
        .root_source_file = b.path("system/services/display/scanout-protocol.zig"),
    });
    runtime_module.addImport("scanout-protocol", scanout_protocol_module);

    // The power protocol: system power's domain-named surface (docs/power.md).
    const power_protocol_module = b.addModule("power-protocol", .{
        .root_source_file = b.path("system/services/power/protocol.zig"),
    });
    runtime_module.addImport("power-protocol", power_protocol_module);

    // Typed volatile MMIO register access + memory-ordering barriers, for drivers on
    // top of an mmio_map grant. Depends only on `builtin` (arch-conditional barriers);
    // no target set, so it inherits each driver's. See library/mmio/mmio.zig.
    const mmio_module = b.addModule("mmio", .{
        .root_source_file = b.path("library/mmio/mmio.zig"),
    });

    // Keyboard layouts compiled from the X11 xkeyboard-config database into native Zig
    // (keycode + modifiers -> keysym/character). The `layouts` tables are generated by
    // tools/make-xkeyboard-config.py; `xkeyboard-config` is the hand-written API over them.
    // No target set, so each inherits its importer's. See library/xkeyboard-config/.
    const xkb_layouts_module = b.addModule("layouts", .{
        .root_source_file = b.path("library/xkeyboard-config/generated/layouts.zig"),
    });
    const xkeyboard_config_module = b.addModule("xkeyboard-config", .{
        .root_source_file = b.path("library/xkeyboard-config/xkeyboard-config.zig"),
        .imports = &.{
            .{ .name = "layouts", .module = xkb_layouts_module },
        },
    });

    // 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=<name> 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 ---
    // SSE2 is part of the x86_64 baseline and UEFI leaves it enabled at handoff,
    // so we keep it: disabling it forces soft-float and makes the compiler unable
    // to encode the vector ops that std's formatting/runtime still emit.
    const kernel_target = b.resolveTargetQuery(.{
        .cpu_arch = .x86_64,
        .os_tag = .freestanding,
        .abi = .none,
    });

    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);

    // --- init: the first user-space program (a system service) ---
    // Built by the shared user-binary recipe (see addUserBinary): freestanding,
    // linked into the kernel's user region against the `runtime` runtime library, and
    // started in ring 3 by the kernel's user-ELF loader.
    const init_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "init", "system/services/init/init.zig");
    // init reads the same `serial` flag the kernel does: its liveness heartbeat is a
    // serial/test-build diagnostic (the QEMU harness's init tests assert on it, and
    // -Dserial images emit it), so a flashable image runs a purely event-driven PID 1
    // that wakes only for real work. The test harness builds with -Dserial=true, so
    // the heartbeat stays present under test.
    const init_options = b.addOptions();
    init_options.addOption(bool, "serial", serial);
    init_options.addOption(bool, "diagnose", diagnose);
    programModule(init_exe).addImport("build_options", init_options.createModule());

    // --- the rest of the /system tree: services, 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 = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "vfs-test", "system/services/vfs-test/vfs-test.zig");
    const ps2_bus_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "ps2-bus", "system/drivers/ps2-bus/ps2-bus.zig");
    const ps2_keyboard_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "ps2-keyboard", "system/drivers/ps2-bus/keyboard.zig");
    const ps2_mouse_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "ps2-mouse", "system/drivers/ps2-bus/mouse.zig");
    const usb_xhci_bus_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "usb-xhci-bus", "system/drivers/usb-xhci-bus/usb-xhci-bus.zig");
    // The xHCI bus driver builds chapter-9 requests and decodes descriptors from
    // usb-abi, and reports each interface's (class,subclass,protocol) identity via
    // usb-ids.packTriple.
    programModule(usb_xhci_bus_exe).addImport("usb-abi", usb_abi_module);
    programModule(usb_xhci_bus_exe).addImport("usb-ids", usb_ids_module);
    programModule(usb_xhci_bus_exe).addImport("usb-transfer-protocol", usb_transfer_protocol_module);
    // The USB HID class drivers: keyboard and mouse. They own no hardware — each
    // opens its device through runtime.usb (the transfer protocol) and publishes to
    // the input service. They build chapter-9 class requests from usb-abi.
    const usb_hid_keyboard_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "usb-hid-keyboard", "system/drivers/usb-hid/keyboard.zig");
    programModule(usb_hid_keyboard_exe).addImport("usb-abi", usb_abi_module);
    const usb_hid_mouse_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "usb-hid-mouse", "system/drivers/usb-hid/mouse.zig");
    programModule(usb_hid_mouse_exe).addImport("usb-abi", usb_abi_module);
    // The USB mass-storage class driver: opens its device via runtime.usb, drives it
    // with Bulk-Only Transport + SCSI, and serves the block protocol under `.block`.
    const usb_storage_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "usb-storage", "system/drivers/usb-storage/usb-storage.zig");
    programModule(usb_storage_exe).addImport("block-protocol", block_protocol_module);
    // The FAT filesystem server: mounts the block device and serves it into the VFS
    // at /mnt/usb. Its engine (engine.zig / on-disk.zig) is imported relatively.
    const fat_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "fat", "system/services/fat/fat.zig");
    // Threaded: the display runs a mouse-listener thread alongside its compositor loop
    // (docs/threading.md, docs/display.md), so it opts into real atomics/TLS.
    const display_exe = addThreadedUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "display", "system/services/display/display.zig");
    const display_demo_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "display-demo", "system/services/display-demo/display-demo.zig");
    const virtio_gpu_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "virtio-gpu", "system/drivers/virtio-gpu/virtio-gpu.zig");
    const shared_memory_server_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "shared-memory-server", "system/services/shared-memory-server/shared-memory-server.zig");
    const shared_memory_client_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "shared-memory-client", "system/services/shared-memory-client/shared-memory-client.zig");
    const fat_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "fat-test", "system/services/fat/fat-test.zig");
    const pci_bus_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "pci-bus", "system/drivers/pci-bus/pci-bus.zig");
    // The PCI bus driver decodes each function's class triple to human names in its
    // boot log (class/subclass/prog-IF), so pull in the shared pci-class reference.
    programModule(pci_bus_exe).addImport("pci-class", pci_class_module);
    // A test fixture, not a real driver: hellos to the device manager, then faults —
    // what the driver-restart scenario drives the crash-loop cap with.
    const crash_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "crash-test", "system/services/crash-test/crash-test.zig");
    const device_list_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "device-list", "system/services/device-list/device-list.zig");
    // 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). Both are placeholders until M20.1 (acpi) and the
    // ARM bring-up (fdt).
    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_source: []const u8 = switch (discovery) {
        .acpi => "system/services/acpi/acpi.zig",
        .fdt => "system/services/fdt/fdt.zig",
    };
    const discovery_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "discovery", discovery_source);
    if (discovery == .acpi) programModule(discovery_exe).addImport("aml", aml_module);
    const device_manager_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "device-manager", "system/services/device-manager/device-manager.zig");
    // Names the xHCI PCI class triple from the shared taxonomy instead of a bare 0x0C0330.
    programModule(device_manager_exe).addImport("pci-class", pci_class_module);
    // The manager matches reported USB interfaces by their (class,subclass,protocol)
    // triple (usbDriverForIdentity), built from the named usb-ids codes.
    programModule(device_manager_exe).addImport("usb-ids", usb_ids_module);
    // 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 = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "input", "system/services/input/input.zig");
    const input_source_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "input-source", "system/services/input-source/input-source.zig");
    const input_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "input-test", "system/services/input-test/input-test.zig");
    const args_echo_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "args-echo", "system/services/args-echo/args-echo.zig");
    const process_test_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "process-test", "system/services/process-test/process-test.zig");
    const logger_exe = addUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "logger", "system/services/logger/logger.zig");
    // The first multi-threaded binary: exercises runtime.Thread over the thread ABI
    // (docs/threading.md). Built threaded so its shared-memory poll is real.
    const thread_test_exe = addThreadedUserBinary(b, kernel_target, runtime_module, mmio_module, xkeyboard_config_module, acpi_ids_module, "thread-test", "system/services/thread-test/thread-test.zig");

    // 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 at boot and builds the
    // in-RAM initial_ramdisk table from the tree — 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.
    const 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() },
        .{ .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() },
        .{ .path = "system/tests/vfs-test", .binary = vfstest_exe.getEmittedBin() },
        .{ .path = "system/tests/fat-test", .binary = fat_test_exe.getEmittedBin() },
        .{ .path = "system/tests/shared-memory-server", .binary = shared_memory_server_exe.getEmittedBin() },
        .{ .path = "system/tests/shared-memory-client", .binary = shared_memory_client_exe.getEmittedBin() },
        .{ .path = "system/tests/crash-test", .binary = crash_test_exe.getEmittedBin() },
        .{ .path = "system/tests/device-list", .binary = device_list_exe.getEmittedBin() },
        .{ .path = "system/tests/input-source", .binary = input_source_exe.getEmittedBin() },
        .{ .path = "system/tests/input-test", .binary = input_test_exe.getEmittedBin() },
        .{ .path = "system/tests/args-echo", .binary = args_echo_exe.getEmittedBin() },
        .{ .path = "system/tests/process-test", .binary = process_test_exe.getEmittedBin() },
        .{ .path = "system/tests/thread-test", .binary = thread_test_exe.getEmittedBin() },
    };

    // 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 FHS zig-out (installed above) *is* the boot volume — no separate ESP to
    // assemble. QEMU presents it to the guest as a FAT drive below.

    // 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-<timestamp>.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
        "system/devices/device-abi.zig",
        "system/devices/pci-class.zig", // class/subclass/prog-IF name decoding
        "system/devices/acpi-ids.zig", // _HID name decoding
        "system/devices/aml/aml.zig", // AML parse + interpret, incl. Notify dispatch (M21)
        "system/devices/usb-abi.zig", // wire sizes + bit packings + set-up packet encodings
        "system/devices/usb-ids.zig", // class/subclass/protocol code assignments
        "library/mmio/mmio.zig", // barriers assemble + registers round-trip
        "system/drivers/ps2-bus/scancode.zig", // set-2 decode + keyboard state machine
        "system/drivers/ps2-bus/mouse-packet.zig", // 3-byte mouse packet assembly
        "system/drivers/usb-hid/hid-report.zig", // HID boot-report keyboard/mouse decode
        "system/drivers/usb-storage/bulk-only-transport.zig", // CBW/CSW wrapper sizes
        "system/drivers/usb-storage/scsi.zig", // SCSI CDB encodings (big-endian)
        "system/vfs-protocol.zig", // NodeKind / DirectoryEntry sizes + op values
        "system/services/fat/on-disk.zig", // FAT on-disk struct sizes + type detection
        "system/services/fat/engine.zig", // FAT read/write over a RAM-backed image
        "system/services/display/compositor.zig", // Rect math + fill/composite/blit-tile
        "system/services/display/protocol.zig", // pack(): native pixel encoding per format
        "system/drivers/virtio-gpu/virtio-gpu-protocol.zig", // virtio-gpu command struct sizes
        "system/drivers/virtio-gpu/virtio-pci.zig", // virtio 1.0 PCI transport struct sizes
    }) |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 xkeyboard-config keymap tests need its generated `layouts` import wired, so they
    // don't fit the plain loop above. Its keycode->character assertions are the end-to-end
    // proof that the xkb-data -> generator -> Zig-lookup pipeline is correct.
    const xkb_tests = b.addTest(.{
        .root_module = b.createModule(.{
            .root_source_file = b.path("library/xkeyboard-config/xkeyboard-config.zig"),
            .target = target,
            .optimize = optimize,
            .imports = &.{
                .{ .name = "layouts", .module = xkb_layouts_module },
            },
        }),
    });
    test_step.dependOn(&b.addRunArtifact(xkb_tests).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);

    // runtime.time's Instant/Duration arithmetic. time.zig pulls in system.zig (the
    // syscall wrappers), which needs the `abi` module, so it doesn't fit the plain
    // loop above.
    const time_tests = b.addTest(.{
        .root_module = b.createModule(.{
            .root_source_file = b.path("library/runtime/time.zig"),
            .target = target,
            .optimize = optimize,
            .imports = &.{
                .{ .name = "abi", .module = abi_module },
            },
        }),
    });
    test_step.dependOn(&b.addRunArtifact(time_tests).step);

    // runtime.Thread's lock/condvar state machines (Mutex/Condition/RwLock/WaitGroup). Its
    // Futex seam falls back to std.Thread.Futex off the danos target, so the tests exercise
    // them with real host threads (docs/threading-plan.md M11). Like time.zig it pulls in
    // system.zig (syscall wrappers), which needs the `abi` module.
    const thread_tests = b.addTest(.{
        .root_module = b.createModule(.{
            .root_source_file = b.path("library/runtime/thread.zig"),
            .target = target,
            .optimize = optimize,
            .imports = &.{
                .{ .name = "abi", .module = abi_module },
            },
        }),
    });
    test_step.dependOn(&b.addRunArtifact(thread_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);
}
