diff --git a/.github/badges/loc.svg b/.github/badges/loc.svg
index 3363c31..d0a99de 100644
--- a/.github/badges/loc.svg
+++ b/.github/badges/loc.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg
index c3c3f29..d4e24ba 100644
--- a/.github/badges/tests.svg
+++ b/.github/badges/tests.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c9ec70b..233cb91 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,7 +35,7 @@ jobs:
run: |
set -o pipefail
: > /tmp/test.log
- for d in sim tools/config-lint qemu/rs485-bridge; do
+ for d in sim tools/config-lint qemu/rs485-bridge qemu/tests/clockprobe; do
echo "== cargo test in $d ==" | tee -a /tmp/test.log
( cd "$d" && cargo test --locked ) 2>&1 | tee -a /tmp/test.log
done
@@ -220,6 +220,11 @@ jobs:
CROSS_COMPILE: arm-linux-gnueabihf-
WARDEN_CCACHE: 1
CCACHE_DIR: /home/runner/.ccache
+ # Matched module set for the out-of-tree wifi/BT drivers (issue #4:
+ # a stale 5.10 .ko was all the panel had). Full `make modules` runs
+ # (zImage alone emits no Module.symvers); only these dirs' .ko files
+ # are collected/uploaded.
+ WARDEN_MODULES_COLLECT: drivers/net/wireless/aic8800
# KERNEL_TARBALL is exported from the SHELL so $HOME expands — a literal
# `~` in a YAML env: value is never tilde-expanded and broke every
# dispatch until caught in review.
@@ -246,6 +251,7 @@ jobs:
path: |
${{ runner.temp }}/kbuild-out/linux-6.18.46/arch/arm/boot/zImage
${{ runner.temp }}/kbuild-out/linux-6.18.46/arch/arm/boot/dts/rockchip/rv1106-warden.dtb
+ ${{ runner.temp }}/kbuild-out/modules-out/*.ko
# Short so kernel images self-expire instead of piling into the
# account-wide storage quota; the prune-artifacts job above is the
# active bound, this is the backstop.
diff --git a/build/build-kernel.sh b/build/build-kernel.sh
index 73f097c..f0caccd 100755
--- a/build/build-kernel.sh
+++ b/build/build-kernel.sh
@@ -19,6 +19,12 @@
# (qemu/configs/virt.fragment builds the QEMU -M virt variant);
# every fragment option is verified to have taken effect
# WARDEN_CCACHE=1 compile through ccache (CI caches ~/.ccache)
+# WARDEN_MODULES_COLLECT
+# space-separated in-tree dirs whose .ko files are wanted
+# (e.g. "drivers/net/wireless/aic8800"). Runs a full
+# `make modules` (the zImage target alone generates no
+# Module.symvers, so per-dir M= builds cannot link) and
+# collects the listed dirs' modules into $WORK/modules-out.
#
# Requires: `python` (not python3) on PATH — the SDK quirk; the CI runner provides
# a project-local venv. Builds are SERIAL on the shared SDK box — never run two.
@@ -157,6 +163,28 @@ log "building zImage + rv1106-warden.dtb (-j$JOBS)"
make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" CC="$KCC" -j"$JOBS" \
zImage rockchip/rv1106-warden.dtb
+# Optional module set (issue #4: the panel loaded a stale 5.10 .ko because
+# this build never produced matched 6.18 modules). Full `make modules` is
+# required — zImage alone emits no Module.symvers, so a per-directory M=
+# build cannot resolve even core symbols. FAILS CLOSED if a listed dir
+# yields no modules.
+if [ -n "${WARDEN_MODULES_COLLECT:-}" ]; then
+ MODOUT="$WORK/modules-out"
+ rm -rf "$MODOUT"; mkdir -p "$MODOUT"
+ log "building modules (full set — needed for Module.symvers)"
+ make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" CC="$KCC" -j"$JOBS" modules
+ for d in $WARDEN_MODULES_COLLECT; do
+ [ -d "$SRC/$d" ] || { echo "FATAL: WARDEN_MODULES_COLLECT dir '$d' not in tree" >&2; exit 1; }
+ n=0
+ while IFS= read -r ko; do
+ cp "$ko" "$MODOUT/"; n=$((n + 1))
+ done < <(find "$SRC/$d" -name '*.ko')
+ [ "$n" -gt 0 ] || { echo "FATAL: no .ko produced under '$d'" >&2; exit 1; }
+ log "collected $n module(s) from $d"
+ done
+ ls -la "$MODOUT"
+fi
+
Z="$SRC/arch/arm/boot/zImage"
D="$SRC/arch/arm/boot/dts/rockchip/rv1106-warden.dtb"
log "build OK"
diff --git a/kernel/rv1106-enablement/timer/PLAN.md b/kernel/rv1106-enablement/timer/PLAN.md
new file mode 100644
index 0000000..0b314f1
--- /dev/null
+++ b/kernel/rv1106-enablement/timer/PLAN.md
@@ -0,0 +1,54 @@
+# arch-timer / vDSO clock fix — plan (issue #3)
+
+Status: DIAGNOSED off-board, fix gated on two bench measurements.
+
+## Established (2026-08-30, qemu/ device sim)
+
+The same kernel family under `qemu-system-arm -M virt` gives musl
+`CLOCK_MONOTONIC`/`_RAW` rate ratio 0.99963 vs `/proc/uptime` (interval
+measurement, `qemu/tests/clockprobe`). The generic 6.18 armv7 vDSO is
+therefore CORRECT; the board symptom (musl reads ~12% high, kernel time
+right) is RV1106-specific. The boot chain runs in the secure world and is
+closed rkbin — the NS view of the CPU timer registers (CNTFRQ, CNTVOFF) is
+whatever it left behind, and only the arch-counter path (vDSO,
+`arch_sys_counter`) trusts them.
+
+## What the bench must answer (single boot of the `_b` slot)
+
+Run `clockprobe` (interval mode) on the 6.18 slot:
+
+1. `ratio_mono` far from 1.0 -> RATE error: CNTFRQ wrong. The true rate =
+ claimed rate (dmesg `arch_timer: cp15 timer running at X MHz`) times the
+ measured ratio.
+2. `ratio_mono` ~= 1.0 but `abs_ratio` far from 1.0 -> OFFSET error: CNTVOFF
+ left nonzero; rate fine.
+
+(The original issue measured only one absolute sample, which cannot
+distinguish these.)
+
+## The fix (both cases, one DT override)
+
+Append to the BOARD dts (`rv1106-warden.dts` — never the vendor dtsi) an
+override on the armv7-timer node:
+
+ arm,cpu-registers-not-fw-configured;
+ clock-frequency = ;
+
+The property makes the driver use the physical counter, ignore CNTVOFF, and
+take the frequency from DT — the documented remedy for firmware that does
+not configure the CPU timer registers. `MEASURED_HZ` comes from bench
+answer 1 (do NOT guess; a wrong value makes every clock wrong instead of
+one path). Ship as an update to the arch/dts patch in `patches/`.
+
+## Regression guards
+
+- Off-board: `qemu/tests/clock-sanity.sh` asserts the vDSO rate in the VM
+ (guards the generic path; cannot see board registers).
+- On-board: re-run `clockprobe` on the patched `_b` slot; both ratios and
+ the absolute ratio must be ~1.0. Record the numbers here and in issue #3.
+
+## Bench access note
+
+2026-08-30: c8a3 is physically dark (CP2102 console silent through two
+remote power cycles; both network paths down) — needs hands at the bench
+before the measurements can run.
diff --git a/qemu/README.md b/qemu/README.md
index a0d201b..c223a74 100644
--- a/qemu/README.md
+++ b/qemu/README.md
@@ -76,6 +76,21 @@ stage-2 init when present.
(a 200 ms hold — an instantaneous press+release lands inside one LVGL poll
and never clicks), and asserts the frame changed. `qmp.py` is the tiny QMP
client.
+- `ota-apply.sh ` (needs `FLARE_EDGE`) — the FULL apply loop the
+ portal scenario stops short of: a real signed tier-1 `.wfw` whose payload
+ is a bootable rootfs is pulled, verified, WRITTEN to rootfs_b
+ (`run.sh --allow-apply` gates it per boot), the AvbABData in `misc` is
+ flipped (mkimage provisions real A/B metadata), and the harness reboots
+ slot `_b` and asserts the applied version is running. The BCB slot CHOICE
+ and the physical reset stay emulated by the harness (ADR-0006 boundary);
+ the VM exports `WARDEN_HARD_RESET=0` so flared's post-apply reset surfaces
+ as a clean reported error instead of a /dev/mem fault.
+- `real-image-boot.sh ` — the real-image
+ milestone: an ACTUAL flare-edge build (matched pair, placed by
+ `mkimage.sh --rootfs-image/--oem-image`) boots through its own init chain
+ to a getty on the VM console; real daemons start. RV1106-only init steps
+ degrade as documented, and binaries predating known fixes reproduce their
+ bugs faithfully (a feature: the VM is a time machine for field issues).
- Watchdog: `run.sh --watchdog`, arm `/dev/watchdog` in the guest, don't pet —
the VM resets ~30 s later (verified). Do NOT combine with a flared payload
expecting survival: flared pets only while the UI heartbeat is fresh.
diff --git a/qemu/mkimage.sh b/qemu/mkimage.sh
index 7b85626..47869f7 100755
--- a/qemu/mkimage.sh
+++ b/qemu/mkimage.sh
@@ -10,6 +10,10 @@
# sudo), then dd'd into a sparse raw image.
#
# Usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V]
+# [--rootfs-image PATH] [--oem-image PATH]
+# --rootfs-image/--oem-image place REAL device images (raw ext4, e.g. a
+# flare-edge build's rootfs.img/oem.img matched pair) into slot A instead of
+# the busybox skeleton; slot B keeps the skeleton as a known-good fallback.
# Env:
# BUSYBOX path to a local busybox binary (skips the download; still verified)
# OUT output dir (default: qemu/out); image at $OUT/disk.img
@@ -27,6 +31,8 @@ PATH="$PATH:/usr/sbin:/sbin"
PORTAL_URL=""
STATE_KV=()
FW_VERSION="0.0.1"
+ROOTFS_IMAGE=""
+OEM_IMAGE=""
while [ $# -gt 0 ]; do
case "$1" in
--portal-url) PORTAL_URL="${2:?--portal-url needs a value}"; shift 2 ;;
@@ -45,6 +51,8 @@ while [ $# -gt 0 ]; do
esac
STATE_KV+=("$2"); shift 2 ;;
--fw-version) FW_VERSION="${2:?--fw-version needs a value}"; shift 2 ;;
+ --rootfs-image) ROOTFS_IMAGE="${2:?--rootfs-image needs a path}"; shift 2 ;;
+ --oem-image) OEM_IMAGE="${2:?--oem-image needs a path}"; shift 2 ;;
*) echo "FATAL: unknown argument '$1' (usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V])" >&2; exit 1 ;;
esac
done
@@ -90,28 +98,79 @@ mkfs_part() {
DISK="$OUT/disk.img"
rm -f "$DISK"
+# dd a REAL raw image into a partition window, fail-closed on overflow.
+place_real_image() { # $1 src image, $2 offset, $3 partition size, $4 name
+ local srcsz
+ srcsz=$(stat -c %s "$1")
+ [ "$srcsz" -le "$3" ] || {
+ echo "FATAL: $4 image $1 ($srcsz bytes) exceeds the $3-byte partition" >&2
+ exit 1
+ }
+ dd if="$1" of="$DISK" bs=4096 seek=$(($2 / 4096)) conv=notrunc,sparse status=none
+ qemu_log " $4: REAL image $(basename "$1") ($((srcsz / 1048576))M) @ $2"
+}
+
place_partition() {
local name="$1" off="$2" size="$3" stage=""
case "$name" in
- rootfs_a|rootfs_b) stage="$ROOT" ;;
+ rootfs_a)
+ if [ -n "$ROOTFS_IMAGE" ]; then
+ [ -f "$ROOTFS_IMAGE" ] || { echo "FATAL: --rootfs-image $ROOTFS_IMAGE not found" >&2; exit 1; }
+ DISK_END_TRACK "$off" "$size"
+ place_real_image "$ROOTFS_IMAGE" "$off" "$size" "$name"
+ return 0
+ fi
+ stage="$ROOT" ;;
+ oem_a)
+ if [ -n "$OEM_IMAGE" ]; then
+ [ -f "$OEM_IMAGE" ] || { echo "FATAL: --oem-image $OEM_IMAGE not found" >&2; exit 1; }
+ DISK_END_TRACK "$off" "$size"
+ place_real_image "$OEM_IMAGE" "$off" "$size" "$name"
+ return 0
+ fi
+ stage="$SCRATCH/empty" ;;
+ rootfs_b) stage="$ROOT" ;;
userdata) stage="$UDATA" ;;
- oem_a|oem_b) stage="$SCRATCH/empty" ;;
+ oem_b) stage="$SCRATCH/empty" ;;
+ # misc carries REAL AvbABData (byte 2048): flared's slotctl fail-closes
+ # on a bad magic before any OTA write, so a zeroed misc blocks apply
+ # scenarios. Bytes mirror flare-edge tools/mk-misc.py provisioning
+ # defaults (A: prio 15 successful, B: prio 14 successful, CRC32-BE).
+ misc) stage="__misc__" ;;
# Boot-chain partitions the VM never reads: present at the right offsets,
# left zeroed. Enumerated (not a wildcard) so a typo'd name in
# blkdevparts.conf fails HERE, not as a confusing mount error at boot.
- env|idblock|uboot|misc|boot_a|boot_b|recovery) stage="" ;;
+ env|idblock|uboot|boot_a|boot_b|recovery) stage="" ;;
*) echo "FATAL: unknown partition name '$name' in blkdevparts.conf" >&2; exit 1 ;;
esac
+ DISK_END_TRACK "$off" "$size"
# dd in 4K blocks — every offset in the canonical layout is 4K-aligned;
# assert rather than assume, a misaligned write would corrupt a neighbor.
if [ $((off % 4096)) -ne 0 ] || [ $((size % 4096)) -ne 0 ]; then
echo "FATAL: partition $name not 4K-aligned (off=$off size=$size)" >&2
exit 1
fi
- # Max, not last: blkdevparts grammar permits explicit @offsets out of order.
- [ $((off + size)) -gt "$DISK_END" ] && DISK_END=$((off + size))
[ -z "$stage" ] && return 0
local img="$SCRATCH/$name.img"
+ if [ "$stage" = "__misc__" ]; then
+ python3 - "$img" "$size" <<'PYMISC'
+import struct, sys, zlib
+img, size = sys.argv[1], int(sys.argv[2])
+s = bytearray(28)
+s[0:4] = b"\0AB0" # AB_MAGIC
+s[4] = 1 # major
+s[8:12] = bytes([15, 0, 1, 0]) # slot A: priority, tries, successful
+s[12:16] = bytes([14, 0, 1, 0]) # slot B
+meta = bytes(s) + struct.pack(">I", zlib.crc32(bytes(s)) & 0xFFFFFFFF)
+buf = bytearray(size)
+buf[2048:2048 + len(meta)] = meta
+open(img, "wb").write(buf)
+PYMISC
+ dd if="$img" of="$DISK" bs=4096 seek=$((off / 4096)) \
+ conv=notrunc,sparse status=none
+ qemu_log " $name: AvbABData provisioned @ +2048"
+ return 0
+ fi
mkfs_part "$stage" "$size" "$img"
dd if="$img" of="$DISK" bs=4096 seek=$((off / 4096)) \
conv=notrunc,sparse status=none
@@ -119,6 +178,8 @@ place_partition() {
}
DISK_END=0
+# Max, not last: blkdevparts grammar permits explicit @offsets out of order.
+DISK_END_TRACK() { [ $(($1 + $2)) -gt "$DISK_END" ] && DISK_END=$(($1 + $2)); return 0; }
qemu_log "building $DISK ($WARDEN_BLKDEVPARTS)"
truncate -s 0 "$DISK"
qemu_each_partition place_partition
diff --git a/qemu/rootfs/etc/rc b/qemu/rootfs/etc/rc
index b6ac1e3..a991a4a 100755
--- a/qemu/rootfs/etc/rc
+++ b/qemu/rootfs/etc/rc
@@ -39,5 +39,12 @@ if ! command -v switch_root >/dev/null; then
return 0
fi
+# Hand the live devtmpfs to the new root: busybox switch_root moves nothing,
+# and a REAL device rootfs's init expects /dev to already be there (its getty
+# opens /dev/console immediately). Our own skeleton init remounts devtmpfs
+# defensively either way.
+mkdir -p /mnt/dev
+mount -o move /dev /mnt/dev 2>/dev/null || mount --move /dev /mnt/dev
+
echo "rc: switching root to rootfs${slot} ($root)"
exec switch_root /mnt /sbin/init
diff --git a/qemu/rootfs/sbin/init b/qemu/rootfs/sbin/init
index bf5626e..ad89734 100755
--- a/qemu/rootfs/sbin/init
+++ b/qemu/rootfs/sbin/init
@@ -70,6 +70,16 @@ export WARDEN_FLARE_INSECURE=1
# here, and flared's /dev/mem poke dies with an external abort (SIGBUS). The
# SCR1 supervisor state machine is modeled in sim/src/hpmcu.rs instead.
export WARDEN_HPMCU=0
+# Same class: the CRU reset ladder's /dev/mem poke is fatal on virt. A
+# post-apply "reboot" surfaces as a clean flared error; the scenario harness
+# performs the actual reboot into the applied slot.
+export WARDEN_HARD_RESET=0
+# OTA apply is opt-in per boot (run.sh --allow-apply): writing rootfs_b is
+# safe inside disk.img but must never be the default posture.
+if grep -qw warden.fwapply /proc/cmdline; then
+ export WARDEN_FW_ALLOW_APPLY=1
+ echo "init: OTA APPLY ENABLED (warden.fwapply)"
+fi
for d in /usr/bin/warden-flared /usr/bin/warden-modbus; do
if [ -x "$d" ]; then
name="$(basename "$d")"
diff --git a/qemu/run.sh b/qemu/run.sh
index a40dde5..e74e985 100755
--- a/qemu/run.sh
+++ b/qemu/run.sh
@@ -22,6 +22,8 @@
# --http-port N hostfwd 127.0.0.1:N -> guest :80 (default 8080; 0 disables)
# --api-port N hostfwd 127.0.0.1:N -> guest :28443 (default 28443; 0 disables)
# --shell interactive shell in the guest instead of daemon hold
+# --allow-apply let flared ACTUALLY apply OTA firmware (writes rootfs_b
+# inside disk.img — safe in the VM, never the default)
set -euo pipefail
QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -30,7 +32,7 @@ QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
OUT="${OUT:-$QEMU_DIR/out}"
KERNEL="" INITRD="$OUT/initramfs.cpio.gz" DISK="" NO_DISK=0 SLOT="_a"
-RTC="" RS485="" WATCHDOG=0 QMP="" DISPLAY_MODE="off" SHELL_FLAG=0
+RTC="" RS485="" WATCHDOG=0 QMP="" DISPLAY_MODE="off" SHELL_FLAG=0 ALLOW_APPLY=0
SSH_PORT=2222 HTTP_PORT=8080 API_PORT=28443
EXTRA=()
@@ -50,6 +52,7 @@ while [ $# -gt 0 ]; do
--http-port) HTTP_PORT="${2:?}"; shift 2 ;;
--api-port) API_PORT="${2:?}"; shift 2 ;;
--shell) SHELL_FLAG=1; shift ;;
+ --allow-apply) ALLOW_APPLY=1; shift ;;
--) shift; EXTRA=("$@"); break ;;
*) echo "FATAL: unknown argument '$1' (see header of $0)" >&2; exit 1 ;;
esac
@@ -99,6 +102,7 @@ if [ -n "$DISK" ] && [ "$NO_DISK" -eq 0 ]; then
-device "virtio-blk-device,drive=vd0" )
fi
[ "$SHELL_FLAG" -eq 1 ] && APPEND="$APPEND warden.shell"
+[ "$ALLOW_APPLY" -eq 1 ] && APPEND="$APPEND warden.fwapply"
[ -n "$RTC" ] && ARGS+=( -rtc "base=$RTC" )
[ "$WATCHDOG" -eq 1 ] && ARGS+=( -device i6300esb -action watchdog=reset )
[ -n "$RS485" ] && ARGS+=( -chardev "socket,id=rs485,path=$RS485,server=on,wait=off"
diff --git a/qemu/tests/clock-sanity.sh b/qemu/tests/clock-sanity.sh
new file mode 100755
index 0000000..c2eb5fb
--- /dev/null
+++ b/qemu/tests/clock-sanity.sh
@@ -0,0 +1,92 @@
+#!/usr/bin/env bash
+# Clock-sanity scenario (issue #3 regression guard): boot the VM, run the
+# musl-static clockprobe in the guest, and assert the vDSO monotonic RATE
+# matches the kernel's /proc/uptime within 1%. Under QEMU -M virt this passes
+# on the current kernel (measured 0.99963) — a regression here means the
+# generic vDSO path broke. The RV1106 *board* leg of issue #3 is a separate,
+# bench-only measurement; this scenario cannot see board-specific CNTFRQ or
+# CNTVOFF misprogramming.
+#
+# FAILS CLOSED on missing prerequisites.
+#
+# Usage: clock-sanity.sh
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
+QDIR="$(cd "$HERE/.." && pwd)" # qemu/
+
+ZIMAGE="${1:-}"
+if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then
+ echo "FATAL: usage: $0 " >&2
+ exit 1
+fi
+command -v qemu-system-arm >/dev/null || {
+ echo "FATAL: qemu-system-arm not on PATH — see qemu/README.md" >&2
+ exit 1
+}
+command -v arm-linux-gnueabihf-gcc >/dev/null || {
+ echo "FATAL: arm-linux-gnueabihf-gcc needed to cross-build the probe" >&2
+ exit 1
+}
+
+# Build the probe for the guest (static musl armv7) and stage it as payload.
+( cd "$HERE/clockprobe" && \
+ CARGO_TARGET_ARMV7_UNKNOWN_LINUX_MUSLEABIHF_LINKER=arm-linux-gnueabihf-gcc \
+ cargo build -q --release --target armv7-unknown-linux-musleabihf )
+install -m 0755 "$HERE/clockprobe/target/armv7-unknown-linux-musleabihf/release/clockprobe" \
+ "$QDIR/payload/clockprobe"
+
+WORK="$(mktemp -d /tmp/wqc.XXXXXX)"
+QEMU_PID=""
+cleanup() {
+ if [ -n "$QEMU_PID" ]; then kill "$QEMU_PID" 2>/dev/null || true; fi
+ rm -rf "$WORK"
+}
+trap cleanup EXIT
+
+bash "$QDIR/mkinitramfs.sh"
+bash "$QDIR/mkimage.sh"
+
+for _attempt in 1 2 3; do
+ PORT=$((22000 + RANDOM % 20000))
+ : > "$WORK/console.log"
+ { sleep 40; printf '/usr/bin/clockprobe\n'; sleep 16; printf 'poweroff -f\n'; sleep 8; } | \
+ bash "$QDIR/run.sh" --kernel "$ZIMAGE" --shell \
+ --ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \
+ > "$WORK/console.log" 2>&1 &
+ QEMU_PID=$!
+ sleep 3
+ kill -0 "$QEMU_PID" 2>/dev/null && break
+ if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then
+ echo "== hostfwd port collision on base $PORT — retrying"
+ QEMU_PID=""
+ continue
+ fi
+ echo "FATAL: VM died at launch:" >&2
+ tail -20 "$WORK/console.log" >&2
+ exit 1
+done
+if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then
+ echo "FATAL: could not launch the VM after 3 port attempts" >&2
+ exit 1
+fi
+
+wait "$QEMU_PID" || true
+grep -a 'CLOCKPROBE' "$WORK/console.log" || {
+ echo "FATAL: probe never ran; console tail:" >&2
+ tail -25 "$WORK/console.log" >&2
+ exit 1
+}
+
+python3 - "$WORK/console.log" <<'EOF'
+import re, sys
+text = open(sys.argv[1], errors="replace").read()
+m = re.search(r"ratio_mono=([0-9.]+) ratio_raw=([0-9.]+)", text)
+if not m:
+ sys.exit("FATAL: no ratio line in console output")
+mono, raw = float(m.group(1)), float(m.group(2))
+ok = abs(mono - 1.0) < 0.01 and abs(raw - 1.0) < 0.01
+print(f"clock-sanity: ratio_mono={mono} ratio_raw={raw} -> {'PASS' if ok else 'FAIL'}")
+sys.exit(0 if ok else 1)
+EOF
+echo "CLOCK-SANITY-PASS"
diff --git a/qemu/tests/clockprobe/Cargo.lock b/qemu/tests/clockprobe/Cargo.lock
new file mode 100644
index 0000000..663026b
--- /dev/null
+++ b/qemu/tests/clockprobe/Cargo.lock
@@ -0,0 +1,16 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "clockprobe"
+version = "0.1.0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
diff --git a/qemu/tests/clockprobe/Cargo.toml b/qemu/tests/clockprobe/Cargo.toml
new file mode 100644
index 0000000..b06b601
--- /dev/null
+++ b/qemu/tests/clockprobe/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "clockprobe"
+version = "0.1.0"
+edition = "2021"
+description = "Interval-based monotonic-clock rate probe: compares musl/vDSO CLOCK_MONOTONIC(+_RAW) against /proc/uptime so rate errors and boot-time offsets (CNTVOFF) separate cleanly. Diagnostic for issue #3."
+license = "GPL-2.0-only"
+
+[dependencies]
+libc = "0.2"
+
+[profile.release]
+strip = true
diff --git a/qemu/tests/clockprobe/src/main.rs b/qemu/tests/clockprobe/src/main.rs
new file mode 100644
index 0000000..510fd0d
--- /dev/null
+++ b/qemu/tests/clockprobe/src/main.rs
@@ -0,0 +1,25 @@
+// Differential clock probe for warden-sdk issue #3: compares the musl/vDSO
+// CLOCK_MONOTONIC rate against the kernel's own /proc/uptime over a fixed
+// interval, so boot-time offsets cancel and only the RATE ratio remains.
+use std::{fs, thread, time::Duration};
+
+fn mono(clock: libc::clockid_t) -> f64 {
+ let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
+ unsafe { libc::clock_gettime(clock, &mut ts) };
+ ts.tv_sec as f64 + ts.tv_nsec as f64 / 1e9
+}
+
+fn uptime() -> f64 {
+ fs::read_to_string("/proc/uptime").unwrap()
+ .split_whitespace().next().unwrap().parse().unwrap()
+}
+
+fn main() {
+ let interval = 10.0;
+ let (m0, r0, u0) = (mono(libc::CLOCK_MONOTONIC), mono(libc::CLOCK_MONOTONIC_RAW), uptime());
+ thread::sleep(Duration::from_secs_f64(interval));
+ let (m1, r1, u1) = (mono(libc::CLOCK_MONOTONIC), mono(libc::CLOCK_MONOTONIC_RAW), uptime());
+ let (dm, dr, du) = (m1 - m0, r1 - r0, u1 - u0);
+ println!("CLOCKPROBE monotonic={dm:.4} raw={dr:.4} uptime={du:.4} ratio_mono={:.5} ratio_raw={:.5}", dm / du, dr / du);
+ println!("CLOCKPROBE abs monotonic={m1:.2} uptime={u1:.2} abs_ratio={:.5}", m1 / u1);
+}
diff --git a/qemu/tests/ota-apply.sh b/qemu/tests/ota-apply.sh
new file mode 100755
index 0000000..973343e
--- /dev/null
+++ b/qemu/tests/ota-apply.sh
@@ -0,0 +1,165 @@
+#!/usr/bin/env bash
+# FULL OTA apply scenario — the loop the desk e2e stops short of: the real
+# flared inside the VM downloads a real signed tier-1 .wfw whose payload is a
+# bootable rootfs, verifies it, and ACTUALLY WRITES rootfs_b (safe: it is a
+# region inside disk.img); the harness then reboots into slot _b and asserts
+# the applied firmware version is running.
+#
+# Documented emulation gaps (ADR-0006): the BCB slot CHOICE and the physical
+# reset are performed by the harness (cmdline slot + a fresh qemu boot), not
+# by U-Boot/CRU — those stay bench territory.
+#
+# FAILS CLOSED on missing prerequisites.
+#
+# Usage: ota-apply.sh
+# Env: FLARE_EDGE path to a flare-edge checkout (mock portal, mk-wfw, dev key)
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
+QDIR="$(cd "$HERE/.." && pwd)" # qemu/
+# mkfs.ext4 lives in sbin (not on user PATH on Debian).
+PATH="$PATH:/usr/sbin:/sbin"
+
+ZIMAGE="${1:-}"
+if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then
+ echo "FATAL: usage: $0 — the virt.fragment kernel variant" >&2
+ exit 1
+fi
+if [ -z "${FLARE_EDGE:-}" ] || [ ! -f "$FLARE_EDGE/tools/mock-flare-portal.py" ]; then
+ echo "FATAL: FLARE_EDGE must point at a flare-edge checkout" >&2
+ exit 1
+fi
+[ -x "$QDIR/payload/warden-flared" ] || {
+ echo "FATAL: no qemu/payload/warden-flared (needs the WARDEN_HARD_RESET-gated build)" >&2
+ exit 1
+}
+command -v qemu-system-arm >/dev/null || {
+ echo "FATAL: qemu-system-arm not on PATH — see qemu/README.md" >&2
+ exit 1
+}
+
+WORK="$(mktemp -d /tmp/wqo.XXXXXX)"
+QEMU_PID="" MOCK_PID=""
+cleanup() {
+ if [ -n "$QEMU_PID" ]; then kill "$QEMU_PID" 2>/dev/null || true; fi
+ if [ -n "$MOCK_PID" ]; then kill "$MOCK_PID" 2>/dev/null || true; fi
+ rm -rf "$WORK"
+}
+trap cleanup EXIT
+
+DEVICE_ID="$(python3 -c 'import uuid; print(uuid.uuid4())')"
+API_KEY="$(python3 -c 'import secrets; print(secrets.token_hex(24))')"
+PORT=$((20000 + RANDOM % 20000))
+
+# 0. The offer's payload is a REAL bootable rootfs: the same skeleton the
+# disk uses, stamped with the NEW version — booting it is the proof.
+QEMU_DIR="$QDIR"
+OUT="$QDIR/out"
+# shellcheck source=qemu/lib.sh disable=SC1091
+. "$QDIR/lib.sh"
+qemu_get_busybox
+qemu_stage_rootfs "$WORK/newroot"
+printf '0.0.2\n' > "$WORK/newroot/etc/warden-firmware-version"
+printf 'applied-via-ota\n' > "$WORK/newroot/etc/ota-marker"
+truncate -s 64M "$WORK/rootfs-payload.img"
+mkfs.ext4 -F -q -d "$WORK/newroot" "$WORK/rootfs-payload.img"
+
+FW_SIGNING_KEY_FILE="$FLARE_EDGE/tools/testdata/fw-dev-key.seed" \
+ WARDEN_KERNEL_VERSION=6.18.46 WARDEN_BUILDROOT_VERSION=2025.02 \
+ WARDEN_UBOOT_VERSION=2017.09 \
+ bash "$FLARE_EDGE/tools/mk-wfw.sh" "$WORK/rootfs-payload.img" 1 0.0.2 "$WORK/offer.wfw"
+
+# 1. mock portal offering it.
+python3 "$FLARE_EDGE/tools/mock-flare-portal.py" \
+ --port "$PORT" --device "$DEVICE_ID:$API_KEY" \
+ --wfw "$WORK/offer.wfw" > "$WORK/mock.log" 2>&1 &
+MOCK_PID=$!
+mock_ready=0
+for _ in $(seq 1 50); do
+ curl -so /dev/null "http://127.0.0.1:$PORT/" && { mock_ready=1; break; }
+ kill -0 "$MOCK_PID" 2>/dev/null || { echo "FATAL: mock portal died:" >&2; cat "$WORK/mock.log" >&2; exit 1; }
+ sleep 0.2
+done
+[ "$mock_ready" = 1 ] || { echo "FATAL: mock never answered on :$PORT" >&2; exit 1; }
+echo "== mock portal on :$PORT offering 0.0.2 (payload: bootable rootfs, 64M)"
+
+# 2. image at version 0.0.1, enrolment seeded.
+bash "$QDIR/mkinitramfs.sh"
+bash "$QDIR/mkimage.sh" \
+ --portal-url "http://10.0.2.2:$PORT" \
+ --state "flare.device_id=$DEVICE_ID" \
+ --state "flare.api_key=$API_KEY" \
+ --state "flare.site=qemu-devsim" \
+ --fw-version 0.0.1
+
+# 3. boot slot _a WITH APPLY ENABLED; flared should pull, verify, write
+# rootfs_b, and surface the (gated) reboot attempt.
+QEMU_PID=""
+for _attempt in 1 2 3; do
+ VMBASE=$((20000 + RANDOM % 20000))
+ : > "$WORK/console.log"
+ bash "$QDIR/run.sh" --kernel "$ZIMAGE" --allow-apply \
+ --ssh-port "$VMBASE" --http-port $((VMBASE + 1)) --api-port $((VMBASE + 2)) \
+ > "$WORK/console.log" 2>&1 &
+ QEMU_PID=$!
+ sleep 3
+ kill -0 "$QEMU_PID" 2>/dev/null && break
+ if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then
+ echo "== hostfwd port collision on base $VMBASE — retrying"
+ QEMU_PID=""
+ continue
+ fi
+ echo "FATAL: VM died at launch:" >&2; tail -20 "$WORK/console.log" >&2; exit 1
+done
+if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then
+ echo "FATAL: could not launch the VM after 3 port attempts" >&2
+ exit 1
+fi
+
+# 4. wait for the apply to conclude. flared logs to its in-guest file, not
+# the console — but the next check-in REPORTS the outcome to the portal:
+# detail "hard reset failed after slot flip" is the exact post-apply state
+# under the gated reset (write done, AvbABData flipped, reboot refused).
+deadline=$((SECONDS + 420))
+staged=0
+while [ $SECONDS -lt $deadline ]; do
+ grep -aq 'hard reset failed after slot flip' "$WORK/mock.log" && { staged=1; break; }
+ grep -aq 'WARDEN-QEMU-MOUNT-FAILED' "$WORK/console.log" && {
+ echo "FATAL: guest mount failed" >&2; exit 1; }
+ kill -0 "$QEMU_PID" 2>/dev/null || {
+ echo "FATAL: VM exited early" >&2; tail -30 "$WORK/console.log" >&2; exit 1; }
+ sleep 3
+done
+[ "$staged" = 1 ] || {
+ echo "FATAL: apply never reached the post-flip state within 420s; tails:" >&2
+ tail -20 "$WORK/console.log" >&2
+ tail -10 "$WORK/mock.log" >&2
+ exit 1
+}
+grep -aq "GET /api/v1/devices/$DEVICE_ID/firmware/assets/.* -> 200" "$WORK/mock.log" || {
+ echo "FATAL: staged without a portal asset download?!" >&2
+ exit 1
+}
+echo "== apply staged (asset downloaded, rootfs_b written, reset gated) — rebooting into _b"
+kill "$QEMU_PID" 2>/dev/null || true
+wait "$QEMU_PID" 2>/dev/null || true
+QEMU_PID=""
+
+# 5. the harness performs the "reboot": boot slot _b, assert the OTA'd rootfs
+# is what runs.
+{ sleep 40; printf 'cat /etc/warden-firmware-version /etc/ota-marker\n'; sleep 3; printf 'poweroff -f\n'; sleep 8; } | \
+ timeout 180 bash "$QDIR/run.sh" --kernel "$ZIMAGE" --slot _b --shell \
+ --ssh-port 0 --http-port 0 --api-port 0 \
+ > "$WORK/boot-b.log" 2>&1 || true
+grep -aq 'WARDEN-QEMU-ROOTFS-OK slot=_b' "$WORK/boot-b.log" || {
+ echo "FATAL: slot _b did not boot; tail:" >&2; tail -25 "$WORK/boot-b.log" >&2; exit 1
+}
+grep -aq '^0.0.2' "$WORK/boot-b.log" || {
+ echo "FATAL: _b is not running the applied 0.0.2 firmware" >&2
+ grep -a 'warden-firmware-version' -A2 "$WORK/boot-b.log" >&2 || true
+ exit 1
+}
+grep -aq 'applied-via-ota' "$WORK/boot-b.log" || {
+ echo "FATAL: OTA marker missing on _b" >&2; exit 1
+}
+echo "OTA-APPLY-PASS: 0.0.1 -> 0.0.2 applied over the air and booted from slot _b"
diff --git a/qemu/tests/real-image-boot.sh b/qemu/tests/real-image-boot.sh
new file mode 100755
index 0000000..296e12a
--- /dev/null
+++ b/qemu/tests/real-image-boot.sh
@@ -0,0 +1,82 @@
+#!/usr/bin/env bash
+# Real-image milestone: boot an ACTUAL flare-edge build (rootfs.img + oem.img
+# matched pair) in the VM on the 6.18 kernel and assert its own init chain
+# reaches multi-user: the vendor rcS runs, real warden daemons start, and a
+# getty answers on the console.
+#
+# Documented caveats (this is a fidelity milestone, not full parity): the
+# RV1106-only init steps degrade on virt (backlight, goodix, npu, the 5.10
+# /oem modules fail vermagic), and binaries older than the flare-edge #106
+# fix reproduce that crash faithfully. Interactive login uses the image's own
+# credentials — deliberately not recorded here.
+#
+# FAILS CLOSED on missing prerequisites.
+#
+# Usage: real-image-boot.sh
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
+QDIR="$(cd "$HERE/.." && pwd)" # qemu/
+
+ZIMAGE="${1:-}"; ROOTFS="${2:-}"; OEM="${3:-}"
+for f in "$ZIMAGE" "$ROOTFS" "$OEM"; do
+ if [ -z "$f" ] || [ ! -f "$f" ]; then
+ echo "FATAL: usage: $0 — '$f' missing" >&2
+ exit 1
+ fi
+done
+command -v qemu-system-arm >/dev/null || {
+ echo "FATAL: qemu-system-arm not on PATH — see qemu/README.md" >&2
+ exit 1
+}
+
+WORK="$(mktemp -d /tmp/wqr.XXXXXX)"
+QEMU_PID=""
+cleanup() {
+ if [ -n "$QEMU_PID" ]; then kill "$QEMU_PID" 2>/dev/null || true; fi
+ rm -rf "$WORK"
+}
+trap cleanup EXIT
+
+bash "$QDIR/mkinitramfs.sh"
+bash "$QDIR/mkimage.sh" --rootfs-image "$ROOTFS" --oem-image "$OEM"
+
+for _attempt in 1 2 3; do
+ PORT=$((23000 + RANDOM % 20000))
+ : > "$WORK/console.log"
+ bash "$QDIR/run.sh" --kernel "$ZIMAGE" \
+ --ssh-port "$PORT" --http-port $((PORT + 1)) --api-port $((PORT + 2)) \
+ > "$WORK/console.log" 2>&1 &
+ QEMU_PID=$!
+ sleep 3
+ kill -0 "$QEMU_PID" 2>/dev/null && break
+ if grep -aq 'Could not set up host forwarding' "$WORK/console.log"; then
+ echo "== hostfwd port collision on base $PORT — retrying"
+ QEMU_PID=""
+ continue
+ fi
+ echo "FATAL: VM died at launch:" >&2; tail -20 "$WORK/console.log" >&2; exit 1
+done
+if [ -z "$QEMU_PID" ] || ! kill -0 "$QEMU_PID" 2>/dev/null; then
+ echo "FATAL: could not launch the VM after 3 port attempts" >&2
+ exit 1
+fi
+
+deadline=$((SECONDS + 180))
+ok_switch=0 ok_daemons=0 ok_getty=0
+while [ $SECONDS -lt $deadline ]; do
+ grep -aq 'rc: switching root to rootfs_a' "$WORK/console.log" && ok_switch=1
+ [ "$(grep -ac 'Starting warden-' "$WORK/console.log")" -ge 2 ] && ok_daemons=1
+ grep -aq 'login:' "$WORK/console.log" && ok_getty=1
+ [ $ok_switch -eq 1 ] && [ $ok_daemons -eq 1 ] && [ $ok_getty -eq 1 ] && break
+ kill -0 "$QEMU_PID" 2>/dev/null || {
+ echo "FATAL: VM exited early" >&2; tail -30 "$WORK/console.log" >&2; exit 1; }
+ sleep 3
+done
+
+fail=0
+[ $ok_switch -eq 1 ] || { echo "FAIL: never switch_rooted into the real image"; fail=1; }
+[ $ok_daemons -eq 1 ] || { echo "FAIL: the image's own init never started warden daemons"; fail=1; }
+[ $ok_getty -eq 1 ] || { echo "FAIL: no getty login prompt on the console"; fail=1; }
+[ $fail -eq 0 ] || { tail -25 "$WORK/console.log" >&2; exit 1; }
+echo "REAL-IMAGE-BOOT-PASS: the flare-edge image reached multi-user on the VM"