diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ce3a7bb..8f3f633 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,8 +2,9 @@
#
# Policy (mirrors flare-edge): only GitHub-owned actions get the repo token; the one
# third-party helper (taiki-e/install-action) is pinned and never handed a token.
-# Host-testable jobs run on GitHub-hosted runners; only the heavy kernel build uses
-# the self-hosted [self-hosted, warden-sdk] runner on bfe-mpc-0640 (added in P5).
+# Every job runs on GitHub-hosted runners — no self-hosted runner may be reachable
+# from this repo's workflows (ADR-0007: public-repo fork PRs would otherwise be
+# able to run code on private infrastructure). kernel-build is dispatch-only.
name: ci
on:
@@ -24,6 +25,7 @@ permissions:
jobs:
test:
runs-on: ubuntu-latest
+ timeout-minutes: 25
outputs:
passed: ${{ steps.result.outputs.passed }}
coverage: ${{ steps.result.outputs.coverage }}
@@ -33,7 +35,7 @@ jobs:
run: |
set -o pipefail
: > /tmp/test.log
- for d in sim tools/config-lint; do
+ for d in sim tools/config-lint qemu/rs485-bridge; do
echo "== cargo test in $d ==" | tee -a /tmp/test.log
( cd "$d" && cargo test --locked ) 2>&1 | tee -a /tmp/test.log
done
@@ -57,6 +59,7 @@ jobs:
mcdc:
# 100% MC/DC (condition coverage) enforced on every Tier-1 driver harness.
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: install gcc-14
@@ -77,6 +80,7 @@ jobs:
# Smoke-run the sim micro-benchmarks and emit the ns/op trend JSON. Regression
# gating against stored history is future work (no flare-edge pattern to copy).
runs-on: ubuntu-latest
+ timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: run sim benchmarks
@@ -85,10 +89,42 @@ jobs:
cargo bench --locked --bench sim_bench 1> bench.txt 2> bench.json
echo "== timings =="; cat bench.txt
echo "== trend json =="; grep '"bench"' bench.json
+ - name: run rs485-bridge benchmarks
+ working-directory: qemu/rs485-bridge
+ run: |
+ cargo bench --locked --bench bridge_bench 1> bench.txt 2> bench.json
+ echo "== timings =="; cat bench.txt
+ echo "== trend json =="; grep '"bench"' bench.json
+
+ qemu-tools:
+ # The qemu/ device-sim build tooling must stay healthy on a plain hosted
+ # runner: shellcheck the scripts, build the initramfs (pinned busybox,
+ # fail-closed sha), and build the A/B disk image (unprivileged mkfs -d).
+ # Booting needs a zImage and therefore lives in kernel-build's smoke step.
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@v4
+ - name: shellcheck qemu scripts
+ run: |
+ sudo apt-get update -qq && sudo apt-get install -y -qq shellcheck
+ shellcheck -x qemu/*.sh qemu/tests/*.sh build/*.sh \
+ qemu/rootfs/etc/warden-lib.sh qemu/rootfs/etc/rc \
+ qemu/rootfs/sbin/init qemu/rootfs/init
+ - name: cache pinned busybox
+ uses: actions/cache@v4
+ with:
+ path: qemu/out/busybox-armv7l
+ key: busybox-armv7l-${{ hashFiles('qemu/busybox.sha256') }}
+ - name: build initramfs
+ run: bash qemu/mkinitramfs.sh
+ - name: build A/B disk image
+ run: bash qemu/mkimage.sh
patches-apply:
# The RV1106 series must apply cleanly onto pristine linux-6.18.46.
runs-on: ubuntu-latest
+ timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: cache pristine kernel tarball
@@ -96,11 +132,8 @@ jobs:
with:
path: ~/linux-6.18.46.tar.xz
key: linux-6.18.46-tarball
- - name: fetch + verify pristine
- run: |
- [ -f ~/linux-6.18.46.tar.xz ] || \
- curl -fSL https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.18.46.tar.xz -o ~/linux-6.18.46.tar.xz
- echo "$(cat build/linux-6.18.46.tar.xz.sha256) $HOME/linux-6.18.46.tar.xz" | sha256sum -c -
+ - name: fetch + verify pristine (shared fail-closed fetcher)
+ run: bash build/fetch-kernel-tarball.sh "$HOME/linux-6.18.46.tar.xz"
- name: apply the series in order
run: |
tar -C /tmp -xf ~/linux-6.18.46.tar.xz
@@ -119,6 +152,7 @@ jobs:
prune-artifacts:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
+ timeout-minutes: 10
permissions:
actions: write
steps:
@@ -142,35 +176,69 @@ jobs:
exit 0
kernel-build:
- # Full hermetic build on the warden-sdk self-hosted runner (bfe-mpc-0640,
- # ADR-0004). Manual-dispatch by design — a full kernel build is too heavy to run
- # on every push; trigger it via `gh workflow run ci.yml` / the Actions UI.
+ # Full hermetic build on a GitHub-hosted runner (ADR-0007; supersedes the
+ # self-hosted half of ADR-0004 — a self-hosted runner must never be reachable
+ # from a public repo's workflows). Manual-dispatch by design — a full kernel
+ # build is heavy; trigger via `gh workflow run ci.yml` / the Actions UI.
if: github.event_name == 'workflow_dispatch'
needs: [prune-artifacts]
- runs-on: [self-hosted, warden-sdk]
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
steps:
- uses: actions/checkout@v4
+ - name: install cross toolchain + kernel build deps + qemu
+ run: |
+ sudo apt-get update -qq
+ sudo apt-get install -y -qq gcc-arm-linux-gnueabihf qemu-system-arm \
+ cpio bc bison flex libssl-dev ccache
- name: provision `python` (SDK quirk — build calls bare python)
run: |
mkdir -p "$RUNNER_TEMP/bin"
ln -sf "$(command -v python3)" "$RUNNER_TEMP/bin/python"
echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
+ - name: cache pristine kernel tarball
+ uses: actions/cache@v4
+ with:
+ path: ~/linux-6.18.46.tar.xz
+ key: linux-6.18.46-tarball
+ # Ephemeral runners rebuild the whole tree every dispatch (~9 min of
+ # compile); ccache recovers most of it for an unchanged/lightly-changed
+ # series. Keyed on the config + patches so a real change misses cleanly.
+ - name: cache ccache
+ uses: actions/cache@v4
+ with:
+ path: ~/.ccache
+ key: kbuild-ccache-${{ hashFiles('build/warden_defconfig', 'patches/*.patch') }}
+ restore-keys: kbuild-ccache-
- name: build zImage + rv1106-warden.dtb
env:
# WORK must be OUTSIDE the repo checkout: build-kernel.sh applies the patch
# series with `git apply`, which silently ignores out-of-subdir paths when
# run inside another git repo (issue #1). $RUNNER_TEMP is outside the checkout.
WORK: ${{ runner.temp }}/kbuild-out
- JOBS: 4 # belt-and-braces bound in addition to the runner's cgroup cap
- # The kernel is freestanding; use the stable generic arm cross toolchain
- # (Debian gcc-arm-linux-gnueabihf on the runner) rather than depending on
- # the ephemeral Luckfox SDK checkout path.
+ # The kernel is freestanding; the generic arm cross toolchain links it.
CROSS_COMPILE: arm-linux-gnueabihf-
- run: bash build/build-kernel.sh
+ WARDEN_CCACHE: 1
+ CCACHE_DIR: /home/runner/.ccache
+ # 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.
+ run: |
+ export KERNEL_TARBALL="$HOME/linux-6.18.46.tar.xz"
+ bash build/build-kernel.sh
+ ccache -s | head -4
+ # Boot smoke under QEMU: the zImage this job just built must reach the
+ # initramfs sentinel on -M virt (verified 2026-08-29: the canonical
+ # config boots virt as-is). FAIL-CLOSED on a missing qemu-system-arm.
+ - name: boot smoke (qemu-system-arm -M virt)
+ run: |
+ bash qemu/mkinitramfs.sh
+ bash qemu/tests/boot-smoke.sh \
+ "$RUNNER_TEMP/kbuild-out/linux-6.18.46/arch/arm/boot/zImage"
# Best-effort: the build IS the gate. Uploading the zImage/dtb to GitHub
# artifact storage can fail on an account-wide storage-quota hit (recalculated
# every 6-12h) that has nothing to do with this build — don't red-X a good
- # kernel build over it. The outputs also remain on the self-hosted runner host.
+ # kernel build over it.
- uses: actions/upload-artifact@v4
continue-on-error: true
with:
@@ -187,6 +255,7 @@ jobs:
needs: [test]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
+ timeout-minutes: 15
permissions:
contents: write
steps:
diff --git a/.gitignore b/.gitignore
index 862a5b0..471c91e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,8 @@ target/
# build artifacts (firmware/kernel objects are rebuilt from source)
*.o
*.a
+*.elf
+*.map
# scratch / editor
*.swp
@@ -14,8 +16,14 @@ target/
__pycache__/
*.pyc
-# local tooling state (code-review harness cross-session memory, etc.)
+# local tooling state
.claude/
# driver MC/DC harness build dirs
**/test/build/
+
+# qemu device sim: build outputs (initramfs, disk images, cached busybox) and
+# payload binaries (dropped in from flare-edge builds, never committed)
+qemu/out/
+qemu/payload/*
+!qemu/payload/README.md
diff --git a/LICENSE b/LICENSE
index b0b40ae..9efa6fb 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,10 +1,338 @@
-warden-sdk is dual-licensed under either of
+ GNU GENERAL PUBLIC LICENSE
+ Version 2, June 1991
- * Apache License, Version 2.0 (LICENSE-APACHE)
- * MIT license (LICENSE-MIT)
+ Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
+
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
-at your option.
+ Preamble
-Unless you explicitly state otherwise, any contribution intentionally submitted
-for inclusion in this work by you, as defined in the Apache-2.0 license, shall be
-dual licensed as above, without any additional terms or conditions.
+ The licenses for most software are designed to take away your
+freedom to share and change it. By contrast, the GNU General Public
+License is intended to guarantee your freedom to share and change free
+software--to make sure the software is free for all its users. This
+General Public License applies to most of the Free Software
+Foundation's software and to any other program whose authors commit to
+using it. (Some other Free Software Foundation software is covered by
+the GNU Lesser General Public License instead.) You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+this service if you wish), that you receive source code or can get it
+if you want it, that you can change the software or use pieces of it
+in new free programs; and that you know you can do these things.
+
+ To protect your rights, we need to make restrictions that forbid
+anyone to deny you these rights or to ask you to surrender the rights.
+These restrictions translate to certain responsibilities for you if you
+distribute copies of the software, or if you modify it.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must give the recipients all the rights that
+you have. You must make sure that they, too, receive or can get the
+source code. And you must show them these terms so they know their
+rights.
+
+ We protect your rights with two steps: (1) copyright the software, and
+(2) offer you this license which gives you legal permission to copy,
+distribute and/or modify the software.
+
+ Also, for each author's protection and ours, we want to make certain
+that everyone understands that there is no warranty for this free
+software. If the software is modified by someone else and passed on, we
+want its recipients to know that what they have is not the original, so
+that any problems introduced by others will not reflect on the original
+authors' reputations.
+
+ Finally, any free program is threatened constantly by software
+patents. We wish to avoid the danger that redistributors of a free
+program will individually obtain patent licenses, in effect making the
+program proprietary. To prevent this, we have made it clear that any
+patent must be licensed for everyone's free use or not licensed at all.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
+
+ 0. This License applies to any program or other work which contains
+a notice placed by the copyright holder saying it may be distributed
+under the terms of this General Public License. The "Program", below,
+refers to any such program or work, and a "work based on the Program"
+means either the Program or any derivative work under copyright law:
+that is to say, a work containing the Program or a portion of it,
+either verbatim or with modifications and/or translated into another
+language. (Hereinafter, translation is included without limitation in
+the term "modification".) Each licensee is addressed as "you".
+
+Activities other than copying, distribution and modification are not
+covered by this License; they are outside its scope. The act of
+running the Program is not restricted, and the output from the Program
+is covered only if its contents constitute a work based on the
+Program (independent of having been made by running the Program).
+Whether that is true depends on what the Program does.
+
+ 1. You may copy and distribute verbatim copies of the Program's
+source code as you receive it, in any medium, provided that you
+conspicuously and appropriately publish on each copy an appropriate
+copyright notice and disclaimer of warranty; keep intact all the
+notices that refer to this License and to the absence of any warranty;
+and give any other recipients of the Program a copy of this License
+along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and
+you may at your option offer warranty protection in exchange for a fee.
+
+ 2. You may modify your copy or copies of the Program or any portion
+of it, thus forming a work based on the Program, and copy and
+distribute such modifications or work under the terms of Section 1
+above, provided that you also meet all of these conditions:
+
+ a) You must cause the modified files to carry prominent notices
+ stating that you changed the files and the date of any change.
+
+ b) You must cause any work that you distribute or publish, that in
+ whole or in part contains or is derived from the Program or any
+ part thereof, to be licensed as a whole at no charge to all third
+ parties under the terms of this License.
+
+ c) If the modified program normally reads commands interactively
+ when run, you must cause it, when started running for such
+ interactive use in the most ordinary way, to print or display an
+ announcement including an appropriate copyright notice and a
+ notice that there is no warranty (or else, saying that you provide
+ a warranty) and that users may redistribute the program under
+ these conditions, and telling the user how to view a copy of this
+ License. (Exception: if the Program itself is interactive but
+ does not normally print such an announcement, your work based on
+ the Program is not required to print an announcement.)
+
+These requirements apply to the modified work as a whole. If
+identifiable sections of that work are not derived from the Program,
+and can be reasonably considered independent and separate works in
+themselves, then this License, and its terms, do not apply to those
+sections when you distribute them as separate works. But when you
+distribute the same sections as part of a whole which is a work based
+on the Program, the distribution of the whole must be on the terms of
+this License, whose permissions for other licensees extend to the
+entire whole, and thus to each and every part regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest
+your rights to work written entirely by you; rather, the intent is to
+exercise the right to control the distribution of derivative or
+collective works based on the Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of
+a storage or distribution medium does not bring the other work under
+the scope of this License.
+
+ 3. You may copy and distribute the Program (or a work based on it,
+under Section 2) in object code or executable form under the terms of
+Sections 1 and 2 above provided that you also do one of the following:
+
+ a) Accompany it with the complete corresponding machine-readable
+ source code, which must be distributed under the terms of Sections
+ 1 and 2 above on a medium customarily used for software interchange; or,
+
+ b) Accompany it with a written offer, valid for at least three
+ years, to give any third party, for a charge no more than your
+ cost of physically performing source distribution, a complete
+ machine-readable copy of the corresponding source code, to be
+ distributed under the terms of Sections 1 and 2 above on a medium
+ customarily used for software interchange; or,
+
+ c) Accompany it with the information you received as to the offer
+ to distribute corresponding source code. (This alternative is
+ allowed only for noncommercial distribution and only if you
+ received the program in object code or executable form with such
+ an offer, in accord with Subsection b above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source
+code means all the source code for all modules it contains, plus any
+associated interface definition files, plus the scripts used to
+control compilation and installation of the executable. However, as a
+special exception, the source code distributed need not include
+anything that is normally distributed (in either source or binary
+form) with the major components (compiler, kernel, and so on) of the
+operating system on which the executable runs, unless that component
+itself accompanies the executable.
+
+If distribution of executable or object code is made by offering
+access to copy from a designated place, then offering equivalent
+access to copy the source code from the same place counts as
+distribution of the source code, even though third parties are not
+compelled to copy the source along with the object code.
+
+ 4. You may not copy, modify, sublicense, or distribute the Program
+except as expressly provided under this License. Any attempt
+otherwise to copy, modify, sublicense or distribute the Program is
+void, and will automatically terminate your rights under this License.
+However, parties who have received copies, or rights, from you under
+this License will not have their licenses terminated so long as such
+parties remain in full compliance.
+
+ 5. You are not required to accept this License, since you have not
+signed it. However, nothing else grants you permission to modify or
+distribute the Program or its derivative works. These actions are
+prohibited by law if you do not accept this License. Therefore, by
+modifying or distributing the Program (or any work based on the
+Program), you indicate your acceptance of this License to do so, and
+all its terms and conditions for copying, distributing or modifying
+the Program or works based on it.
+
+ 6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the
+original licensor to copy, distribute or modify the Program subject to
+these terms and conditions. You may not impose any further
+restrictions on the recipients' exercise of the rights granted herein.
+You are not responsible for enforcing compliance by third parties to
+this License.
+
+ 7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues),
+conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot
+distribute so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you
+may not distribute the Program at all. For example, if a patent
+license would not permit royalty-free redistribution of the Program by
+all those who receive copies directly or indirectly through you, then
+the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under
+any particular circumstance, the balance of the section is intended to
+apply and the section as a whole is intended to apply in other
+circumstances.
+
+It is not the purpose of this section to induce you to infringe any
+patents or other property right claims or to contest validity of any
+such claims; this section has the sole purpose of protecting the
+integrity of the free software distribution system, which is
+implemented by public license practices. Many people have made
+generous contributions to the wide range of software distributed
+through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing
+to distribute software through any other system and a licensee cannot
+impose that choice.
+
+This section is intended to make thoroughly clear what is believed to
+be a consequence of the rest of this License.
+
+ 8. If the distribution and/or use of the Program is restricted in
+certain countries either by patents or by copyrighted interfaces, the
+original copyright holder who places the Program under this License
+may add an explicit geographical distribution limitation excluding
+those countries, so that distribution is permitted only in or among
+countries not thus excluded. In such case, this License incorporates
+the limitation as if written in the body of this License.
+
+ 9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies a version number of this License which applies to it and "any
+later version", you have the option of following the terms and conditions
+either of that version or of any later version published by the Free
+Software Foundation. If the Program does not specify a version number of
+this License, you may choose any version ever published by the Free Software
+Foundation.
+
+ 10. If you wish to incorporate parts of the Program into other free
+programs whose distribution conditions are different, write to the author
+to ask for permission. For software which is copyrighted by the Free
+Software Foundation, write to the Free Software Foundation; we sometimes
+make exceptions for this. Our decision will be guided by the two goals
+of preserving the free status of all derivatives of our free software and
+of promoting the sharing and reuse of software generally.
+
+ NO WARRANTY
+
+ 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
+FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
+OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
+PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
+OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
+TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
+REPAIR OR CORRECTION.
+
+ 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
+OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
+TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
+YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
+PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGES.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+convey the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software; you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation; either version 2 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along
+ with this program; if not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program is interactive, make it output a short notice like this
+when it starts in an interactive mode:
+
+ Gnomovision version 69, Copyright (C) year name of author
+ Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, the commands you use may
+be called something other than `show w' and `show c'; they could even be
+mouse-clicks or menu items--whatever suits your program.
+
+You should also get your employer (if you work as a programmer) or your
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. Here is a sample; alter the names:
+
+ Yoyodyne, Inc., hereby disclaims all copyright interest in the program
+ `Gnomovision' (which makes passes at compilers) written by James Hacker.
+
+ , 1 April 1989
+ Moe Ghoul, President of Vice
+
+This General Public License does not permit incorporating your program into
+proprietary programs. If your program is a subroutine library, you may
+consider it more useful to permit linking proprietary applications with the
+library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License.
diff --git a/LICENSE-APACHE b/LICENSE-APACHE
deleted file mode 100644
index d645695..0000000
--- a/LICENSE-APACHE
+++ /dev/null
@@ -1,202 +0,0 @@
-
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
diff --git a/LICENSE-MIT b/LICENSE-MIT
deleted file mode 100644
index 1f8cf8c..0000000
--- a/LICENSE-MIT
+++ /dev/null
@@ -1,39 +0,0 @@
-MIT License
-
-Copyright (c) 2026 BlueFlare
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
-
--------------------------------------------------------------------------------
-Third-party components carry their own licenses, which are not superseded by
-the above:
-
- - LVGL (the UI library, fetched during build, not vendored here) is MIT
- licensed by its authors.
- - The Luckfox / Rockchip RV1106 SDK and its cross-toolchain are covered by
- their respective vendor licenses.
- - vendor/asic-rs is 256 Foundation's asic-rs, vendored into this repository
- with git subtree and licensed under Apache-2.0. Its license text is kept
- at vendor/asic-rs/LICENSE.txt. Files modified locally must carry a notice
- saying so, per Apache-2.0 section 4(b); see vendor/README.md.
-
-The contents of branding/ (BlueFlare logos, wordmarks, and splash artwork) are
-NOT covered by the MIT license above. They are proprietary to BlueFlare, all
-rights reserved, and are included only so the firmware builds and displays as
-shipped. Do not reuse them in derivative works.
diff --git a/README.md b/README.md
index 7cc502b..e39d0ca 100644
--- a/README.md
+++ b/README.md
@@ -12,12 +12,14 @@ the rest of the firmware: tested, benchmarked, reproducible, and honest about
what runs on real silicon versus what we simulate.
> Status: **bringup.** The hardware **simulator** and its tests, the RV1106 kernel
-> forward-port as a reviewable `patches/` series, the hermetic kernel build, and two
-> Tier-1 drivers at 100% MC/DC are all in and CI-green on `main`. What
-> remains before this is on the production build path: installing the self-hosted
-> kernel-build runner (see `docs/ci-cd.md`) and having flare-edge consume warden-sdk
-> as a dependency — both [maintainer]-gated. Until then, flare-edge still builds firmware from
-> the vendored SDK + `sdk-patches/`.
+> forward-port as a reviewable `patches/` series, the hermetic kernel build, two
+> Tier-1 drivers at 100% MC/DC, and the **QEMU device sim** (`qemu/`, ADR-0006:
+> boots the real kernel + real userspace on `-M virt` — check-in/OTA against the
+> mock portal, watchdog, RS485-to-sim bridge, 720x720 display + touch, all
+> emulation-verified) are in. What remains before this is on the production build
+> path: having flare-edge consume warden-sdk as a dependency (maintainer-gated).
+> Until then, flare-edge still builds firmware from the vendored SDK +
+> `sdk-patches/`.
## Why a new SDK
@@ -90,6 +92,8 @@ than duplicate each other.
```
sim/ the hardware simulator (Rust): membus/devmem, HPMCU, CRU, Modbus, RGA, NPU.
+qemu/ the device simulator (ADR-0006): QEMU -M virt boots the real kernel and
+ real userspace; A/B disk layout, RS485 bridge into sim/, scenario tests.
drivers/ our own hardened drivers + their seams (relays, freshness; more migrate in).
patches/ the RV1106 kernel forward-port delta onto pristine linux-6.18.46 (subsystem-split).
kernel/ forward-port docs + provenance (rv1106-enablement/, PROVENANCE.md).
@@ -117,6 +121,17 @@ Evaluated against the stack philosophy — **openness, hardness, modernness**:
## Relationship to flare-edge
flare-edge (WardenOS: the LVGL UI + the `flared` daemon) is the product; warden-sdk
-is what builds and tests it. During bootstrap, flare-edge consumes warden-sdk piece
+is what builds and tests it. flare-edge is BlueFlare's private companion repo —
+not publicly available — so flare-edge issue references and checkout paths in
+this repo's docs are context, not reachable links. During bootstrap, flare-edge consumes warden-sdk piece
by piece: first the simulator (as a dev/test dependency), later the image build.
No flare-edge code moves here — only the SDK/build/sim/driver-seam layer.
+
+## License
+
+**GPL-2.0-only**, repo-wide (see `LICENSE`; a per-file SPDX identifier governs
+where one is present, e.g. a few GPL-2.0-or-later kernel files). The kernel
+material in `patches/` and `kernel/rv1106-enablement/` is derivative of the
+Linux kernel and of GPL-2.0 vendor code either way — per-driver origin and
+license are tracked in `kernel/rv1106-enablement/PROVENANCE.md`. Contributions
+are accepted under the same license (inbound = outbound).
diff --git a/build/build-kernel.sh b/build/build-kernel.sh
index 4e5c832..73f097c 100755
--- a/build/build-kernel.sh
+++ b/build/build-kernel.sh
@@ -10,8 +10,15 @@
# Env:
# KERNEL_TARBALL path to a local linux-6.18.46.tar.xz (skips the download)
# SDK_TC dir holding the arm-rockchip830 uclibc cross toolchain bin/
+# CROSS_COMPILE cross-compiler prefix (default arm-rockchip830-linux-uclibcgnueabihf-;
+# CI overrides with the generic arm-linux-gnueabihf-)
# WORK build scratch dir (default: a mktemp under $TMPDIR)
# JOBS parallel make jobs (default: nproc)
+# WARDEN_KCONFIG_FRAGMENT
+# optional kconfig fragment merged onto warden_defconfig
+# (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)
#
# 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.
@@ -21,9 +28,8 @@ KVER=6.18.46
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # build/
REPO="$(cd "$HERE/.." && pwd)"
PATCHES="$REPO/patches"
-SHA_FILE="$HERE/linux-$KVER.tar.xz.sha256"
JOBS="${JOBS:-$(nproc)}"
-URL="https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-$KVER.tar.xz"
+# The tarball URL + sha256 pin live in fetch-kernel-tarball.sh (shared with CI).
# A caller-provided WORK (e.g. CI's ${{ github.workspace }}/kbuild-out, from which
# artifacts are uploaded) is left intact; a scratch dir we mktemp'd here is our own
@@ -44,21 +50,9 @@ command -v python >/dev/null || { echo "need 'python' (not python3) on PATH —
# 1. obtain + verify the pristine tarball
mkdir -p "$WORK"
TB="${KERNEL_TARBALL:-$WORK/linux-$KVER.tar.xz}"
-if [ ! -f "$TB" ]; then
- log "downloading $URL"
- curl -fSL "$URL" -o "$TB"
-fi
-# Fail closed: a missing pin (e.g. forgotten on a KVER bump) or a KERNEL_TARBALL
-# pointed at an arbitrary file must refuse to build, never silently skip the check
-# — the pristine tarball is the ONLY external input and integrity is the whole point.
-[ -f "$SHA_FILE" ] || {
- echo "FATAL: no pinned sha256 for linux-$KVER (expected $SHA_FILE) — refusing to build from an unverified tarball" >&2
- exit 1
-}
-want="$(cat "$SHA_FILE")"
-got="$(sha256sum "$TB" | awk '{print $1}')"
-[ "$want" = "$got" ] || { echo "tarball sha256 mismatch: want $want got $got" >&2; exit 1; }
-log "tarball sha256 verified"
+# Fetch + fail-closed sha256 verification live in ONE place shared with CI
+# (a missing pin or a mismatch always refuses to build).
+bash "$HERE/fetch-kernel-tarball.sh" "$TB"
# 2. extract pristine
SRC="$WORK/linux-$KVER"
@@ -97,6 +91,19 @@ log "patch series applied ($(basename "$SENTINEL") present)"
# 4. configure
log "configuring (warden_defconfig)"
cp "$HERE/warden_defconfig" "$SRC/.config"
+# Optional kconfig fragment overlay (e.g. qemu/configs/virt.fragment for the
+# QEMU -M virt device-sim variant). Fail closed if set but unreadable — never
+# silently build the wrong kernel. Unset => the canonical RV1106 build,
+# byte-identical to a build without this hook.
+if [ -n "${WARDEN_KCONFIG_FRAGMENT:-}" ]; then
+ [ -f "$WARDEN_KCONFIG_FRAGMENT" ] || {
+ echo "FATAL: WARDEN_KCONFIG_FRAGMENT set but not a file: $WARDEN_KCONFIG_FRAGMENT" >&2
+ exit 1
+ }
+ FRAG="$(cd "$(dirname "$WARDEN_KCONFIG_FRAGMENT")" && pwd)/$(basename "$WARDEN_KCONFIG_FRAGMENT")"
+ log "merging kconfig fragment $(basename "$FRAG")"
+ ( cd "$SRC" && ARCH=arm ./scripts/kconfig/merge_config.sh -m .config "$FRAG" )
+fi
# CROSS_COMPILE defaults to the Luckfox SDK uclibc prefix (set SDK_TC to its bin/),
# but the kernel is freestanding, so a caller may override with a generic arm cross
# toolchain instead — e.g. CROSS_COMPILE=arm-linux-gnueabihf- (in Debian's
@@ -108,9 +115,46 @@ command -v "${CROSS_COMPILE}gcc" >/dev/null \
|| { echo "cross toolchain ${CROSS_COMPILE}gcc not on PATH (set SDK_TC, or CROSS_COMPILE to one that is)" >&2; exit 1; }
make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" olddefconfig >/dev/null
+# Fragment took-effect assertion: merge_config -m only pastes text, and
+# olddefconfig silently resolves any symbol whose dependencies are unmet —
+# a fragment option could be dropped without a word. Verify every explicit
+# request in the fragment survived into the final .config; fail loud if not.
+if [ -n "${WARDEN_KCONFIG_FRAGMENT:-}" ]; then
+ frag_fail=0
+ while IFS= read -r line || [ -n "$line" ]; do
+ case "$line" in
+ CONFIG_*=*)
+ grep -qxF "$line" "$SRC/.config" || {
+ echo "FATAL: fragment option '$line' did not take effect (unmet Kconfig dependency?)" >&2
+ frag_fail=1
+ } ;;
+ "# CONFIG_"*" is not set")
+ # A disable succeeded if the symbol is NOT set: Kconfig writes either
+ # the literal "is not set" line or (when dependencies gate the symbol
+ # out) nothing at all — both are valid outcomes. Only "still =value"
+ # is a failed disable. (A typo'd symbol disables nothing and is
+ # harmless by construction.)
+ opt="${line#\# }"; opt="${opt% is not set}"
+ grep -qE "^$opt=" "$SRC/.config" && {
+ echo "FATAL: fragment disabled '$opt' but it is still set in the final .config" >&2
+ frag_fail=1
+ } ;;
+ esac
+ done < "$FRAG"
+ [ "$frag_fail" = 0 ] || exit 1
+ log "fragment options verified in final .config"
+fi
+
+# Optional ccache (CI: cache ~/.ccache across dispatches; harmless if unset).
+KCC="${CROSS_COMPILE}gcc"
+if [ "${WARDEN_CCACHE:-0}" = 1 ]; then
+ command -v ccache >/dev/null || { echo "FATAL: WARDEN_CCACHE=1 but ccache not installed" >&2; exit 1; }
+ KCC="ccache ${CROSS_COMPILE}gcc"
+fi
+
# 5. build zImage + the board dtb
log "building zImage + rv1106-warden.dtb (-j$JOBS)"
-make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" -j"$JOBS" \
+make -C "$SRC" ARCH=arm CROSS_COMPILE="$CROSS_COMPILE" CC="$KCC" -j"$JOBS" \
zImage rockchip/rv1106-warden.dtb
Z="$SRC/arch/arm/boot/zImage"
diff --git a/build/fetch-kernel-tarball.sh b/build/fetch-kernel-tarball.sh
new file mode 100644
index 0000000..6d16cbe
--- /dev/null
+++ b/build/fetch-kernel-tarball.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+# Fetch (with retries) and sha256-verify the pristine kernel tarball into $1.
+# Single source of truth for the URL + verification used by build-kernel.sh
+# and both CI jobs (patches-apply, kernel-build) — a KVER bump edits this file
+# and build-kernel.sh only. FAILS CLOSED: a missing pin refuses to proceed.
+#
+# Usage: fetch-kernel-tarball.sh
+set -euo pipefail
+
+KVER=6.18.46
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # build/
+SHA_FILE="$HERE/linux-$KVER.tar.xz.sha256"
+URL="https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-$KVER.tar.xz"
+
+TB="${1:?usage: fetch-kernel-tarball.sh }"
+
+# Pin first: a forgotten pin on a KVER bump should refuse BEFORE burning a
+# 140MB download it will then reject anyway.
+[ -f "$SHA_FILE" ] || {
+ echo "FATAL: no pinned sha256 for linux-$KVER (expected $SHA_FILE) — refusing an unverified tarball" >&2
+ exit 1
+}
+if [ ! -f "$TB" ]; then
+ echo "== downloading $URL"
+ curl --retry 3 --retry-delay 5 --retry-connrefused -fSL "$URL" -o "$TB"
+fi
+want="$(cat "$SHA_FILE")"
+got="$(sha256sum "$TB" | awk '{print $1}')"
+[ "$want" = "$got" ] || { echo "FATAL: tarball sha256 mismatch: want $want got $got" >&2; exit 1; }
+echo "== tarball sha256 verified: $TB"
diff --git a/docs/architecture.md b/docs/architecture.md
index d3c79e7..812773d 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -65,13 +65,15 @@ supervisor logic runs in CI with no panel.
the boot-mode register's survives-warm-reset / cleared-by-POR behaviour (the MaskRom
recovery maneuver). The matching firmware-side `Bus` seam on flared's `devmem` — so
the shipped ladder can be asserted to poke the confirmed offset, never the wrong-SoC
- one — lands when flare-edge consumes warden-sdk (§7 item 3, [maintainer]-gated), not yet on
+ one — lands when flare-edge consumes warden-sdk (§8 item 3, maintainer-gated), not yet on
flare-edge `main`.
- **`modbus` — RS-485 device end.** Done. `ModbusSlave`: a byte-in/byte-out RTU slave
(CRC16 byte-identical to the master, FC 0x01–0x06/0x0F/0x10/0x11, exception replies,
and fault injection — silent-drop and forced-NAK) so `warden-modbus`'s master can be
hardened to MC/DC against realistic device behaviour with no serial hardware. MEI
- (0x2B/0x0E) identification is the documented follow-up.
+ (0x2B/0x0E) identification is the documented follow-up. The same slave also serves
+ as the QEMU device sim's field bus: `qemu/rs485-bridge/` feeds it from a serial
+ chardev so the guest's real master polls it over what it believes is /dev/ttyS4 (§7).
- **`npu` — NPU load model.** Done. `NpuSim` models `/proc/rknpu/load` (the exact
"NPU load: N%" text the sysmon reads) behind the path seam, so the load-readout UI
is host-testable. NPU *compute* is explicitly out of scope — no inference runs here.
@@ -86,7 +88,7 @@ supervisor logic runs in CI with no panel.
Integration with flare-edge: flared implements `MemBus` for `/dev/mem` and gains
`#[cfg(test)]` tests driving its real arm/beat logic against `HpmcuSim`. This needs
warden-sdk reachable as a Cargo dependency in CI — i.e. a remote for this repo,
-which is a **[maintainer]-go-ahead item** (credential/remote creation). Until then the
+which is a **maintainer go-ahead item** (credential/remote creation). Until then the
firmware-side seam and a local test double land in flare-edge, unified with `sim/`
once the dependency exists. No duplication of *logic* — only the tiny trait.
@@ -152,14 +154,41 @@ kernel move as one matched boot+oem image, never a partial reflash.
series honest against pristine 6.18.46; provenance is in `patches/README.md` and
`kernel/rv1106-enablement/`.
-## 7. Order of work
+## 7. Device emulation (`qemu/`) — see ADR-0006
+
+The third simulator, deliberately not named "sim": a QEMU VM (`-M virt,highmem=off`,
+one Cortex-A7, 256M — the RV1106G3's shape) that boots the real forward-ported
+kernel and real userspace, entering at `-kernel zImage` because everything below
+(BootROM, idblock/DDR-init, U-Boot, the BCB A/B machinery) is closed blobs plus
+mask ROM. The canonical RV1106 zImage boots virt unmodified; an additive kconfig
+fragment (`qemu/configs/virt.fragment` via `WARDEN_KCONFIG_FRAGMENT`) adds the
+scenario devices (PCI serial for RS485, i6300esb watchdog, WireGuard,
+virtio-gpu/input for the 720x720 UI). The virtio disk carries the device's exact
+12-partition `blkdevparts=` A/B layout and the `/dev/block/by-name/` contract.
+
+Where the seams meet: the guest runs the *real* binaries (static musl flared,
+the LVGL fbdev UI); the RS485 bridge (`qemu/rs485-bridge/`) connects a QEMU
+serial chardev to `sim/`'s `ModbusSlave`, so the register-level models serve as
+the VM's field bus — behavior lives in one place, `sim/`, and the VM consumes
+it. Scenario tests: `qemu/tests/boot-smoke.sh` (CI, in kernel-build),
+`portal-scenario.sh` (check-in + OTA offer download against flare-edge's mock
+portal), `ui-shot.sh` (QMP screendump + touch injection). Division of labour
+with the other sims: NPU/RGA/HPMCU *behavior* stays `sim/`; UI *rendering
+development* stays `lvglsim`; the VM is where processes, the kernel, and the
+network meet. §5 still applies — no behavioural sim, this one included, catches
+memory-map faults; and "boots under emulation" is never on-silicon evidence.
+
+## 8. Order of work
1. **Simulator core** — `membus`, `hpmcu`, the `cru` reset ladder, `modbus`, plus the
`rga`/`npu` models. **Done.**
2. **C-driver MC/DC harnesses** — `relays.c` and `freshness.c` at 100% MC/DC, CI-gated
via the shared `drivers/enforce-mcdc.sh`. **Done** (the first C coverage gate).
3. **flared devmem/hpmcu seam + tests** — firmware-side trait, unified with `sim/`
- once flare-edge consumes warden-sdk (a separate, [maintainer]-gated step). **Pending.**
+ once flare-edge consumes warden-sdk (a separate, maintainer-gated step). **Pending.**
4. **Config-lint CI gates** (§5) — the brick-class of bug. **Done.**
5. **Hermetic kernel build** (`build/build-kernel.sh` + the `patches-apply` gate). **Done.**
6. **Kernel 5.10→6.18.46 forward-port** (§6, ADR-0001). **Done** (hardware-verified).
+7. **QEMU device sim** (§7, ADR-0006) — boot smoke, A/B disk harness, RS485 bridge,
+ portal/watchdog/clock scenarios, display+touch. **Done** (emulation-verified;
+ booting the real flare-edge rootfs+oem image pair is a documented later milestone).
diff --git a/docs/ci-cd.md b/docs/ci-cd.md
index 1e9e358..0873833 100644
--- a/docs/ci-cd.md
+++ b/docs/ci-cd.md
@@ -1,51 +1,33 @@
# CI/CD
-`.github/workflows/ci.yml` — everything portable runs on GitHub-hosted
-`ubuntu-latest`; only the heavy kernel build uses the self-hosted runner.
+`.github/workflows/ci.yml` — every job runs on GitHub-hosted `ubuntu-latest`.
+No self-hosted runner is (or may be) reachable from this repo's workflows —
+on a public repo, a fork PR that gets one approved run could otherwise
+execute code on private infrastructure (ADR-0007).
## Jobs
| Job | Runner | What it does |
|---|---|---|
-| `test` | ubuntu-latest | `cargo test` (sim + config-lint) + `cargo-llvm-cov` line coverage on `sim`; outputs `passed`/`coverage`. |
+| `test` | ubuntu-latest | `cargo test` (sim + config-lint + qemu/rs485-bridge) + `cargo-llvm-cov` line coverage on `sim`; outputs `passed`/`coverage`. |
| `mcdc` | ubuntu-latest | 100% MC/DC enforced on every `drivers/*/test` (gcc-14 `-fcondition-coverage`). |
-| `bench` | ubuntu-latest | Smoke-runs the sim micro-benchmarks; emits ns/op trend JSON. |
+| `bench` | ubuntu-latest | Smoke-runs the sim + rs485-bridge micro-benchmarks; emits ns/op trend JSON. |
| `patches-apply` | ubuntu-latest | Fetches pristine linux-6.18.46 (cached, sha256-verified) and applies `patches/*` in order. |
-| `kernel-build` | **[self-hosted, warden-sdk]** | `build/build-kernel.sh` → `zImage` + `rv1106-warden.dtb`, uploaded as an artifact. Dispatch-gated until the runner is fully provisioned (below). |
+| `qemu-tools` | ubuntu-latest | shellcheck on `qemu/**.sh`; builds the initramfs (pinned busybox) and the A/B disk image. |
+| `kernel-build` | ubuntu-latest, **dispatch-only** | apt-installs the cross toolchain + qemu, `build/build-kernel.sh` → `zImage` + `rv1106-warden.dtb`, QEMU `-M virt` boot smoke (fail-closed), artifact upload (best-effort). Trigger: `gh workflow run ci.yml`. |
+| `prune-artifacts` | ubuntu-latest, dispatch-only | Deletes `kernel-rv1106` artifacts beyond the newest 3. |
| `badges` | ubuntu-latest | Renders loc/tests/coverage shields on push to `main` (`[skip ci]` + `paths-ignore` loop guard). |
-## The self-hosted runner (`bfe-mpc-0640`)
+## History: the self-hosted runner (retired)
-A **third** repo-scoped runner instance on `bfe-mpc-0640` (alongside `flare` and
-`flare-edge`), registered with the label **`warden-sdk`** as
-`bfe-mpc-0640-warden-sdk`, in `~/actions-runner-warden-sdk`.
-
-> **INSTALLED + ONLINE (2026-08-25).** The runner is a running systemd service
-> (`actions.runner.bfe-noah-warden-sdk.bfe-mpc-0640-warden-sdk.service`, `enabled`,
-> cgroup-capped `CPUQuota=400%`/`MemoryMax=6G`) and `kernel-build` has been verified
-> end-to-end (RV1106 6.18.46 → `zImage` 8.25 MB + `rv1106-warden.dtb`). Steps 1–2
-> below are the record of that install (they needed `user`'s sudo on 0640); steps
-> 3–4 are handled inside the workflow, so the host needs no manual toolchain/python.
-
-1. **Install as a service** (persistence): `cd ~/actions-runner-warden-sdk &&
- sudo ./svc.sh install user && sudo ./svc.sh start`. Until then the runner is
- *offline* and `kernel-build` only runs when dispatched against an online runner.
-2. **Resource cap** (protect the shared host): a drop-in at
- `/etc/systemd/system/actions.runner.bfe-noah-warden-sdk.*.service.d/*.conf` with
- `CPUQuota=400%` + `MemoryMax=6G`, then `sudo systemctl daemon-reload`. The build
- inherits that cgroup. (The workflow also passes `JOBS=4` as a belt-and-braces bound.)
-3. **Kernel cross toolchain** — done in the workflow: the `kernel-build` job sets
- `CROSS_COMPILE=arm-linux-gnueabihf-` (Debian `gcc-arm-linux-gnueabihf`, already on
- the runner) and `build-kernel.sh` honors it. The kernel is freestanding, so the
- generic arm cross compiler links it — no Luckfox SDK toolchain path needed. (To use
- the SDK uclibc toolchain instead, set `SDK_TC` to its `bin/` and drop the override.)
-4. **`python`** (not python3) — done in the workflow: the `kernel-build` job symlinks
- `python`→`python3` into `$RUNNER_TEMP/bin` and prepends it to `$GITHUB_PATH`. No
- host-side venv/shim needed.
-
-Host build deps: `dtc bc flex bison libssl-dev` — already present on 0640.
+`kernel-build` originally ran on a repo-scoped self-hosted runner (ADR-0004,
+2026-08-25, verified end-to-end) because hosted minutes were metered on the
+private repo. Going public made hosted minutes free and made a self-hosted
+registration a liability, so ADR-0007 moved the job to `ubuntu-latest` and
+retired the registration. Site-specific install records for that runner live
+in our private deployment log, not here.
## Badges
-Static shields SVGs are committed by the `badges` job (private repo can't use
-dynamic shields). The GitHub-native `ci.yml` status badge works live regardless.
+Static shields SVGs are committed by the `badges` job. The GitHub-native
+`ci.yml` status badge works live regardless.
diff --git a/docs/decisions/0003-standalone-repo.md b/docs/decisions/0003-standalone-repo.md
index 36aa658..b08f93c 100644
--- a/docs/decisions/0003-standalone-repo.md
+++ b/docs/decisions/0003-standalone-repo.md
@@ -1,6 +1,8 @@
# ADR 0003 — warden-sdk is a standalone repo
-**Status:** Accepted (2026-08-25).
+**Status:** Accepted (2026-08-25). Repo-visibility half superseded by ADR-0007
+(2026-08-30) — warden-sdk went public; the "private for now" consequence below
+no longer holds. Original decision kept for the record.
## Context
Our real SDK changes lived as uncommitted edits in a 2GB opaque vendor fork, with
@@ -10,11 +12,11 @@ no CI, tests, or versioning of their own. The SDK requirement (future-features-2
## Decision
A **private** `bfe-noah/warden-sdk` GitHub repo, standalone from day one with its own
CI/versioning. Work lands on a `bringup` branch; the first commit to `main` is gated
-on a passing code-review-harness run, green CI, and [maintainer]'s fresh explicit go-ahead.
+on a passing review run, green CI, and the maintainer's fresh explicit go-ahead.
## Consequences
- flare-edge consumes warden-sdk later (flared depending on `warden-sim`, drivers
- built from here) — a separate, [maintainer]-gated integration step; flare-edge is not
+ built from here) — a separate, maintainer-gated integration step; flare-edge is not
edited by the SDK-completion effort.
- Private for now (references bench devices / in-progress hardening); can be opened
later once scrubbed, matching how `flare-deployment` is handled.
diff --git a/docs/decisions/0004-ci-runner.md b/docs/decisions/0004-ci-runner.md
index 847098e..9fd1fc6 100644
--- a/docs/decisions/0004-ci-runner.md
+++ b/docs/decisions/0004-ci-runner.md
@@ -1,6 +1,8 @@
# ADR 0004 — CI/CD runner: 3rd repo-scoped self-hosted runner on 0640
-**Status:** Accepted (2026-08-25).
+**Status:** Superseded in part by ADR-0007 (2026-08-30) — `kernel-build` moved
+to GitHub-hosted runners for the public repo and the self-hosted registration
+is retired. Original decision below, kept for the record. (2026-08-25.)
## Context
The heavy kernel/firmware build needs the SDK toolchain and Buildroot's baked-in
diff --git a/docs/decisions/0005-driver-source-of-truth.md b/docs/decisions/0005-driver-source-of-truth.md
index 053007c..8ac83bc 100644
--- a/docs/decisions/0005-driver-source-of-truth.md
+++ b/docs/decisions/0005-driver-source-of-truth.md
@@ -12,7 +12,7 @@ scratch tree (`flare-edge/research/linux-6.18.46/`).
Bring **hardened copies into `warden-sdk/drivers/`** as the canonical source-of-truth,
each with its HAL seam and a 100% MC/DC host harness. The RV1106 kernel deltas are
formalized as a patch series in `patches/`. flare-edge consumes warden-sdk later
-(separate, [maintainer]-gated step).
+(separate, maintainer-gated step).
## Consequences
- Realizes the seam architecture (ADR-referenced in `docs/architecture.md`).
diff --git a/docs/decisions/0006-qemu-device-sim.md b/docs/decisions/0006-qemu-device-sim.md
new file mode 100644
index 0000000..cd23ce9
--- /dev/null
+++ b/docs/decisions/0006-qemu-device-sim.md
@@ -0,0 +1,58 @@
+# ADR 0006 — QEMU device sim: generic `-M virt`, entered at the kernel
+
+**Status:** Accepted (2026-08-29).
+
+## Context
+The two existing simulators cannot test the *device*: `lvglsim` (flare-edge)
+is an SDL rendering harness, and `sim/` models registers behind driver seams.
+Init ordering, the daemons as real processes, networking/enrollment against
+FLARE, OTA, and the watchdog were testable only on a bench panel — flare-edge's
+fault suite marks five scenarios "HIL, human prompts", its Playwright e2e needs
+a live panel on the LAN, and its OTA desk test stops at "reached APPLYING".
+
+Two ways to emulate the panel were considered:
+
+1. **A custom RV1106 QEMU board model.** Nothing exists upstream or in the
+ community, so this means writing VOP/CRU/GRF/eMMC/HPMCU device models from
+ scratch and maintaining them against QEMU — months of work that duplicates
+ what `sim/` already models in Rust. It still could not run the boot chain:
+ BootROM is mask ROM and the DDR-init/idblock stages are closed rkbin blobs.
+2. **The generic `-M virt` machine, entering at `-kernel zImage`.** The
+ forward-ported 6.18.46 config is multi_v7-derived and already carries
+ `ARCH_VIRT` plus the full virtio set — the canonical RV1106 zImage boots
+ virt **unmodified** (verified 2026-08-29). Peripherals become virtio
+ substitutes; SoC-block behavior stays in `sim/`, bridged in (the RS485
+ chardev bridge) rather than re-modeled.
+
+## Decision
+Option 2. `qemu/` holds the harness: `qemu-system-arm -M virt,highmem=off
+-cpu cortex-a7 -smp 1 -m 256M` (the RV1106G3's shape), one canonical kernel
+image plus an optional additive config fragment (`qemu/configs/virt.fragment`
+via the `WARDEN_KCONFIG_FRAGMENT` hook — PCI/pci-serial/i6300esb/WireGuard/
+virtio-gpu/virtio-input; the RV1106 build is byte-identical with the variable
+unset). The VM carries the device's real 12-partition `blkdevparts=` A/B
+layout on a virtio disk and populates the `/dev/block/by-name/` contract.
+The name is `qemu/`, not any variant of "sim" — the wikis already warn that
+"sim" is two different things.
+
+Notable mechanics: `highmem=off` because the non-LPAE 32-bit kernel cannot
+reach virt's default 40-bit PCIe ECAM; `-global virtio-mmio.force-legacy=false`
+because virtio-gpu/input are VERSION_1-only devices.
+
+## Consequences
+- Everything below the kernel is **out of scope**: BootROM, idblock, U-Boot,
+ the real BCB-driven A/B selection and bootcount auto-revert. The initramfs
+ `warden.slot=` switch emulates U-Boot's *choice*, not the mechanism. The
+ untested A/B rollback chain stays bench territory.
+- Display, input, network, and storage are **substitutes** (virtio), not
+ models. "Boots/works under emulation" is never evidence of "works on
+ silicon"; the VM narrows which claims need a panel.
+- The VM is the first environment that runs production userspace binaries on
+ a non-RV1106 physical memory map, which makes it a canary for baked-in
+ hardware assumptions (it immediately found flare-edge #106, a fatal SIGBUS
+ in flared's HPMCU probe, and #107, a Y2038 time_t truncation in the UI).
+- The guest deliberately deviates from production in documented ways
+ (`WARDEN_FLARE_INSECURE=1` for the desk mock portal, `WARDEN_HPMCU=0`);
+ qemu/README.md carries the emulated-vs-not table.
+- The kernel-build CI job gains a fail-closed qemu boot smoke; hosted runners
+ build (but cannot boot) the initramfs and disk image.
diff --git a/docs/decisions/0007-public-repo-hosted-kernel-build.md b/docs/decisions/0007-public-repo-hosted-kernel-build.md
new file mode 100644
index 0000000..f568587
--- /dev/null
+++ b/docs/decisions/0007-public-repo-hosted-kernel-build.md
@@ -0,0 +1,37 @@
+# ADR 0007 — Public repo: kernel-build moves to GitHub-hosted runners
+
+**Status:** Accepted (2026-08-30). Supersedes the runner half of ADR-0004.
+
+## Context
+The repo is going public (free Actions minutes for hosted runners; open-source
+alignment with the stack philosophy). Two facts change the ADR-0004 calculus:
+
+1. **A self-hosted runner on a public repo is a standing hazard.** A fork PR
+ can modify workflow files; once any run of theirs is approved, workflows
+ can target the repo's registered self-hosted runners — i.e. arbitrary code
+ on the private host, which also serves production. GitHub's own guidance is
+ to never attach self-hosted runners to public repos, and personal-account
+ repos have no runner groups to scope the risk away.
+2. **The build never needed the SDK host.** ADR-0004's premise ("needs the SDK
+ toolchain and Buildroot's baked-in absolute paths") does not apply to
+ `kernel-build`: the hermetic build is freestanding, uses Debian's
+ `gcc-arm-linux-gnueabihf`, and self-provisions `python`. It fits a hosted
+ runner (4 vCPU / 16 GB), and public-repo minutes are free.
+
+## Decision
+`kernel-build` runs on `ubuntu-latest`, apt-installing its toolchain, kernel
+build deps, and qemu-system-arm, with the pristine tarball cached like
+`patches-apply` does. It stays `workflow_dispatch`-only for now (a full build
+per push is still noisy; flipping it to push-on-main later is one line). The
+`warden-sdk` self-hosted runner instance is **deregistered from this repo**
+before it goes public; the flare and flare-edge runner instances on the same
+host are unaffected (those repos stay private).
+
+## Consequences
+- No path from public workflows to private infrastructure; nothing to babysit
+ in fork-PR approval settings beyond GitHub's defaults (still set "require
+ approval for all outside contributors" as belt-and-braces).
+- Kernel artifacts no longer persist on the runner host; the GitHub artifact
+ (5-day retention + prune job) is the only build output channel.
+- Hosted kernel builds are slower than the 0640 box but free and parallel;
+ the boot-smoke step rides along unchanged.
diff --git a/drivers/README.md b/drivers/README.md
index b197586..2cee72b 100644
--- a/drivers/README.md
+++ b/drivers/README.md
@@ -38,7 +38,7 @@ is already modelled and tested here** in `../sim/`:
**Why the Tier-2 *source* isn't vendored here yet:** `modbus_engine.c` and
`warden_rga.c` pull in shared UI headers (`platform.h`, `settings.h`, `lv_*`) and
librga. Copying those in would duplicate exactly the shared surface the
-**flare-edge↔warden-sdk unification** (ADR-0003/0005, a separate [maintainer]-gated step) is
+**flare-edge↔warden-sdk unification** (ADR-0003/0005, a separate maintainer-gated step) is
meant to resolve cleanly. So the Tier-2 *models* (the hardware ends) live here now;
the Tier-2 *driver sources* migrate in with the unification, at which point their
existing flare-edge harnesses point at this repo.
diff --git a/drivers/enforce-mcdc.sh b/drivers/enforce-mcdc.sh
index 9cb2d4c..7206e08 100755
--- a/drivers/enforce-mcdc.sh
+++ b/drivers/enforce-mcdc.sh
@@ -52,5 +52,5 @@ if ! echo "$cond_line" | grep -q "100.00%"; then
rc=1
fi
-[ "$rc" = "0" ] && echo "RESULT: 100% MC/DC + all checks green ✓"
+[ "$rc" = "0" ] && echo "RESULT: 100% MC/DC + all checks green OK"
exit "$rc"
diff --git a/kernel/docs/bringup.md b/kernel/docs/bringup.md
index 0d05b64..6f8bc4c 100644
--- a/kernel/docs/bringup.md
+++ b/kernel/docs/bringup.md
@@ -1,6 +1,6 @@
# RV1106 → Linux 6.18 forward-port (self-built, from vendor 5.10)
-**Decision ([maintainer], 2026-08-23):** forward-port the RV1106 SoC enablement from the
+**Decision (2026-08-23):** forward-port the RV1106 SoC enablement from the
Rockchip **vendor 5.10** tree straight to **Linux 6.18 LTS**, ourselves, using **no
plan44 code**, built on **our Buildroot 2025.02 LTS + uClibc**. We own the tree.
diff --git a/kernel/docs/m2-boot-on-c8a3.md b/kernel/docs/m2-boot-on-c8a3.md
index 4f09b51..97e516a 100644
--- a/kernel/docs/m2-boot-on-c8a3.md
+++ b/kernel/docs/m2-boot-on-c8a3.md
@@ -7,9 +7,10 @@ findings — they worked on the first hardware try.**
## The safe test path (A/B slot _b, never touch _a)
-c8a3 is the XPS-connected Warden: eth0 `[bench-ip]` over USB-gadget ([bench-creds],
-dropbear — use `scp -O`, no sftp-server), plus the serial console on xps
-`/dev/ttyUSB2` @115200 and Zigbee power (`plug_cmd.py cycle`). It runs an A/B
+c8a3 is the desk-connected bench Warden: reachable over its USB-gadget ethernet
+(dev-build dropbear; address and bench credentials live in the private
+deployment notes — use `scp -O`, no sftp-server), plus the serial console at
+115200 and remotely switchable power. It runs an A/B
firmware (boot_a=mmcblk0p5 / boot_b=mmcblk0p6, 32 MiB each; rootfs_a/_b; AvbABData
in `misc` sector 4 / byte 2048).
@@ -51,7 +52,7 @@ With the correct format, U-Boot loaded my kernel + my DTB and printed my DT mode
string (`Model: WardenOS 86-Panel (RV1106) — M2 earlycon bring-up`), then
`Starting kernel ...`.
-## Result: ✅ M2 achieved — the 6.18 kernel boots on hardware
+## Result: [x] M2 achieved — the 6.18 kernel boots on hardware
Six attempts, each auto-recovering to `_a`, then a clean boot:
@@ -84,7 +85,7 @@ pre-MMU), then fixed:
CP2102 bench adapter — so the M2 DT pins 115200 for readable bring-up; production
overrides to 1.5M.
-## M3 (same session): ✅ the full WardenOS runs on the 6.18 kernel
+## M3 (same session): [x] the full WardenOS runs on the 6.18 kernel
Adding the eMMC `dw_mmc` node (`mmc@ffa90000`, clocks from cru + grf_cru) was the
only change M3 needed — the mmc/ext4 drivers are already in-config. The kernel
@@ -96,7 +97,8 @@ won't load (vermagic → M5), and there's no backlight/framebuffer yet (→ M4).
**Console lesson applied:** with `console=ttyS2,115200` the whole boot is readable
on the CP2102 (the 1.5M vendor rate is garbage on it). Serial login uses the same
-`c8a3_run.py` [bench-creds] helper as the 5.10 firmware — the userspace is unchanged.
+`c8a3_run.py` helper (dev-build bench credentials, see private deployment notes)
+as the 5.10 firmware — the userspace is unchanged.
Next: M4 (VOP2 display + panel + touch), M5 (AIC8800 SDIO port + our 4 patches),
M6 (RGA/watchdog/HPMCU/USB-OTG); plus a lean defconfig + Buildroot-on-6.18 cleanup.
diff --git a/kernel/rv1106-enablement/CAPABILITIES-AUDIT.md b/kernel/rv1106-enablement/CAPABILITIES-AUDIT.md
index fd06830..7a10279 100644
--- a/kernel/rv1106-enablement/CAPABILITIES-AUDIT.md
+++ b/kernel/rv1106-enablement/CAPABILITIES-AUDIT.md
@@ -4,44 +4,44 @@ Every block in the vendor SoC DT (`rv1106.dtsi`), classified. Goal: a 6.18 drive
for every capability the hardware actually has, open-source, verified. Camera/ISP
is the only whole class deliberately skipped — the 86-Panel has no camera.
-## ✅ Done / at parity (verified on warden-c8a3)
+## Done / at parity (verified on warden-c8a3)
CRU clk · pinctrl (+ioc/pmuioc) · GIC · arch timer · pl330 DMA · 8250 uart (×3) ·
dw_mmc eMMC · i2c · dw-wdt · **RTC** · **tsadc** · **RGA** (rga2, hw 3.3.87975) ·
PWM backlight · **USB host** (dwc3/xhci) + usb2phy · grf/pmu syscons.
-## ✅ Verified this run (2026-08-25) — all on warden-c8a3, self-built 6.18.46
+## Verified this run (2026-08-25) — all on warden-c8a3, self-built 6.18.46
- **AIC8800 wifi** — wlan0 up, scanned the site AP at −43 dBm (modules; `wifi/VERIFIED-on-c8a3.md`).
- **TRNG** — /dev/hwrng, real HW entropy (`rng-otp/`).
- **OTP/nvmem** — rockchip-otp0 reads chip id (`rng-otp/`).
- **GMAC** — eth0 Link Up 100 Mbps/Full (`gmac/`).
- **SARADC** — iio:device0 reads 2 ch; the −22 was vref, not clk (`adc/SARADC-FIX.md`).
-## ✅ VOP display — VERIFIED this run (2026-08-25)
+## VOP display — VERIFIED this run (2026-08-25)
Full WardenOS Dashboard renders on the 86-Panel on 6.18 (`_b`), webcam-verified,
pixel-identical to stock `_a`. Two VOP driver bugs were the final black-screen
cause: `rgb_dclk_pol` hardcoded inverted (panel needs 0), and the wrong primary
scanout window (rv1106 uses **WIN1**, not rv1126's WIN2). Full chain + `_a`-vs-`_b`
register diff in `display/VERIFIED.md`.
-## ✅ GT911 touch — VERIFIED this run (2026-08-25)
-UI responds to taps/swipes on the panel ([maintainer]-confirmed); GT911 detected
+## GT911 touch — VERIFIED this run (2026-08-25)
+UI responds to taps/swipes on the panel (confirmed by hand at the panel); GT911 detected
(`ID 911, version 1060`), `/dev/input/event0` held by warden-ui. Fix:
`CONFIG_TOUCHSCREEN_GOODIX=y` (built-in — the rootfs `goodix.ko` is a 5.10 build
that can't load on 6.18) + GT911 node on `&i2c3`. Details in `touch/VERIFIED.md`.
-## 🔨 In flight
+## In flight
- **i2s-tdm** — DAI builds; needs the codec + card (below).
- **AIC8800 BT** — module built (6.18 vermagic); HCI bring-up not yet exercised.
-## ✅ audio — VERIFIED this run
+## audio — VERIFIED this run
card `rv1106-acodec` + pcmC0D0p/c (`audio/`); audible test @ bench with display.
## Remaining blocks — final status (all real capabilities now verified)
| Block | verdict | note |
|---|---|---|
-| **mailbox** (HPMCU) | ✅ VERIFIED | A7↔RISC-V-SCR1 round-trip, 5/5 exact echoes (`mailbox/VERIFIED.md`). Took 3 hardware-found fixes: rv1106 num_chans=1 (1 shared IRQ, not 4), CLK_CORE_MCU IGNORE_UNUSED (6.18 was gating the coprocessor clock), + an open SCR1 echo firmware with A2B_INTEN. The /dev/mem SRAM watchdog stays as a separate dead-man's-switch. |
-| **NPU** (rknpu) | ✅ open driver VERIFIED; compute deferred | open GPL rknpu 0.9.2 driver, /dev/dri/card1, version ioctl PASS (`npu/VERIFIED.md`). Open *compute* (a regcmd compiler) is a from-scratch ~person-year register-RE project — no RV1106 prior art, no public TRM Part 2, mainline accel/rocket+Teflon are RK3588-only. Ship the driver, no blob. |
-| **pvtm** | ✅ VERIFIED | both core+pmu PVT monitors probe; debugfs ring-osc reads (`pvtm/PORT-DONE.md`). |
+| **mailbox** (HPMCU) | VERIFIED | A7↔RISC-V-SCR1 round-trip, 5/5 exact echoes (`mailbox/VERIFIED.md`). Took 3 hardware-found fixes: rv1106 num_chans=1 (1 shared IRQ, not 4), CLK_CORE_MCU IGNORE_UNUSED (6.18 was gating the coprocessor clock), + an open SCR1 echo firmware with A2B_INTEN. The /dev/mem SRAM watchdog stays as a separate dead-man's-switch. |
+| **NPU** (rknpu) | [x] open driver VERIFIED; compute deferred | open GPL rknpu 0.9.2 driver, /dev/dri/card1, version ioctl PASS (`npu/VERIFIED.md`). Open *compute* (a regcmd compiler) is a from-scratch ~person-year register-RE project — no RV1106 prior art, no public TRM Part 2, mainline accel/rocket+Teflon are RK3588-only. Ship the driver, no blob. |
+| **pvtm** | VERIFIED | both core+pmu PVT monitors probe; debugfs ring-osc reads (`pvtm/PORT-DONE.md`). |
| **crypto-v3** (accel) | deferred (documented) | ~100 KB whole-subsystem replacement of mainline's rk3288 crypto + heavy crypto-API deltas; the **CPU crypto extensions (AES/SHA, batch2 =y) already cover the functional need** — an offload optimization, not a capability gap. |
| camera/ISP, SPI | N/A | no such hardware on the 86-Panel. |
@@ -51,7 +51,7 @@ deferred item is the crypto *accelerator* (CPU crypto already covers it) and ope
NPU *compute* (a person-year RE effort, scoped in `npu/OPEN-NPU-PLAN.md`). No
unexplored gap remains.
-## ❌ Not applicable (no hardware on the 86-Panel)
+## Not applicable (no hardware on the 86-Panel)
cif · csi2-dphy · mipi-csi2 · rkisp (all camera/ISP) · SPI (no on-board SPI device).
## Order (after wifi)
diff --git a/kernel/rv1106-enablement/DRIVER-PARITY.md b/kernel/rv1106-enablement/DRIVER-PARITY.md
index 0fff95a..133c49f 100644
--- a/kernel/rv1106-enablement/DRIVER-PARITY.md
+++ b/kernel/rv1106-enablement/DRIVER-PARITY.md
@@ -3,47 +3,47 @@
Goal: every driver the panel's 5.10 kernel runs must work at ≥ parity on our
self-built 6.18. Source of truth = the running 5.10 system on warden-c8a3
(`lsmod` + `/proc/interrupts`, captured 2026-08-24). Verify each on hardware via
-the A/B `_b`-slot loop (`../docs/m2-boot-on-c8a3.md`); ✅ means confirmed on
+the A/B `_b`-slot loop (`../docs/m2-boot-on-c8a3.md`); [x] means confirmed on
c8a3, not just compiled.
| Driver / node | 5.10 evidence | mainline? | 6.18 status |
|---|---|---|---|
-| CRU clock (clk-rv1106) | — | ported | ✅ M2 |
-| pinctrl-rockchip (rv1106) | — | ported | ✅ M2 |
-| GIC-400 / arch_timer | arch_timer | mainline | ✅ M2 |
-| dw_mmc (eMMC) | dw-mci | mainline | ✅ M3 |
-| 8250 uart2 (console) | ttyS2 | mainline | ✅ M2 |
-| GPIO (rockchip, ×5 banks) | gpio-rockchip | mainline | ✅ batch1 (chips 0–4) |
-| DMA (pl330, ff420000) | ff420000.dma-controller | mainline | ✅ batch1 |
-| uart1 / uart4 | ttyS1, ttyS4 | mainline | ✅ batch1 |
-| I2C (dw-apb, ff460000=i2c3) | ff460000.i2c | mainline | ✅ batch1 (i2c-3) |
-| watchdog (dw-wdt, ff5a0000) | ff5a0000.watchdog | mainline | ✅ batch1 (watchdog0) |
-| tsadc thermal (ff3c8000) | rockchip_thermal | ported (data+init+macros) | ✅ soc-thermal reads 39.8°C |
-| SARADC (ff3c0000) | ff3c0000.saradc | ported (2-ch v2 data) | ✅ iio:device0 reads 2ch (adc-keys); fixed -22 via vref-supply |
-| TRNG (rng@ff448000) | rockchip,trngv1 | mainline (rk3588 IP) | ✅ /dev/hwrng, real entropy (`rng-otp/`) |
-| OTP/nvmem (ff3d0000) | rockchip,rv1106-otp | ported (px30_otp_read) | ✅ rockchip-otp0, reads chip id |
-| GMAC (ffa80000) | rockchip,rv1106-gmac | ported (dwmac-rk rv1106_ops) | ✅ eth0 Link Up 100M/Full (`gmac/`) |
-| GPIO_SYSFS (legacy /sys/class/gpio) | — | mainline (config) | ⬜ goodix script needs it |
-| PWM (rockchip) | — | mainline (=m) | ⬜ batch2 =y (backlight) |
-| RTC (rv1106-rtc) | — | ported (vendor driver) | ✅ /dev/rtc0 registers + reads |
-| USB2 phy (inno, rv1106) | rockchip_usb2phy_* | ported (data, no tuning) | ✅ probes → USB up |
-| USB host (DWC3→xhci, ffb00000) | xhci-hcd:usb1 | mainline | ✅ xhci host registered |
-| USB OTG gadget (DWC3, eth0) | eth0 | mainline dwc3 | 🔨 host works; eth0 needs dr_mode=peripheral |
-| crypto (aes/ccm/ctr/arc4) | modules | mainline | ⬜ batch2 (config =y) |
-| PSCI node (removed) | — | — | ✅ deleted (no secure monitor → SMC fault) |
-| VOP display (ff990000) | ff990000.vop | ported (rv1126 sibling) | 🔨 binds+DRM+card0; connector WIP |
-| PWM backlight (pwm1) | — | mainline (rk3328 fallback) | ✅ backlight up (brightness) |
-| RGB666 720×720 panel | — | panel-dpi | 🔨 probes; bus_format + connector WIP |
-| GT911 touch (goodix) | goodix, gt911 | mainline | ⬜ M4 (needs GPIO_SYSFS ✅ + node) |
-| GPIO_SYSFS / crypto / CFG80211 | — | mainline (config) | ✅ =y (batch2) |
-| AIC8800 wifi (bsp/fdrv) | aic8800_* | **out-of-tree** | ✅ M5 — wlan0 up, scanned the site AP at −43dBm (modules, `wifi/VERIFIED-on-c8a3.md`) |
-| AIC8800 BT (btlpm) | aic8800_btlpm | **out-of-tree** | 🔨 module built (6.18 vermagic); HCI bring-up not yet exercised |
-| NPU (rknpu, ff660000) | rknpu, ff660000.npu | **out-of-tree** | 🔨 M6 built, 0 errors/0 warnings, 99 `rknpu`-prefixed symbols in `System.map`, `&npu {status="okay"}` in the dtb — **not yet flashed/probed on hardware** (build-only session; see `npu/PORT-PROGRESS.md`) |
-| RGA 2D (rga2) | rga2 | ported (vendor char-dev) | ✅ /dev/rga, hw 3.3.87975 |
-| I2S audio (i2s-tdm) | i2s | rv1126 fallback (=y) | ✅ cpu DAI registers (part of the card below) |
-| Audio codec (acodec) | rockchip,rv1106-codec | ported (rv1106_codec.c) | ✅ card `rv1106-acodec`, pcmC0D0p/c (`audio/`); audible test @ bench |
-| HPMCU mailbox (ff5c0000) | rockchip,rv1106-mailbox | rk3368 fallback +rv1106 num_chans=1 | ✅ A7<->SCR1 round-trip, 5/5 exact (`mailbox/VERIFIED.md`) |
-| PVTM (core+pmu ring-osc) | rockchip,rv1106-*-pvtm | ported (vendor, no mainline) | ✅ both probe; debugfs reads (`pvtm/`) |
-| FIQ debugger (ttyFIQ0) | fiq_glue | rockchip | ⬜ optional (we use ttyS2) |
+| CRU clock (clk-rv1106) | — | ported | [x] M2 |
+| pinctrl-rockchip (rv1106) | — | ported | [x] M2 |
+| GIC-400 / arch_timer | arch_timer | mainline | [x] M2 |
+| dw_mmc (eMMC) | dw-mci | mainline | [x] M3 |
+| 8250 uart2 (console) | ttyS2 | mainline | [x] M2 |
+| GPIO (rockchip, ×5 banks) | gpio-rockchip | mainline | [x] batch1 (chips 0–4) |
+| DMA (pl330, ff420000) | ff420000.dma-controller | mainline | [x] batch1 |
+| uart1 / uart4 | ttyS1, ttyS4 | mainline | [x] batch1 |
+| I2C (dw-apb, ff460000=i2c3) | ff460000.i2c | mainline | [x] batch1 (i2c-3) |
+| watchdog (dw-wdt, ff5a0000) | ff5a0000.watchdog | mainline | [x] batch1 (watchdog0) |
+| tsadc thermal (ff3c8000) | rockchip_thermal | ported (data+init+macros) | [x] soc-thermal reads 39.8°C |
+| SARADC (ff3c0000) | ff3c0000.saradc | ported (2-ch v2 data) | [x] iio:device0 reads 2ch (adc-keys); fixed -22 via vref-supply |
+| TRNG (rng@ff448000) | rockchip,trngv1 | mainline (rk3588 IP) | [x] /dev/hwrng, real entropy (`rng-otp/`) |
+| OTP/nvmem (ff3d0000) | rockchip,rv1106-otp | ported (px30_otp_read) | [x] rockchip-otp0, reads chip id |
+| GMAC (ffa80000) | rockchip,rv1106-gmac | ported (dwmac-rk rv1106_ops) | [x] eth0 Link Up 100M/Full (`gmac/`) |
+| GPIO_SYSFS (legacy /sys/class/gpio) | — | mainline (config) | [ ] goodix script needs it |
+| PWM (rockchip) | — | mainline (=m) | [ ] batch2 =y (backlight) |
+| RTC (rv1106-rtc) | — | ported (vendor driver) | [x] /dev/rtc0 registers + reads |
+| USB2 phy (inno, rv1106) | rockchip_usb2phy_* | ported (data, no tuning) | [x] probes → USB up |
+| USB host (DWC3→xhci, ffb00000) | xhci-hcd:usb1 | mainline | [x] xhci host registered |
+| USB OTG gadget (DWC3, eth0) | eth0 | mainline dwc3 | [wip] host works; eth0 needs dr_mode=peripheral |
+| crypto (aes/ccm/ctr/arc4) | modules | mainline | [ ] batch2 (config =y) |
+| PSCI node (removed) | — | — | [x] deleted (no secure monitor → SMC fault) |
+| VOP display (ff990000) | ff990000.vop | ported (rv1126 sibling) | [wip] binds+DRM+card0; connector WIP |
+| PWM backlight (pwm1) | — | mainline (rk3328 fallback) | [x] backlight up (brightness) |
+| RGB666 720×720 panel | — | panel-dpi | [wip] probes; bus_format + connector WIP |
+| GT911 touch (goodix) | goodix, gt911 | mainline | [ ] M4 (needs GPIO_SYSFS [x] + node) |
+| GPIO_SYSFS / crypto / CFG80211 | — | mainline (config) | [x] =y (batch2) |
+| AIC8800 wifi (bsp/fdrv) | aic8800_* | **out-of-tree** | [x] M5 — wlan0 up, scanned the site AP at −43dBm (modules, `wifi/VERIFIED-on-c8a3.md`) |
+| AIC8800 BT (btlpm) | aic8800_btlpm | **out-of-tree** | [wip] module built (6.18 vermagic); HCI bring-up not yet exercised |
+| NPU (rknpu, ff660000) | rknpu, ff660000.npu | **out-of-tree** | [x] open GPL driver VERIFIED on hardware — `/dev/dri/card1`, `rknpu_version_test` PASS (power/clock/reset path exercised); open *compute* (regcmd) remains a from-scratch RE project (`npu/VERIFIED.md`, `npu/OPEN-NPU-PLAN.md`) |
+| RGA 2D (rga2) | rga2 | ported (vendor char-dev) | [x] /dev/rga, hw 3.3.87975 |
+| I2S audio (i2s-tdm) | i2s | rv1126 fallback (=y) | [x] cpu DAI registers (part of the card below) |
+| Audio codec (acodec) | rockchip,rv1106-codec | ported (rv1106_codec.c) | [x] card `rv1106-acodec`, pcmC0D0p/c (`audio/`); audible test @ bench |
+| HPMCU mailbox (ff5c0000) | rockchip,rv1106-mailbox | rk3368 fallback +rv1106 num_chans=1 | [x] A7<->SCR1 round-trip, 5/5 exact (`mailbox/VERIFIED.md`) |
+| PVTM (core+pmu ring-osc) | rockchip,rv1106-*-pvtm | ported (vendor, no mainline) | [x] both probe; debugfs reads (`pvtm/`) |
+| FIQ debugger (ttyFIQ0) | fiq_glue | rockchip | [ ] optional (we use ttyS2) |
-Legend: ✅ verified on hardware · 🔨 built, not yet verified · ⬜ not started.
+Legend: [x] verified on hardware · [wip] built, not yet verified · [ ] not started.
diff --git a/kernel/rv1106-enablement/OVERNIGHT-PLAN.md b/kernel/rv1106-enablement/OVERNIGHT-PLAN.md
index 5f2ff9e..27f6bd2 100644
--- a/kernel/rv1106-enablement/OVERNIGHT-PLAN.md
+++ b/kernel/rv1106-enablement/OVERNIGHT-PLAN.md
@@ -1,6 +1,6 @@
# Overnight autonomous kernel-completion workflow
-**Directive ([maintainer], 2026-08-24 night):** port/enable **every** remaining RV1106
+**Directive (2026-08-24 night):** port/enable **every** remaining RV1106
hardware capability on the self-built 6.18 kernel, **open-source-first** — we want
*source* we can read, harden, and bend to our needs (vendor SDK source, upstream,
community repos, or reverse-engineering), never binary blobs. Not just ports —
diff --git a/kernel/rv1106-enablement/OVERNIGHT-RESULTS.md b/kernel/rv1106-enablement/OVERNIGHT-RESULTS.md
index f51264f..791a6d0 100644
--- a/kernel/rv1106-enablement/OVERNIGHT-RESULTS.md
+++ b/kernel/rv1106-enablement/OVERNIGHT-RESULTS.md
@@ -1,13 +1,13 @@
# Overnight kernel-enablement run — results (2026-08-24 night → 08-25)
-Goal ([maintainer]): port/enable **every** remaining RV1106 hardware capability on the
+Goal: port/enable **every** remaining RV1106 hardware capability on the
self-built Linux 6.18.46, open-source-first, verify on hardware, so the display's
last mile can start in the morning. Runs on warden-c8a3 (`_b` slot = our 6.18).
-## ✅ Verified on hardware this run
+## Verified on hardware this run
| Driver | Evidence |
|---|---|
-| **AIC8800 wifi** (M5) | wlan0 up ([device-mac]), `iw scan` found the site AP −43 dBm + others. Modules (built-in deadlocks the two-stage SDIO bring-up). |
+| **AIC8800 wifi** (M5) | wlan0 up, `iw scan` found the site AP at −43 dBm + others. Modules (built-in deadlocks the two-stage SDIO bring-up). |
| **TRNG** | `/dev/hwrng`, `rng_current=rockchip-rng`, real entropy. |
| **OTP/nvmem** | `rockchip-otp0` reads chip id ("MR1"). |
| **GMAC** (wired eth) | `eth0: Link is Up - 100 Mbps/Full`. |
@@ -33,7 +33,7 @@ mailbox (binds via rk3368 fallback but no client to exercise), crypto-v3 (CPU
crypto extensions already cover it; 100 KB port), NPU (no open userspace),
pvtm (DVFS-only). Camera/ISP/SPI: no hardware. **No unexplored capability gap.**
-## Morning (needs [maintainer] at the bench)
+## Morning (needs a human at the bench)
1. **Display connector** — VOP binds + DRM card0 + panel probes, but no connector
link yet; needs eyes on the panel (pixels can't be verified over serial).
2. **Audible audio** — `speaker-test`/`aplay` through the acodec.
diff --git a/kernel/rv1106-enablement/PORT-STATUS.md b/kernel/rv1106-enablement/PORT-STATUS.md
index 14df286..3a0b640 100644
--- a/kernel/rv1106-enablement/PORT-STATUS.md
+++ b/kernel/rv1106-enablement/PORT-STATUS.md
@@ -20,7 +20,7 @@ from the **vendor 5.10.160** tree, no plan44 code, built with our
- rv1106's siblings **rv1126/rv1108 exist in both trees**, so their 5.10→6.18 delta is a
working template for the framework API changes.
-## M1 — clock driver (`clk-rv1106.c`, 1294 lines): ✅ COMPILES CLEAN on 6.18
+## M1 — clock driver (`clk-rv1106.c`, 1294 lines): [x] COMPILES CLEAN on 6.18
Build-fix loop against 6.18 — `clk-rv1106.o` (85732 bytes) builds with no errors.
Fixed (captured in `clk/`):
1. **Kconfig + Makefile hooks** — added `CONFIG_CLK_RV1106` (mirrors CLK_RV1126).
@@ -31,7 +31,7 @@ Fixed (captured in `clk/`):
4. **`rockchip_clk_register_armclk` signature change** — 5.10 took
`(num_parents, parent_clk, alt_parent_clk)`; 6.18 takes `(parent_names[], num_parents)`
and drives the mux from `reg_data.mux_core_main/alt`. Adapted the call to the 6.18 form
- using a parent-names array (sibling-delta from rv1126). **⚠ PORT-VERIFY**: the mux input
+ using a parent-names array (sibling-delta from rv1126). **PORT-VERIFY**: the mux input
list `{ "gpll","cpll","apll" }` and the `mux_core_main/alt=2` mapping are a best-effort
from the 5.10 intent (main=apll) — they set the **CPU clock source**, so they must be
checked against the RV1106 `CORECLKSEL_CON` register map (TRM) and validated on hardware
@@ -39,9 +39,9 @@ Fixed (captured in `clk/`):
5. **`CLK_FRAC_DIVIDER_NO_LIMIT`** — Rockchip downstream-only frac-divider flag (6 uses on
the UART frac clocks); mainline has no min/max opt-out, mapped to 0 (default limit).
- **⚠ PORT-VERIFY**: UART fractional baud accuracy.
+ **PORT-VERIFY**: UART fractional baud accuracy.
-## M1 — pinctrl (`pinctrl-rockchip.c/.h`): ✅ COMPILES CLEAN on 6.18
+## M1 — pinctrl (`pinctrl-rockchip.c/.h`): [x] COMPILES CLEAN on 6.18
`pinctrl-rockchip.o` (173688 bytes) builds no-errors. Transplanted from vendor 5.10 (effort S,
zero API drift — the survey's assessment held): added `RV1106` to the type enum; a 159-line
block of `RV1106_DRV/PULL/SMT_*` macros + 3 `rv1106_calc_*_reg_and_bit()` functions; `case
@@ -58,12 +58,12 @@ RV1106:` in the 3 pull functions + the RK3568 drive-strength group; `rv1106_pin_
- **Still PORT-VERIFY:** GPIO4 bank pin-count (`pin_banks` says 24, DT `gpio-ranges` says 32) —
carried from vendor unchanged; needs TRM/hardware.
-## M1 — mach: ✅ DONE
+## M1 — mach: DONE
`mach-rockchip` RV1106/RV1103 SoC recognition added as a DT-compat entry (no
`CPU_RV1106` symbol recreated). Captured as `mach/0001-rv1106-soc-recognition.patch`.
**M1 is complete: clk + pinctrl + mach all compile clean on 6.18.**
-## M2 — earlycon build: ✅ DONE (boot pending hardware)
+## M2 — earlycon build: DONE (boot pending hardware)
The first full kernel build with our SoC drivers, 2026-08-24:
- **`multi_v7_defconfig` + `configs/m2-earlycon.fragment` builds an 11.8 MB zImage**
with `clk-rv1106.o` (85732 B) and `pinctrl-rockchip.o` (173688 B, our rv1106 data)
@@ -80,7 +80,7 @@ The first full kernel build with our SoC drivers, 2026-08-24:
console baud (1.5M assumed). A wrong DDR/clock value silently hangs before or just after
earlycon.
-## M2 — boot: ✅ DONE — "it's alive" on warden-c8a3 (2026-08-24)
+## M2 — boot: DONE — "it's alive" on warden-c8a3 (2026-08-24)
The self-built **Linux 6.18.46 boots on real RV1106 hardware**, through our ported
drivers, verified over the serial console. It reaches earlycon, the arch timer
(BogoMIPS calibrated), **our `clk-rv1106` CRU driver**, pinctrl, and the mainline
@@ -105,7 +105,7 @@ Three bring-up bugs were found and fixed on hardware, all captured in the DT:
comes up far enough to clock the UART and the arch timer on hardware. A wrong CPU
mux/PLL would show later (cpufreq / peripheral rates), still to be checked.
-## M3 — rootfs boot: ✅ DONE — the full WardenOS runs on the 6.18 kernel (2026-08-24)
+## M3 — rootfs boot: DONE — the full WardenOS runs on the 6.18 kernel (2026-08-24)
Adding the eMMC (`dw_mmc`) node to the DT was all M3 needed — the drivers are already
in the config. On hardware:
```
diff --git a/kernel/rv1106-enablement/PROVENANCE.md b/kernel/rv1106-enablement/PROVENANCE.md
index 76f69eb..d350243 100644
--- a/kernel/rv1106-enablement/PROVENANCE.md
+++ b/kernel/rv1106-enablement/PROVENANCE.md
@@ -1,6 +1,6 @@
# Driver provenance & openness ledger
-[maintainer]'s directive: **every** driver we run on the self-built 6.18 kernel must be
+Standing directive: **every** driver we run on the self-built 6.18 kernel must be
open source we can read, harden, and bend — not a binary blob — and this applies
to the drivers *already* ported, not just the new ones. This ledger records, for
each, where the source came from and under what license. Every entry is GPL-2.0
diff --git a/kernel/rv1106-enablement/adc/SARADC-FIX.md b/kernel/rv1106-enablement/adc/SARADC-FIX.md
index d293cda..15911d0 100644
--- a/kernel/rv1106-enablement/adc/SARADC-FIX.md
+++ b/kernel/rv1106-enablement/adc/SARADC-FIX.md
@@ -1,4 +1,4 @@
-# SARADC — ✅ VERIFIED on warden-c8a3 (2026-08-25); the -22 was vref, not clk
+# SARADC — VERIFIED on warden-c8a3 (2026-08-25); the -22 was vref, not clk
The rockchip_saradc probe failed `-22` NOT at clk_set_rate (no "failed to set
adc clk rate" ever printed) but at `regulator_get_voltage(info->vref)` — with no
diff --git a/kernel/rv1106-enablement/audio/PORT-PROGRESS.md b/kernel/rv1106-enablement/audio/PORT-PROGRESS.md
index 42e38d8..f1aeee3 100644
--- a/kernel/rv1106-enablement/audio/PORT-PROGRESS.md
+++ b/kernel/rv1106-enablement/audio/PORT-PROGRESS.md
@@ -224,7 +224,7 @@ stuck in `deferred probe pending: asoc-simple-card: parse error`, no card.
i2s0_8ch node already carries the `rockchip,rv1126-i2s-tdm` fallback compatible +
`#sound-dai-cells=<0>`, so the mainline driver binds it).
-**✅ VERIFIED on c8a3:** `/proc/asound/cards` → `0 [rv1106acodec]: simple-card -
+**VERIFIED on c8a3:** `/proc/asound/cards` → `0 [rv1106acodec]: simple-card -
rv1106-acodec`; `aplay -l` → `card 0: rv1106acodec, device 0:
ffae0000.i2s-rv1106-hifi`; `/dev/snd/` has `controlC0 pcmC0D0p pcmC0D0c` (playback
+ capture). Audible speaker test deferred to the bench (with the display).
diff --git a/kernel/rv1106-enablement/build-m2.sh b/kernel/rv1106-enablement/build-m2.sh
index 9a651dd..c8b5f90 100755
--- a/kernel/rv1106-enablement/build-m2.sh
+++ b/kernel/rv1106-enablement/build-m2.sh
@@ -8,7 +8,7 @@
# Defaults match this workspace.
set -euo pipefail
-FE="${FE:-}"
+FE="${FE:?set FE to a flare-edge checkout path}"
KTREE="${KTREE:-$FE/research/linux-6.18.46}"
SDK_TC="${SDK_TC:-$FE/sdk/tools/linux/toolchain/arm-rockchip830-linux-uclibcgnueabihf/bin}"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
diff --git a/kernel/rv1106-enablement/display/README.md b/kernel/rv1106-enablement/display/README.md
index ebd4326..e44680e 100644
--- a/kernel/rv1106-enablement/display/README.md
+++ b/kernel/rv1106-enablement/display/README.md
@@ -39,7 +39,7 @@ rockchip-drm display-subsystem: bound ff990000.vop
brightness settable). **The VOP driver port is validated** — the register data,
version, feature, and resets are right.
-## ✅ RESOLVED — full UI renders on the panel (2026-08-25)
+## RESOLVED — full UI renders on the panel (2026-08-25)
The connector *and* the deeper black-screen chain that followed it are fixed; the
86-Panel now draws the full WardenOS Dashboard on 6.18 (`_b`), verified by webcam.
diff --git a/kernel/rv1106-enablement/display/VERIFIED.md b/kernel/rv1106-enablement/display/VERIFIED.md
index a73f1f0..c2d3236 100644
--- a/kernel/rv1106-enablement/display/VERIFIED.md
+++ b/kernel/rv1106-enablement/display/VERIFIED.md
@@ -1,4 +1,4 @@
-# Display (VOP + RGB panel) — ✅ VERIFIED on warden-c8a3 (2026-08-25)
+# Display (VOP + RGB panel) — VERIFIED on warden-c8a3 (2026-08-25)
The 86-Panel renders the **full WardenOS Dashboard UI** on our self-built Linux
6.18.46 (`_b` slot), pixel-identical to the stock 5.10 `_a` slot. Verified by
diff --git a/kernel/rv1106-enablement/gmac/PORT-DONE.md b/kernel/rv1106-enablement/gmac/PORT-DONE.md
index ca1f03c..e915a75 100644
--- a/kernel/rv1106-enablement/gmac/PORT-DONE.md
+++ b/kernel/rv1106-enablement/gmac/PORT-DONE.md
@@ -1,4 +1,4 @@
-# GMAC (wired 10/100 ethernet) — ✅ VERIFIED on warden-c8a3 (2026-08-25)
+# GMAC (wired 10/100 ethernet) — VERIFIED on warden-c8a3 (2026-08-25)
**Result: `eth0: Link is Up - 100Mbps/Full - flow control rx/tx`** on our
self-built 6.18.46. The 86-Panel's RMII MAC + on-die 10/100 FEPHY works; a real
diff --git a/kernel/rv1106-enablement/mailbox/PLAN.md b/kernel/rv1106-enablement/mailbox/PLAN.md
index 74b421a..2d03d84 100644
--- a/kernel/rv1106-enablement/mailbox/PLAN.md
+++ b/kernel/rv1106-enablement/mailbox/PLAN.md
@@ -25,7 +25,7 @@ watchdog exactly as-is (different threat model, different job).
- **Controller (Linux side): non-issue.** `drivers/mailbox/rockchip-mailbox.c` is
upstream in mainline 6.18 and **already binds on our exact kernel** via the
generic `rockchip,rk3368-mailbox` **fallback compatible** with **zero patching**
- — recorded in `CAPABILITIES-AUDIT.md:30`, confirmed by source read. RV1106's DT
+ — recorded in `CAPABILITIES-AUDIT.md`'s Remaining-blocks table (mailbox row), confirmed by source read. RV1106's DT
declares both instances with that fallback string. Gated today only by
`status="disabled"` + `CONFIG_ROCKCHIP_MBOX` being absent from the defconfig.
- **HPMCU firmware (MCU side): we already do the hard part.** WardenOS has a
@@ -241,7 +241,7 @@ adopt rpmsg/virtio unless the payload complexity genuinely demands it.
moot, since our `hpmcu.rs` is an independent hardware-validated reimplementation.
---
-_Cross-refs: `../CAPABILITIES-AUDIT.md:30`, `../REMAINING-PORTS.md §7`,
+_Cross-refs: `../CAPABILITIES-AUDIT.md`, `../REMAINING-PORTS.md §7`,
`../../luckfox-pico-86-panel/riscv-mcu.md`,
`.../raw/followup-riscv-mcu.md`,
`flare-edge/major-app-additions/docs/decisions/0002-hpmcu-watchdog.md`,
diff --git a/kernel/rv1106-enablement/mailbox/VERIFIED.md b/kernel/rv1106-enablement/mailbox/VERIFIED.md
index 793b2c3..733520e 100644
--- a/kernel/rv1106-enablement/mailbox/VERIFIED.md
+++ b/kernel/rv1106-enablement/mailbox/VERIFIED.md
@@ -1,4 +1,4 @@
-# HPMCU mailbox — ✅ 100% VERIFIED on warden-c8a3 (2026-08-25)
+# HPMCU mailbox — [x] 100% VERIFIED on warden-c8a3 (2026-08-25)
A fully-open A7 ↔ HPMCU (RISC-V SCR1) hardware-mailbox round-trip on our self-built
Linux 6.18.46. Open kernel driver + open SCR1 firmware, **zero blobs**.
@@ -7,11 +7,11 @@ Linux 6.18.46. Open kernel driver + open SCR1 firmware, **zero blobs**.
SCR1 echo firmware running: `DBG_STATE = 0x584F424D` ("MBOX"). Five round-trips,
Linux → mailbox → SCR1 → mailbox → Linux, **all exact**:
```
-sent 0x0000beef/0x600df00d -> B2A 0x0000BEEF/0x600DF00D e=4 ✓
-sent 0x0000c0de/0x12345678 -> B2A 0x0000C0DE/0x12345678 e=5 ✓
-sent 0x0000face/0xdeadbeef -> B2A 0x0000FACE/0xDEADBEEF e=6 ✓
-sent 0x00001234/0xcafef00d -> B2A 0x00001234/0xCAFEF00D e=7 ✓
-sent 0x0000aa55/0x55aa55aa -> B2A 0x0000AA55/0x55AA55AA e=8 ✓
+sent 0x0000beef/0x600df00d -> B2A 0x0000BEEF/0x600DF00D e=4 OK
+sent 0x0000c0de/0x12345678 -> B2A 0x0000C0DE/0x12345678 e=5 OK
+sent 0x0000face/0xdeadbeef -> B2A 0x0000FACE/0xDEADBEEF e=6 OK
+sent 0x00001234/0xcafef00d -> B2A 0x00001234/0xCAFEF00D e=7 OK
+sent 0x0000aa55/0x55aa55aa -> B2A 0x0000AA55/0x55AA55AA e=8 OK
```
Echo counter increments 1:1 with sends; both CMD and DAT echo back verbatim.
diff --git a/kernel/rv1106-enablement/mailbox/scr1-echo/Makefile b/kernel/rv1106-enablement/mailbox/scr1-echo/Makefile
index 5008b9f..54678a7 100644
--- a/kernel/rv1106-enablement/mailbox/scr1-echo/Makefile
+++ b/kernel/rv1106-enablement/mailbox/scr1-echo/Makefile
@@ -1,7 +1,8 @@
# HPMCU mailbox-echo firmware — bare-metal RV32IMC for the RV1106 SCR1 core.
# Same xPack riscv-none-embed-gcc 10.2.0 + flags as the watchdog firmware.
-XPACK ?= /sdk/sysdrv/source/mcu/prebuilts/gcc/linux-x86/riscv64/xpack-riscv-none-embed-gcc-10.2.0-1.2/bin
+# Point XPACK at the xpack riscv toolchain bin/ inside a flare-edge SDK checkout.
+XPACK ?= $(error set XPACK to the xpack-riscv-none-embed-gcc bin/ directory)
CROSS ?= $(XPACK)/riscv-none-embed-
CC = $(CROSS)gcc
diff --git a/kernel/rv1106-enablement/npu/OPEN-NPU-PLAN.md b/kernel/rv1106-enablement/npu/OPEN-NPU-PLAN.md
index ec4a2eb..7f2e372 100644
--- a/kernel/rv1106-enablement/npu/OPEN-NPU-PLAN.md
+++ b/kernel/rv1106-enablement/npu/OPEN-NPU-PLAN.md
@@ -197,7 +197,8 @@ the whole problem.
`PROVENANCE.md`: the kernel driver is portable GPL; the closed piece is the
userspace RKNN runtime + regcmd format (a blob). **Per directive we do not ship
-that blob.** `CAPABILITIES-AUDIT.md:32` rates NPU "not worth shipping" until an
+that blob.** `CAPABILITIES-AUDIT.md`'s Remaining-blocks table rates the NPU
+"open driver VERIFIED; compute deferred" until an
open encoder exists.
### URLs
@@ -299,5 +300,5 @@ ever begun; otherwise this is the documented reason open NPU compute is deferred
---
_Cross-refs: `PORT-PLAN.md` (authoritative file-by-file kernel port),
-`../../docs/npu-graphics-feasibility.md`, `../CAPABILITIES-AUDIT.md:32`,
+`../../docs/npu-graphics-feasibility.md`, `../CAPABILITIES-AUDIT.md`,
`../PROVENANCE.md`, `../DRIVER-PARITY.md:41`, `../REMAINING-PORTS.md §6`._
diff --git a/kernel/rv1106-enablement/npu/PORT-PLAN.md b/kernel/rv1106-enablement/npu/PORT-PLAN.md
index c096b54..da06af7 100644
--- a/kernel/rv1106-enablement/npu/PORT-PLAN.md
+++ b/kernel/rv1106-enablement/npu/PORT-PLAN.md
@@ -8,7 +8,7 @@ target as the rest of this port: **Linux 6.18.46 vanilla**
(`flare-edge/research/linux-6.18.46/`), forward-ported from vendor 5.10.160, built
with our `arm-rockchip830-...-gcc 8.3` toolchain — see `../PORT-STATUS.md` and
`../../docs/bringup.md` for the method and milestones this slots into (M6, listed
-in `../DRIVER-PARITY.md` as "NPU (rknpu, ff660000) | out-of-tree | ⬜ M6").
+in `../DRIVER-PARITY.md` as "NPU (rknpu, ff660000) | out-of-tree | [ ] M6").
Builds on `warden-sdk/docs/npu-graphics-feasibility.md`, which already read this
same driver source to answer a narrower question (can the NPU do graphics — no).
diff --git a/kernel/rv1106-enablement/npu/PORT-PROGRESS.md b/kernel/rv1106-enablement/npu/PORT-PROGRESS.md
index 905aecd..7264463 100644
--- a/kernel/rv1106-enablement/npu/PORT-PROGRESS.md
+++ b/kernel/rv1106-enablement/npu/PORT-PROGRESS.md
@@ -331,6 +331,6 @@ the value of checking the decoded string, not just the ioctl return code.
## Also updated this session
-`../DRIVER-PARITY.md`'s NPU row: `⬜ M6 — plan: npu/PORT-PLAN.md` -> `🔨 M6 built,
+`../DRIVER-PARITY.md`'s NPU row: `[ ] M6 — plan: npu/PORT-PLAN.md` -> `[wip] M6 built,
0 errors/0 warnings, 99 rknpu-prefixed symbols in System.map, &npu
{status="okay"} in the dtb — not yet flashed/probed on hardware`.
diff --git a/kernel/rv1106-enablement/npu/VERIFIED.md b/kernel/rv1106-enablement/npu/VERIFIED.md
index fa4e5d9..f6ddfe0 100644
--- a/kernel/rv1106-enablement/npu/VERIFIED.md
+++ b/kernel/rv1106-enablement/npu/VERIFIED.md
@@ -1,4 +1,4 @@
-# NPU (rknpu) open kernel driver — ✅ VERIFIED on warden-c8a3 (2026-08-25)
+# NPU (rknpu) open kernel driver — VERIFIED on warden-c8a3 (2026-08-25)
The open GPL rknpu kernel driver runs on our self-built Linux 6.18.46. This is the
achievable open end state (Tier A in `OPEN-NPU-PLAN.md`); open *compute* remains a
diff --git a/kernel/rv1106-enablement/pvtm/PORT-DONE.md b/kernel/rv1106-enablement/pvtm/PORT-DONE.md
index f5ba37a..03d99bf 100644
--- a/kernel/rv1106-enablement/pvtm/PORT-DONE.md
+++ b/kernel/rv1106-enablement/pvtm/PORT-DONE.md
@@ -1,4 +1,4 @@
-# PVTM (Process-Voltage-Temperature Monitor) — ✅ VERIFIED on warden-c8a3 (2026-08-25)
+# PVTM (Process-Voltage-Temperature Monitor) — VERIFIED on warden-c8a3 (2026-08-25)
Whole-driver port: mainline 6.18 has **no** rockchip pvtm driver; the vendor
`drivers/soc/rockchip/rockchip_pvtm.c` (GPL-2.0, 1046L) supports rv1106. Copied it in
diff --git a/kernel/rv1106-enablement/rga/PORT-PLAN.md b/kernel/rv1106-enablement/rga/PORT-PLAN.md
index c6660db..6e1c01b 100644
--- a/kernel/rv1106-enablement/rga/PORT-PLAN.md
+++ b/kernel/rv1106-enablement/rga/PORT-PLAN.md
@@ -284,7 +284,7 @@ implied by this kernel port.
reports true (`querystring(RGA_VERSION)` succeeds — `warden_rga.c:458-466`) and the
Monitor page's "RGA" load metric moves during graph scroll (`warden_rga_load_pct()`,
`warden_rga.c:226-241`) — the same on-target evidence bar as every other change in
- this repo (`../../CLAUDE.md`: "UI/daemon changes are verified on a real panel").
+ this repo (repo policy: "UI/daemon changes are verified on a real panel").
## Summary of residual risk (PORT-VERIFY-class items)
@@ -294,8 +294,9 @@ implied by this kernel port.
- **CMA pool sizing** — 10 MiB was sized against the 5.10 image's actual usage (graph
canvas + scanout mirror at 720×720×4B ≈ 2 MiB each); carry the same size unless a
future accounting shows it's tight.
-- **Driver-parity table** (`../DRIVER-PARITY.md`) should move `RGA 2D (rga2)` from ⬜ to
- 🔨/✅ as these steps land, same convention as every other M-milestone row.
+- **Driver-parity table** (`../DRIVER-PARITY.md`) should move `RGA 2D (rga2)` from [ ] to
+ [wip]/[x] as these steps land, same convention as every other M-milestone row
+(the parity row already reads [x]).
## Sources
diff --git a/kernel/rv1106-enablement/rng-otp/PORT-DONE.md b/kernel/rv1106-enablement/rng-otp/PORT-DONE.md
index 46cfc86..a96688f 100644
--- a/kernel/rv1106-enablement/rng-otp/PORT-DONE.md
+++ b/kernel/rv1106-enablement/rng-otp/PORT-DONE.md
@@ -1,6 +1,6 @@
# TRNG + OTP port (batch A) — 6.18
-## TRNG (hardware RNG) — ✅ VERIFIED on warden-c8a3 (2026-08-25)
+## TRNG (hardware RNG) — VERIFIED on warden-c8a3 (2026-08-25)
rv1106's `rockchip,trngv1` is the same standalone TRNG_V1 IP as rk3588 (identical
register map). Mainline `drivers/char/hw_random/rockchip-rng.c` already drives it.
@@ -21,7 +21,7 @@ rv1106's clock/reset names need no special handling.) Kconfig
`dd if=/dev/hwrng bs=16` → `c697 503d f9db 6b84 50e4 e1ee f232 b2ae` (real HW
entropy, non-zero). Hardware entropy source for the panel's crypto/keys.
-## OTP / nvmem — ✅ VERIFIED on warden-c8a3 (2026-08-25)
+## OTP / nvmem — VERIFIED on warden-c8a3 (2026-08-25)
Reads real data: `dd .../rockchip-otp0/nvmem bs=1 count=16 | xxd` →
`5211 02fe 084d 5231 0000 0000 3b15 0000` (contains "MR1" chip id) — no timeout.
Mainline `drivers/nvmem/rockchip-otp.c` gains an `rv1106_data` + compatible.
diff --git a/kernel/rv1106-enablement/touch/VERIFIED.md b/kernel/rv1106-enablement/touch/VERIFIED.md
index 383c9c5..13dd1db 100644
--- a/kernel/rv1106-enablement/touch/VERIFIED.md
+++ b/kernel/rv1106-enablement/touch/VERIFIED.md
@@ -1,7 +1,7 @@
-# GT911 capacitive touch — ✅ VERIFIED on warden-c8a3 (2026-08-25)
+# GT911 capacitive touch — VERIFIED on warden-c8a3 (2026-08-25)
Touch works on our self-built 6.18.46 (`_b`): the WardenOS UI responds to
-taps/swipes ([maintainer] confirmed on the physical panel). Objective evidence:
+taps/swipes (confirmed by hand on the physical panel). Objective evidence:
```
Goodix-TS 3-0014: ID 911, version: 1060
diff --git a/kernel/rv1106-enablement/wifi/PORT-PLAN.md b/kernel/rv1106-enablement/wifi/PORT-PLAN.md
index 895dfa7..17c5c9f 100644
--- a/kernel/rv1106-enablement/wifi/PORT-PLAN.md
+++ b/kernel/rv1106-enablement/wifi/PORT-PLAN.md
@@ -120,7 +120,7 @@ cfg80211.ko → libarc4.ko → ctr.ko → ccm.ko → libaes.ko → aes_generic.k
see `insmod_wifi.sh:126-137` and `wifi-bluetooth-aic8800.md`); nothing to
replicate there. The crypto modules (arc4/ctr/ccm/aes) are dependencies of the
driver's internal key-handling, not aic8800-specific — confirm they're already
-`=y`/reachable in the 6.18 config (crypto is currently listed as "⬜ batch2" in
+`=y`/reachable in the 6.18 config (crypto is currently listed as "[ ] batch2" in
`../DRIVER-PARITY.md`; flip alongside this work).
**DT node — correction to the task's framing.** The task description assumed
@@ -295,7 +295,7 @@ No breaking changes found in `sdio_driver`, `sdio_claim_host`/`release_host`,
`sdio_readb`/`writesb`, `sdio_set_block_size` between 5.10 and 6.18 (low
research depth on this axis — treat as low-risk, smoke-test rather than
line-audit). The mainline `dw_mmc`/`dw_mmc-rockchip` host driver is already
-proven on 6.18 for eMMC (`../DRIVER-PARITY.md`: `dw_mmc (eMMC) | mainline | ✅ M3`)
+proven on 6.18 for eMMC (`../DRIVER-PARITY.md`: `dw_mmc (eMMC) | mainline | [x] M3`)
and `CONFIG_MMC_DW_ROCKCHIP=y` is already in the live `.config` — the SDIO
*controller* side of this port is de-risked; only the AIC8800 *card driver*
above the `sdmmc` bus is new work.
@@ -410,7 +410,7 @@ CONFIG_BT_HCIUART_H4=y # already =y (H4 is the transport this board's BT a
# per hardware-86-panel.md: UART1/ttyS1, hciattach -s 1500000
# ... any 1500000 flow nosleep)
CONFIG_CRYPTO_ARC4=y CONFIG_CRYPTO_CTR=y CONFIG_CRYPTO_CCM=y CONFIG_CRYPTO_AES=y # driver's
- # internal key-handling deps, currently "⬜ batch2" in
+ # internal key-handling deps, currently "[ ] batch2" in
# ../DRIVER-PARITY.md — confirm =y, not =m, alongside this work
```
Do **not** enable `CONFIG_MAC80211` for this driver — confirmed by source
@@ -537,7 +537,7 @@ Follow the existing A/B `_b`-slot hardware-verification loop
attaches and `hciattach -s 1500000 /dev/ttyS1 any 1500000 flow nosleep`
(the known-good invocation) brings up an HCI device.
8. **Update on landing**: mark the `AIC8800 wifi (bsp/fdrv)` and
- `AIC8800 BT (btlpm)` rows in `../DRIVER-PARITY.md` ✅, with the same
+ `AIC8800 BT (btlpm)` rows in `../DRIVER-PARITY.md` [x], with the same
"hardware-verified, not just compiled" bar every other row uses.
## Sources
diff --git a/kernel/rv1106-enablement/wifi/VERIFIED-on-c8a3.md b/kernel/rv1106-enablement/wifi/VERIFIED-on-c8a3.md
index dc16636..25d937b 100644
--- a/kernel/rv1106-enablement/wifi/VERIFIED-on-c8a3.md
+++ b/kernel/rv1106-enablement/wifi/VERIFIED-on-c8a3.md
@@ -10,10 +10,10 @@ live RF scan.
`Start app: 00120000`, BSP_RC=0.
- `insmod aic8800_fdrv.ko` → `ieee80211 phy0: HT supp 1, VHT supp 1, HE supp 1`,
FDRV_RC=0.
-- `wlan0: ... link/ether [device-mac]`
-- `iw dev wlan0 scan` found real APs:
- - **the site AP [bssid] 2412 MHz −43 dBm**
- - a neighboring guest AP [bssid] 2412 MHz −73 dBm
+- `wlan0: ... link/ether `
+- `iw dev wlan0 scan` found real APs (SSIDs/BSSIDs redacted for publication):
+ - **the site AP at 2412 MHz, −43 dBm**
+ - a neighboring guest AP at 2412 MHz, −73 dBm
- +several more, correct signal strengths → RF path fully functional.
## Why MODULES, not built-in (=y)
diff --git a/patches/README.md b/patches/README.md
index 44023d7..6195373 100644
--- a/patches/README.md
+++ b/patches/README.md
@@ -27,6 +27,13 @@ with `../build/warden_defconfig` → `zImage` + `rv1106-warden.dtb`).
| `70-audio-codec.patch` | `rv1106_codec` + I2S wiring |
| `80-misc-thermal-rtc-adc.patch` | tsadc, rtc-rockchip, saradc, trng, otp, gmac, goodix touch |
+## License
+
+Everything in this directory is a derivative work of the Linux kernel and of
+GPL-2.0 vendor kernel code: **GPL-2.0-only** (or the per-file SPDX identifier
+where one is present), the same license the whole repository carries. Per-driver
+origin and license are tracked in `kernel/rv1106-enablement/PROVENANCE.md`.
+
## Provenance & regeneration
Baseline: `linux-6.18.46` from kernel.org (`build/linux-6.18.46.tar.xz.sha256`
diff --git a/qemu/README.md b/qemu/README.md
new file mode 100644
index 0000000..02af94b
--- /dev/null
+++ b/qemu/README.md
@@ -0,0 +1,102 @@
+# qemu/ — the WardenOS device simulator
+
+A QEMU virtual machine that boots the real forward-ported kernel (`build/` +
+`patches/`) and real userspace, so the *device* — init, daemons, networking,
+OTA, watchdog, display — can be tested off-hardware. The third simulator in
+the stack, deliberately not named "sim":
+
+- `lvglsim` (flare-edge) — SDL desktop build of the UI. Rendering only.
+- `sim/` (this repo) — register-level Rust models of RV1106 blocks behind
+ driver seams.
+- `qemu/` (this) — the whole machine above the kernel entry point, running
+ the real binaries.
+
+Decision record: `docs/decisions/0006-qemu-device-sim.md`.
+
+## The boundary (read this before trusting a green run)
+
+There is no RV1106 machine model in QEMU and everything below the kernel is
+closed rkbin blobs plus mask ROM, so the VM **enters at `-kernel zImage`** on
+`-M virt,highmem=off` (single Cortex-A7, 256M — the RV1106G3's shape).
+
+| Emulated / substituted | Not emulated (stays bench / `sim/` territory) |
+|---|---|
+| Kernel boot, init ordering, switch_root | BootROM, idblock/DDR-init, SPL, U-Boot |
+| A/B *outcome* (`warden.slot=` cmdline) | Real BCB A/B selection, bootcount auto-revert |
+| Storage: virtio-blk with the device's exact `blkdevparts=` layout + `/dev/block/by-name/` contract | eMMC controller itself |
+| Network: virtio-net (slirp, hostfwd 22/80/28443) | GMAC, AIC8800 wifi, usb0 gadget |
+| Display: virtio-gpu 720x720 via fbdev emulation | VOP/RGB666 pipeline, CH32V003 panel init, RGA blits |
+| Touch: virtio-tablet (QMP `input-send-event`) | GT911 on I2C3 |
+| Watchdog: i6300esb (PCI), `-action watchdog=reset` | DW watchdog @0xff5a0000, HPMCU supervisor |
+| RS485: pci-serial chardev bridged to `sim/`'s `ModbusSlave` | Real UART4 timing/electrical behavior |
+| RTC: PL031 (`--rtc` reproduces the no-RTC 2021-clock incident class) | The unpopulated backup-cell reality |
+
+**"Boots/works under emulation" is never evidence of "works on silicon."**
+The VM narrows which claims need a panel; on-device claims still need
+on-device evidence. Conversely, the VM is the first environment that runs
+production binaries on a non-RV1106 memory map — it found flare-edge #106
+(fatal SIGBUS in flared's HPMCU probe) and #107 (Y2038 time_t truncation)
+on its first two boots of real userspace.
+
+Documented guest deviations from production, set by stage-2 init:
+`WARDEN_FLARE_INSECURE=1` (the desk mock portal is plain HTTP) and
+`WARDEN_HPMCU=0` (no mailbox SRAM on virt; flared >= flare-edge#106 fix
+required, or the daemon dies of SIGBUS).
+
+## Quick start
+
+```sh
+# 1. kernel: canonical build boots the VM as-is; the fragment variant adds
+# the scenario devices (PCI serial, watchdog, WireGuard, virtio-gpu/input)
+WORK=$HOME/kbuild-out CROSS_COMPILE=arm-linux-gnueabihf- \
+ WARDEN_KCONFIG_FRAGMENT=qemu/configs/virt.fragment bash build/build-kernel.sh
+
+# 2. initramfs (sha256-pinned static busybox + qemu/rootfs/) and A/B disk
+bash qemu/mkinitramfs.sh
+bash qemu/mkimage.sh # options: --portal-url --state K=V --fw-version
+
+# 3. run (see run.sh header for all flags)
+bash qemu/run.sh --kernel $HOME/kbuild-out/linux-6.18.46/arch/arm/boot/zImage --shell
+```
+
+Payload: drop static musl armv7 binaries into `qemu/payload/` (see its
+README) — `warden-flared`, `warden-modbus`, and `warden-ui` (the LVGL
+fbdev+evdev build from flare-edge `tools/build-ui-vm.sh`) are started by
+stage-2 init when present.
+
+## Scenario tests (`qemu/tests/`)
+
+- `boot-smoke.sh ` — sentinel-asserting boot; runs in CI inside the
+ kernel-build job.
+- `portal-scenario.sh ` (needs `FLARE_EDGE=`) — the real
+ flared in the VM against the desk mock portal: authenticated check-in,
+ firmware desired-state pull, and download of a real signed tier-1 `.wfw`
+ offer. Verify/stage/APPLYING run as a dry run (no `WARDEN_FW_ALLOW_APPLY`);
+ flipping it on inside the VM is the documented stretch — apply writes
+ `/dev/block/by-name/rootfs_b` inside disk.img, then `--slot _b` boots it.
+- `ui-shot.sh ` — display+touch: boots headless with virtio-gpu,
+ QMP-screendumps the 720x720 UI, taps the Metrics tab via `input-send-event`
+ (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.
+- 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.
+
+## Gotchas that cost time (so they cost it once)
+
+- AF_UNIX socket paths cap at ~108 chars — keep `--rs485`/`--qmp` paths short.
+- A serial port that is closed discards incoming bytes: hold ONE fd open
+ across write and read when scripting the guest side of the RS485 bridge.
+- `highmem=off` and `-global virtio-mmio.force-legacy=false` are load-bearing
+ (32-bit ECAM reach; virtio-1-only gpu/input) — both live ONLY in run.sh,
+ which every script (boot smoke included) delegates to.
+- Never pass `earlyprintk`: DEBUG_UART_PHYS is the RV1106's 0xff4c0000.
+
+## Host requirements
+
+`qemu-system-arm` (Debian 13 ships QEMU 10), `curl`, `cpio`, `mkfs.ext4`,
+`gcc-arm-linux-gnueabihf` (kernel build), `python3` (+`cryptography` for the
+portal scenario's `.wfw` signing). CI: the hosted `qemu-tools` job builds the
+tooling; the boot smoke runs inside the (also hosted, dispatch-only)
+`kernel-build` job, which apt-installs its own toolchain and qemu (ADR-0007).
diff --git a/qemu/blkdevparts.conf b/qemu/blkdevparts.conf
new file mode 100644
index 0000000..4778504
--- /dev/null
+++ b/qemu/blkdevparts.conf
@@ -0,0 +1,10 @@
+# The device's canonical 12-partition A/B layout, expressed for the VM's
+# virtio disk (vda). On hardware the same string names mmcblk0 and is baked
+# into the U-Boot env — source: flare-edge docs/decisions/0003-partition-layout.md.
+# There is no MBR/GPT anywhere: U-Boot and Linux both parse this string, which
+# is why handing it to the VM kernel on the cmdline reproduces the exact
+# partition map (vda9 = rootfs_a = hardware mmcblk0p9).
+#
+# Sourced by qemu/mkimage.sh (computes byte offsets from it) and qemu/run.sh
+# (passes it verbatim in -append). Single source of truth — edit only here.
+WARDEN_BLKDEVPARTS='vda:32K(env),512K@32K(idblock),512K(uboot),512K(misc),32M(boot_a),32M(boot_b),128M(oem_a),128M(oem_b),1G(rootfs_a),1G(rootfs_b),32M(recovery),1G(userdata)'
diff --git a/qemu/busybox.sha256 b/qemu/busybox.sha256
new file mode 100644
index 0000000..35abe05
--- /dev/null
+++ b/qemu/busybox.sha256
@@ -0,0 +1 @@
+cd04052b8b6885f75f50b2a280bfcbf849d8710c8e61d369c533acf307eda064
diff --git a/qemu/configs/virt.fragment b/qemu/configs/virt.fragment
new file mode 100644
index 0000000..e833b3a
--- /dev/null
+++ b/qemu/configs/virt.fragment
@@ -0,0 +1,36 @@
+# QEMU -M virt kernel variant — merged onto build/warden_defconfig via
+# WARDEN_KCONFIG_FRAGMENT (see build/build-kernel.sh). The RV1106 zImage stays
+# canonical and byte-identical when the variable is unset.
+#
+# The canonical zImage already BOOTS on -M virt as-is (verified 2026-08-29:
+# the multi_v7 heritage supplies ARCH_VIRT + the virtio set, and DEBUG_LL's
+# hardcoded RV1106 UART is inert as long as `earlyprintk` is never passed on
+# the cmdline). This fragment therefore only ADDS what the device-sim
+# scenarios need beyond the canonical config.
+
+# -M virt exposes exactly one PL011 (probed: QEMU 10 dtb has a single
+# pl011@9000000); every additional device below rides the machine's PCIe
+# (ECAM "host generic") root.
+CONFIG_PCI=y
+CONFIG_PCI_HOST_GENERIC=y
+
+# Second UART for the RS485/Modbus bridge: -device pci-serial (16550-class,
+# shows up as ttyS0; stage-2 init aliases it to the device's /dev/ttyS4).
+CONFIG_SERIAL_8250_PCI=y
+
+# /dev/watchdog for flared's watchdog_loop() — untestable on both existing
+# sims. i6300esb is the watchdog QEMU offers on arm virt (PCI device):
+# -device i6300esb -action watchdog=reset.
+CONFIG_WATCHDOG=y
+CONFIG_I6300ESB_WDT=y
+
+# wg0 mesh scenarios (flared owns identity, the panel shells out `wg`).
+CONFIG_WIREGUARD=y
+
+# Display + touch: virtio-gpu scanout with fbdev emulation (the VM UI build
+# uses LVGL's fbdev backend — no libdrm needed in the guest), virtio-tablet
+# for absolute-coordinate touch injection via QMP.
+CONFIG_FB=y
+CONFIG_DRM_VIRTIO_GPU=y
+CONFIG_DRM_FBDEV_EMULATION=y
+CONFIG_VIRTIO_INPUT=y
diff --git a/qemu/lib.sh b/qemu/lib.sh
new file mode 100644
index 0000000..66324b7
--- /dev/null
+++ b/qemu/lib.sh
@@ -0,0 +1,81 @@
+# shellcheck shell=bash
+# Shared helpers for the qemu/ device-sim build scripts. Sourced, not executed.
+# Callers must run under `set -euo pipefail` and define QEMU_DIR (the qemu/ dir).
+
+BB_VER=1.31.0
+BB_URL="https://busybox.net/downloads/binaries/${BB_VER}-defconfig-multiarch-musl/busybox-armv7l"
+
+qemu_log() { printf '\033[36m== %s\033[0m\n' "$*"; }
+
+# Fetch (or accept via $BUSYBOX) the pinned static armv7 busybox and verify it
+# against qemu/busybox.sha256. FAILS CLOSED: a missing pin refuses to build,
+# never silently skips verification — mirroring build/build-kernel.sh's
+# tarball handling. Sets $BB to the verified binary's path.
+qemu_get_busybox() {
+ local sha_file="$QEMU_DIR/busybox.sha256"
+ local out="${OUT:-$QEMU_DIR/out}"
+ mkdir -p "$out"
+ BB="${BUSYBOX:-$out/busybox-armv7l}"
+ # Pin first: a missing pin refuses BEFORE downloading, same ordering as
+ # build/fetch-kernel-tarball.sh.
+ [ -f "$sha_file" ] || {
+ echo "FATAL: no pinned sha256 for busybox (expected $sha_file) — refusing to build from an unverified binary" >&2
+ exit 1
+ }
+ if [ ! -f "$BB" ]; then
+ qemu_log "downloading $BB_URL"
+ curl --retry 3 --retry-delay 5 --retry-connrefused -fSL "$BB_URL" -o "$BB"
+ fi
+ local want got
+ want="$(cat "$sha_file")"
+ got="$(sha256sum "$BB" | awk '{print $1}')"
+ [ "$want" = "$got" ] || { echo "busybox sha256 mismatch: want $want got $got" >&2; exit 1; }
+ qemu_log "busybox sha256 verified"
+}
+
+# Stage the shared rootfs skeleton (qemu/rootfs/ + busybox) into $1.
+# Requires qemu_get_busybox to have run (uses $BB).
+qemu_stage_rootfs() {
+ local root="$1"
+ mkdir -p "$root/bin" "$root/sbin" "$root/dev" "$root/proc" "$root/sys" \
+ "$root/etc" "$root/tmp" "$root/mnt" "$root/userdata" "$root/oem" \
+ "$root/usr/bin" "$root/usr/share/udhcpc"
+ install -m 0755 "$BB" "$root/bin/busybox"
+ cp -a "$QEMU_DIR/rootfs/." "$root/"
+ chmod 0755 "$root/init" "$root/sbin/init" "$root/etc/rc" \
+ "$root/usr/share/udhcpc/default.script"
+}
+
+# Parse a "SIZE[@OFFSET](NAME)" blkdevparts entry list (without the "vda:"
+# prefix) and invoke a callback `$1 name offset_bytes size_bytes` per entry.
+qemu_each_partition() {
+ local cb="$1" parts entry size_s off_s name size off
+ parts="${WARDEN_BLKDEVPARTS#*:}"
+ off=0
+ local IFS=','
+ for entry in $parts; do
+ name="${entry##*(}"; name="${name%)}"
+ size_s="${entry%%(*}"
+ if [ "${size_s#*@}" != "$size_s" ]; then
+ off_s="${size_s#*@}"; size_s="${size_s%@*}"
+ off="$(qemu_to_bytes "$off_s")"
+ fi
+ size="$(qemu_to_bytes "$size_s")"
+ "$cb" "$name" "$off" "$size"
+ off=$((off + size))
+ done
+}
+
+qemu_to_bytes() {
+ local v="$1" n mult=1
+ case "$v" in
+ *K) n="${v%K}"; mult=1024 ;;
+ *M) n="${v%M}"; mult=1048576 ;;
+ *G) n="${v%G}"; mult=1073741824 ;;
+ *) n="$v" ;;
+ esac
+ case "$n" in
+ ''|*[!0-9]*) echo "FATAL: bad blkdevparts size/offset token: '$v'" >&2; exit 1 ;;
+ esac
+ echo $((n * mult))
+}
diff --git a/qemu/mkimage.sh b/qemu/mkimage.sh
new file mode 100755
index 0000000..7b85626
--- /dev/null
+++ b/qemu/mkimage.sh
@@ -0,0 +1,126 @@
+#!/usr/bin/env bash
+# Build the VM's virtio disk image carrying the device's canonical 12-partition
+# A/B layout (qemu/blkdevparts.conf — the same string U-Boot and Linux parse on
+# hardware; there is no MBR/GPT). Every partition is placed at the exact offset
+# the cmdline string declares; rootfs_a/rootfs_b/oem_a/oem_b/userdata get ext4,
+# the boot-chain partitions (env/idblock/uboot/misc/boot_a/boot_b/recovery)
+# stay zeroed — the VM enters at -kernel and never reads them.
+#
+# Built entirely UNPRIVILEGED: per-partition mkfs.ext4 -d (no loop mounts, no
+# sudo), then dd'd into a sparse raw image.
+#
+# Usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V]
+# Env:
+# BUSYBOX path to a local busybox binary (skips the download; still verified)
+# OUT output dir (default: qemu/out); image at $OUT/disk.img
+set -euo pipefail
+
+QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=qemu/lib.sh disable=SC1091
+. "$QEMU_DIR/lib.sh"
+# shellcheck source=qemu/blkdevparts.conf disable=SC1091
+. "$QEMU_DIR/blkdevparts.conf"
+OUT="${OUT:-$QEMU_DIR/out}"
+# mkfs.ext4 lives in sbin, which user shells on Debian don't have on PATH.
+PATH="$PATH:/usr/sbin:/sbin"
+
+PORTAL_URL=""
+STATE_KV=()
+FW_VERSION="0.0.1"
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --portal-url) PORTAL_URL="${2:?--portal-url needs a value}"; shift 2 ;;
+ --state)
+ case "${2:?--state needs KEY=VALUE}" in
+ *=*) ;;
+ *) echo "FATAL: --state needs KEY=VALUE, got '$2'" >&2; exit 1 ;;
+ esac
+ case "${2%%=*}" in
+ .|..)
+ echo "FATAL: --state key cannot be '.' or '..'" >&2
+ exit 1 ;;
+ *[!A-Za-z0-9_.]*|'')
+ echo "FATAL: --state key '${2%%=*}' must match [A-Za-z0-9_.]+ (it becomes a filename)" >&2
+ exit 1 ;;
+ esac
+ STATE_KV+=("$2"); shift 2 ;;
+ --fw-version) FW_VERSION="${2:?--fw-version needs a value}"; shift 2 ;;
+ *) echo "FATAL: unknown argument '$1' (usage: mkimage.sh [--portal-url URL] [--state KEY=VALUE]... [--fw-version V])" >&2; exit 1 ;;
+ esac
+done
+
+qemu_get_busybox
+
+SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/warden-qemu-image.XXXXXX")"
+trap 'rm -rf "$SCRATCH"' EXIT
+
+# Stage the rootfs tree once (skeleton + payload into /usr/bin), reused for
+# both slots so A and B start byte-identical, like a factory flash.
+ROOT="$SCRATCH/root"
+qemu_stage_rootfs "$ROOT"
+for p in "$QEMU_DIR"/payload/*; do
+ [ -f "$p" ] || continue
+ case "$(basename "$p")" in README.md) continue ;; esac
+ install -m 0755 "$p" "$ROOT/usr/bin/$(basename "$p")"
+done
+
+# Firmware version stamp — same path the device build writes; flared reads its
+# running version here (downgrade rules key off it).
+printf '%s\n' "$FW_VERSION" > "$ROOT/etc/warden-firmware-version"
+
+# Seed persistent state (flared: one file per key under /userdata/warden).
+# Newline-terminated, matching how flare-edge's fw-e2e-test.sh seeds the store.
+UDATA="$SCRATCH/userdata"
+mkdir -p "$UDATA/warden"
+[ -n "$PORTAL_URL" ] && printf '%s\n' "$PORTAL_URL" > "$UDATA/warden/flare.url"
+for kv in ${STATE_KV[@]+"${STATE_KV[@]}"}; do
+ printf '%s\n' "${kv#*=}" > "$UDATA/warden/${kv%%=*}"
+done
+
+mkdir -p "$SCRATCH/empty"
+
+# mkfs an ext4 partition image of exactly $2 bytes from staged dir $1.
+mkfs_part() {
+ local stage="$1" bytes="$2" img="$3"
+ rm -f "$img"
+ truncate -s "$bytes" "$img"
+ mkfs.ext4 -F -q -d "$stage" "$img"
+}
+
+DISK="$OUT/disk.img"
+rm -f "$DISK"
+
+place_partition() {
+ local name="$1" off="$2" size="$3" stage=""
+ case "$name" in
+ rootfs_a|rootfs_b) stage="$ROOT" ;;
+ userdata) stage="$UDATA" ;;
+ oem_a|oem_b) stage="$SCRATCH/empty" ;;
+ # 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="" ;;
+ *) echo "FATAL: unknown partition name '$name' in blkdevparts.conf" >&2; exit 1 ;;
+ esac
+ # 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"
+ mkfs_part "$stage" "$size" "$img"
+ dd if="$img" of="$DISK" bs=4096 seek=$((off / 4096)) \
+ conv=notrunc,sparse status=none
+ qemu_log " $name: ext4, $((size / 1048576))M @ $off"
+}
+
+DISK_END=0
+qemu_log "building $DISK ($WARDEN_BLKDEVPARTS)"
+truncate -s 0 "$DISK"
+qemu_each_partition place_partition
+truncate -s "$DISK_END" "$DISK"
+qemu_log "disk image: $DISK ($(du -h "$DISK" | cut -f1) used, $((DISK_END / 1048576))M apparent)"
diff --git a/qemu/mkinitramfs.sh b/qemu/mkinitramfs.sh
new file mode 100755
index 0000000..9146188
--- /dev/null
+++ b/qemu/mkinitramfs.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+# Build the QEMU device-sim initramfs: the pinned static busybox + qemu/rootfs/.
+# The busybox binary is the ONLY external input (sha256-pinned, fail-closed —
+# see qemu/lib.sh).
+#
+# Env:
+# BUSYBOX path to a local busybox binary (skips the download; still verified)
+# OUT output dir (default: qemu/out); initramfs at $OUT/initramfs.cpio.gz
+set -euo pipefail
+
+QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=qemu/lib.sh disable=SC1091
+. "$QEMU_DIR/lib.sh"
+OUT="${OUT:-$QEMU_DIR/out}"
+
+qemu_get_busybox
+
+# Assemble in a scratch dir, removed on exit (scratch-dir leaks were a past
+# review finding in this repo).
+SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/warden-qemu-initramfs.XXXXXX")"
+trap 'rm -rf "$SCRATCH"' EXIT
+qemu_stage_rootfs "$SCRATCH/root"
+
+# Pack (newc cpio, gzip). No device nodes required: /init mounts devtmpfs and
+# reopens the console itself, so the archive builds unprivileged.
+( cd "$SCRATCH/root" && find . -print0 | cpio -0 -o -H newc -R +0:+0 2>/dev/null ) \
+ | gzip -9 > "$OUT/initramfs.cpio.gz"
+qemu_log "initramfs: $OUT/initramfs.cpio.gz ($(du -h "$OUT/initramfs.cpio.gz" | cut -f1))"
diff --git a/qemu/payload/README.md b/qemu/payload/README.md
new file mode 100644
index 0000000..f5554a8
--- /dev/null
+++ b/qemu/payload/README.md
@@ -0,0 +1,25 @@
+# qemu/payload/ — guest binaries (never committed)
+
+Drop **static musl armv7** binaries here; `qemu/mkimage.sh` copies everything
+in this directory (except this README) into `/usr/bin/` of both rootfs slots.
+Static musl is the same target the device uses for its Rust daemons, so the
+exact production binaries run unmodified in the VM.
+
+Typical payload, built in a flare-edge checkout:
+
+```sh
+# flared (static musl armv7)
+tools/build-flared.sh --local
+# warden-modbus and friends: see tools/build-firmware.sh for the recipes
+```
+
+Then:
+
+```sh
+cp /target/armv7-unknown-linux-musleabihf/release/warden-flared qemu/payload/
+```
+
+Stage-2 init starts `warden-flared`, `warden-modbus`, and `warden-ui` (the
+UI additionally needs `--display on|headless` + the virt.fragment kernel for
+/dev/fb0) automatically when present (logs land in `/tmp/.log` inside
+the guest). An empty payload is valid — the image boots busybox-only.
diff --git a/qemu/rootfs/etc/rc b/qemu/rootfs/etc/rc
new file mode 100755
index 0000000..b6ac1e3
--- /dev/null
+++ b/qemu/rootfs/etc/rc
@@ -0,0 +1,43 @@
+#!/bin/busybox sh
+# shellcheck shell=dash
+# Stage-1 rc: sourced by /init (still PID 1, initramfs root) when a virtio
+# disk is present. Emulates U-Boot's slot choice — mount the validated slot's
+# rootfs and switch_root into it. This is an EMULATION of the A/B selection
+# outcome, not the BCB/bootcount mechanism itself.
+#
+# Every guarded failure path `return`s to /init (valid in a sourced script;
+# /init then falls through to shell/poweroff). The final exec is the one
+# unguardable step: if switch_root itself fails to launch, the shell — PID 1 —
+# exits and the kernel panics; the applet-existence check below catches the
+# only preventable variant of that.
+
+# shellcheck source=qemu/rootfs/etc/warden-lib.sh disable=SC1091
+. /etc/warden-lib.sh
+
+warden_populate_by_name
+slot="$(warden_slot)"
+
+root="/dev/block/by-name/rootfs${slot}"
+if [ ! -e "$root" ]; then
+ echo "rc: $root missing — staying in initramfs"
+ return 0
+fi
+
+mkdir -p /mnt
+if ! mount -t ext4 "$root" /mnt; then
+ echo "rc: mount of $root failed — staying in initramfs"
+ return 0
+fi
+if [ ! -x /mnt/sbin/init ]; then
+ echo "rc: $root has no /sbin/init — staying in initramfs"
+ umount /mnt
+ return 0
+fi
+if ! command -v switch_root >/dev/null; then
+ echo "rc: busybox lacks switch_root — staying in initramfs"
+ umount /mnt
+ return 0
+fi
+
+echo "rc: switching root to rootfs${slot} ($root)"
+exec switch_root /mnt /sbin/init
diff --git a/qemu/rootfs/etc/warden-lib.sh b/qemu/rootfs/etc/warden-lib.sh
new file mode 100644
index 0000000..6e24d10
--- /dev/null
+++ b/qemu/rootfs/etc/warden-lib.sh
@@ -0,0 +1,43 @@
+# shellcheck shell=sh
+# Shared helpers for the VM's stage-1 (/init + /etc/rc, initramfs) and stage-2
+# (/sbin/init, disk rootfs) boot scripts. Present in both filesystems because
+# both are staged from the same qemu/rootfs/ skeleton. ONE copy of each rule —
+# the slot-validation drift between two hand-copied parsers was a real
+# review finding.
+
+# Populate /dev/block/by-name/ symlinks from sysfs uevents — the
+# contract flare-edge's slotctl.rs relies on. blkdevparts= gives every vda
+# partition a PARTNAME.
+warden_populate_by_name() {
+ mkdir -p /dev/block/by-name
+ for uev in /sys/class/block/vda*/uevent; do
+ [ -f "$uev" ] || continue
+ partname=""
+ devname=""
+ while IFS='=' read -r k v; do
+ case "$k" in
+ PARTNAME) partname="$v" ;;
+ DEVNAME) devname="$v" ;;
+ esac
+ done < "$uev"
+ [ -n "$partname" ] && [ -n "$devname" ] \
+ && ln -sf "/dev/$devname" "/dev/block/by-name/$partname"
+ done
+}
+
+# Parse warden.slot= from the cmdline (whole-token, never substring) and
+# VALIDATE it — echoes "_a" or "_b", falling back to _a with a warning.
+warden_slot() {
+ slot="_a"
+ # shellcheck disable=SC2013 # cmdline TOKENS are the unit here, not lines
+ for tok in $(cat /proc/cmdline); do
+ case "$tok" in
+ warden.slot=*) slot="${tok#warden.slot=}" ;;
+ esac
+ done
+ case "$slot" in
+ _a|_b) ;;
+ *) echo "warden-lib: bad warden.slot='$slot', falling back to _a" >&2; slot="_a" ;;
+ esac
+ echo "$slot"
+}
diff --git a/qemu/rootfs/init b/qemu/rootfs/init
new file mode 100755
index 0000000..948f165
--- /dev/null
+++ b/qemu/rootfs/init
@@ -0,0 +1,34 @@
+#!/bin/busybox sh
+# shellcheck shell=dash
+# WardenOS QEMU device sim: initramfs /init (PID 1).
+#
+# Phase-1 duty: prove the kernel booted on -M virt — print the sentinel the
+# smoke test greps for, then power off (PSCI SYSTEM_OFF, so qemu exits).
+# `warden.shell` on the kernel cmdline drops to an interactive shell instead.
+
+/bin/busybox mount -t devtmpfs devtmpfs /dev 2>/dev/null
+
+# The cpio archive carries no device nodes (it is built unprivileged, no
+# mknod); reopen stdio on the real console now that devtmpfs is mounted.
+exec /dev/console 2>&1
+
+/bin/busybox --install -s /bin
+mount -t proc proc /proc
+mount -t sysfs sysfs /sys
+
+echo "WARDEN-QEMU-BOOT-OK"
+
+# With a virtio disk attached, hand over to the stage-1 rc (by-name symlinks,
+# slot select, switch_root). It only returns on failure — then fall through to
+# the diskless shell/poweroff behavior below.
+if [ -b /dev/vda ]; then
+ # shellcheck source=qemu/rootfs/etc/rc disable=SC1091
+ . /etc/rc
+fi
+
+if grep -qw warden.shell /proc/cmdline; then
+ echo "warden.shell: interactive shell (exit to power off)"
+ setsid cttyhack sh
+fi
+
+poweroff -f
diff --git a/qemu/rootfs/sbin/init b/qemu/rootfs/sbin/init
new file mode 100755
index 0000000..bf5626e
--- /dev/null
+++ b/qemu/rootfs/sbin/init
@@ -0,0 +1,96 @@
+#!/bin/busybox sh
+# shellcheck shell=dash
+# Stage-2 init: PID 1 on the disk rootfs (rootfs_a or rootfs_b), reached via
+# switch_root from the initramfs. Brings up the minimum a WardenOS userspace
+# needs — mounts, by-name symlinks, network, serial alias — then starts any
+# payload daemons and holds. This stands in for the device's BusyBox SysV
+# /etc/init.d/S* sequence; it is deliberately tiny, not a model of it.
+
+/bin/busybox mount -t devtmpfs devtmpfs /dev 2>/dev/null
+exec /dev/console 2>&1
+/bin/busybox --install -s /bin
+
+mount -t proc proc /proc
+mount -t sysfs sysfs /sys
+mount -t tmpfs tmpfs /tmp
+
+# shellcheck source=qemu/rootfs/etc/warden-lib.sh disable=SC1091
+. /etc/warden-lib.sh
+
+# Fresh devtmpfs — repopulate the by-name contract; same VALIDATED slot rule
+# as stage 1 (shared helper, so the two can never drift).
+warden_populate_by_name
+slot="$(warden_slot)"
+
+# The device's matched mounts: persistent state and the slot's oem partition.
+# Fail-fast: a scenario against an image whose userdata cannot mount would
+# otherwise burn its whole deadline before failing generically. warden.shell
+# still gets a shell for post-mortem.
+mount_fatal() {
+ if ! mount -t ext4 "/dev/block/by-name/$1" "$2"; then
+ echo "WARDEN-QEMU-MOUNT-FAILED $1"
+ if grep -qw warden.shell /proc/cmdline; then
+ echo "warden.shell: post-mortem shell (exit powers off)"
+ setsid cttyhack sh
+ fi
+ poweroff -f
+ fi
+}
+mount_fatal userdata /userdata
+mount_fatal "oem${slot}" /oem
+mkdir -p /userdata/warden
+
+# RS485: warden-modbus hardcodes /dev/ttyS4 at compile time; alias it to the
+# VM's pci-serial UART when one is present (needs the virt.fragment kernel).
+[ -c /dev/ttyS0 ] && ln -sf /dev/ttyS0 /dev/ttyS4
+
+# Network: slirp user-mode net on eth0 (DHCP, fallback to QEMU's static map).
+# The fallback keys off the interface actually having an address — udhcpc
+# exiting 0 only proves a lease, not that the hook script applied it.
+ip link set lo up
+if [ -e /sys/class/net/eth0 ]; then
+ ip link set eth0 up
+ udhcpc -i eth0 -n -q -t 5 -T 2 >/dev/null 2>&1 || true
+ if ! ip -4 addr show dev eth0 | grep -q 'inet '; then
+ ip addr add 10.0.2.15/24 dev eth0 2>/dev/null
+ ip route replace default via 10.0.2.2 dev eth0
+ echo "nameserver 10.0.2.3" > /etc/resolv.conf
+ fi
+fi
+
+hostname warden-qemu
+
+echo "WARDEN-QEMU-ROOTFS-OK slot=${slot}"
+
+# Payload daemons (dropped into /usr/bin by qemu/mkimage.sh from qemu/payload/).
+# WARDEN_FLARE_INSECURE=1: the VM's portal is the desk mock over plain HTTP.
+# This is a dev instrument — a production device build never sets it.
+export WARDEN_FLARE_INSECURE=1
+# No HPMCU on -M virt: the mailbox SRAM (0xff6fff00) is unmapped bus space
+# 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
+for d in /usr/bin/warden-flared /usr/bin/warden-modbus; do
+ if [ -x "$d" ]; then
+ name="$(basename "$d")"
+ echo "init: starting $name"
+ "$d" > "/tmp/${name}.log" 2>&1 &
+ fi
+done
+
+# The UI (LVGL fbdev+evdev build from flare-edge tools/build-ui-vm.sh) needs
+# virtio-gpu's fbdev: present only with run.sh --display on|headless AND the
+# virt.fragment kernel.
+if [ -x /usr/bin/warden-ui ] && [ -c /dev/fb0 ]; then
+ echo "init: starting warden-ui (fbdev)"
+ /usr/bin/warden-ui > /tmp/warden-ui.log 2>&1 &
+fi
+
+if grep -qw warden.shell /proc/cmdline; then
+ echo "warden.shell: interactive shell (exit powers off)"
+ setsid cttyhack sh
+ poweroff -f
+fi
+
+# Hold: daemons run, console idles, scenarios drive the VM from outside.
+while :; do sleep 3600; done
diff --git a/qemu/rootfs/usr/share/udhcpc/default.script b/qemu/rootfs/usr/share/udhcpc/default.script
new file mode 100755
index 0000000..3020db7
--- /dev/null
+++ b/qemu/rootfs/usr/share/udhcpc/default.script
@@ -0,0 +1,18 @@
+#!/bin/busybox sh
+# Minimal udhcpc hook for the VM (busybox looks here by default).
+[ -n "$1" ] || exit 1
+case "$1" in
+ deconfig)
+ ip addr flush dev "$interface"
+ ip link set "$interface" up
+ ;;
+ bound|renew)
+ ip addr replace "$ip/${mask:-24}" dev "$interface"
+ [ -n "${router:-}" ] && ip route replace default via "${router%% *}" dev "$interface"
+ if [ -n "${dns:-}" ]; then
+ : > /etc/resolv.conf
+ for d in $dns; do echo "nameserver $d" >> /etc/resolv.conf; done
+ fi
+ ;;
+esac
+exit 0
diff --git a/qemu/rs485-bridge/Cargo.lock b/qemu/rs485-bridge/Cargo.lock
new file mode 100644
index 0000000..769b4fe
--- /dev/null
+++ b/qemu/rs485-bridge/Cargo.lock
@@ -0,0 +1,14 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "warden-rs485-bridge"
+version = "0.1.0"
+dependencies = [
+ "warden-sim",
+]
+
+[[package]]
+name = "warden-sim"
+version = "0.1.0"
diff --git a/qemu/rs485-bridge/Cargo.toml b/qemu/rs485-bridge/Cargo.toml
new file mode 100644
index 0000000..6950b87
--- /dev/null
+++ b/qemu/rs485-bridge/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "warden-rs485-bridge"
+version = "0.1.0"
+edition = "2021"
+description = "Bridges a QEMU serial chardev (unix socket) to the warden-sim Modbus RTU slave, so the VM guest's RS-485 master polls the same simulated field bus the unit tests do — fault injection included."
+license = "GPL-2.0-only"
+
+[lib]
+name = "warden_rs485_bridge"
+path = "src/lib.rs"
+
+[[bin]]
+name = "rs485-bridge"
+path = "src/main.rs"
+
+# Std-only on purpose: the sim crate it wraps is zero-dep, and unix sockets +
+# read timeouts need nothing external.
+[dependencies]
+warden-sim = { path = "../../sim" }
+
+[dev-dependencies]
+
+# Dependency-free micro-benchmarks, same pattern as sim/benches/sim_bench.rs.
+[[bench]]
+name = "bridge_bench"
+harness = false
diff --git a/qemu/rs485-bridge/benches/bridge_bench.rs b/qemu/rs485-bridge/benches/bridge_bench.rs
new file mode 100644
index 0000000..b8f8fd2
--- /dev/null
+++ b/qemu/rs485-bridge/benches/bridge_bench.rs
@@ -0,0 +1,44 @@
+//! Micro-benchmarks for the RS-485 bridge dispatch path — same dependency-free
+//! fixed-iteration pattern as sim/benches/sim_bench.rs: human timings to
+//! stdout, one JSON line per benchmark to stderr for CI trend capture.
+//!
+//! Run: `cargo bench` (or `cargo run --release --bench bridge_bench`).
+
+use std::time::Instant;
+use warden_rs485_bridge::{handle_control_line, Bus};
+use warden_sim::modbus::read_holding;
+
+fn bench(name: &str, iters: u64, mut f: F) {
+ for _ in 0..(iters / 10).max(1) {
+ f(); // warm up
+ }
+ let t = Instant::now();
+ for _ in 0..iters {
+ f();
+ }
+ let ns = t.elapsed().as_nanos() as f64 / iters as f64;
+ println!("{name:<24} {ns:>9.1} ns/op ({iters} iters)");
+ eprintln!("{{\"bench\":\"{name}\",\"ns_per_op\":{ns:.1},\"iters\":{iters}}}");
+}
+
+fn main() {
+ const N: u64 = 1_000_000;
+
+ // Full request->reply dispatch through the locked slave (the per-poll cost
+ // a guest master pays on the simulated bus, minus socket I/O).
+ {
+ let bus = Bus::new(1, 128, 64);
+ let req = read_holding(1, 2, 4);
+ bench("bridge_dispatch", N, || {
+ let _ = bus.slave.lock().unwrap().handle_frame(&req);
+ });
+ }
+
+ // Control-channel command parse + register write.
+ {
+ let bus = Bus::new(1, 128, 64);
+ bench("control_line", N, || {
+ let _ = handle_control_line("holding 5=1234", &bus);
+ });
+ }
+}
diff --git a/qemu/rs485-bridge/src/lib.rs b/qemu/rs485-bridge/src/lib.rs
new file mode 100644
index 0000000..22f0b22
--- /dev/null
+++ b/qemu/rs485-bridge/src/lib.rs
@@ -0,0 +1,388 @@
+//! Bridge a QEMU serial chardev (unix socket) to `warden_sim::ModbusSlave`.
+//!
+//! The guest side is the *master* (flare-edge's `warden-modbus` scanner, polling
+//! what it believes is /dev/ttyS4); this bridge is the wire and every slave on
+//! it. Frames are delimited by an inter-frame gap of silence: RTU's 3.5-char
+//! rule cannot survive a socket transport, so a wall-clock gap stands in for it.
+//! A mis-split frame fails CRC inside `handle_frame`, which answers `None` —
+//! exactly a real slave staying silent — and the master already treats silence
+//! as a timeout, so the failure mode degrades to a dropped poll, never a
+//! phantom reply.
+//!
+//! A second unix socket (the control channel) scripts the simulated bus from
+//! test harnesses: fault injection (`drop`, `exception`, `clear`) and register
+//! seeding/reading, one command per line.
+
+use std::io::{Read, Write};
+use std::os::unix::net::UnixStream;
+use std::sync::Mutex;
+use std::time::Duration;
+use warden_sim::ModbusSlave;
+
+/// Default inter-frame gap. Generous next to real RTU (3.5 chars at 9600 baud
+/// is ~4 ms) because a loaded host can stall a reader; the guest master's
+/// response timeout is orders of magnitude larger.
+pub const DEFAULT_GAP: Duration = Duration::from_millis(10);
+
+/// Accumulation cap: a Modbus RTU ADU is at most 256 bytes, so anything past
+/// 2x that without an inter-frame gap is a misbehaving master streaming
+/// continuously — drop the buffer instead of growing without bound.
+const MAX_PENDING: usize = 512;
+
+/// The shared bus: the slave plus its declared dimensions. The sim's register
+/// setters panic on out-of-range indices (deliberate test-harness semantics);
+/// the control channel must bounds-check first so a typo in a scenario script
+/// answers `err` instead of killing the VM's whole field bus.
+pub struct Bus {
+ pub slave: Mutex,
+ pub regs: usize,
+ pub bits: usize,
+}
+
+impl Bus {
+ pub fn new(address: u8, regs: usize, bits: usize) -> Self {
+ Bus {
+ slave: Mutex::new(ModbusSlave::new(address, regs, bits)),
+ regs,
+ bits,
+ }
+ }
+}
+
+/// Pump one serial connection until EOF: accumulate bytes, dispatch a frame to
+/// the slave after `gap` of silence, write back the reply when the slave
+/// answers. Any pending bytes are dispatched on EOF so a final unflushed frame
+/// is not lost.
+pub fn pump_serial(
+ stream: &UnixStream,
+ slave: &Mutex,
+ gap: Duration,
+) -> std::io::Result<()> {
+ stream.set_read_timeout(Some(gap))?;
+ let mut buf: Vec = Vec::new();
+ let mut chunk = [0u8; 256];
+ let mut discards: u64 = 0;
+ loop {
+ match (&*stream).read(&mut chunk) {
+ Ok(0) => {
+ if !buf.is_empty() {
+ dispatch(&mut buf, stream, slave)?;
+ }
+ return Ok(());
+ }
+ Ok(n) => {
+ buf.extend_from_slice(&chunk[..n]);
+ if buf.len() > MAX_PENDING {
+ // Rate-limit the log and back off for one gap so a master
+ // streaming continuously cannot peg a core and flood
+ // stderr — mirroring the accept-loop backoff.
+ discards += 1;
+ if discards == 1 || discards.is_multiple_of(256) {
+ eprintln!(
+ "rs485: {} bytes buffered with no inter-frame gap — \
+ discarding (misbehaving master? {} discards so far)",
+ buf.len(),
+ discards
+ );
+ }
+ buf.clear();
+ std::thread::sleep(gap);
+ }
+ }
+ Err(e)
+ if e.kind() == std::io::ErrorKind::WouldBlock
+ || e.kind() == std::io::ErrorKind::TimedOut =>
+ {
+ if !buf.is_empty() {
+ dispatch(&mut buf, stream, slave)?;
+ }
+ }
+ Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
+ Err(e) => return Err(e),
+ }
+ }
+}
+
+fn dispatch(
+ buf: &mut Vec,
+ stream: &UnixStream,
+ slave: &Mutex,
+) -> std::io::Result<()> {
+ let reply = slave.lock().unwrap().handle_frame(buf);
+ match &reply {
+ Some(r) => eprintln!("rs485: {} -> {}", hex(buf), hex(r)),
+ None => eprintln!("rs485: {} -> (silence)", hex(buf)),
+ }
+ buf.clear();
+ if let Some(r) = reply {
+ (&*stream).write_all(&r)?;
+ }
+ Ok(())
+}
+
+fn hex(bytes: &[u8]) -> String {
+ bytes
+ .iter()
+ .map(|b| format!("{b:02x}"))
+ .collect::>()
+ .join("")
+}
+
+/// Execute one control-channel command against the slave. One command per
+/// line; the reply is `ok`, `ok `, or `err `.
+///
+/// drop answer the next n requests with silence
+/// exception NAK everything with this exception code (0x01..)
+/// clear clear injected faults
+/// holding = seed a holding register
+/// input = seed an input register
+/// coil =<0|1> seed a coil
+/// discrete =<0|1> seed a discrete input
+/// get-holding read a holding register back
+/// get-coil read a coil back
+/// ping liveness check
+pub fn handle_control_line(line: &str, bus: &Bus) -> String {
+ let mut words = line.split_whitespace();
+ let cmd = match words.next() {
+ Some(c) => c,
+ None => return "err empty command".into(),
+ };
+ let arg = words.next();
+ if words.next().is_some() {
+ return format!("err trailing arguments after '{cmd}'");
+ }
+ // Each arm states its own bound (bus.regs for register space, bus.bits for
+ // bit space) INLINE — a previous string-keyed lookup defaulted silently to
+ // the bit bound, which would have handed a future `get-input` command the
+ // wrong range and reintroduced the out-of-range panic this check prevents.
+ let mut s = bus.slave.lock().unwrap();
+ match (cmd, arg) {
+ ("ping", None) => "ok".into(),
+ ("clear", None) => {
+ s.clear_faults();
+ "ok".into()
+ }
+ ("drop", Some(n)) => match n.parse::() {
+ Ok(n) => {
+ s.drop_next(n);
+ "ok".into()
+ }
+ Err(_) => format!("err bad count '{n}'"),
+ },
+ ("exception", Some(c)) => match parse_u16(c) {
+ Some(c) if c <= 0xff => {
+ s.force_exception(c as u8);
+ "ok".into()
+ }
+ _ => format!("err bad exception code '{c}'"),
+ },
+ ("holding" | "input", Some(kv)) => match parse_addr_val(kv, bus.regs) {
+ Ok((a, v)) => {
+ if cmd == "holding" {
+ s.set_holding(a, v);
+ } else {
+ s.set_input(a, v);
+ }
+ "ok".into()
+ }
+ Err(e) => e,
+ },
+ ("coil" | "discrete", Some(kv)) => match parse_addr_val(kv, bus.bits) {
+ Ok((a, v)) => {
+ if cmd == "coil" {
+ s.set_coil(a, v != 0);
+ } else {
+ s.set_discrete(a, v != 0);
+ }
+ "ok".into()
+ }
+ Err(e) => e,
+ },
+ ("get-holding", Some(a)) => match parse_addr(a, bus.regs) {
+ Ok(a) => format!("ok {}", s.holding(a)),
+ Err(e) => e,
+ },
+ ("get-coil", Some(a)) => match parse_addr(a, bus.bits) {
+ Ok(a) => format!("ok {}", u8::from(s.coil(a))),
+ Err(e) => e,
+ },
+ _ => format!("err unknown or malformed command '{line}'"),
+ }
+}
+
+/// Parse "=" with the address bounds-checked against `bound`.
+fn parse_addr_val(kv: &str, bound: usize) -> Result<(usize, u16), String> {
+ let Some((a, v)) = kv.split_once('=') else {
+ return Err(format!("err expected =, got '{kv}'"));
+ };
+ let (Some(a), Some(v)) = (parse_u16(a), parse_u16(v)) else {
+ return Err(format!("err expected =, got '{kv}'"));
+ };
+ if (a as usize) >= bound {
+ return Err(format!("err address {a} out of range (0..{bound})"));
+ }
+ Ok((a as usize, v))
+}
+
+/// Parse a bare address, bounds-checked against `bound`.
+fn parse_addr(a: &str, bound: usize) -> Result {
+ match parse_u16(a) {
+ Some(v) if (v as usize) < bound => Ok(v as usize),
+ Some(v) => Err(format!("err address {v} out of range (0..{bound})")),
+ None => Err(format!("err bad address '{a}'")),
+ }
+}
+
+fn parse_u16(s: &str) -> Option {
+ if let Some(h) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) {
+ u16::from_str_radix(h, 16).ok()
+ } else {
+ s.parse().ok()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::thread;
+ use std::time::Duration;
+ use warden_sim::modbus::{crc_ok, read_holding};
+
+ // Test gap is much larger than DEFAULT_GAP so a loaded CI runner cannot
+ // split a frame the test wrote in two deliberate chunks: the 2ms
+ // inter-chunk pause has a 60x margin against the 120ms dispatch gap
+ // (25ms gave only 12.5x and was flagged as a flake risk on contended
+ // 2-vCPU hosted runners).
+ const GAP: Duration = Duration::from_millis(120);
+ const SETTLE: Duration = Duration::from_millis(400);
+
+ fn bus() -> Bus {
+ let b = Bus::new(1, 16, 16);
+ b.slave.lock().unwrap().set_holding(2, 0xbeef);
+ b
+ }
+
+ fn with_pump(bus: &Bus, f: F) {
+ let (master, wire) = UnixStream::pair().unwrap();
+ thread::scope(|sc| {
+ sc.spawn(|| pump_serial(&wire, &bus.slave, GAP).unwrap());
+ f(&master);
+ master.shutdown(std::net::Shutdown::Both).unwrap();
+ });
+ }
+
+ fn read_reply(master: &UnixStream) -> Vec {
+ master
+ .set_read_timeout(Some(Duration::from_secs(2)))
+ .unwrap();
+ let mut buf = [0u8; 256];
+ let n = (&*master).read(&mut buf).expect("expected a reply frame");
+ buf[..n].to_vec()
+ }
+
+ #[test]
+ fn whole_frame_gets_a_valid_reply() {
+ let s = bus();
+ with_pump(&s, |master| {
+ (&*master).write_all(&read_holding(1, 2, 1)).unwrap();
+ let reply = read_reply(master);
+ assert!(crc_ok(&reply), "reply must carry a valid CRC");
+ // addr, fc, byte count, 0xbeef
+ assert_eq!(&reply[..5], &[1, 0x03, 2, 0xbe, 0xef]);
+ });
+ }
+
+ #[test]
+ fn frame_split_across_writes_within_gap_is_one_frame() {
+ let s = bus();
+ with_pump(&s, |master| {
+ let req = read_holding(1, 2, 1);
+ let (a, b) = req.split_at(3);
+ (&*master).write_all(a).unwrap();
+ thread::sleep(Duration::from_millis(2)); // well inside GAP
+ (&*master).write_all(b).unwrap();
+ let reply = read_reply(master);
+ assert_eq!(&reply[..5], &[1, 0x03, 2, 0xbe, 0xef]);
+ });
+ }
+
+ #[test]
+ fn two_frames_separated_by_gap_get_two_replies() {
+ let s = bus();
+ with_pump(&s, |master| {
+ (&*master).write_all(&read_holding(1, 2, 1)).unwrap();
+ let first = read_reply(master);
+ assert_eq!(&first[..5], &[1, 0x03, 2, 0xbe, 0xef]);
+ thread::sleep(SETTLE);
+ (&*master).write_all(&read_holding(1, 2, 1)).unwrap();
+ let second = read_reply(master);
+ assert_eq!(second, first);
+ });
+ }
+
+ #[test]
+ fn injected_drop_is_silence_then_recovery() {
+ let s = bus();
+ assert_eq!(handle_control_line("drop 1", &s), "ok");
+ with_pump(&s, |master| {
+ (&*master).write_all(&read_holding(1, 2, 1)).unwrap();
+ master
+ // Well past GAP: the dropped frame must have been dispatched
+ // (and answered with silence) before the next request is
+ // written, or the two would merge in the pending buffer.
+ .set_read_timeout(Some(Duration::from_millis(500)))
+ .unwrap();
+ let mut buf = [0u8; 16];
+ assert!(
+ (&*master).read(&mut buf).is_err(),
+ "dropped request must produce silence"
+ );
+ (&*master).write_all(&read_holding(1, 2, 1)).unwrap();
+ let reply = read_reply(master);
+ assert_eq!(&reply[..5], &[1, 0x03, 2, 0xbe, 0xef]);
+ });
+ }
+
+ #[test]
+ fn control_seeds_and_reads_registers() {
+ let s = bus();
+ assert_eq!(handle_control_line("holding 5=1234", &s), "ok");
+ assert_eq!(handle_control_line("get-holding 5", &s), "ok 1234");
+ assert_eq!(handle_control_line("coil 3=1", &s), "ok");
+ assert_eq!(handle_control_line("get-coil 3", &s), "ok 1");
+ assert_eq!(handle_control_line("holding 0xF=0xff", &s), "ok");
+ assert_eq!(handle_control_line("get-holding 15", &s), "ok 255");
+ // Out of range must answer err, never panic the bus (16-reg slave).
+ assert!(handle_control_line("holding 0x10=0xff", &s).starts_with("err"));
+ assert!(handle_control_line("get-holding 16", &s).starts_with("err"));
+ assert!(handle_control_line("coil 16=1", &s).starts_with("err"));
+ assert_eq!(handle_control_line("ping", &s), "ok");
+ }
+
+ #[test]
+ fn control_rejects_malformed_lines() {
+ let s = bus();
+ assert!(handle_control_line("", &s).starts_with("err"));
+ assert!(handle_control_line("drop many", &s).starts_with("err"));
+ assert!(handle_control_line("holding 5", &s).starts_with("err"));
+ assert!(handle_control_line("exception 300", &s).starts_with("err"));
+ assert!(handle_control_line("frobnicate 1", &s).starts_with("err"));
+ assert!(handle_control_line("drop 1 2", &s).starts_with("err"));
+ }
+
+ #[test]
+ fn forced_exception_naks_and_clear_recovers() {
+ let s = bus();
+ assert_eq!(handle_control_line("exception 0x02", &s), "ok");
+ with_pump(&s, |master| {
+ (&*master).write_all(&read_holding(1, 2, 1)).unwrap();
+ let nak = read_reply(master);
+ assert_eq!(&nak[..3], &[1, 0x83, 0x02], "fc|0x80 + exception code");
+ assert_eq!(handle_control_line("clear", &s), "ok");
+ thread::sleep(SETTLE);
+ (&*master).write_all(&read_holding(1, 2, 1)).unwrap();
+ let reply = read_reply(master);
+ assert_eq!(&reply[..5], &[1, 0x03, 2, 0xbe, 0xef]);
+ });
+ }
+}
diff --git a/qemu/rs485-bridge/src/main.rs b/qemu/rs485-bridge/src/main.rs
new file mode 100644
index 0000000..6e1cc7e
--- /dev/null
+++ b/qemu/rs485-bridge/src/main.rs
@@ -0,0 +1,136 @@
+//! CLI wiring for the RS-485 bridge. All behavior lives in the lib (tested
+//! there); this file only parses arguments, connects sockets, and spawns the
+//! control listener.
+//!
+//! Typical use (matches qemu/run.sh --rs485). Put the sockets in a private
+//! per-run directory (mktemp -d) — short (AF_UNIX caps paths at ~108 chars)
+//! and not guessable/pre-creatable by other local users, unlike a fixed
+//! /tmp name:
+//!
+//! d=$(mktemp -d /tmp/rs485.XXXXXX)
+//! qemu/run.sh --kernel ... --rs485 "$d/serial.sock" &
+//! rs485-bridge --serial "$d/serial.sock" --control "$d/ctl.sock"
+
+use std::io::{BufRead, BufReader, Write};
+use std::os::unix::net::{UnixListener, UnixStream};
+use std::process::exit;
+use std::time::{Duration, Instant};
+use warden_rs485_bridge::{handle_control_line, pump_serial, Bus, DEFAULT_GAP};
+
+fn usage() -> ! {
+ eprintln!(
+ "usage: rs485-bridge --serial [--control ] [--address N] \
+ [--regs N] [--bits N] [--gap-ms N]"
+ );
+ exit(2);
+}
+
+fn main() {
+ let mut serial: Option = None;
+ let mut control: Option = None;
+ let mut address: u8 = 1;
+ let mut regs: usize = 128;
+ let mut bits: usize = 64;
+ let mut gap = DEFAULT_GAP;
+
+ let mut args = std::env::args().skip(1);
+ while let Some(a) = args.next() {
+ let mut val = |name: &str| {
+ let v = args.next().unwrap_or_else(|| {
+ eprintln!("{name} needs a value");
+ usage()
+ });
+ // A following flag means the value was omitted — report the real
+ // problem instead of swallowing the flag as a bogus value.
+ if v.starts_with("--") {
+ eprintln!("{name} needs a value, got flag '{v}'");
+ usage()
+ }
+ v
+ };
+ match a.as_str() {
+ "--serial" => serial = Some(val("--serial")),
+ "--control" => control = Some(val("--control")),
+ "--address" => address = val("--address").parse().unwrap_or_else(|_| usage()),
+ "--regs" => regs = val("--regs").parse().unwrap_or_else(|_| usage()),
+ "--bits" => bits = val("--bits").parse().unwrap_or_else(|_| usage()),
+ "--gap-ms" => {
+ gap = Duration::from_millis(val("--gap-ms").parse().unwrap_or_else(|_| usage()))
+ }
+ _ => usage(),
+ }
+ }
+ let serial = serial.unwrap_or_else(|| usage());
+
+ // The bus is shared between the serial pump and the control channel.
+ // 'static so the control thread needs no scoped lifetime: the bridge runs
+ // until killed.
+ let bus: &'static Bus = Box::leak(Box::new(Bus::new(address, regs, bits)));
+
+ if let Some(path) = control {
+ // Clear a stale socket from a previous run. A failure here that is not
+ // "nothing to remove" (e.g. someone else's file behind /tmp's sticky
+ // bit) will make the bind below fail — surface both errors.
+ let removed = std::fs::remove_file(&path);
+ let listener = UnixListener::bind(&path).unwrap_or_else(|e| {
+ eprintln!("FATAL: cannot bind control socket {path}: {e}");
+ if let Err(re) = removed {
+ if re.kind() != std::io::ErrorKind::NotFound {
+ eprintln!(" (removing the pre-existing file also failed: {re})");
+ }
+ }
+ exit(1);
+ });
+ eprintln!("rs485: control socket at {path}");
+ std::thread::spawn(move || {
+ // Explicit error handling: `.flatten()` would turn a persistent
+ // accept() failure (fd exhaustion etc.) into a silent hot loop.
+ for conn in listener.incoming() {
+ let conn = match conn {
+ Ok(c) => c,
+ Err(e) => {
+ eprintln!("rs485: control accept failed: {e} — backing off");
+ std::thread::sleep(Duration::from_millis(200));
+ continue;
+ }
+ };
+ let reader = BufReader::new(conn.try_clone().expect("clone control conn"));
+ let mut writer = conn;
+ for line in reader.lines() {
+ let line = match line {
+ Ok(l) => l,
+ Err(_) => break,
+ };
+ let reply = handle_control_line(&line, bus);
+ if writeln!(writer, "{reply}").is_err() {
+ break;
+ }
+ }
+ }
+ });
+ }
+
+ // QEMU (chardev server=on) may come up after us: retry the connect briefly
+ // instead of racing the VM launch.
+ let deadline = Instant::now() + Duration::from_secs(15);
+ let stream = loop {
+ match UnixStream::connect(&serial) {
+ Ok(s) => break s,
+ Err(e) if Instant::now() < deadline => {
+ eprintln!("rs485: waiting for {serial} ({e})");
+ std::thread::sleep(Duration::from_millis(500));
+ }
+ Err(e) => {
+ eprintln!("FATAL: cannot connect serial socket {serial}: {e}");
+ exit(1);
+ }
+ }
+ };
+ eprintln!("rs485: connected to {serial}, slave address {address}, gap {gap:?}");
+
+ if let Err(e) = pump_serial(&stream, &bus.slave, gap) {
+ eprintln!("FATAL: serial pump: {e}");
+ exit(1);
+ }
+ eprintln!("rs485: serial closed (VM gone), exiting");
+}
diff --git a/qemu/run.sh b/qemu/run.sh
new file mode 100755
index 0000000..a40dde5
--- /dev/null
+++ b/qemu/run.sh
@@ -0,0 +1,117 @@
+#!/usr/bin/env bash
+# Launch the WardenOS device VM (qemu-system-arm -M virt, single Cortex-A7,
+# 256M — the RV1106G3's shape). See qemu/README.md for what this does and does
+# not emulate.
+#
+# Usage: run.sh --kernel [options] [-- ]
+# --kernel PATH zImage (canonical or virt.fragment variant)
+# --initrd PATH initramfs (default: qemu/out/initramfs.cpio.gz)
+# --disk PATH virtio disk image from mkimage.sh (default: qemu/out/disk.img
+# when present; pass --no-disk for a diskless initramfs boot)
+# --no-disk boot without a disk (initramfs shell/smoke behavior)
+# --slot _a|_b rootfs slot to boot (default _a)
+# --rtc DATE guest RTC base, e.g. 2021-01-01 — reproduces the no-RTC
+# "device boots believing 2021" incident class
+# --rs485 SOCK unix socket chardev for the RS485/Modbus bridge
+# (pci-serial: needs the virt.fragment kernel)
+# --watchdog add i6300esb watchdog, reset on expiry (fragment kernel)
+# --qmp SOCK QMP unix socket (screendump, input-send-event, quit)
+# --display MODE off (default, -nographic) | on (gtk window) | headless
+# (virtio-gpu without a window; screendump via --qmp)
+# --ssh-port N hostfwd 127.0.0.1:N -> guest :22 (default 2222; 0 disables)
+# --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
+set -euo pipefail
+
+QEMU_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+# shellcheck source=qemu/blkdevparts.conf disable=SC1091
+. "$QEMU_DIR/blkdevparts.conf"
+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
+SSH_PORT=2222 HTTP_PORT=8080 API_PORT=28443
+EXTRA=()
+
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --kernel) KERNEL="${2:?}"; shift 2 ;;
+ --initrd) INITRD="${2:?}"; shift 2 ;;
+ --disk) DISK="${2:?}"; shift 2 ;;
+ --no-disk) NO_DISK=1; shift ;;
+ --slot) SLOT="${2:?}"; shift 2 ;;
+ --rtc) RTC="${2:?}"; shift 2 ;;
+ --rs485) RS485="${2:?}"; shift 2 ;;
+ --watchdog) WATCHDOG=1; shift ;;
+ --qmp) QMP="${2:?}"; shift 2 ;;
+ --display) DISPLAY_MODE="${2:?}"; shift 2 ;;
+ --ssh-port) SSH_PORT="${2:?}"; shift 2 ;;
+ --http-port) HTTP_PORT="${2:?}"; shift 2 ;;
+ --api-port) API_PORT="${2:?}"; shift 2 ;;
+ --shell) SHELL_FLAG=1; shift ;;
+ --) shift; EXTRA=("$@"); break ;;
+ *) echo "FATAL: unknown argument '$1' (see header of $0)" >&2; exit 1 ;;
+ esac
+done
+
+if [ -z "$KERNEL" ] || [ ! -f "$KERNEL" ]; then
+ echo "FATAL: --kernel required and must exist (got '${KERNEL:-}')" >&2
+ exit 1
+fi
+[ -f "$INITRD" ] || {
+ echo "FATAL: initramfs not found at $INITRD — run qemu/mkinitramfs.sh" >&2
+ exit 1
+}
+case "$SLOT" in _a|_b) ;; *) echo "FATAL: --slot must be _a or _b" >&2; exit 1 ;; esac
+
+if [ "$NO_DISK" -eq 0 ] && [ -z "$DISK" ] && [ -f "$OUT/disk.img" ]; then
+ DISK="$OUT/disk.img"
+fi
+if [ -n "$DISK" ] && [ ! -f "$DISK" ]; then
+ echo "FATAL: disk image $DISK not found — run qemu/mkimage.sh (or pass --no-disk)" >&2
+ exit 1
+fi
+
+# NOTE: never add `earlyprintk` — the config's DEBUG_UART_PHYS is the RV1106's
+# 0xff4c0000, which does not exist on -M virt.
+APPEND="console=ttyAMA0 rdinit=/init"
+# Port 0 disables a forward — a boot smoke needs no host ports and must not
+# fail on a busy default port.
+NETDEV="user,id=n0"
+[ "$SSH_PORT" != 0 ] && NETDEV="$NETDEV,hostfwd=tcp:127.0.0.1:${SSH_PORT}-:22"
+[ "$HTTP_PORT" != 0 ] && NETDEV="$NETDEV,hostfwd=tcp:127.0.0.1:${HTTP_PORT}-:80"
+[ "$API_PORT" != 0 ] && NETDEV="$NETDEV,hostfwd=tcp:127.0.0.1:${API_PORT}-:28443"
+ARGS=(
+ -M "virt,highmem=off" -cpu cortex-a7 -smp 1 -m 256M
+ # virtio-mmio defaults to the legacy (0.9) transport; virtio-gpu and
+ # virtio-input are VERSION_1-only devices and never bind without this.
+ -global "virtio-mmio.force-legacy=false"
+ -kernel "$KERNEL" -initrd "$INITRD"
+ -netdev "$NETDEV"
+ -device "virtio-net-device,netdev=n0"
+ -no-reboot
+)
+
+if [ -n "$DISK" ] && [ "$NO_DISK" -eq 0 ]; then
+ APPEND="$APPEND blkdevparts=$WARDEN_BLKDEVPARTS warden.slot=$SLOT"
+ ARGS+=( -drive "if=none,file=$DISK,format=raw,id=vd0"
+ -device "virtio-blk-device,drive=vd0" )
+fi
+[ "$SHELL_FLAG" -eq 1 ] && APPEND="$APPEND warden.shell"
+[ -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"
+ -device "pci-serial,chardev=rs485" )
+[ -n "$QMP" ] && ARGS+=( -qmp "unix:$QMP,server=on,wait=off" )
+
+case "$DISPLAY_MODE" in
+ off) ARGS+=( -nographic ) ;;
+ on) ARGS+=( -device "virtio-gpu-device,xres=720,yres=720"
+ -device virtio-tablet-device -serial mon:stdio ) ;;
+ headless) ARGS+=( -device "virtio-gpu-device,xres=720,yres=720"
+ -device virtio-tablet-device -display none -serial mon:stdio ) ;;
+ *) echo "FATAL: --display must be off|on|headless" >&2; exit 1 ;;
+esac
+
+exec qemu-system-arm "${ARGS[@]}" -append "$APPEND" ${EXTRA[@]+"${EXTRA[@]}"}
diff --git a/qemu/tests/boot-smoke.sh b/qemu/tests/boot-smoke.sh
new file mode 100755
index 0000000..15338e4
--- /dev/null
+++ b/qemu/tests/boot-smoke.sh
@@ -0,0 +1,47 @@
+#!/usr/bin/env bash
+# Boot the warden kernel under qemu-system-arm -M virt and assert the initramfs
+# sentinel appears on the console. FAILS CLOSED: a missing zImage, initramfs,
+# or qemu binary is an error, never a skip.
+#
+# Usage: boot-smoke.sh [initramfs.cpio.gz]
+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 [initramfs] — zImage missing or not a file: '${ZIMAGE:-}'" >&2
+ exit 1
+fi
+INITRD="${2:-$QDIR/out/initramfs.cpio.gz}"
+[ -f "$INITRD" ] || {
+ echo "FATAL: initramfs not found at $INITRD — run qemu/mkinitramfs.sh first" >&2
+ exit 1
+}
+command -v qemu-system-arm >/dev/null || {
+ echo "FATAL: qemu-system-arm not on PATH (apt-get install qemu-system-arm) — see qemu/README.md" >&2
+ exit 1
+}
+
+LOG="$(mktemp "${TMPDIR:-/tmp}/warden-qemu-smoke.XXXXXX")"
+trap 'rm -f "$LOG"' EXIT
+
+# Delegate the qemu invocation to run.sh (--no-disk) so the machine shape
+# (-M virt,highmem=off, cpu, memory, virtio topology) lives in exactly one
+# place — the two hand-copied invocations had already drifted once.
+# timeout -k: a wedged qemu that ignores SIGTERM gets SIGKILLed 10s later
+# instead of holding the job until the workflow-level timeout.
+timeout -k 10 180 bash "$QDIR/run.sh" \
+ --kernel "$ZIMAGE" --initrd "$INITRD" --no-disk \
+ --ssh-port 0 --http-port 0 --api-port 0 \
+ &2
+ exit 1
+ }
+
+grep -q "WARDEN-QEMU-BOOT-OK" "$LOG" || {
+ echo "FATAL: boot sentinel WARDEN-QEMU-BOOT-OK not found in console log" >&2
+ exit 1
+}
+echo "boot smoke OK"
diff --git a/qemu/tests/portal-scenario.sh b/qemu/tests/portal-scenario.sh
new file mode 100755
index 0000000..89e101f
--- /dev/null
+++ b/qemu/tests/portal-scenario.sh
@@ -0,0 +1,150 @@
+#!/usr/bin/env bash
+# End-to-end device scenario: the REAL warden-flared, running inside the VM,
+# checks in to flare-edge's mock FLARE portal on the host and pulls its
+# firmware desired-state — the exact device-initiated HTTPS(-shaped) flow a
+# panel performs, with zero flare-edge code changes (the portal URL is a state
+# file; 10.0.2.2 is slirp's host alias).
+#
+# FAILS CLOSED on every missing prerequisite — never a soft skip.
+#
+# Usage: portal-scenario.sh
+# Env: FLARE_EDGE path to a flare-edge checkout (provides mock-flare-portal.py)
+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 — 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 (mock-flare-portal.py not found under '${FLARE_EDGE:-}')" >&2
+ exit 1
+fi
+[ -x "$QDIR/payload/warden-flared" ] || {
+ echo "FATAL: no qemu/payload/warden-flared — build a static musl armv7 flared (see qemu/payload/README.md)" >&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
+}
+
+# Short-named scratch: AF_UNIX socket paths are capped at ~108 chars.
+WORK="$(mktemp -d /tmp/wqp.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. a real signed tier-1 .wfw offer (version above the image's 0.0.1), so the
+# scenario exercises desired-state -> download -> signature/hash verify, not
+# just an empty 204. Signed with the committed desk key; the payload flared
+# is a FLARED_DEV_KEY=1 build that trusts it (desk-testing only).
+head -c 8388608 /dev/urandom > "$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 on the host, our device pre-registered (no pairing needed —
+# the same credential-seeding shortcut fw-e2e-test.sh uses), offering the .wfw.
+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
+# The loop must not fall through silently: an alive-but-unresponsive mock
+# would otherwise surface 420s later as an unrelated assertion timeout.
+[ "$mock_ready" = 1 ] || {
+ echo "FATAL: mock portal never answered on :$PORT within 10s" >&2
+ tail -10 "$WORK/mock.log" >&2
+ exit 1
+}
+echo "== mock portal on :$PORT, device $DEVICE_ID"
+
+# 2. image seeded with the portal URL + credentials.
+bash "$QDIR/mkinitramfs.sh"
+# All four enrolment keys — flare::enrolment() returns None (and the report
+# loop parks forever) unless flare.site is present too.
+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"
+
+# 3. boot the VM headless (daemons run; console log to file). Random hostfwd
+# ports can collide with another process — detect the early qemu bind
+# failure and retry with a fresh base rather than failing spuriously.
+QEMU_PID=""
+for _attempt in 1 2 3; do
+ VMBASE=$((20000 + RANDOM % 20000))
+ : > "$WORK/console.log"
+ bash "$QDIR/run.sh" --kernel "$ZIMAGE" \
+ --ssh-port "$VMBASE" --http-port $((VMBASE + 1)) --api-port $((VMBASE + 2)) \
+ > "$WORK/console.log" 2>&1 &
+ QEMU_PID=$!
+ sleep 3
+ if kill -0 "$QEMU_PID" 2>/dev/null; then
+ break
+ fi
+ 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. assert: rootfs up, and the portal saw — from OUR device id — an
+# authenticated check-in, the firmware desired-state pull, and the signed
+# .wfw asset download (i.e. flared accepted the offer and fetched it; the
+# verify+stage+APPLYING that follow are a dry run without
+# WARDEN_FW_ALLOW_APPLY, exactly like fw-e2e-test.sh).
+deadline=$((SECONDS + 420))
+ok_boot=0 ok_report=0 ok_fw=0 ok_asset=0
+while [ $SECONDS -lt $deadline ]; do
+ [ $ok_boot -eq 0 ] && grep -aq 'WARDEN-QEMU-ROOTFS-OK' "$WORK/console.log" && {
+ ok_boot=1; echo "== VM userspace up"; }
+ grep -aq "POST /api/v1/devices/$DEVICE_ID/report -> 200" "$WORK/mock.log" && ok_report=1
+ grep -aq "GET /api/v1/devices/$DEVICE_ID/firmware -> 200" "$WORK/mock.log" && ok_fw=1
+ grep -aq "GET /api/v1/devices/$DEVICE_ID/firmware/assets/.* -> 200" "$WORK/mock.log" && ok_asset=1
+ [ $ok_report -eq 1 ] && [ $ok_fw -eq 1 ] && [ $ok_asset -eq 1 ] && break
+ grep -aq 'WARDEN-QEMU-MOUNT-FAILED' "$WORK/console.log" && {
+ echo "FATAL: guest partition mount failed (bad image?):" >&2
+ grep -a 'WARDEN-QEMU-MOUNT-FAILED' "$WORK/console.log" >&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 2
+done
+
+echo "== portal log (our device's requests):"
+grep -a "$DEVICE_ID" "$WORK/mock.log" | tail -5 || true
+
+fail=0
+[ $ok_boot -eq 1 ] || { echo "FAIL: VM never reached WARDEN-QEMU-ROOTFS-OK"; fail=1; }
+[ $ok_report -eq 1 ] || { echo "FAIL: no authenticated check-in (POST report 200) seen"; fail=1; }
+[ $ok_fw -eq 1 ] || { echo "FAIL: no firmware desired-state pull (GET firmware 200) seen"; fail=1; }
+[ $ok_asset -eq 1 ] || { echo "FAIL: signed .wfw asset was never downloaded"; fail=1; }
+if [ $fail -eq 0 ]; then echo "PORTAL-SCENARIO-PASS"; else echo "PORTAL-SCENARIO-FAIL"; exit 1; fi
diff --git a/qemu/tests/qmp.py b/qemu/tests/qmp.py
new file mode 100755
index 0000000..0737cfb
--- /dev/null
+++ b/qemu/tests/qmp.py
@@ -0,0 +1,65 @@
+#!/usr/bin/env python3
+"""Tiny QMP client for the qemu/ device-sim tests.
+
+ qmp.py screendump
+ qmp.py tap # absolute 0..32767 (virtio-tablet)
+ qmp.py quit
+"""
+import json
+import socket
+import sys
+import time
+
+
+def rpc(sock, sock_file, obj):
+ sock.sendall((json.dumps(obj) + "\n").encode())
+ while True:
+ line = sock_file.readline()
+ if not line:
+ raise RuntimeError("QMP connection closed")
+ msg = json.loads(line)
+ if "return" in msg:
+ return msg["return"]
+ if "error" in msg:
+ raise RuntimeError(f"QMP error: {msg['error']}")
+ # asynchronous events are interleaved; skip them
+
+
+def main():
+ if len(sys.argv) < 3:
+ sys.exit(__doc__)
+ path, cmd = sys.argv[1], sys.argv[2]
+ need = {"screendump": 4, "tap": 5, "quit": 3}
+ if cmd not in need:
+ sys.exit(f"unknown command {cmd}\n{__doc__}")
+ if len(sys.argv) < need[cmd]:
+ sys.exit(f"{cmd}: missing argument(s)\n{__doc__}")
+
+ s = socket.socket(socket.AF_UNIX)
+ s.connect(path)
+ f = s.makefile("r")
+ f.readline() # greeting banner
+ rpc(s, f, {"execute": "qmp_capabilities"})
+
+ if cmd == "screendump":
+ rpc(s, f, {"execute": "screendump", "arguments": {"filename": sys.argv[3]}})
+ elif cmd == "tap":
+ x, y = int(sys.argv[3]), int(sys.argv[4])
+ press = [
+ {"type": "abs", "data": {"axis": "x", "value": x}},
+ {"type": "abs", "data": {"axis": "y", "value": y}},
+ {"type": "btn", "data": {"down": True, "button": "left"}},
+ ]
+ release = [{"type": "btn", "data": {"down": False, "button": "left"}}]
+ rpc(s, f, {"execute": "input-send-event", "arguments": {"events": press}})
+ # Hold the press across several LVGL indev poll periods (33 ms each):
+ # an instantaneous press+release lands inside one poll and no click
+ # is ever registered.
+ time.sleep(0.2)
+ rpc(s, f, {"execute": "input-send-event", "arguments": {"events": release}})
+ elif cmd == "quit":
+ s.sendall(b'{"execute":"quit"}\n')
+
+
+if __name__ == "__main__":
+ main()
diff --git a/qemu/tests/ui-shot.sh b/qemu/tests/ui-shot.sh
new file mode 100755
index 0000000..4b4fdb7
--- /dev/null
+++ b/qemu/tests/ui-shot.sh
@@ -0,0 +1,150 @@
+#!/usr/bin/env bash
+# Display + touch scenario: boot the VM headless with virtio-gpu, wait for the
+# LVGL UI (fbdev build) to render a real frame, then inject an absolute touch
+# tap on the Metrics tab (virtio-tablet) and ASSERT the frame changed — the
+# device-level analogue of flare-edge's Xvfb/xdotool sim-test.sh. Readiness is
+# polled from screendumps on bounded deadlines, never guessed with fixed
+# sleeps: TCG renders CPU-bound and a loaded host can be arbitrarily slow.
+#
+# FAILS CLOSED on missing prerequisites.
+#
+# Usage: ui-shot.sh [out-dir]
+set -euo pipefail
+
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # qemu/tests/
+QDIR="$(cd "$HERE/.." && pwd)" # qemu/
+
+ZIMAGE="${1:-}"
+OUTDIR="${2:-$QDIR/out}"
+if [ -z "$ZIMAGE" ] || [ ! -f "$ZIMAGE" ]; then
+ echo "FATAL: usage: $0 [out-dir] — the virt.fragment kernel variant" >&2
+ exit 1
+fi
+[ -x "$QDIR/payload/warden-ui" ] || {
+ echo "FATAL: no qemu/payload/warden-ui — build it with flare-edge tools/build-ui-vm.sh" >&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
+}
+
+# Short-named scratch: AF_UNIX socket paths are capped at ~108 chars.
+WORK="$(mktemp -d /tmp/wqu.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"
+
+# Random hostfwd ports can collide — detect qemu's early bind failure and
+# retry with a fresh base rather than failing spuriously.
+for _attempt in 1 2 3; do
+ PORT=$((21000 + RANDOM % 20000))
+ : > "$WORK/console.log"
+ bash "$QDIR/run.sh" --kernel "$ZIMAGE" --display headless --qmp "$WORK/qmp.sock" \
+ --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 + 120))
+while [ $SECONDS -lt $deadline ]; do
+ grep -aq 'init: starting warden-ui' "$WORK/console.log" && break
+ kill -0 "$QEMU_PID" 2>/dev/null || { echo "FATAL: VM exited early" >&2; tail -25 "$WORK/console.log" >&2; exit 1; }
+ sleep 2
+done
+grep -aq 'init: starting warden-ui' "$WORK/console.log" || {
+ echo "FATAL: warden-ui never started (no fb0? wrong kernel?)" >&2
+ tail -25 "$WORK/console.log" >&2
+ exit 1
+}
+
+qmp() { python3 "$HERE/qmp.py" "$WORK/qmp.sock" "$@"; }
+
+# Frame is "real" once it has more than a handful of distinct colors (a blank
+# or console-only frame has very few).
+frame_rendered() { # $1 = ppm path
+ python3 - "$1" <<'EOF'
+import sys
+data = open(sys.argv[1], "rb").read()
+parts = data.split(b"\n", 3) # P6 header: magic, dims, maxval, raw RGB
+pixels = parts[3] if len(parts) == 4 else b""
+distinct = len(set(pixels[i:i+3] for i in range(0, min(len(pixels), 3*720*720), 3)))
+print(f"{sys.argv[1]}: {len(pixels)} bytes, {distinct} distinct colors")
+sys.exit(0 if distinct > 16 else 1)
+EOF
+}
+
+# The VM can die mid-poll (OOM, crash): check liveness before every QMP
+# call so the failure is OUR message + console evidence, not a python
+# traceback — and preserve the console log before the trap removes $WORK.
+vm_alive_or_die() {
+ kill -0 "$QEMU_PID" 2>/dev/null && return 0
+ echo "FATAL: VM exited during the screendump poll" >&2
+ tail -25 "$WORK/console.log" >&2
+ mkdir -p "$OUTDIR"; cp "$WORK/console.log" "$OUTDIR/ui-shot-console.log" || true
+ exit 1
+}
+
+# Poll for the first rendered frame (bounded, no guessed sleep).
+rendered=0
+deadline=$((SECONDS + 90))
+while [ $SECONDS -lt $deadline ]; do
+ vm_alive_or_die
+ qmp screendump "$WORK/shot1.ppm"
+ if frame_rendered "$WORK/shot1.ppm"; then rendered=1; break; fi
+ sleep 3
+done
+[ "$rendered" = 1 ] || {
+ echo "FATAL: UI never rendered a non-blank frame within 90s" >&2
+ mkdir -p "$OUTDIR"; cp "$WORK/console.log" "$OUTDIR/ui-shot-console.log" || true
+ exit 1
+}
+
+# Tap the "Metrics" tab: pixel (373,40) of 720x720 scaled to the QMP absolute
+# range 0..32767 — switching tabs must repaint the content area. Poll for the
+# repaint rather than guessing a delay.
+vm_alive_or_die
+qmp tap 16975 1820
+changed=0
+# 90s, matching the first-frame budget: TCG repaints are CPU-bound and a
+# contended CI runner can be arbitrarily slower than this dev box (same
+# margin reasoning as the rs485 test-gap widening).
+deadline=$((SECONDS + 90))
+while [ $SECONDS -lt $deadline ]; do
+ sleep 2
+ vm_alive_or_die
+ qmp screendump "$WORK/shot2.ppm"
+ if ! cmp -s "$WORK/shot1.ppm" "$WORK/shot2.ppm"; then changed=1; break; fi
+done
+
+mkdir -p "$OUTDIR"
+cp "$WORK/shot1.ppm" "$OUTDIR/ui-shot1.ppm"
+cp "$WORK/shot2.ppm" "$OUTDIR/ui-shot2.ppm" 2>/dev/null || true
+
+[ "$changed" = 1 ] || {
+ echo "FATAL: tapping the Metrics tab did not change the frame within 90s — touch is not reaching the UI" >&2
+ cp "$WORK/console.log" "$OUTDIR/ui-shot-console.log" || true
+ exit 1
+}
+echo "tap on the Metrics tab repainted the frame (touch reached the UI)"
+echo "UI-SHOT-PASS (screenshots in $OUTDIR/ui-shot{1,2}.ppm)"
diff --git a/sim/Cargo.toml b/sim/Cargo.toml
index d7e4f6e..21c40cf 100644
--- a/sim/Cargo.toml
+++ b/sim/Cargo.toml
@@ -3,7 +3,7 @@ name = "warden-sim"
version = "0.1.0"
edition = "2021"
description = "Host-side hardware simulator for WardenOS: register/SRAM bus, HPMCU (RISC-V) watchdog, RGA, NPU. Lets driver and supervisor logic run and be tested with no panel."
-license = "MIT OR Apache-2.0"
+license = "GPL-2.0-only"
[lib]
name = "warden_sim"
diff --git a/tools/config-lint/Cargo.toml b/tools/config-lint/Cargo.toml
index a2efd61..578fd08 100644
--- a/tools/config-lint/Cargo.toml
+++ b/tools/config-lint/Cargo.toml
@@ -3,7 +3,7 @@ name = "warden-config-lint"
version = "0.1.0"
edition = "2021"
description = "Static target-config checks for WardenOS: catch memory-map faults (the 0x40000 MCU-load brick class) and other flash-time config mistakes before a flash, not on the bench."
-license = "MIT OR Apache-2.0"
+license = "GPL-2.0-only"
[[bin]]
name = "config-lint"