git: a7ebe2d436e5 - main - boot-test.sh: Test many boot loader combinations.

From: Warner Losh <imp_at_FreeBSD.org>
Date: Sat, 11 Jul 2026 19:53:43 UTC
The branch main has been updated by imp:

URL: https://cgit.FreeBSD.org/src/commit/?id=a7ebe2d436e5d2fabf223796f992683c17acf50f

commit a7ebe2d436e5d2fabf223796f992683c17acf50f
Author:     Warner Losh <imp@FreeBSD.org>
AuthorDate: 2026-07-11 19:36:51 +0000
Commit:     Warner Losh <imp@FreeBSD.org>
CommitDate: 2026-07-11 19:45:37 +0000

    boot-test.sh: Test many boot loader combinations.
    
    This is a script that eventually will test boot with qemu all the
    supproted combinations for the boot loader. There's several things that
    could be done with gptboot or boot0sio (or not) that aren't tested. We
    don't test the 10-odd hardware root devices we support, nor do we test
    complex scenarios like RELAXED vs STRICT zfs efi booting.
    
    However, the scenarios we do support are included here. We test aarch64,
    amd64, armv7, powerpc64, powerpc64le, and riscv64 for BIOS, UEFI, and
    Prep and OpenFirmware (as appropriate) crossed with CDROM, MBR and GPT
    (and some hybrid) crossed with lua, 4th and simple loaders. Plus some
    linuxboot and memdisk scenarios, including the recently added
    compression for ram disk scenarios:
    
    === Results: 67 passed, 3 failed, 9 timed out (of 79) ===
    
    The timeouts are well understood, usually failure to find the root
    disk. The failures are bad console assumptions. netboot-bios fails
    because TFTP with a single packet buffer in qemu gives horrible
    throughput, so the test takes 18-20 minutes. Now that I have a
    dashboard, I can fix the rest one by one.
    
    There's also a powerpc architecture that you can request specifically,
    but it's just for convenience and tests with the non-functional mac99
    qemu machine. I will eventually eliminate this architecture. I added it
    to make sure the FreeBSD version wasn't too hard coded since this
    framework pulls from CD images to get the binaries for the minimal root
    used in testing and there's no 15.x 32-bit powerpc images.
    
    We need to add http and nfs root booting tests, but that's for the
    future. Plus there's some other functional tests that we should also add
    for different types of root (usb, sata, sas, nvme, ufs, emmc, sd, etc)
    that would be useful to test, especailly the non-sata/non-nvme ones.
    How we do that is still TBD.
    
    I leaned on claude to iterate over the recipes that I've developed over
    the years, collected off the internet or got on IRC recently to produce
    this framework. Most of this code is fairly good, while a few parts,
    especailly some of the comments, are detectable as AI produced. My plans
    are to iteratively improve those.  Since this is just a test, and since
    I've broken many scenarios w/o realizing, it's a good tradeoff. I've not
    made it an ATF test since we test all the architectures, but I'm open to
    feedback in this area.
    
    Total time to test all the architectures is about 10 minutes. It assumes
    you've built GENERIC* and the boot loader for all the architectures too.
    In the future, I plan on moving to MINIMAL for all the boot testing, but
    likely only after PCI devmatch is integrated into it. That would be
    incrementally faster test times.
    
    The man page is decent, but was also generated by Claude with only
    trivial edits by me to date.... But at least there's a man page for it,
    though neither it nor the script is installed onto the system.
    
    Sponsored by: Netflix
    Assisted-by: Claude Code (Opus 4.6, Opus 4.8(1M) and Sonet 5.0)
    Differential Revision:  https://reviews.freebsd.org/D58008
---
 tools/boot/boot-test.json |   97 +++
 tools/boot/boot-test.sh   | 1677 +++++++++++++++++++++++++++++++++++++++++++++
 tools/boot/boot-test.sh.8 |  303 ++++++++
 3 files changed, 2077 insertions(+)

diff --git a/tools/boot/boot-test.json b/tools/boot/boot-test.json
new file mode 100644
index 000000000000..91beb64e44e6
--- /dev/null
+++ b/tools/boot/boot-test.json
@@ -0,0 +1,97 @@
+{
+  "defaults": {
+    "byte_order": "little",
+    "kernconf": "GENERIC",
+    "timeout": 90,
+    "caps": [],
+    "freebsd_version": "15.1",
+    "netboot_nic": "e1000",
+    "netboot_subnet_base": "198.18.0.0",
+    "efi_loaders": ["loader_lua", "loader_4th", "loader_simp", "boot1"]
+  },
+  "arch": {
+    "amd64": {
+      "qemu_bin": "qemu-system-x86_64",
+      "efi_firmware": "/usr/local/share/qemu/edk2-x86_64-code.fd",
+      "efi_bootname": "bootx64",
+      "caps": ["bios", "efi", "linuxboot", "zfs", "mbr", "cd", "netboot"],
+      "bios_loaders": ["lua", "4th", "simp"],
+      "linux_console": "console=ttyS0,115200",
+      "target": "amd64",
+      "target_arch": "amd64",
+      "netboot_ipxe": "/usr/local/share/ipxe/ipxe.efi-x86_64.usb",
+      "netboot_efi_vars": "/usr/local/share/qemu/edk2-i386-vars.fd",
+      "netboot_memdisk": "/usr/local/share/syslinux/bios/memdisk/memdisk",
+      "hints": "sys/amd64/conf/GENERIC.hints"
+    },
+    "aarch64": {
+      "qemu_bin": "qemu-system-aarch64",
+      "qemu_machine": "-machine virt,gic-version=3",
+      "qemu_extra": "-cpu cortex-a57 -smp 4",
+      "efi_firmware": "/usr/local/share/qemu/edk2-aarch64-code.fd",
+      "efi_bootname": "bootaa64",
+      "caps": ["efi", "linuxboot", "zfs", "netboot"],
+      "netboot_nic": "virtio-net-device",
+      "target": "arm64",
+      "target_arch": "aarch64"
+    },
+    "armv7": {
+      "qemu_bin": "qemu-system-arm",
+      "qemu_machine": "-machine virt",
+      "qemu_extra": "-cpu cortex-a15",
+      "efi_firmware": "/usr/local/share/qemu/edk2-arm-code.fd",
+      "efi_bootname": "bootarm",
+      "caps": ["efi", "netboot"],
+      "netboot_nic": "virtio-net-device",
+      "target": "arm",
+      "target_arch": "armv7"
+    },
+    "riscv64": {
+      "qemu_bin": "qemu-system-riscv64",
+      "qemu_machine": "-machine virt",
+      "qemu_extra": "-bios /usr/local/share/opensbi/lp64/generic/firmware/fw_jump.elf -kernel /usr/local/share/u-boot/u-boot-qemu-riscv64/u-boot.bin",
+      "efi_bootname": "bootriscv64",
+      "caps": ["efi", "zfs", "netboot"],
+      "netboot_nic": "virtio-net-device",
+      "target": "riscv",
+      "target_arch": "riscv64"
+    },
+    "powerpc": {
+      "qemu_bin": "qemu-system-ppc",
+      "qemu_machine": "-machine mac99,via=pmu",
+      "qemu_extra": "-vga none -smp 1 -no-reboot",
+      "byte_order": "big",
+      "caps": ["ofw", "cd"],
+      "efi_loaders": [],
+      "target": "powerpc",
+      "target_arch": "powerpc",
+      "freebsd_version": "14.4",
+      "timeout": 180,
+      "disabled": true
+    },
+    "powerpc64": {
+      "qemu_bin": "qemu-system-ppc64",
+      "qemu_machine": "-machine pseries,cap-hpt-max-page-size=16M",
+      "qemu_extra": "-vga none -smp 1 -no-reboot",
+      "byte_order": "big",
+      "caps": ["prep", "cd"],
+      "efi_loaders": [],
+      "target": "powerpc",
+      "target_arch": "powerpc64",
+      "kernconf": "GENERIC64",
+      "timeout": 180
+    },
+    "powerpc64le": {
+      "qemu_bin": "qemu-system-ppc64",
+      "qemu_machine": "-machine pseries,cap-hpt-max-page-size=16M",
+      "qemu_extra": "-vga none -smp 1 -no-reboot",
+      "caps": ["prep", "cd", "linuxboot"],
+      "efi_loaders": [],
+      "linux_console": "console=hvc0",
+      "target": "powerpc",
+      "target_arch": "powerpc64le",
+      "kernconf": "GENERIC64LE",
+      "timeout": 180
+    }
+  }
+}
diff --git a/tools/boot/boot-test.sh b/tools/boot/boot-test.sh
new file mode 100755
index 000000000000..5ead7260d781
--- /dev/null
+++ b/tools/boot/boot-test.sh
@@ -0,0 +1,1677 @@
+#!/bin/sh
+#
+# boot-test.sh - Automated boot loader regression tests
+#
+# Builds a minimal bootable tree, assembles disk images for all supported boot
+# configurations, then runs each in QEMU with a timeout looking for "SUCCESS".
+#
+# All tests run as an unprivileged user. No root required, with one exception:
+# netboot-bios/netboot-efi need a real tap(4) device + dnsmasq (so DHCP can
+# carry a root-path) instead of QEMU's slirp networking, and both creating a tap
+# and giving it an address require root. That setup runs once (via sudo,
+# prompting interactively) and is left running -- later runs detect the existing
+# setup and skip sudo entirely.  Assumes buildworld and buildkernel have already
+# been done for the target architecture.
+#
+# Usage:
+#   cd /usr/src/stand && sh ../tools/boot/boot-test.sh [options]
+#
+# Options:
+#   -a ARCH	Architecture to test; repeat -a to test several
+#		(default: host arch)
+#		Supported: amd64, aarch64, armv7, riscv64, powerpc, powerpc64,
+#		powerpc64le
+#   -A		Test every supported architecture
+#   -b		Skip build/install phase (reuse existing tree)
+#   -B		Skip build/install and image creation (reuse existing images)
+#   -l          Tell us the log directory and exit
+#   -j JOBS	Max parallel QEMU instances (default: unlimited)
+#   -o DIR	Output directory for images and logs
+#   -t REGEX	Only run tests matching REGEX
+#   -T SECONDS	QEMU timeout (default: per-arch, 60; 180 for powerpc64)
+#   --netboot-teardown
+#		Destroy the tap(4)/dnsmasq netboot setup (run with sudo)
+
+set -e
+
+die() {
+    echo "FATAL: $*" >&2
+    exit 1
+}
+
+# FreeBSD port package (origin) that provides a command or file; "" = base
+# system.
+pkg_for() {
+    case "$1" in
+    jq)			echo textproc/jq ;;
+    expect)		echo lang/expect ;;
+    qemu-system-*)	echo emulators/qemu ;;
+    *ipxe*)		echo sysutils/ipxe ;;
+    *syslinux*|*memdisk*) echo sysutils/syslinux ;;
+    *edk2*)		echo emulators/qemu ;;	# edk2-*.fd ship with qemu
+    *)			echo "" ;;		# makefs/mkimg etc. = base
+    esac
+}
+
+# Hard requirement: a command that must be in PATH, else abort naming the pkg.
+need_cmd() {
+    which "$1" >/dev/null 2>&1 && return 0
+    p=$(pkg_for "$1")
+    [ -n "${p}" ] && die "$1 not found; install with: pkg install ${p##*/}"
+    die "$1 not found (expected in the base system)"
+}
+
+# Optional requirement: a file for a feature; warn + skip (return 1) if absent.
+have_file() {
+    [ -e "$1" ] && return 0
+    p=$(pkg_for "$1")
+    echo "  WARNING: $1 missing${p:+; pkg install ${p##*/}} -- skipping" >&2
+    return 1
+}
+
+# --------------------------------------------------------------------------
+# Architecture configuration
+#
+# Per-arch parameters live in boot-test.json, as an arch object merged over a
+# "defaults" object. Arrays are expanded to a space separated list. Missing
+# values default to an empty string. ARCH_CAPS is a cached copy of the
+# capabilities array for the architecture. TARGET and TARGET_ARCH are cached due
+# to heavy use. Other parameters are fetched as needed as their use is
+# infrequent.
+# --------------------------------------------------------------------------
+
+# Test whether the current arch declares a capability, e.g. "has efi".
+has() {
+    case " ${ARCH_CAPS} " in
+	*" $1 "*) return 0 ;;
+    esac
+    return 1
+}
+
+# Fetch the named parameter for this $ARCH.
+param() {
+    jq -r --arg a "${ARCH}" --arg k "$1" '
+	(.defaults + .arch[$a])[$k] as $v
+	| if   ($v | type) == "array" then $v | join(" ")
+	  elif $v == null             then ""
+	  else                             $v end
+    ' "${CONF}"
+}
+
+# Validate ${ARCH} and cache the parameters that are heavily used in global
+# variables.
+load_arch_config() {
+    jq -e --arg a "${ARCH}" '.arch | has($a)' "${CONF}" >/dev/null 2>&1 \
+	|| die "Unknown architecture: ${ARCH}"
+    ARCH_CAPS=$(param caps)
+    TARGET=$(param target)
+    TARGET_ARCH=$(param target_arch)
+}
+
+# Derive every per-arch value for ${1} into the ARCH_*/TARGET/OUTDIR/... globals.
+# Callers isolate architectures via subshells (build) or job backgrounding (run)
+# -- both snapshot these globals -- so they never collide across arches, and we
+# never have to pass the whole bundle around.
+setup_arch_env() {
+    ARCH=$1
+    load_arch_config
+    TIMEOUT=${TIMEOUT_OVERRIDE:-$(param timeout)}
+    MK="make TARGET=${TARGET} TARGET_ARCH=${TARGET_ARCH}"
+    ARCH_OBJDIR=$(${MK} -v .OBJDIR)
+    [ -n "${ARCH_OBJDIR}" ] || die "Cannot determine OBJDIR for ${ARCH}"
+    OUTDIR=${OUTDIR_OVERRIDE:-${ARCH_OBJDIR}/boot-test}
+    IMGDIR=${OUTDIR}/images
+    LOGDIR=${OUTDIR}/logs
+    DESTDIR=${OUTDIR}/tree
+    TESTLIST=${OUTDIR}/test-list.txt
+    mkdir -p ${IMGDIR} ${LOGDIR}
+}
+
+# --- Configuration ---
+
+SKIP_BUILD=false
+SKIP_IMAGES=false
+TEST_FILTER=""
+MAX_JOBS=0
+OUTDIR_OVERRIDE=""	# from -o; only valid with a single arch
+TIMEOUT_OVERRIDE=""	# from -T; else each arch's param timeout
+ARCHES=""		# from -a (repeatable) / -A; defaults to $(uname -p)
+ALL=false
+
+# State shared by tap(4)/dnsmasq netboot networking (see netboot_network_setup /
+# netboot_helper below). Key off user's ID to allow multiple people to
+# run the script at the same time.
+NETBOOT_STATE_DIR=${TMPDIR:-/tmp}/boot-test-net.${SUDO_UID:-$(id -u)}
+
+# --netboot-helper/--netboot-teardown are internal entry points used to run
+# privileged setup/teardown via sudo (see netboot_network_setup); they bypass
+# normal option parsing and are dispatched once every function is defined, in
+# the Main section at the bottom of this script.
+NETBOOT_MODE=""
+case "$1" in
+    --netboot-helper)   NETBOOT_MODE=helper; NETBOOT_HELPER_PLAN=$2 ;;
+    --netboot-teardown) NETBOOT_MODE=teardown ;;
+esac
+
+do_report_dirs=false
+if [ -z "${NETBOOT_MODE}" ]; then
+    while getopts "a:AbBlj:o:t:T:" opt; do
+	case "$opt" in
+	    a) ARCHES="${ARCHES} $OPTARG" ;;
+	    A) ALL=true ;;
+	    b) SKIP_BUILD=true ;;
+	    B) SKIP_BUILD=true; SKIP_IMAGES=true ;;
+	    j) MAX_JOBS="$OPTARG" ;;
+	    l) do_report_dirs=true ;;
+	    o) OUTDIR_OVERRIDE="$OPTARG" ;;
+	    t) TEST_FILTER="$OPTARG" ;;
+	    T) TIMEOUT_OVERRIDE="$OPTARG" ;;
+	    ?) echo "Usage: $0 [-a arch]... | -A] [-b] [-B] [-t regex] [-T secs] [-j jobs] [-o dir]" >&2
+	       exit 1 ;;
+	esac
+    done
+
+    # Resolve the config file next to this script before we cd elsewhere.
+    CONF="$(cd "$(dirname "$0")" && pwd)/boot-test.json"
+    [ -f "${CONF}" ] || die "Config file not found: ${CONF}"
+
+    SRCTOP=$(make -v SRCTOP) || die "Run from stand/ directory in a FreeBSD source tree"
+    cd ${SRCTOP}/stand
+
+    # Build the architecture list from json. Partially supported architectures
+    # are omitted from -A, but accessible with a direct -a.
+    if ${ALL}; then
+	for a in $(jq -r '.arch | keys_unsorted[]' "${CONF}"); do
+	    if [ $(jq ".arch.${a}.disabled" "${CONF}") != "true" ]; then
+		ARCHES="$ARCHES $a"
+	    fi
+	done
+    fi
+    [ -n "${ARCHES}" ] || ARCHES=$(uname -p)
+
+    # -o names one output directory, so it only makes sense for a single arch.
+    if [ -n "${OUTDIR_OVERRIDE}" ] && [ $(echo ${ARCHES} | wc -w) -gt 1 ]; then
+	die "-o cannot be combined with multiple architectures"
+    fi
+fi
+
+# The smallest FAT32 filesystem is 33292 KB
+espsize=33292
+
+# Linux kernel version for linuxboot tests
+LINUX_VERSION=6.18.2
+
+# --------------------------------------------------------------------------
+# QEMU command builders
+#
+# qemu_base wraps the constant bits -- binary, memory, machine, per-arch extra
+# flags, and the -nographic/serial tail -- around the device arguments each
+# specific builder passes in (disks, CDs, firmware, bios).
+# --------------------------------------------------------------------------
+
+qemu_base() {
+    echo "$(param qemu_bin) -m 1g $(param qemu_machine) $(param qemu_extra) $* -nographic -monitor none -serial stdio"
+}
+
+# -drive for the EFI firmware pflash; empty when the arch has no separate
+# firmware (e.g. riscv64's u-boot payload).
+qemu_efi_firmware() {
+    [ -z "$(param efi_firmware)" ] && return 0
+    echo "-drive file=$(param efi_firmware),format=raw,if=pflash,readonly=on"
+}
+
+# Custom OpenBIOS cached beside the ISOs, if present (QEMU's bundled one is
+# missing fixes we need for now); empty otherwise.
+qemu_ofw_bios() {
+    [ -f "${ISODIR}/openbios-ppc" ] || return 0
+    echo "-bios ${ISODIR}/openbios-ppc"
+}
+
+qemu_bios()       { qemu_base "-drive file=$1,format=raw"; }
+qemu_efi()        { qemu_base "$(qemu_efi_firmware) -drive file=$1,format=raw"; }
+qemu_bios_cdrom() { qemu_base "-cdrom $1"; }
+qemu_efi_cdrom()  { qemu_base "$(qemu_efi_firmware) -cdrom $1"; }
+
+# OFW disk on the default (macio IDE) bus: OpenBIOS aliases it "hd" and
+# auto-probes hd:,\\:tbxi for the Apple_Bootstrap (boot1.hfs) partition.
+# virtio disks are not reachable as "hd", so OF finds nothing and drops to "0 >".
+qemu_ofw()        { qemu_base "$(qemu_ofw_bios) -drive file=$1,format=raw"; }
+# -boot d boots the (macio IDE) CD-ROM, which OpenBIOS probes cd:,\\:tbxi.
+qemu_ofw_cdrom()  { qemu_base "$(qemu_ofw_bios) -boot d -cdrom $1"; }
+
+# pseries PReP disk boot: virtio-blk (vtbd0), matching freebsd-ci; SLOF finds
+# the PReP boot partition on it and runs boot1.elf.
+qemu_prep()       { qemu_base "-drive if=none,file=$1,format=raw,id=hd0 -device virtio-blk,drive=hd0"; }
+# pseries CD boot: SLOF boots the El Torito CHRP image; -boot d selects the CD.
+qemu_prep_cdrom() { qemu_base "-cdrom $1 -boot d"; }
+
+# Direct linuxboot: hand the Linux kernel and initrd to QEMU on the command
+# line (-kernel/-initrd) rather than off an ESP.  Used by platforms with no
+# EFI/ESP (e.g. powerpc64le/pseries); the FreeBSD root disk is attached the
+# same way as every other test.
+qemu_linuxboot() {
+    extra="-kernel $2 -initrd $3"
+    [ -n "$(param linux_console)" ] && extra="${extra} -append $(param linux_console)"
+    qemu_base "${extra} -drive file=$1,format=raw"
+}
+
+# Netboot over $1 -- vmnet(4): dnsmasq owns DHCP/TFTP/root-path, which we use so
+# we use tftp, not NFS, for all the files. QEMU's netdev type is still "tap" (it
+# treats vmnet(4) and tap(4) identically).  $2 = bios|efi (efi adds the pflash
+# firmware; bios relies on the NIC's PXE option ROM).
+qemu_netboot() {
+    netif=$1
+    fw=""
+    [ "$2" = efi ] && fw="$(qemu_efi_firmware)"
+    qemu_base "${fw} -netdev tap,id=net0,ifname=${netif},script=no,downscript=no -device $(param netboot_nic),netdev=net0 -boot n"
+}
+
+# RAM-disk netboot (x86 EFI).  Boots iPXE from -hda (edk2's own PXE is disabled
+# via fw_cfg); iPXE DHCPs, fetches the bootfile (an iPXE script) over TFTP, and
+# chains loader.efi with memdisk=<url>.  The loader downloads that image and
+# boots it entirely from RAM -- no NFS and no DHCP root-path needed.  Mirrors
+# ~/memdisk/do-memddisk-efi.  ${OUTDIR}/netboot-vars.fd is a writable edk2 vars
+# copy made by assemble_netboot.
+#
+# We'll need to to http boots in the future, and that will likely require
+# we don't use the ipxe USB path we use here. We use that because Tianocore
+# expects http/https booting when the obvious '-boot n' sort of things
+# are used.
+qemu_netboot_ramdisk() {
+    netif=$1
+    echo "$(param qemu_bin) -M q35 -cpu max -m 2g \
+	-drive if=pflash,format=raw,readonly=on,file=$(param efi_firmware) \
+	-drive if=pflash,format=raw,file=${OUTDIR}/netboot-vars.fd \
+	-hda ${OUTDIR}/netboot-ipxe.img \
+	-device virtio-net,netdev=net0 \
+	-netdev tap,id=net0,ifname=${netif},script=no,downscript=no \
+	-fw_cfg name=opt/org.tianocore/IPv4PXESupport,string=no \
+	-fw_cfg name=opt/org.tianocore/IPv6PXESupport,string=no \
+	-nographic -monitor none -serial stdio"
+}
+
+# --------------------------------------------------------------------------
+# Phase 0: Extract minimal userland from release ISO
+# --------------------------------------------------------------------------
+
+# Binaries needed for a minimal bootable userland.
+# Libraries are inferred from these via ldd on the host equivalents.
+USERLAND_BINS="sbin/fastboot sbin/halt sbin/init bin/sh sbin/sysctl"
+
+ISODIR=${HOME}/iso
+
+find_iso() {
+    local isotgt=${TARGET}
+    [ ${TARGET} != ${TARGET_ARCH} ] && isotgt="${isotgt}-${TARGET_ARCH}"
+    local isoname="${ISODIR}/FreeBSD-$(param freebsd_version)-RELEASE-${isotgt}-disc1.iso.xz"
+    [ -f "${isoname}" ] && echo "${isoname}" && return
+    die "No ISO found for ${ARCH}: ${isoname} not found"
+}
+
+install_minimal_userland() {
+    # Split the declaration from the assignment: `local iso=$(find_iso)` would
+    # swallow find_iso's exit status (local returns 0), so its die() -- which
+    # runs in the $() subshell -- would not stop this script.
+    local iso
+    iso=$(find_iso) || exit 1
+    echo "  Extracting minimal userland from release ISO ${iso}..."
+
+    # Determine library paths from host equivalents of our binaries
+    local host_bins=""
+    for b in ${USERLAND_BINS}; do
+	host_bins="${host_bins} /${b}"
+    done
+    lib_paths=$(ldd ${host_bins} 2>/dev/null \
+	| awk 'NF == 4 { print $3 }' | sort -u)
+
+    # Build the list of paths to extract: binaries + libraries + rtld
+    local extract_list=""
+    for b in ${USERLAND_BINS}; do
+	extract_list="${extract_list} ./${b}"
+    done
+    for l in ${lib_paths}; do
+	extract_list="${extract_list} .${l}"
+    done
+    extract_list="${extract_list} ./libexec/ld-elf.so.1"
+
+    # Some architectures (e.g., armv7) need libgcc_s.so.1 even though the host
+    # binaries don't. Always try to extract it. We'll ignore it if not there.
+    extract_list="${extract_list} ./lib/libgcc_s.so.1"
+
+    # Extract the files from the tarball.
+    tar -C ${DESTDIR} -xf ${iso} ${extract_list} \
+	>> ${LOGDIR}/installuserland.log 2>&1 || true
+}
+
+# --------------------------------------------------------------------------
+# Phase 1: Build the boot tree
+# --------------------------------------------------------------------------
+
+build_tree() {
+    echo "=== Phase 1: Building boot tree ==="
+
+    rm -rf ${DESTDIR}
+    mkdir -p ${DESTDIR}/boot/defaults
+    mkdir -p ${DESTDIR}/boot/kernel
+    mkdir -p ${DESTDIR}/boot/uboot
+    mkdir -p ${DESTDIR}/sbin ${DESTDIR}/bin \
+	${DESTDIR}/lib ${DESTDIR}/libexec \
+	${DESTDIR}/etc ${DESTDIR}/dev
+
+    # Install kernel
+    # I'd prefer this to be MINIMAL, but GENERIC is needed until I work out what
+    # different devices we boot from...
+    (cd ${SRCTOP} && ${MK} installkernel \
+	KERNCONF=$(param kernconf) \
+	MODULES_OVERRIDE="ufs zfs acl_nfs4 crypto zlib cd9660" \
+	DESTDIR=${DESTDIR} \
+	MK_KERNEL_SYMBOLS=no \
+	MK_INSTALL_AS_USER=yes) > ${LOGDIR}/installkernel.log 2>&1 \
+	|| die "Kernel install failed (see ${LOGDIR}/installkernel.log)"
+
+    # Install boot loaders
+    ${MK} buildenv \
+	DESTDIR=${DESTDIR} \
+	MK_MAN=no \
+	MK_INSTALL_AS_USER=yes \
+	MK_DEBUG_FILES=no \
+	BUILDENV_SHELL="make all install" \
+	>> ${LOGDIR}/installloader.log 2>&1 \
+	|| die "Boot loader install failed (see ${LOGDIR}/installloader.log)"
+
+    # Install minimal userland (works for both native and cross builds)
+    install_minimal_userland
+
+    # Remove default loader symlinks -- we add them back per-image via
+    # mtree overlays to test each loader variant individually.
+    # /boot/loader is the BIOS stage-3 (amd64 only).
+    # /boot/loader.efi is what boot1.efi chainloads (all EFI platforms).
+    # OFW/PReP are the exception: their /boot/loader is the one real loader
+    # (no lua/4th/simp variants are built), chainloaded by boot1.hfs (mac99)
+    # or boot1.elf (pseries PReP), so keep it in the tree.
+    has ofw || has prep || rm -f ${DESTDIR}/boot/loader
+    rm -f ${DESTDIR}/boot/loader.efi
+
+    # Serial console configuration.  boot.config is consumed only by the
+    # BIOS boot blocks; its -h/-D/-S flags are meaningless on EFI/OFW, so
+    # only write it where BIOS booting is supported.
+    if has bios; then
+	echo -h -D -S115200 > ${DESTDIR}/boot.config
+    fi
+    # Unified loader.conf: always load ufs, zfs, and cd9660
+    cat > ${DESTDIR}/boot/loader.conf <<EOF
+boot_serial=YES
+comconsole_speed=115200
+autoboot_delay=1
+ufs_load="YES"
+zfs_load="YES"
+cd9660_load="YES"
+EOF
+    local hints=$(param hints)
+    if [ -n "${hints}" ] && [ -f "${SRCTOP}/${hints}" ]; then
+	cp "${SRCTOP}/${hints}" ${DESTDIR}/boot/device.hints
+    fi
+
+    # Test /etc/rc - prints success and halts
+    cat > ${DESTDIR}/etc/rc <<'RCEOF'
+#!/bin/sh
+
+sysctl machdep.bootmethod
+echo "RC COMMAND RUNNING -- SUCCESS!!!!!"
+halt -p
+RCEOF
+    chmod +x ${DESTDIR}/etc/rc
+
+    # Create fstab used by UFS mtree overlays
+    cat > ${OUTDIR}/fstab.ufs <<EOF
+/dev/ufs/root	/	ufs	rw	1	1
+EOF
+    # Create fstab used by CD mtree overlays
+    cat > ${OUTDIR}/fstab.cd <<EOF
+/dev/iso9660/FBSDTEST	/	cd9660	ro	0	0
+EOF
+
+    echo "Boot tree built in ${DESTDIR}"
+}
+
+# --------------------------------------------------------------------------
+# Phase 2: Create base filesystem images
+#
+# Uses mtree overlays to vary the /boot/loader and /etc/fstab
+# without copying the tree. The base tree has no /boot/loader or
+# /etc/fstab; each image adds what it needs via an mtree spec file
+# passed as a second source to makefs.
+# --------------------------------------------------------------------------
+
+# Create a UFS image with a specific loader variant via mtree overlay
+make_one_ufs() {
+    variant=$1
+    img=${IMGDIR}/bootable-ufs-${variant}.img
+    mt=$(mktemp ${OUTDIR}/ufs-mtree.XXXXXX)
+
+    echo "  Creating UFS image with loader_${variant}..."
+    echo "./etc/fstab type=file mode=0644 contents=${OUTDIR}/fstab.ufs" > ${mt}
+    # BIOS: /boot/loader -> loader_<variant>
+    if [ -n "$(param bios_loaders)" ]; then
+	echo "./boot/loader type=file mode=0644 contents=${DESTDIR}/boot/loader_${variant}" >> ${mt}
+    fi
+    # OFW: boot1.hfs chainloads the single /boot/loader already in the
+    # tree (no per-variant overlay needed).
+    # EFI: /boot/loader.efi -> loader_<variant>.efi (needed for boot1.efi chainload)
+    if has efi; then
+	echo "./boot/loader.efi type=file mode=0755 contents=${DESTDIR}/boot/loader_${variant}.efi" >> ${mt}
+    fi
+    makefs -t ffs -B $(param byte_order) -M 10m -o label=root -o version=2 \
+	${img} ${mt} ${DESTDIR} >> ${LOGDIR}/imagebuild.log 2>&1
+    rm -f ${mt}
+}
+
+# Create a ZFS image with a specific loader variant via mtree overlay
+make_one_zfs() {
+    variant=$1
+    img=${IMGDIR}/bootable-zfs-${variant}.img
+    mt=$(mktemp ${OUTDIR}/zfs-mtree.XXXXXX)
+
+    echo "  Creating ZFS image with loader_${variant}..."
+    > ${mt}
+    # BIOS: /boot/loader -> loader_<variant>
+    if [ -n "$(param bios_loaders)" ]; then
+	echo "./boot/loader type=link link=loader_${variant}" >> ${mt}
+    fi
+    # OFW: boot1.hfs chainloads the single /boot/loader already in the
+    # tree (no per-variant overlay needed).
+    # EFI: /boot/loader.efi -> loader_<variant>.efi (needed for boot1.efi chainload)
+    if has efi; then
+	echo "./boot/loader.efi type=file mode=0755 contents=${DESTDIR}/boot/loader_${variant}.efi" >> ${mt}
+    fi
+    makefs -t zfs -s 100m \
+	-o poolname=ztestroot -o bootfs=ztestroot -o rootpath=/ \
+	${img} ${mt} ${DESTDIR} >> ${LOGDIR}/imagebuild.log 2>&1
+    rm -f ${mt}
+}
+
+# Create an ESP with the given EFI loader using an mtree spec
+make_one_esp() {
+    loader_name=$1
+    esp=${IMGDIR}/${loader_name}.esp
+    mt=$(mktemp ${OUTDIR}/esp-mtree.XXXXXX)
+
+    echo "  Creating ESP with ${loader_name}.efi..."
+    cat > ${mt} <<EOF
+./efi type=dir uname=root gname=wheel mode=0755
+./efi/boot type=dir uname=root gname=wheel mode=0755
+./efi/boot/$(param efi_bootname).efi type=file uname=root gname=wheel mode=0755 contents=${DESTDIR}/boot/${loader_name}.efi
+EOF
+    makefs -t msdos \
+	-o fat_type=32 \
+	-o sectors_per_cluster=1 \
+	-o volume_label=EFISYS \
+	-s ${espsize}k \
+	${esp} ${mt} >> ${LOGDIR}/imagebuild.log 2>&1
+    rm -f ${mt}
+}
+
+# Create a small ESP for CD hybrid boot using an mtree spec
+make_cd_esp() {
+    file=$1
+    loader=$2
+    mt=$(mktemp ${OUTDIR}/cd-esp-mtree.XXXXXX)
+
+    cat > ${mt} <<EOF
+./efi type=dir uname=root gname=wheel mode=0755
+./efi/boot type=dir uname=root gname=wheel mode=0755
+./efi/boot/$(param efi_bootname).efi type=file uname=root gname=wheel mode=0755 contents=${loader}
+EOF
+    makefs -t msdos \
+	-o fat_type=12 \
+	-o sectors_per_cluster=1 \
+	-o volume_label=EFISYS \
+	-s 2048k \
+	${file} ${mt} >> ${LOGDIR}/imagebuild.log 2>&1
+    rm -f ${mt}
+}
+
+# Find the pre-built Linux kernel EFI binary for linuxboot
+find_linux_kernel() {
+    target_arch=$(${MK} -v TARGET_ARCH)
+    linuxboot_dir=${ARCH_OBJDIR}/../../linuxboot/data/output
+
+    # amd64 has .efi suffix, others don't
+    for f in \
+	${linuxboot_dir}/${target_arch}.linux.v${LINUX_VERSION}.efi \
+	${linuxboot_dir}/${target_arch}.linux.v${LINUX_VERSION} \
+	${linuxboot_dir}/${target_arch}.v${LINUX_VERSION}.efi \
+	${linuxboot_dir}/${target_arch}.v${LINUX_VERSION}; do
+	if [ -f "$f" ]; then
+	    echo "$f"
+	    return
+	fi
+    done
+    return 1
+}
+
+# Build a linuxboot initrd using tar --format newc with an mtree spec.
+# No root/sudo required -- device nodes are written directly into the
+# cpio archive via mtree type=char entries.
+make_linuxboot_initrd() {
+    initrd=${IMGDIR}/linuxboot-initrd.cpio.gz
+    mt=$(mktemp ${OUTDIR}/initrd-mtree.XXXXXX)
+
+    echo "  Creating linuxboot initrd..."
+
+    # Build mtree spec for the initrd contents
+    cat > ${mt} <<EOF
+./init type=file mode=0755 contents=${DESTDIR}/boot/loader.kboot
+./dev type=dir mode=0755
+./dev/console type=char mode=0600 device=freebsd,5,0
+./dev/tty type=char mode=0600 device=freebsd,5,1
+./dev/ttyS0 type=char mode=0600 device=freebsd,4,64
+./boot type=dir mode=0755
+./boot/defaults type=dir mode=0755
+./boot/defaults/loader.conf type=file mode=0644 contents=${DESTDIR}/boot/defaults/loader.conf
+./boot/lua type=dir mode=0755
+EOF
+
+    # Add all lua files
+    for f in ${DESTDIR}/boot/lua/*.lua; do
+	[ -f "$f" ] || continue
+	bn=$(basename $f)
+	echo "./boot/lua/${bn} type=file mode=0644 contents=$f" >> ${mt}
+    done
+
+    # Add loader.help.kboot if present
+    if [ -f "${DESTDIR}/boot/loader.help.kboot" ]; then
+	echo "./boot/loader.help.kboot type=file mode=0644 contents=${DESTDIR}/boot/loader.help.kboot" >> ${mt}
+    fi
+
+    # Create the kboot-specific loader.conf
+    kboot_conf=$(mktemp ${OUTDIR}/kboot-loader-conf.XXXXXX)
+    cat > ${kboot_conf} <<EOF
+# Kboot configuration -- FreeBSD ${ARCH}
+boot_serial="YES"
+EOF
+    if [ "${ARCH}" = "amd64" ]; then
+	cat >> ${kboot_conf} <<EOF
+hw.uart.console="io:1016,br:115200"
+EOF
+    fi
+    echo "./boot/loader.conf type=file mode=0644 contents=${kboot_conf}" >> ${mt}
+
+    # Create the initrd as a gzip-compressed newc cpio archive
+    tar --format newc -cf - @${mt} 2>> ${LOGDIR}/imagebuild.log | \
+	gzip > ${initrd}
+
+    rm -f ${mt} ${kboot_conf}
+}
+
+# Build a linuxboot ESP containing the Linux kernel, initrd, and startup.nsh
+make_linuxboot_esp() {
+    linux_kernel=$1
+    esp=${IMGDIR}/linuxboot.esp
+    mt=$(mktemp ${OUTDIR}/linuxboot-esp-mtree.XXXXXX)
+
+    echo "  Creating linuxboot ESP..."
+
+    # Generate startup.nsh
+    startup=${OUTDIR}/startup.nsh
+    cat > ${startup} <<EOF
+\\linux.efi $(param linux_console) initrd=\\initrd
+EOF
+
+    cat > ${mt} <<EOF
+./startup.nsh type=file mode=0644 contents=${startup}
+./linux.efi type=file mode=0755 contents=${linux_kernel}
+./initrd type=file mode=0644 contents=${IMGDIR}/linuxboot-initrd.cpio.gz
+EOF
+    makefs -t msdos \
+	-o fat_type=32 \
+	-o sectors_per_cluster=1 \
+	-o volume_label=EFISYS \
+	-s 100m \
+	${esp} ${mt} >> ${LOGDIR}/imagebuild.log 2>&1
+    rm -f ${mt}
+}
+
+make_base_images() {
+    echo "=== Phase 2: Creating base filesystem images ==="
+
+    # UFS images - one per BIOS loader variant, plus one for EFI (uses lua)
+    if [ -n "$(param bios_loaders)" ]; then
+	for v in $(param bios_loaders); do
+	    make_one_ufs $v
+	done
+    else
+	# EFI-only arches still need one UFS image
+	make_one_ufs lua
+    fi
+
+    # ZFS images (if supported)
+    if has zfs; then
+	if [ -n "$(param bios_loaders)" ]; then
+	    for v in $(param bios_loaders); do
+		make_one_zfs $v
+	    done
+	else
+	    make_one_zfs lua
+	fi
+    fi
+
+    # ESP images (if EFI is supported)
+    if has efi; then
+	for l in $(param efi_loaders); do
+	    make_one_esp $l
+	done
+    fi
+
+    # Linuxboot initrd + ESP (if supported and Linux kernel is available)
+    if has linuxboot; then
+	linux_kernel=$(find_linux_kernel) || true
+	if [ -n "${linux_kernel}" ]; then
+	    make_linuxboot_initrd
+	    # EFI arches chainload the kernel+initrd off an ESP; platforms
+	    # without EFI hand them to QEMU directly (-kernel/-initrd), so
+	    # they need no ESP.
+	    has efi && make_linuxboot_esp ${linux_kernel}
+	else
+	    echo "  WARNING: Linux kernel not found for linuxboot, skipping"
+	    echo "    Expected in: ${ARCH_OBJDIR}/../../linuxboot/data/output/"
+	fi
+    fi
+
+    echo "Base images created in ${IMGDIR}"
+}
+
+# --------------------------------------------------------------------------
+# Phase 3: Assemble disk images and register tests
+# --------------------------------------------------------------------------
+
+# Test registration - uses temp files since /bin/sh doesn't have arrays.
+# ${TESTLIST} and ${OUTDIR} are set per-arch by setup_arch_env; the list is
+# truncated in build_all before each arch's images are (re)assembled.
+register_test() {
+    name=$1
+    shift
+    echo "$*" > ${OUTDIR}/test-cmd-${name}.sh
+    echo "${name}" >> ${TESTLIST}
+}
+
+# Like register_test, but for tests that need a real tap(4) interface
+# (assigned later by netboot_network_setup, once every arch is built) instead
+# of QEMU's slirp net.  The command is written out now with a placeholder tap
+# name; netboot_network_setup patches it in once the tap is assigned.
+# ${NETBOOT_PLAN} accumulates across every arch (unlike ${TESTLIST}, it is not
+# truncated per-arch), so it must already exist by the time build_all runs --
+# see Main.  $5 optionally names a different command builder than the default
+# qemu_netboot (e.g. qemu_netboot_ramdisk) -- it's always called as
+# "builder __NETBOOT_TAP__ ${fw}", so a non-default builder that doesn't need
+# ${fw} just ignores its second arg.
+register_netboot_test() {
+    name=$1
+    tftpdir=$2
+    bootfile=$3
+    fw=$4
+    builder=${5:-qemu_netboot}
+    cmdfile=${OUTDIR}/test-cmd-${name}.sh
+    echo "$(${builder} __NETBOOT_TAP__ ${fw})" > ${cmdfile}
+    echo "${name}" >> ${TESTLIST}
+    echo "${cmdfile} ${tftpdir} ${bootfile}" >> ${NETBOOT_PLAN}
+}
+
+assemble_efi_gpt() {
+    echo "  Assembling EFI+GPT images..."
+    for loader in $(param efi_loaders); do
+	esp=${IMGDIR}/${loader}.esp
+	fstypes="ufs"
+	has zfs && fstypes="ufs zfs"
+	for fs in ${fstypes}; do
+	    name="efi-gpt-${fs}-${loader}"
+	    img=${IMGDIR}/${name}.img
+
+	    # For EFI, the stage-3 in the filesystem doesn't matter, use lua variant
+	    case ${fs} in
+		ufs) fsimg=${IMGDIR}/bootable-ufs-lua.img; ptype="freebsd-ufs" ;;
+		zfs) fsimg=${IMGDIR}/bootable-zfs-lua.img; ptype="freebsd-zfs" ;;
+	    esac
+
+	    mkimg -s gpt \
+		-p efi:=${esp} \
+		-p ${ptype}:=${fsimg} \
+		-o ${img} >> ${LOGDIR}/imagebuild.log 2>&1
+
+	    register_test ${name} $(qemu_efi ${img})
+	done
+    done
+}
+
+assemble_efi_mbr() {
+    echo "  Assembling EFI+MBR images..."
+    for loader in $(param efi_loaders); do
+	esp=${IMGDIR}/${loader}.esp
+	name="efi-mbr-ufs-${loader}"
+	img=${IMGDIR}/${name}.img
+	ufs=${IMGDIR}/bootable-ufs-lua.img
+
+	mkimg -s bsd -p freebsd-ufs:=${ufs} -o ${img}.s2 >> ${LOGDIR}/imagebuild.log 2>&1
+	mkimg -a 1 -s mbr -p efi:=${esp} -p freebsd:=${img}.s2 -o ${img} >> ${LOGDIR}/imagebuild.log 2>&1
+	rm -f ${img}.s2
+
+	register_test ${name} $(qemu_efi ${img})
+    done
+}
+
+assemble_bios_gpt() {
+    echo "  Assembling BIOS+GPT images..."
+    for variant in $(param bios_loaders); do
+	# UFS
+	name="bios-gpt-ufs-loader_${variant}"
+	img=${IMGDIR}/${name}.img
+	ufs=${IMGDIR}/bootable-ufs-${variant}.img
+
+	mkimg -s gpt -b ${DESTDIR}/boot/pmbr \
+	    -p freebsd-boot:=${DESTDIR}/boot/gptboot \
+	    -p freebsd-ufs:=${ufs} \
+	    -o ${img} >> ${LOGDIR}/imagebuild.log 2>&1
+
+	register_test ${name} $(qemu_bios ${img})
+
+	# ZFS
+	if has zfs; then
+	    name="bios-gpt-zfs-loader_${variant}"
+	    img=${IMGDIR}/${name}.img
+	    zfs=${IMGDIR}/bootable-zfs-${variant}.img
+
+	    mkimg -s gpt -b ${DESTDIR}/boot/pmbr \
+		-p freebsd-boot:=${DESTDIR}/boot/gptzfsboot \
+		-p freebsd-zfs:=${zfs} \
+		-o ${img} >> ${LOGDIR}/imagebuild.log 2>&1
+
+	    register_test ${name} $(qemu_bios ${img})
+	fi
+    done
+}
+
+assemble_bios_mbr() {
+    echo "  Assembling BIOS+MBR images..."
+    for variant in $(param bios_loaders); do
+	name="bios-mbr-ufs-loader_${variant}"
+	img=${IMGDIR}/${name}.img
+	ufs=${IMGDIR}/bootable-ufs-${variant}.img
+
+	mkimg -s bsd -b ${DESTDIR}/boot/boot \
+	    -p freebsd-ufs:=${ufs} -o ${img}.s1 >> ${LOGDIR}/imagebuild.log 2>&1
+	# Note: boot0sio has a longish timeout, and does work but
+	# takes longer than 30s so we use mbr.
+	mkimg -a 1 -s mbr -b ${DESTDIR}/boot/mbr \
*** 1182 LINES SKIPPED ***