#!/usr/bin/env python3
"""Reproducible QEMU integration tests for danos.

For each test case it builds the kernel with `-Dtest-case=<name>`, boots it
headless in QEMU with the serial port captured to a file, and asserts that the
expected marker appears in that output before a timeout. The kernel's serial log
is machine-readable (see src/kernel/tests.zig and src/kernel/arch/*/serial.zig), so no
screen-scraping is involved.

Structured per-architecture so a second CPU (e.g. an AArch64 Raspberry Pi) is a
matter of adding an ARCHES entry and its serial/boot support — the test cases and
the runner stay the same.

Usage:
    python3 test/qemu_test.py                # run all cases on the default arch
    python3 test/qemu_test.py --arch x86_64  # pick an architecture
    python3 test/qemu_test.py smoke fault-pf # run only named cases
"""

import argparse
import json
import os
import re
import shutil
import socket
import subprocess
import sys
import tempfile
import time

REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
WORK = os.path.join(REPO, "zig-out", "qemu-test")

# --- Architecture configurations -------------------------------------------
# Each describes how to build and boot that architecture. Only the fields that
# actually differ between architectures live here.
ARCHES = {
    "x86_64": {
        "zig_flags": [],  # build.zig currently pins the kernel to x86_64
        "qemu": "qemu-system-x86_64",
        # Firmware lives in different places per OS/distro; resolve_firmware()
        # picks the first that exists so the harness runs unconfigured on Arch,
        # Debian/Ubuntu, Fedora, and macOS (Homebrew). Override by reordering or
        # dropping an absolute path at the front.
        "ovmf_code": [
            "/usr/share/edk2/x64/OVMF_CODE.4m.fd",          # Arch
            "/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)
        ],
        "ovmf_vars": [
            "/usr/share/edk2/x64/OVMF_VARS.4m.fd",          # Arch
            "/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)
        ],
        # zig-out is itself the FHS-shaped boot volume (docs/efi.md): the build
        # installs BOOTX64.efi, the kernel, init, and the initial-ramdisk at their
        # boot paths. The harness presents zig-out to the guest directly — exactly
        # as `zig build run-x86-64` does — so there is no separate ESP to assemble.
        # Built as a function so we can splice in per-run paths.
        "qemu_args": lambda a, boot_volume, vars_fd, serial: [
            "-machine", "q35", "-m", "128M",
            "-drive", f"if=pflash,format=raw,readonly=on,file={a['ovmf_code']}",
            "-drive", f"if=pflash,format=raw,file={vars_fd}",
            # Boot off a FAT USB device: the boot volume is a mass-storage device on
            # the xHCI bus (usb-kbd/usb-mouse ride the same controller). `boot_volume`
            # is the FAT image the build produces. bootindex=0 steers OVMF to it.
            "-device", "qemu-xhci,id=xhci",
            "-device", "usb-kbd,bus=xhci.0",
            "-device", "usb-mouse,bus=xhci.0",
            "-drive", f"if=none,id=bootusb,format=raw,file={boot_volume}",
            "-device", "usb-storage,bus=xhci.0,drive=bootusb,removable=on,bootindex=0",
            "-net", "none",
            "-vga", "none", "-device", "VGA,edid=on,xres=1280,yres=720",
            "-display", "none",
            "-serial", f"file:{serial}",
            "-no-reboot",  # exit instead of rebooting on a triple fault
        ],
    },
    # To add an architecture, e.g. "aarch64": provide its qemu binary, firmware,
    # boot method, and the AArch64 kernel/serial support in src/kernel/arch/aarch64/.
}

# --- Test cases ------------------------------------------------------------
# `expect`: a regex that must appear in serial output => pass.
# `fail`:   optional regex whose appearance => immediate fail.
CASES = [
    # smoke also proves the QMP channel: the harmless query must be delivered
    # (handshake + command) before the case may pass — see run_case.
    {"name": "smoke",
     "qmp_after": {"delay": 2, "command": "query-status"},
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "discovery",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "wx",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "timer",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "clock",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Wall-clock: the CMOS RTC read at boot gives a plausible current epoch (the
    # foundation for filesystem mtime).
    {"name": "wall-clock",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "vmm",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "heap",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "sched",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "priority",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "sleep",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "event",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "ipc",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Synchronous IPC: a client and server ping-pong 100 calls through an
    # endpoint (rendezvous, reply routing, cross-address-space copy).
    {"name": "ipc-call",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # IPC capability passing (M13): a client hands the server an endpoint in a call,
    # the server hands one back in its reply; each is verified same-object + shared.
    {"name": "ipc-cap",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # DMA memory (M14): contiguous frame allocation, below-4G cap, coherent mapping,
    # and reclaim on teardown.
    {"name": "dma",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # MSI (M15): allocate a per-device vector and deliver it as a notification (a
    # self-IPI stands in for the device's MSI write, since the HPET has no MSI).
    {"name": "msi",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # IOMMU (M16): boot with an emulated VT-d unit and confirm danos parses the DMAR
    # table and reads the unit's registers. Detection only — enforcement is future.
    {"name": "iommu",
     "qemu_extra": ["-device", "intel-iommu,intremap=off"],
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Port I/O grants: a claimed device's io_port resource lets a driver read/write its
    # ports (PS/2 status 0x64), gated by the claim; out-of-range/unclaimed is refused.
    {"name": "ioport",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Display handoff (D1): the kernel seeds the loader's framebuffer as a claimable
    # `display` device with a write-combining memory resource; the claim + mmio_map path
    # maps it, and the leaf is genuinely write-combining (PAT entry 4), not the UC default.
    {"name": "display",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Display service (D2/D3): the user-space compositor claims the framebuffer, allocates
    # a cacheable back buffer, clears it, and presents that composed frame (double-buffer
    # path); then a startup self-check composites two overlapping layers and confirms the
    # overlap shows the top layer (D3). Matched on the service's own heartbeats.
    {"name": "display-service",
     "expect": r"display: online \d+x\d+ pitch \d+[\s\S]*display: presented frame 0[\s\S]*display: compositor self-check ok",
     "fail":   r"display: could not|self-check FAILED|CPU EXCEPTION|KERNEL PANIC"},
    # Display demo (D4): a separate process (display-demo) drives the compositor over the
    # layer client API — wallpaper + a moving rectangle + a cursor, presented in a loop.
    # `display-demo: ok` is printed only after it drove a run of frames of motion through
    # the service (the visible motion is a screenshot via `zig build run-x86-64`).
    {"name": "display-demo",
     "expect": r"display-demo: scene up[\s\S]*display-demo: ok",
     "fail":   r"display-demo: (no display|create failed)|display: could not|CPU EXCEPTION|KERNEL PANIC"},
    # Threaded compositor tracks a mouse (docs/threading.md, docs/display.md): the display
    # runs a mouse-listener thread alongside its compositor loop. `input-source mouse`
    # publishes pure motion -> the input service fans it to the display's listener -> the
    # listener accumulates it into a cursor position handed to the render loop over a
    # single-slot channel. `display: cursor tracking mouse ok` latches once the cursor has
    # tracked a run of that motion end to end.
    {"name": "display-cursor",
     "smp": 4,
     "expect": r"display: online \d+x\d+[\s\S]*display: cursor tracking mouse ok",
     "fail":   r"display: (could not|mouse subscribe failed)|CPU EXCEPTION|KERNEL PANIC"},
    # Shared memory (v2 V2): shared-memory-client creates a region, writes a pattern, and passes its
    # capability to shared-memory-server, which maps it and confirms the same bytes — proving
    # cross-process shared pages over the extended capability passing.
    {"name": "shared-memory",
     "expect": r"shared-memory: shared 4096 bytes ok",
     "fail":   r"shared-memory: (shared FAILED|create failed|no server|call failed|map)|CPU EXCEPTION|KERNEL PANIC"},
    # virtio-gpu driver (v2 V3): boot with an emulated virtio-gpu. The device-manager stack
    # discovers the PCI function and spawns the driver, which brings up the control virtqueue,
    # creates a 2D scanout resource backed by DMA memory, set_scanouts it, paints a test
    # pattern, transfers + flushes it, and waits for the device's used-ring ack, then reads
    # the backing back. `scanout WxH online` + `flush acked, pixel check ok` are the markers.
    {"name": "virtio-gpu",
     "qemu_extra": ["-device", "virtio-gpu-pci"],
     "expect": r"virtio-gpu: scanout \d+x\d+ online[\s\S]*virtio-gpu: flush acked, pixel check ok",
     "fail":   r"virtio-gpu:.*(failed|not acked|mismatch|unable to claim|not a virtio-gpu|too small|no PCI capability|does not offer|rejected|missing common-config|not a mapped resource|could not spawn)|CPU EXCEPTION|KERNEL PANIC"},
    # Native backend + hot-attach (v2 V4): boot the compositor + display-demo with an emulated
    # virtio-gpu. The driver announces its shared scanout surface to the compositor, which maps
    # it, upgrades off the GOP floor, and drives frames through the native backend — reading a
    # pixel back to confirm the composited frame reached the shared surface, while the demo runs.
    {"name": "display-native",
     "qemu_extra": ["-device", "virtio-gpu-pci"],
     "mem": "512M",  # boots the compositor + demo + the whole device-manager driver stack at once
     # Order-independent: the demo's `ok` may print before or after the driver announces, so
     # require all three markers to appear somewhere rather than in a fixed order.
     "expect": r"(?s)(?=.*display: scanout upgraded to virtio-gpu)(?=.*display: native present verified)(?=.*display-demo: ok)",
     "fail":   r"display: native present FAILED|display: could not|display-demo: (no display|create failed)|CPU EXCEPTION|KERNEL PANIC"},
    # Mode-set + EDID + fenced presents (v2 V5): same boot as display-native. After upgrading,
    # the compositor queries the driver's modes, switches to a different resolution, and confirms
    # the backend now reports it; each present is fenced — completion-acknowledged and tear-free,
    # not vblank-paced (docs/display-v2.md, "Fenced is not vsync"). (The driver also logs the
    # EDID preferred mode during bring-up.) Reuses the display-native kernel scenario.
    {"name": "display-modeset",
     "build_case": "display-native",
     "qemu_extra": ["-device", "virtio-gpu-pci"],
     "mem": "512M",
     "expect": r"(?s)(?=.*display: mode set to \d+x\d+, verified)(?=.*display: fenced present ok)",
     "fail":   r"display: mode set FAILED|display: mode-set self-check: |display: native present FAILED|CPU EXCEPTION|KERNEL PANIC"},
    # Resilience: driver restart + re-attach (v2 V6). device-manager (in test-scanout-restart
    # mode) kills the virtio-gpu driver once after it hellos; the restart policy respawns it, it
    # re-announces, and the compositor re-attaches — surviving the loss. Expect the initial
    # upgrade AND the re-attach; any CPU exception / panic (the compositor crashing) is a fail.
    {"name": "display-reattach",
     "qemu_extra": ["-device", "virtio-gpu-pci"],
     "mem": "512M",
     "expect": r"(?s)(?=.*display: scanout upgraded to virtio-gpu)(?=.*display: scanout re-attached)",
     "fail":   r"CPU EXCEPTION|KERNEL PANIC|display: could not"},
    # Monotonic clock (clock() syscall source): calibrated, advancing, never backwards.
    {"name": "clock",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Parallelism: needs more than one core, so this case boots with -smp 4.
    {"name": "smp",
     "smp":    4,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Affinity: a pinned task must never migrate off its core.
    {"name": "affinity",
     "smp":    4,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Stress the big kernel lock across cores; heavier, so a longer timeout.
    {"name": "smp-stress",
     "smp":    4,
     "timeout": 150,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Retry: a forced first-wake failure must still bring every core online.
    {"name": "smp-retry",
     "smp":    4,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # TSC clocksource + cross-core warp check (real Intel/AMD / KVM path). TCG won't
    # advertise an invariant TSC, so the kernel forces the TSC clocksource on for this
    # case (gated in kernel.zig) and runs the per-AP warp check across the 4 cores;
    # their TSCs are synchronized, so it stays on the TSC (no HPET fallback). The rest
    # of the suite exercises the HPET fallback instead. See docs/timers.md.
    {"name": "tsc-sync",
     "smp":    4,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "fault-ud", "expect": r"invalid opcode \(vector 6\)"},
    {"name": "fault-pf", "expect": r"page fault \(vector 14\)"},
    {"name": "fault-df", "expect": r"double fault \(vector 8\)"},
    # Fault on an application processor: a #DF pinned to core 1 must be caught by that
    # core's own IST (N >= 1), proving per-core TSS works; a broken one triple-faults.
    {"name": "fault-ap-df",
     "smp":    4,
     "expect": r"core [1-9]\d*: double fault \(vector 8\)",
     "fail":   r"could not pin"},
    {"name": "fault-nx",
     "expect": r"page fault \(vector 14\)",
     "fail":   r"NX not enforced"},
    {"name": "fault-null", "expect": r"page fault \(vector 14\)"},
    # Memory grants: the mmap/munmap path hands out user pages into a process's
    # arena, translate resolves them, munmap frees them, and no frames leak.
    {"name": "usermem",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Isolation: a ring-3 read of a kernel-only page must #PF with error code
    # 0x5 (present|user) at the user IP. ([\s\S] spans lines; `.` doesn't.)
    {"name": "user-pf",
     "expect": r"page fault \(vector 14\)[\s\S]*error code : 0x5[\s\S]*IP\s*: 0x00007000000000",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Fault recovery: a scheduled ring-3 process that faults is killed — resources
    # reclaimed, core kept — while init's heartbeat proves the OS survived.
    {"name": "fault-recovery",
     "timeout": 60,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},

    # docs/threading-plan.md M1: address-space refcount — spaces destroyed exactly
    # once per process, no leak/double-free (the foundation shared-address-space threads need).
    {"name": "address-space-refcount",
     "timeout": 60,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},

    # docs/threading-plan.md M2: runtime.Thread.spawn — a worker thread runs in the
    # caller's address space (a shared-memory write, observed by the main thread).
    {"name": "thread-spawn",
     "timeout": 60,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},

    # docs/threading-plan.md M3: join + parallelism — N workers each do K atomic
    # increments (total exactly N*K after join) and run on >1 core; plus detach.
    {"name": "thread-join",
     "smp":    4,
     "timeout": 60,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},

    # docs/threading-plan.md M4: futex — a thread parks in futex_wait and is woken by
    # futex_wake (serial order waiting/waking/woke), and timedWait reports a timeout.
    {"name": "thread-futex",
     "smp":    4,
     "timeout": 60,
     "expect": r"thread-futex: waiting[\s\S]*thread-futex: waking[\s\S]*thread-futex: woke[\s\S]*DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},

    # docs/threading-plan.md M5: Mutex + Condition — a bounded producer/consumer moves
    # N unique items across cores; the consumed checksum matches exactly (no loss).
    {"name": "thread-mutex",
     "smp":    4,
     "timeout": 60,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},

    # docs/threading-plan.md M6: thread identity — getCurrentId is distinct and non-zero
    # for the main thread and two workers.
    {"name": "thread-id",
     "smp":    4,
     "timeout": 60,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},

    # docs/threading-plan.md M7: thread-safe allocation — N threads hammer the shared heap
    # (per-aspace mmap arena + locked free list) with no cross-block corruption.
    {"name": "thread-alloc",
     "smp":    4,
     "timeout": 60,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},

    # docs/threading-plan.md M8: the task reaper — spawn+kill many processes; total kernel
    # stack bytes return to baseline (every dead task's stack reclaimed, no leak).
    {"name": "task-reap",
     "smp":    4,
     "timeout": 60,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},

    # docs/threading-plan.md M10: per-thread fs.base — two threads keep private %fs:8 TLS
    # slots across context switches (no cross-talk).
    {"name": "thread-tls",
     "smp":    4,
     "timeout": 60,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},

    # docs/threading-plan.md M11: RwLock — readers/writers across cores; a reader never
    # observes a half-written value (writers hold it exclusively).
    {"name": "thread-rwlock",
     "smp":    4,
     "timeout": 60,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Process arguments: argv arrives on the SysV entry stack (argv[0] = the spawned
    # name, argv[1..] = the system_spawn argument blob) and echoes back intact.
    {"name": "args",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # The real user binary: the bootloader ships /system/services/init off the ESP, the
    # kernel loads the ELF and runs it in ring 3, and it writes + exits cleanly.
    {"name": "init",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Real processes: /system/services/init loaded as a scheduled ring-3 process with its
    # own address space, run twice (create/exit/teardown/recreate), coexisting
    # with a kernel task under preemption.
    {"name": "process",
     "smp":    4,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # process_enumerate: a task-table snapshot lists spawned processes by name and
    # id alongside the kernel tasks, and a too-small buffer still reports the total.
    {"name": "process-list",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # process_kill + the exit notification: only the supervisor may kill; a blocked
    # victim is reaped in place and a spinning one dies by the deferred (tick) path;
    # each death posts one exit badge to the endpoint given at spawn.
    {"name": "process-kill",
     "smp":    4,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # The user-side whole: process-test supervises two children from ring 3 —
    # spawn with an exit endpoint, enumerate, kill, notification, gone.
    {"name": "supervision",
     "smp":    4,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # M17.1: a dead process's device claims are released by the reap — kill a child
    # holding a claim, the device must be claimable again (process-lifecycle.md).
    {"name": "claim-release",
     "smp":    4,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # M17.3: published exit events — the VFS subscribes, a client dies holding an
    # open handle, and the VFS releases it (process-lifecycle.md "Who learns of a death").
    {"name": "vfs-client-death",
     "smp":    4,
     "timeout": 90,  # the park client waits out the whole USB->block->fat chain
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # M17.4: signals over IPC — ping, reload, terminate (clean exit), the one-shot
    # timer, and the stop sequence's two endings, all driven from ring 3.
    {"name": "signals",
     "smp":    4,
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # M18.2: bus tree reports — the xHCI driver scans its root-hub ports and
    # reports both QEMU devices; the manager mirrors, prunes on the reporter's
    # death, and the respawned driver re-reports (docs/device-manager.md).
    {"name": "usb-report",
     "smp":    4,
     "timeout": 150,
     # The xHCI bus + usb-kbd/usb-mouse come from the default boot config now
     # (every case boots off a usb-storage device on that bus).
     "expect": r"device-manager: child added[\s\S]*"
               r"device-manager: child added[\s\S]*"
               r"device-manager: test mode: killing the reporter[\s\S]*"
               r"device-manager: child removed[\s\S]*"
               r"device-manager: restarting \S*usb-xhci-bus[\s\S]*"
               r"device-manager: child added",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # USB HID end to end: boot the full tree, enumerate the xHCI, and let the
    # manager spawn the USB keyboard driver, which opens its device over the
    # transfer protocol, asks for boot protocol, subscribes to its interrupt
    # endpoint, and comes up — proof the class-driver <-> controller path works.
    {"name": "usb-hid",
     "smp":    4,
     "timeout": 150,
     # usb-kbd/usb-mouse ride the default boot xHCI bus (see qemu_args).
     "expect": r"(?=[\s\S]*usb-hid-keyboard: ok)(?=[\s\S]*usb-hid-mouse: ok)",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Keyboard echo: inject a known phrase via QMP send-key; the usb-hid-keyboard
    # driver decodes it and echoes each character to the log (the simple
    # real-hardware keyboard check — type a phrase, read it back off the stick,
    # or watch it live on screen in a -Ddiagnose boot). Proves the whole path:
    # HID report -> decode -> layout -> character.
    {"name": "usb-key-echo",
     "build_case": "usb-hid",
     "smp":    4,
     "timeout": 150,
     "qmp_sequence": [
         {"delay": 8, "command": "send-key", "arguments": {"keys": [{"type": "qcode", "data": c}]}}
         for c in ["k", "e", "y", "t", "e", "s", "t"]
     ],
     "expect": r"keytest",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # USB hub (B4a/B4b, docs/usb-hub.md): a USB2 hub on the xHCI bus with a
    # keyboard behind it (a STATIC boot topology — no hot-plug event needed).
    # B4a: the hub enumerates and powers its downstream ports. B4b extends the
    # expect to the downstream keyboard binding usb-hid-keyboard.
    {"name": "usb-hub",
     "build_case": "usb-hid",
     "smp":    4,
     "timeout": 150,
     # A SECOND xhci controller carries the hub topology, isolated from the boot
     # controller's auto-assigned devices (whose ports the hub would collide
     # with). danos spawns a second usb-xhci-bus for it. The hub sits on port 1,
     # the keyboard on the hub's downstream port 1 (port=1.1).
     "qemu_extra": ["-device", "qemu-xhci,id=xhci2",
                    "-device", "usb-hub,bus=xhci2.0,port=1",
                    "-device", "usb-kbd,bus=xhci2.0,port=1.1"],
     # B4a: the hub powers its ports. B4b: the keyboard behind it enumerates
     # (route string + TT) and binds usb-hid-keyboard.
     "expect": r"(?s)(?=.*hub slot \d+: \d+ downstream ports powered)"
               r"(?=.*hub slot \d+ port \d+ device vendor 0x0627)"
               r"(?=.*usb-hid-keyboard: ok \(device 3)",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # USB mass storage end to end: the boot usb-storage device (the FAT32 image,
    # which has a real 0x55AA boot sector) is enough — the manager spawns
    # usb-storage, which opens the device, runs the Bulk-Only / SCSI bring-up,
    # reads its capacity, and reads block 0 (the 0x55AA boot sig). Proof of the
    # bulk transfer path + BOT + SCSI end to end.
    {"name": "usb-storage",
     "smp":    4,
     "timeout": 150,
     "expect": r"usb-storage: ready[\s\S]*usb-storage: block 0 signature 0x55aa",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # FAT mount end to end: the fat server mounts the boot usb-storage device (the
    # FAT32 image) into the VFS at /mnt/usb. A fat-test client then lists and reads
    # through the mount — proof of the whole stack: block device -> FAT parse ->
    # VFS routing -> file read.
    {"name": "fat-mount",
     "smp":    4,
     "timeout": 150,
     "expect": r"fat: mounted /mnt/usb[\s\S]*fat-test: ok",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Phase 2b: mkdir/unlink through the mount. Reuses the fat-mount build — the
    # fat-test client, after listing, makes a directory, writes+reads a file inside
    # it, then removes the file, exercising the whole VFS -> fat mutation path.
    {"name": "fat-mutations",
     "build_case": "fat-mount",
     "smp":    4,
     "timeout": 150,
     "expect": r"fat-test: mutations ok",
     "fail":   r"fat-test: mutations FAILED|fat-test: mkdir .* failed|DANOS-TEST-RESULT: FAIL"},
    # Phase 2c: rename through the mount — fat-test renames the file it created
    # before removing it, and confirms the old name is gone.
    {"name": "fat-rename",
     "build_case": "fat-mount",
     "smp":    4,
     "timeout": 150,
     "expect": r"fat-test: rename ok",
     "fail":   r"fat-test: mutations FAILED|DANOS-TEST-RESULT: FAIL"},
    # Phase 2d: filesystem timestamps — a freshly-created file's mtime is a real
    # current wall-clock time (stamped from the RTC), read back through stat.
    {"name": "fat-mtime",
     "build_case": "fat-mount",
     "smp":    4,
     "timeout": 150,
     "expect": r"fat-test: mtime ok",
     "fail":   r"fat-test: mutations FAILED|DANOS-TEST-RESULT: FAIL"},
    # Boot-from-USB smoke: the whole system now boots off the FAT32 image on a
    # usb-storage device (OVMF -> \EFI\BOOT\BOOTX64.efi -> kernel), so the kernel
    # reaching its PASS marker at all proves the USB boot path end to end. Reuses
    # the smoke kernel build; the value is the explicit, named regression guard.
    {"name": "usb-boot",
     "build_case": "smoke",
     "qmp_after": {"delay": 2, "command": "query-status"},
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # M20.1: the ring-3 AML parse (the acpi service maps the blobs and parses
    # them) finds exactly the Device count the kernel's own parse produced.
    {"name": "acpi-parse",
     "smp":    4,
     "timeout": 60,
     "expect": r"acpi-parse: ok",
     "fail":   r"acpi-parse: too few|DANOS-TEST-RESULT: FAIL"},
    # M20.3: the flip — ps2-bus now comes up from the acpi service's report, not
    # a kernel-built node. Ordered: report -> spawn -> the driver attaches its
    # keyboard, proving discovery runs entirely in ring 3 (docs/discovery.md).
    {"name": "acpi-ps2",
     "smp":    4,
     "timeout": 150,
     "expect": r"discovery: reported PNP0303[\s\S]*"
               r"device-manager: spawned \S*ps2-bus[\s\S]*"
               r"ps2-bus: keyboard driver attached",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # M21.1: the SCI + power button. Boot the manager (which spawns the acpi
    # service); ~4s in, QMP system_powerdown raises the ACPI power-button fixed
    # event; the service's SCI handler must log the press (docs/acpi.md).
    {"name": "power-button",
     "smp":    4,
     "timeout": 60,
     "qmp_after": {"delay": 4, "command": "system_powerdown"},
     "expect": r"power: button pressed",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # M21.3 capstone: orderly shutdown. Boot init (the full tree comes up);
    # ~5s in, QMP system_powerdown raises the power button; the acpi service
    # publishes it, init stops its children then requests S5, and QEMU exits.
    # The ordered regex proves button -> shutting-down -> entering-S5; the case
    # passes on QEMU's self-exit through S5 (docs/power.md).
    {"name": "orderly-shutdown",
     "smp":    4,
     "timeout": 90,
     "qmp_after": {"delay": 5, "command": "system_powerdown"},
     "expect": r"power: button pressed[\s\S]*"
               r"init: shutting down[\s\S]*"
               r"power: entering S5",
     "fail":   r"power: S5 write did not take|DANOS-TEST-RESULT: FAIL"},
    # M8: the boot log is persisted to the USB FAT volume. Reuses the orderly-
    # shutdown build (full tree + power button): the logger service announces its
    # per-boot directory once storage mounts (first marker), then the power
    # button drives the orderly stop — the logger, stopped first, final-drains
    # and reports the flush (second marker) before S5.
    {"name": "logger",
     "build_case": "orderly-shutdown",
     "smp":    4,
     "timeout": 150,
     "qmp_after": {"delay": 8, "command": "system_powerdown"},
     "expect": r"logger: logging to /var/log/\d{4}-\d{2}-\d{2}T\d{6}Z[\s\S]*"
               r"init: shutting down[\s\S]*"
               r"logger: flushed through sequence \d+[\s\S]*"
               r"power: entering S5",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # M20.2: the acpi service evaluates _CRS/_STA in ring 3 and registers +
    # reports its _HID devices — the two PS/2 nodes must appear with resources
    # (keyboard: io 0x60/0x64 + IRQ = 3; mouse: IRQ = 1) (docs/discovery.md).
    {"name": "acpi-report",
     "smp":    4,
     "timeout": 150,
     "expect": r"discovery: reported PNP0303 \(device \d+, 3 resources\)[\s\S]*"
               r"discovery: reported PNP0F13 \(device \d+, 1 resources\)",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # M19.1/M19.3: the ring-3 PCI scan. pci-bus walks the ECAM through its mmio_map
    # grant and registers every function it finds; the kernel's own walk retired, so
    # the broker starts empty and the driver populates it. The manager then runs the
    # restart drill: ~1 s after the scan it kills pci-bus, prunes its child tree, and
    # respawns it to re-claim, re-scan, and re-register the same functions. The kernel
    # test asserts the broker equivalence (empty before, populated after, no
    # duplicates); this ordered regex asserts the drill itself over the whole serial
    # log — the backreference requires the respawn to re-scan the same count, and the
    # full-capture match is immune to the transient-line races an in-kernel poll hits.
    {"name": "pci-scan",
     "smp":    4,
     "timeout": 60,
     "expect": r"pci-bus: (\d+) functions found[\s\S]*"
               r"device-manager: test mode: killing the reporter[\s\S]*"
               r"device-manager: restarting \S*pci-bus[\s\S]*"
               r"pci-bus: \1 functions found[\s\S]*"
               r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # M18.3: the application surface — device-list enumerates the tree over IPC,
    # subscribes (endpoint as capability), and observes the removed/added events
    # the reporter's test-kill produces (docs/device-manager.md).
    {"name": "device-list",
     "smp":    4,
     "timeout": 150,
     "expect": r"device-list: \d+ devices[\s\S]*"
               r"device-list: subscribed[\s\S]*"
               r"device-manager: test mode: killing the reporter[\s\S]*"
               r"device-list: removed \(device[\s\S]*"
               r"device-list: added \(device",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # M18.1: the device manager's hello + restart policy — xHCI hellos clean and
    # stays; crash-test faults, is restarted with backoff (re-claiming its device
    # each time), and hits the crash-loop cap (docs/device-manager.md).
    {"name": "driver-restart",
     "smp":    4,
     "timeout": 150,
     "expect": r"usb-xhci-bus: hello acknowledged[\s\S]*"
               r"device-manager: restarting crash-test[\s\S]*"
               r"device-manager: crash-test is failing repeatedly",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # The initial_ramdisk: the loader ferries a bundle of user binaries; the kernel parses
    # it and spawns each as a ring-3 process (here the VFS-server stub heartbeats).
    {"name": "initial-ramdisk",
     "timeout": 60,  # the acpi service's boot-time SCI setup can push the marker past 30s under load
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # The user-space VFS: a client opens/writes/reads a file through the rt file
    # API, which IPCs the VFS server process; the round trip must match.
    # (A usb-hotplug case was prototyped here, but QEMU's qemu-xhci does not
    # raise a runtime port-change event to a polling driver on device_add, so it
    # cannot exercise the path. The hot-plug code — port-change queue, teardown
    # via Disable Slot, ChildRemoved reporting — is validated on real hardware,
    # flagged for the user. The qmp_sequence harness support it added remains.)
    # Hub-behind-hub (B4c): route strings compose across tiers — a keyboard two
    # hubs deep enumerates and binds. Static nested topology on a 2nd controller.
    {"name": "usb-hub-nested",
     "build_case": "usb-hid",
     "smp":    4,
     "timeout": 150,
     "qemu_extra": ["-device", "qemu-xhci,id=xhci2",
                    "-device", "usb-hub,bus=xhci2.0,port=1",
                    "-device", "usb-hub,bus=xhci2.0,port=1.1",
                    "-device", "usb-kbd,bus=xhci2.0,port=1.1.1"],
     "expect": r"(?s)(?=.*hub slot \d+ port \d+ device vendor 0x0409)"
               r"(?=.*usb-hid-keyboard: ok \(device 3)",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Hub-downstream disconnect (B4c): device_del the keyboard behind the hub;
    # the hub's status-change endpoint reports it, the device is torn down
    # (ChildRemoved + Disable Slot). QEMU's hub DOES raise downstream changes
    # (unlike root-port hot-plug).
    {"name": "usb-hub-unplug",
     "build_case": "usb-hid",
     "smp":    4,
     "timeout": 150,
     "qemu_extra": ["-device", "qemu-xhci,id=xhci2",
                    "-device", "usb-hub,bus=xhci2.0,port=1",
                    "-device", "usb-kbd,bus=xhci2.0,port=1.1,id=dkbd"],
     "qmp_after": {"delay": 8, "command": "device_del", "arguments": {"id": "dkbd"}},
     "expect": r"(?s)(?=.*usb-hid-keyboard: ok \(device 3)"
               r"(?=.*slot \d+ disconnected)",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # The kernel VFS root (M-F): the mount table serves the initrd at /system —
    # path resolution, node status/read (an ELF magic), and directory listing,
    # asserted kernel-side.
    {"name": "kvfs",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    {"name": "vfs",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # The input service: a synthetic keyboard source publishes events, the service
    # broadcasts them over the async ipc_send primitive, and a subscriber (which joined by
    # passing its endpoint as a capability) receives them — source -> service -> subscriber.
    {"name": "input",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # Device manager: a ring-3 service enumerates /system/devices, matches the PCI host
    # bridge to pci-bus, and spawns it — end-to-end proof of discover -> match -> spawn
    # -> driver-up (the spawned pci-bus logs "<N> functions found").
    {"name": "device-manager",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # device_register containment (in-kernel): registering a child whose MMIO window
    # escapes its parent's grant is refused (NotContained) — else dev_register would map
    # arbitrary physical memory — while an identical re-register stays idempotent.
    {"name": "containment",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # IRQ teardown: an exiting driver's line is masked and its slot cleared (so no
    # ISR notifies a freed endpoint), and a sibling owner sharing that endpoint
    # keeps its own binding. A long-running driver never reaches this teardown path.
    {"name": "irqfree",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # The MMIO-grant teardown fix: a device-granted frame must not be reclaimed
    # as RAM when its address space is destroyed.
    {"name": "iopass",
     "expect": r"DANOS-TEST-RESULT: PASS",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
    # The ACPI power path succeeds by QEMU *exiting* (S5 off / reset), so match the
    # pre-transition marker; the FAIL line only appears if the transition didn't take.
    # Soft-off (S5) is owned by the ring-3 acpi service now (see orderly-shutdown);
    # the kernel keeps only reboot (FADT reset register, no AML).
    {"name": "reboot",
     "expect": r"DANOS-POWER: attempting reboot",
     "fail":   r"DANOS-TEST-RESULT: FAIL"},
]

TIMEOUT = 30  # seconds per case


def build(arch, case):
    # -Dserial: the harness asserts on markers the kernel writes to serial0, so the
    # serial log sink must be compiled in. It is off by default (a flashed real-
    # hardware image keeps its log in RAM instead; see build.zig / serial.zig).
    cmd = ["zig", "build", f"-Dtest-case={case}", "-Dserial=true"] + arch["zig_flags"]
    r = subprocess.run(cmd, cwd=REPO, capture_output=True, text=True)
    if r.returncode != 0:
        return r.stderr.strip() or r.stdout.strip()
    return None


def resolve_firmware(arch):
    """Collapse the ovmf_code/ovmf_vars candidate lists to the first path that
    exists on this machine. Mutates `arch` in place; idempotent (a resolved
    string is left untouched). Exits with a clear message if none is found."""
    for key, label in (("ovmf_code", "OVMF_CODE"), ("ovmf_vars", "OVMF_VARS")):
        val = arch[key]
        if isinstance(val, str):
            continue  # already resolved on a previous call
        for cand in val:
            if os.path.exists(cand):
                arch[key] = cand
                break
        else:
            sys.exit(f"error: no {label} firmware image found; looked in:\n  "
                     + "\n  ".join(val)
                     + "\nInstall OVMF (edk2-ovmf / ovmf) or add its path above.")


def qmp_send(path, command, arguments=None):
    """One QMP command: connect, capabilities handshake, execute. Raises on any
    failure — the caller retries until the guest's socket is ready. This is how
    a case injects a host-side event into the running guest: system_powerdown
    (the ACPI power button, docs/power.md) or device_add/device_del (USB
    hot-plug, docs/driver-model.md)."""
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    sock.settimeout(5)
    try:
        sock.connect(path)
        stream = sock.makefile("rw")
        stream.readline()  # the QMP greeting
        stream.write(json.dumps({"execute": "qmp_capabilities"}) + "\n")
        stream.flush()
        stream.readline()  # {"return": {}}
        message = {"execute": command}
        if arguments:
            message["arguments"] = arguments
        stream.write(json.dumps(message) + "\n")
        stream.flush()
        stream.readline()
    finally:
        sock.close()


def run_case(arch, case):
    # A case's kernel build defaults to its name; `build_case` decouples the two
    # so a case can reuse another's kernel (e.g. usb-boot reuses smoke's).
    err = build(arch, case.get("build_case", case["name"]))
    if err:
        return False, "build failed:\n" + err

    # The bootable FAT32 USB image the build produced (tools/make-fat-image.py),
    # presented to the guest as a usb-storage device (see qemu_args).
    # Boot a per-run COPY of the image: the guest MUTATES its boot volume (the
    # fat tests create/delete files; the logger writes /var/log), and QEMU is
    # hard-killed after a match — booting the build artifact in place let one
    # run's leftovers fail the next (a stale TESTDIR trips the mkdir-duplicate
    # refusal) and dirtied the build cache's own output.
    built_volume = os.path.join(REPO, "zig-out", "danos-usb.img")
    boot_volume = os.path.join(WORK, "boot-volume.img")
    shutil.copy(built_volume, boot_volume)
    vars_fd = os.path.join(WORK, "vars.fd")
    shutil.copy(arch["ovmf_vars"], vars_fd)
    serial = os.path.join(WORK, "serial.log")
    if os.path.exists(serial):
        os.remove(serial)

    expect = re.compile(case["expect"])
    fail = re.compile(case["fail"]) if case.get("fail") else None

    cmd = [arch["qemu"]] + arch["qemu_args"](arch, boot_volume, vars_fd, serial)
    if case.get("smp"):  # some cases need more than one core (e.g. parallelism)
        cmd += ["-smp", str(case["smp"])]
    if case.get("mem"):  # a case that boots the whole system at once needs more than the 128M floor
        cmd[cmd.index("-m") + 1] = case["mem"]
    if case.get("qemu_extra"):  # extra qemu args, e.g. -device intel-iommu for the IOMMU case
        cmd += case["qemu_extra"]
    # A QMP control socket, always present (additive): how a case's `qmp_after`
    # hook injects host-side events into the guest mid-run. Kept under a short temp
    # dir, not WORK: a unix socket path is capped at ~104 bytes (sun_path), and a
    # deep worktree path (e.g. .claude/worktrees/<name>/zig-out/qemu-test/qmp.sock)
    # blows that limit on macOS, so QEMU fails to bind and exits before booting.
    qmp_path = os.path.join(tempfile.gettempdir(), f"danos-qmp-{os.getpid()}.sock")
    if os.path.exists(qmp_path):
        os.remove(qmp_path)
    cmd += ["-qmp", f"unix:{qmp_path},server,nowait"]
    # Hooks: a single qmp_after {"delay","command"} or a qmp_sequence list of
    # {"delay","command","arguments"} — every hook must deliver before a pass.
    qmp_hooks = case.get("qmp_sequence") or ([case["qmp_after"]] if case.get("qmp_after") else [])
    qmp_pending = [dict(hook, sent=False) for hook in qmp_hooks]
    started = time.monotonic()
    qemu = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    try:
        timeout = case.get("timeout", TIMEOUT)
        deadline = time.monotonic() + timeout
        while time.monotonic() < deadline:
            time.sleep(0.2)
            for hook in qmp_pending:
                if not hook["sent"] and time.monotonic() - started >= hook["delay"]:
                    try:
                        qmp_send(qmp_path, hook["command"], hook.get("arguments"))
                        hook["sent"] = True
                    except OSError:
                        pass  # socket not up yet; retry next tick
            text = ""
            if os.path.exists(serial):
                with open(serial, "r", errors="replace") as f:
                    text = f.read()
            if fail and fail.search(text):
                return False, "hit failure marker"
            if expect.search(text):
                if any(not hook["sent"] for hook in qmp_pending):
                    continue  # every hook must deliver before the case may pass
                return True, "matched " + repr(case["expect"])
            if qemu.poll() is not None:  # QEMU exited on its own
                if expect.search(text):
                    return True, "matched " + repr(case["expect"])
                return False, "QEMU exited before matching (triple fault?)"
        return False, f"timed out after {timeout}s without matching {case['expect']!r}"
    finally:
        if qemu.poll() is None:
            qemu.terminate()
            try:
                qemu.wait(timeout=5)
            except subprocess.TimeoutExpired:
                qemu.kill()


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--arch", default="x86_64", choices=sorted(ARCHES))
    ap.add_argument("cases", nargs="*", help="case names to run (default: all)")
    args = ap.parse_args()

    arch = ARCHES[args.arch]
    if not shutil.which(arch["qemu"]):
        print(f"error: {arch['qemu']} not found on PATH", file=sys.stderr)
        return 2
    resolve_firmware(arch)

    selected = [c for c in CASES if not args.cases or c["name"] in args.cases]
    os.makedirs(WORK, exist_ok=True)

    print(f"danos qemu tests  arch={args.arch}  cases={len(selected)}\n")
    failures = 0
    for case in selected:
        print(f"  {case['name']:<12} ... ", end="", flush=True)
        ok, detail = run_case(arch, case)
        if not ok:
            # Keep the evidence: serial.log is otherwise overwritten by the
            # next case, and an intermittent failure's log is unrecoverable.
            source = os.path.join(WORK, "serial.log")
            if os.path.exists(source):
                shutil.copy(source, os.path.join(WORK, f"{case['name']}-failed-serial.log"))
        print(("PASS" if ok else "FAIL") + f"  ({detail})")
        if not ok:
            failures += 1

    print()
    total = len(selected)
    print(f"{total - failures}/{total} passed")
    return 1 if failures else 0


if __name__ == "__main__":
    sys.exit(main())
