[earlgrey] add QEMU support

Wire the lowRISC QEMU ot-earlgrey machine into target/earlgrey as a
target (parallel to verilator). QEMU runs ~50x faster
than verilator (2.6s vs minutes) and is part of the earlgrey_qemu_tests
workflow within ci so the tests run on every PR.

Active QEMU tests:
- //target/earlgrey/tests/ipc/user:ipc_runner_qemu_test        PASS 2.6s
- //target/earlgrey/tests/threads/kernel:threads_runner_qemu_test  PASS 12.5s
50/50 stability runs, zero flakes.

Build changes:
- third_party/qemu/: lowRISC QEMU release archive pinned to
  v10.2.0-2026-01-15; hermetic pip-parse for hjson + pyelftools
  (@openprot_python_deps, Bazel-managed Python 3.11).
- third_party/opentitan_rtl/: vendored RTL constants for cfggen.
  RMA OTP vmem consumed from @opentitan_devbundle (no local copy).
- target/earlgrey/tooling/qemu.bzl: qemu_cfg / qemu_otp / qemu_flash
  rules + pure-function helpers callable from opentitan_runner.bzl.
- target/earlgrey/tooling/qemu_runner.py + qemu_start.sh: Python
  harness with HMP cont, UART Unix-socket reader, pw_tokenizer
  detokenization, SIGTERM-safe cleanup.

Target glue:
- target_type=qemu added; entry.rs .fake_rom_ext cfg widened from
  `verilator` to `any(verilator, qemu)`.
  Silicon ROM delta verified at 0 bytes.
- config.rs gets qemu SYSTEM_CLOCK_HZ = 24_000_000 (matches
  opentitan/sw/device/lib/arch/device_sim_qemu.c).
- opentitan_runner.bzl gains interface="qemu" branch with _QEMU_ATTRS
  separated from _BASE_ATTRS to avoid testonly contamination of
  non-test opentitan_runner targets. interface attr gained values=[...]
  constraint.

CI: new earlgrey_qemu_tests workflow added to workflows.json, wired
into ci group; ci_tests filters now exclude -qemu to prevent
double-counting.

Signed-off-by: Miguel Osorio <miguelosorio@google.com>
diff --git a/.gitignore b/.gitignore
index e90698a..1195f04 100644
--- a/.gitignore
+++ b/.gitignore
@@ -127,3 +127,7 @@
 
 # Build scripts
 build.rs.bk
+
+# Python bytecode
+__pycache__/
+*.pyc
diff --git a/MODULE.bazel b/MODULE.bazel
index 065bb35..332ce62 100644
--- a/MODULE.bazel
+++ b/MODULE.bazel
@@ -130,3 +130,17 @@
 
 hsm = use_extension("//third_party/hsm:extensions.bzl", "hsm")
 use_repo(hsm, "cloud_kms_hsm")
+
+# ── lowRISC QEMU (ot-earlgrey machine for earlgrey QEMU tests) ──
+# Tag v10.2.0-2026-01-15, sha256 9e97f93b... (locked in third_party/qemu/extensions.bzl).
+qemu = use_extension("//third_party/qemu:extensions.bzl", "qemu")
+use_repo(qemu, "qemu_opentitan", "qemu_opentitan_src")
+
+# ── Python deps for QEMU tooling scripts (cfggen, flashgen) ──
+pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip")
+pip.parse(
+    hub_name = "openprot_python_deps",
+    python_version = "3.11",
+    requirements_lock = "//third_party/qemu:requirements.txt",
+)
+use_repo(pip, "openprot_python_deps")
diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock
index a3a6cfa..8a91f25 100644
--- a/MODULE.bazel.lock
+++ b/MODULE.bazel.lock
@@ -552,6 +552,41 @@
         ]
       }
     },
+    "//third_party/qemu:extensions.bzl%qemu": {
+      "general": {
+        "bzlTransitiveDigest": "vYbS6zbBSUEYrJr8ZFwTQQ36P+gfjwYHsC6c3Of1Mbw=",
+        "usagesDigest": "sd4NrZaNp1CVFJ9pAaTO/LvX7gn0a5KpGnmBklHnLK4=",
+        "recordedFileInputs": {},
+        "recordedDirentsInputs": {},
+        "envVariables": {},
+        "generatedRepoSpecs": {
+          "qemu_opentitan_src": {
+            "repoRuleId": "@@bazel_tools//tools/build_defs/repo:http.bzl%http_archive",
+            "attributes": {
+              "url": "https://github.com/lowRISC/qemu/releases/download/v10.2.0-2026-01-15/qemu-ot-earlgrey-v10.2.0-2026-01-15-x86_64-unknown-linux-gnu.tar.gz",
+              "build_file": "@@//third_party/qemu:BUILD.qemu_opentitan.bazel",
+              "sha256": "9e97f93b09912c904e84f06571e7b49023ccb405dd3caa232ad1e82a3f7b381c",
+              "patch_cmds": [
+                "touch .this.is.the.archive"
+              ]
+            }
+          },
+          "qemu_opentitan": {
+            "repoRuleId": "@@//third_party/qemu:extensions.bzl%qemu_bazel_build_or_forward",
+            "attributes": {
+              "qemu_src": "@@+qemu+qemu_opentitan_src//:qemu_src"
+            }
+          }
+        },
+        "recordedRepoMappingEntries": [
+          [
+            "",
+            "bazel_tools",
+            "bazel_tools"
+          ]
+        ]
+      }
+    },
     "@@aspect_rules_esbuild+//esbuild:extensions.bzl%esbuild": {
       "general": {
         "bzlTransitiveDigest": "j6sp9ShWeG247Tw+aD0AaskiS6b/Dz94ci6OQrXvF5o=",
@@ -2149,7 +2184,7 @@
     "@@rules_libusb+//:extensions.bzl%libusb": {
       "general": {
         "bzlTransitiveDigest": "6s4LH70dpiebZQRs79OFjy6WpIaYps0B2+QpeixbTao=",
-        "usagesDigest": "mS8IemUWNRRKkBagACH+EOS/mM9aBoJISPKaD2aTfbI=",
+        "usagesDigest": "lbrZG+wyaTNaFb643SLJ9csKroS3S04dyqjShKEgymY=",
         "recordedFileInputs": {},
         "recordedDirentsInputs": {},
         "envVariables": {},
@@ -16617,8 +16652,8 @@
     },
     "@@rules_rust+//crate_universe/private:internal_extensions.bzl%cu_nr": {
       "general": {
-        "bzlTransitiveDigest": "C5Hj0oKc853CHNTtSUTsu5NL9s0l9VNnFPRu5dUvN2o=",
-        "usagesDigest": "v4We18mWSPeKV4GPp9Gne78W+jZOgP2pC1i4UN9br1g=",
+        "bzlTransitiveDigest": "KrH+YrNOYgkslLKDNwjzG0KdYsMKdr/q6Ogs0S7tx3Q=",
+        "usagesDigest": "ZmL90WEq2B6/NJ8rtHAqdnDPn+/9xG/GWR5K4UU4tyo=",
         "recordedFileInputs": {},
         "recordedDirentsInputs": {},
         "envVariables": {},
@@ -16682,7 +16717,7 @@
               "binary": "cargo-bazel",
               "cargo_lockfile": "@@rules_rust+//crate_universe:Cargo.lock",
               "cargo_toml": "@@rules_rust+//crate_universe:Cargo.toml",
-              "version": "1.86.0",
+              "version": "1.95.0",
               "timeout": 900,
               "rust_toolchain_cargo_template": "@rust_host_tools//:bin/{tool}",
               "rust_toolchain_rustc_template": "@rust_host_tools//:bin/{tool}",
diff --git a/presubmit/license.py b/presubmit/license.py
index cfc9ce9..055d4fe 100644
--- a/presubmit/license.py
+++ b/presubmit/license.py
@@ -83,6 +83,7 @@
     # keep-sorted: start
     r"\bdocs/src/third_party/.*",
     r"\bthird_party/.*\.bazelrc$",
+    r"\bthird_party/qemu/.*",
     # keep-sorted: end
     # Diff/Patch files
     # keep-sorted: start
diff --git a/target/earlgrey/BUILD.bazel b/target/earlgrey/BUILD.bazel
index 4dd3c83..43cafb5 100644
--- a/target/earlgrey/BUILD.bazel
+++ b/target/earlgrey/BUILD.bazel
@@ -38,6 +38,7 @@
     values = [
         "silicon",
         "fpga",
+        "qemu",
         "verilator",
     ],
 )
@@ -53,6 +54,11 @@
 )
 
 config_setting(
+    name = "qemu",
+    flag_values = {":target_type": "qemu"},
+)
+
+config_setting(
     name = "verilator",
     flag_values = {":target_type": "verilator"},
 )
@@ -71,6 +77,7 @@
     ],
     crate_features = select({
         ":fpga": ["fpga"],
+        ":qemu": ["qemu"],
         ":silicon": ["silicon"],
         ":verilator": ["verilator"],
         "//conditions:default": [],
@@ -136,6 +143,7 @@
     srcs = ["config.rs"],
     crate_features = select({
         ":fpga": ["fpga"],
+        ":qemu": ["qemu"],
         ":silicon": ["silicon"],
         ":verilator": ["verilator"],
         "//conditions:default": [],
diff --git a/target/earlgrey/README.md b/target/earlgrey/README.md
index 64312f2..38deb0a 100644
--- a/target/earlgrey/README.md
+++ b/target/earlgrey/README.md
@@ -31,6 +31,11 @@
 
             bazelisk run //target/earlgrey/ipc/user:ipc_runner_verilator
 
+    .. tab-item:: QEMU
+        .. code-block:: console
+
+            bazelisk run //target/earlgrey/tests/ipc/user:ipc_runner_qemu_test
+
 ## Testing
 
 To run the unittests, run
@@ -47,6 +52,41 @@
 
             bazelisk test --test_output=all --cache_test_results=no //target/earlgrey/unittest_runner:hyper340_test
 
+    .. tab-item:: QEMU
+        .. code-block:: console
+
+            bazelisk test //target/earlgrey/tests/ipc/user:ipc_runner_qemu_test
+
+        This test runs in approximately 2.6 seconds under QEMU.
+        It is included in the ``earlgrey_qemu_tests`` workflow and runs on every PR via the ``ci`` group.
+
+## Adding a QEMU lane to a new earlgrey test
+
+1. **Confirm an existing verilator target** — find the ``opentitan_test`` (or ``opentitan_runner``) rule
+   with ``interface = "verilator"`` in the target's ``BUILD.bazel``.
+
+2. **Add a sibling QEMU test target** in the same ``BUILD.bazel``:
+
+   .. code-block:: python
+
+       opentitan_test(
+           name = "<test>_qemu_test",
+           interface = "qemu",
+           tags = ["qemu"],
+           target = ":<image_target>",
+           timeout = "moderate",
+       )
+
+3. **Omit** ``ecdsa_key``, ``spx_key``, and ``nightly_test`` — those are not needed for QEMU tests.
+
+4. **No workflow wiring needed.** The ``earlgrey_qemu_tests`` build uses
+   ``--build_tag_filters=+qemu --test_tag_filters=+qemu`` and targets ``//target/earlgrey/...``,
+   so any ``opentitan_test`` target tagged ``qemu`` under ``target/earlgrey/`` is automatically
+   picked up and run on every PR via the ``ci`` group.
+
+5. **Canonical example:** ``target/earlgrey/tests/ipc/user/BUILD.bazel`` — see the
+   ``ipc_runner_qemu_test`` target.
+
 ## VS Code setup
 
 .. _rust-analyzer: https://rust-analyzer.github.io/
diff --git a/target/earlgrey/config.rs b/target/earlgrey/config.rs
index 0125590..80f9944 100644
--- a/target/earlgrey/config.rs
+++ b/target/earlgrey/config.rs
@@ -33,6 +33,8 @@
     const SYSTEM_CLOCK_HZ: u64 = 6_000_000;
     #[cfg(feature = "verilator")]
     const SYSTEM_CLOCK_HZ: u64 = 125_000;
+    #[cfg(feature = "qemu")]
+    const SYSTEM_CLOCK_HZ: u64 = 24_000_000;
 }
 
 impl RiscVKernelConfigInterface for KernelConfig {
diff --git a/target/earlgrey/entry.rs b/target/earlgrey/entry.rs
index 99ec235..d7b39f3 100644
--- a/target/earlgrey/entry.rs
+++ b/target/earlgrey/entry.rs
@@ -73,20 +73,25 @@
     "
 );
 
-#[cfg(feature = "verilator")]
+// SECURITY: This fake ROM_EXT trampoline bypasses ROM_EXT signature validation.
+// It must never run on silicon. It is gated behind the non-default "verilator"
+// and "qemu" features, which are only active for simulation targets.
+#[cfg(any(feature = "verilator", feature = "qemu"))]
 global_asm!(
     r#"
     /*
+     * Fake ROM_EXT trampoline shared by verilator and QEMU simulation targets.
+     *
      * We don't want to have separate build targets or linker script templating
-     * to run under verilator (e.g. to set the origin to 0x2000_0000 instead of
-     * the normal value of 0x2001_0000).
+     * to run under verilator or QEMU (e.g. to set the origin to 0x2000_0000
+     * instead of the normal value of 0x2001_0000).
      *
-     * Instead, when building for verilator, we add a ".fake_rom_ext" section that
-     * the linker script locates at 0x2000_0000.  In this section, we construct
-     * the most trivial of ROM_EXT manifests and set the entrypoint to the start
-     * offset of the kernel.
+     * Instead, when building for verilator or QEMU, we add a ".fake_rom_ext"
+     * section that the linker script locates at 0x2000_0000.  In this section,
+     * we construct the most trivial of ROM_EXT manifests and set the entrypoint
+     * to the start offset of the kernel.
      *
-     * The verilator test ROM doesn't do any validation of the manifest header.
+     * The test ROM doesn't do any validation of the manifest header.
      * We don't _need_ to set any version numbers or magic identifier words.
      *
      * We set the following:
diff --git a/target/earlgrey/syscall_latency/BUILD.bazel b/target/earlgrey/syscall_latency/BUILD.bazel
index edf8573..1db2c52 100644
--- a/target/earlgrey/syscall_latency/BUILD.bazel
+++ b/target/earlgrey/syscall_latency/BUILD.bazel
@@ -18,6 +18,7 @@
     codegen_crate_name = "syscall_latency_codegen",
     crate_features = select({
         "//target/earlgrey:fpga": ["fpga"],
+        "//target/earlgrey:qemu": ["qemu"],
         "//target/earlgrey:silicon": ["silicon"],
         "//target/earlgrey:verilator": ["verilator"],
         "//conditions:default": [],
diff --git a/target/earlgrey/syscall_latency/main.rs b/target/earlgrey/syscall_latency/main.rs
index d989989..f96026d 100644
--- a/target/earlgrey/syscall_latency/main.rs
+++ b/target/earlgrey/syscall_latency/main.rs
@@ -14,6 +14,8 @@
 const IO_CLOCK_HZ: u64 = 6_000_000;
 #[cfg(feature = "verilator")]
 const IO_CLOCK_HZ: u64 = 125_000;
+#[cfg(feature = "qemu")]
+const IO_CLOCK_HZ: u64 = 24_000_000;
 
 #[inline(always)]
 fn rv_timer_value(rv_timer: &RvTimer) -> u64 {
diff --git a/target/earlgrey/tests/drivers/gpio/BUILD.bazel b/target/earlgrey/tests/drivers/gpio/BUILD.bazel
index 12f26dc..d1e737b 100644
--- a/target/earlgrey/tests/drivers/gpio/BUILD.bazel
+++ b/target/earlgrey/tests/drivers/gpio/BUILD.bazel
@@ -90,3 +90,6 @@
     ],
     target = ":gpio",
 )
+
+# TODO: QEMU ot-earlgrey machine has no ot_gpio device model — DIRECT_OUT reads
+# return 0. Re-enable when upstream lowRISC QEMU lands ot_gpio emulation.
diff --git a/target/earlgrey/tests/ipc/user/BUILD.bazel b/target/earlgrey/tests/ipc/user/BUILD.bazel
index c2f2ac8..0d7946e 100644
--- a/target/earlgrey/tests/ipc/user/BUILD.bazel
+++ b/target/earlgrey/tests/ipc/user/BUILD.bazel
@@ -111,3 +111,11 @@
     ],
     target = ":ipc",
 )
+
+opentitan_test(
+    name = "ipc_runner_qemu_test",
+    timeout = "moderate",
+    interface = "qemu",
+    tags = ["qemu"],
+    target = ":ipc",
+)
diff --git a/target/earlgrey/tests/threads/kernel/BUILD.bazel b/target/earlgrey/tests/threads/kernel/BUILD.bazel
index e8d84e2..63ba187 100644
--- a/target/earlgrey/tests/threads/kernel/BUILD.bazel
+++ b/target/earlgrey/tests/threads/kernel/BUILD.bazel
@@ -78,6 +78,14 @@
     target = ":threads",
 )
 
+opentitan_test(
+    name = "threads_runner_qemu_test",
+    timeout = "moderate",
+    interface = "qemu",
+    tags = ["qemu"],
+    target = ":threads",
+)
+
 filegroup(
     name = "system_config",
     srcs = ["system.json5"],
diff --git a/target/earlgrey/tests/uart/BUILD.bazel b/target/earlgrey/tests/uart/BUILD.bazel
index 8b07ea4..ae5b0a4 100644
--- a/target/earlgrey/tests/uart/BUILD.bazel
+++ b/target/earlgrey/tests/uart/BUILD.bazel
@@ -141,3 +141,7 @@
     ],
     target = ":uart",
 )
+
+# TODO: QEMU ot-earlgrey machine doesn't fire UART1 RX-watermark interrupts —
+# the loopback test hangs waiting for an interrupt that never arrives.
+# Re-enable when lowRISC QEMU validates interrupt-driven UART under ot-earlgrey.
diff --git a/target/earlgrey/tooling/BUILD.bazel b/target/earlgrey/tooling/BUILD.bazel
index 10a2f11..f2bfe64 100644
--- a/target/earlgrey/tooling/BUILD.bazel
+++ b/target/earlgrey/tooling/BUILD.bazel
@@ -1,8 +1,10 @@
 # Licensed under the Apache-2.0 license
 # SPDX-License-Identifier: Apache-2.0
 
+load("@openprot_python_deps//:requirements.bzl", "requirement")
 load("@pigweed//pw_build:pw_py_importable_runfile.bzl", "pw_py_importable_runfile")
 load("@rules_python//python:defs.bzl", "py_binary")
+load("//target/earlgrey/tooling:qemu.bzl", "qemu_cfg", "qemu_otp")
 
 pw_py_importable_runfile(
     name = "opentitantool-runfiles",
@@ -61,6 +63,18 @@
     import_location = "opentitan.fake_keys.app_prod_ecdsa",
 )
 
+# Canonical per-package cfg/otp targets; also serve as smoke tests for the
+# qemu_cfg and qemu_otp rules (all defaults).
+qemu_cfg(
+    name = "qemu_earlgrey_cfg",
+    testonly = True,
+)
+
+qemu_otp(
+    name = "qemu_rma_otp",
+    testonly = True,
+)
+
 py_binary(
     name = "opentitan_runner",
     srcs = [
@@ -81,3 +95,31 @@
         "@rules_python//python/runfiles",
     ],
 )
+
+exports_files(["qemu_start.sh"])
+
+py_binary(
+    name = "qemu_runner",
+    testonly = True,
+    srcs = ["qemu_runner.py"],
+    data = [":qemu_start.sh"],
+    main = "qemu_runner.py",
+    deps = [
+        "@pigweed//pw_tokenizer/py:detokenize",
+    ],
+)
+
+py_binary(
+    name = "qemu_cfg_gen",
+    srcs = ["qemu_cfg_gen.py"],
+    main = "qemu_cfg_gen.py",
+)
+
+py_binary(
+    name = "qemu_constants_dumper",
+    srcs = ["qemu_constants_dumper.py"],
+    deps = [
+        "//third_party/qemu:ot",
+        requirement("hjson"),
+    ],
+)
diff --git a/target/earlgrey/tooling/opentitan_runner.bzl b/target/earlgrey/tooling/opentitan_runner.bzl
index fcde8db..81042bb 100644
--- a/target/earlgrey/tooling/opentitan_runner.bzl
+++ b/target/earlgrey/tooling/opentitan_runner.bzl
@@ -2,6 +2,7 @@
 # SPDX-License-Identifier: Apache-2.0
 
 load("@pigweed//pw_kernel/tooling:system_image.bzl", "SystemImageInfo")
+load("//target/earlgrey/tooling:qemu.bzl", "gen_flash")
 load("//target/earlgrey/tooling/signing:defs.bzl", "KeySetInfo", "sign_binary")
 
 def _target_type_transition_impl(_, attr):
@@ -9,6 +10,8 @@
         return {"//target/earlgrey:target_type": "fpga"}
     if attr.interface == "verilator":
         return {"//target/earlgrey:target_type": "verilator"}
+    if attr.interface == "qemu":
+        return {"//target/earlgrey:target_type": "qemu"}
 
     return {"//target/earlgrey:target_type": "silicon"}
 
@@ -39,6 +42,60 @@
     if hasattr(ctx.attr, "exit_failure") and ctx.attr.exit_failure:
         optional_args += " --exit-failure='{}'".format(ctx.attr.exit_failure)
 
+    if ctx.attr.interface == "qemu":
+        flash_file = gen_flash(
+            ctx,
+            flashgen = ctx.attr._flashgen,
+            firmware_bin = bin_file,
+            firmware_elf = elf_file,
+        )
+
+        cfg_file = ctx.attr._qemu_cfg[DefaultInfo].files.to_list()[0]
+        otp_file = ctx.attr._qemu_otp[DefaultInfo].files.to_list()[0]
+        qemu_bin = ctx.file._qemu_bin
+        qemu_rom = ctx.file._qemu_rom
+        qemu_start = ctx.file._qemu_start
+        qemu_runner_exe = ctx.executable._qemu_runner
+
+        run_script = ctx.actions.declare_file(ctx.attr.name + ".sh")
+        ctx.actions.write(
+            output = run_script,
+            is_executable = True,
+            content = """#!/bin/bash
+{runner} \
+  --qemu-start {qemu_start} \
+  --qemu-bin {qemu_bin} \
+  --qemu-config {cfg} \
+  --qemu-rom {rom} \
+  --qemu-otp {otp} \
+  --qemu-flash {flash} \
+  --firmware-elf {elf} \
+  --icount 6 \
+  --timeout-seconds 120 \
+  {optional_args}
+""".format(
+                runner = qemu_runner_exe.short_path,
+                qemu_start = qemu_start.short_path,
+                qemu_bin = qemu_bin.short_path,
+                cfg = cfg_file.short_path,
+                rom = qemu_rom.short_path,
+                otp = otp_file.short_path,
+                flash = flash_file.short_path,
+                elf = elf_file.short_path,
+                optional_args = optional_args,
+            ),
+        )
+
+        qemu_runner_runfiles = ctx.attr._qemu_runner[DefaultInfo].default_runfiles.files
+
+        return [DefaultInfo(
+            runfiles = ctx.runfiles(
+                files = [elf_file, bin_file, qemu_bin, qemu_rom, qemu_start, cfg_file, otp_file, flash_file],
+                transitive_files = qemu_runner_runfiles,
+            ),
+            executable = run_script,
+        )]
+
     if ctx.attr.interface == "hyper310" or ctx.attr.interface == "hyper340":
         load_bitstream = "--load-bitstream"
         mechanism = "--mechanism=bootstrap"
@@ -83,7 +140,7 @@
         doc = "ECDSA public key to validate this image",
     ),
     "interface": attr.string(
-        doc = "The interface to use.",
+        values = ["hyper310", "hyper340", "qemu", "teacup", "verilator"],
         mandatory = True,
     ),
     "manifest": attr.label(
@@ -115,6 +172,43 @@
     ),
 }
 
+# Hidden attrs for the qemu interface.  Kept separate from _BASE_ATTRS because
+# several of these deps are testonly=True; merging them into _BASE_ATTRS would
+# prevent non-test opentitan_runner targets from loading without errors.
+_QEMU_ATTRS = {
+    "_flashgen": attr.label(
+        executable = True,
+        cfg = "exec",
+        default = "//third_party/qemu:flashgen",
+    ),
+    "_qemu_bin": attr.label(
+        allow_single_file = True,
+        cfg = "exec",
+        default = "//third_party/qemu:qemu-system-riscv32",
+    ),
+    "_qemu_cfg": attr.label(
+        default = "//target/earlgrey/tooling:qemu_earlgrey_cfg",
+    ),
+    "_qemu_otp": attr.label(
+        default = "//target/earlgrey/tooling:qemu_rma_otp",
+    ),
+    "_qemu_rom": attr.label(
+        allow_single_file = True,
+        cfg = "exec",
+        default = "@opentitan_devbundle//:earlgrey/test_rom/test_rom_sim_verilator.elf",
+    ),
+    "_qemu_runner": attr.label(
+        executable = True,
+        cfg = "exec",
+        default = "//target/earlgrey/tooling:qemu_runner",
+    ),
+    "_qemu_start": attr.label(
+        allow_single_file = True,
+        cfg = "exec",
+        default = "//target/earlgrey/tooling:qemu_start.sh",
+    ),
+}
+
 opentitan_runner = rule(
     implementation = _opentitan_runner_impl,
     executable = True,
@@ -124,7 +218,7 @@
 opentitan_test = rule(
     implementation = _opentitan_runner_impl,
     test = True,
-    attrs = _BASE_ATTRS | {
+    attrs = _BASE_ATTRS | _QEMU_ATTRS | {
         "exit_failure": attr.string(
             default = "FAIL: .+\\n",
             doc = "The regex to look for in the output to determine failure.",
diff --git a/target/earlgrey/tooling/qemu.bzl b/target/earlgrey/tooling/qemu.bzl
new file mode 100644
index 0000000..b411b05
--- /dev/null
+++ b/target/earlgrey/tooling/qemu.bzl
@@ -0,0 +1,211 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+#
+# Three pure-function Bazel rules for generating QEMU backing files:
+#   qemu_cfg   — QEMU readconfig INI (from RTL secrets via cfggen.py)
+#   qemu_otp   — QEMU OTP raw image  (from a .vmem via otptool.py)
+#   qemu_flash — QEMU flash image    (from a firmware .bin via flashgen.py)
+#
+# Ported from opentitan/rules/opentitan/qemu.bzl.  Stripped: ExecEnvInfo,
+# sim_qemu, qemu_params, _test_dispatch, _transform.  Those belong to
+# opentitan's exec-env system which openprot does not use (see plan §"exec_env
+# is unused").
+#
+# Security note (build-side): all file paths flow through ctx.actions.run()
+# argument lists, never through shell interpolation.  Attack surface here is
+# Bazel rule invocation by BUILD authors, not external input.  No runtime
+# user-controlled data crosses this boundary.
+
+# ---------------------------------------------------------------------------
+# Helper functions — call these from within a rule's own ctx to generate a
+# single backing file.  Each takes explicit arguments rather than ctx
+# attribute lookup, so callers (including opentitan_runner.bzl) can supply
+# values from their own ctx without attribute-name collisions.
+# ---------------------------------------------------------------------------
+
+def gen_cfg(ctx, cfggen, qemu_constants):
+    """Generate a QEMU readconfig INI file containing OpenTitan RTL secrets from JSON.
+
+    Args:
+        ctx:            Rule context.
+        cfggen:         Target providing qemu_cfg_gen.py executable (DefaultInfo).
+        qemu_constants: File — qemu_constants.json.
+
+    Returns:
+        The declared output File (.ini).
+    """
+    out = ctx.actions.declare_file(ctx.label.name + ".ini")
+    ctx.actions.run(
+        inputs = [qemu_constants],
+        outputs = [out],
+        executable = cfggen[DefaultInfo].files_to_run,
+        arguments = [
+            "--json",
+            qemu_constants.path,
+            "--out",
+            out.path,
+        ],
+        mnemonic = "QemuCfgGen",
+    )
+    return out
+
+def gen_otp(ctx, otptool, vmem):
+    """Generate a QEMU-compatible raw OTP image from a .vmem file.
+
+    Args:
+        ctx:     Rule context.
+        otptool: Target providing otptool.py executable (DefaultInfo).
+        vmem:    File — OTP .vmem image (e.g. img_rma.vmem).
+
+    Returns:
+        The declared output File (.raw).
+    """
+    out = ctx.actions.declare_file(ctx.label.name + ".raw")
+    ctx.actions.run(
+        inputs = [vmem],
+        outputs = [out],
+        executable = otptool[DefaultInfo].files_to_run,
+        arguments = [
+            "-m",
+            vmem.path,
+            "-r",
+            out.path,
+            "-k",
+            "otp",
+        ],
+        mnemonic = "QemuOtpGen",
+    )
+    return out
+
+def gen_flash(ctx, flashgen, firmware_bin, firmware_elf = None):
+    """Generate a QEMU-compatible flash backing image from a firmware binary.
+
+    NOTE: only single-bank flash images are supported (mirrors opentitan).
+    The firmware binary is placed at offset 0x0 of the flash image.
+
+    Args:
+        ctx:          Rule context.
+        flashgen:     Target providing flashgen.py executable (DefaultInfo).
+        firmware_bin: File — flat binary to splice into the flash image.
+        firmware_elf: File or None — ELF for mtime sanity checks (unused by
+                      QEMU itself; pass None to skip checks via --unsafe-elf).
+
+    Returns:
+        The declared output File (.qemu_bin).
+    """
+    out = ctx.actions.declare_file(ctx.label.name + ".qemu_bin")
+    args = [
+        "-t",
+        "{}@0x0".format(firmware_bin.path),
+    ]
+
+    # When an ELF is present, pass --ignore-time so Bazel's mtime rewriting
+    # does not cause spurious "binary older than ELF" failures.  When absent,
+    # pass --unsafe-elf to skip ELF size validation entirely.
+    if firmware_elf:
+        args += ["--ignore-time"]
+    else:
+        args += ["--unsafe-elf"]
+
+    args += [out.path]
+
+    inputs = [firmware_bin]
+    if firmware_elf:
+        inputs.append(firmware_elf)
+
+    ctx.actions.run(
+        inputs = inputs,
+        outputs = [out],
+        executable = flashgen[DefaultInfo].files_to_run,
+        arguments = args,
+        mnemonic = "QemuFlashGen",
+    )
+    return out
+
+# ---------------------------------------------------------------------------
+# Standalone rules — invoke these directly from a BUILD file to materialize
+# the backing files as named Bazel targets.
+# ---------------------------------------------------------------------------
+
+def _qemu_cfg_impl(ctx):
+    out = gen_cfg(
+        ctx,
+        cfggen = ctx.attr.cfggen,
+        qemu_constants = ctx.file.qemu_constants,
+    )
+    return [DefaultInfo(files = depset([out]))]
+
+qemu_cfg = rule(
+    implementation = _qemu_cfg_impl,
+    attrs = {
+        "cfggen": attr.label(
+            executable = True,
+            cfg = "exec",
+            allow_files = True,
+            default = Label("//target/earlgrey/tooling:qemu_cfg_gen"),
+            doc = "qemu_cfg_gen.py py_binary target.",
+        ),
+        "qemu_constants": attr.label(
+            allow_single_file = True,
+            default = Label("//third_party/opentitan_rtl:qemu_constants"),
+            doc = "qemu_constants.json — QEMU constants in JSON format.",
+        ),
+    },
+)
+
+def _qemu_otp_impl(ctx):
+    out = gen_otp(
+        ctx,
+        otptool = ctx.attr.otptool,
+        vmem = ctx.file.vmem,
+    )
+    return [DefaultInfo(files = depset([out]))]
+
+qemu_otp = rule(
+    implementation = _qemu_otp_impl,
+    attrs = {
+        "otptool": attr.label(
+            executable = True,
+            cfg = "exec",
+            allow_files = True,
+            default = Label("//third_party/qemu:otptool"),
+            doc = "otptool.py py_binary target.",
+        ),
+        "vmem": attr.label(
+            allow_single_file = True,
+            default = Label("@opentitan_devbundle//:earlgrey/otp/img_rma.24.vmem"),
+            doc = "OTP .vmem image to convert (defaults to RMA image).",
+        ),
+    },
+)
+
+def _qemu_flash_impl(ctx):
+    out = gen_flash(
+        ctx,
+        flashgen = ctx.attr.flashgen,
+        firmware_bin = ctx.file.firmware_bin,
+        firmware_elf = ctx.file.firmware_elf,
+    )
+    return [DefaultInfo(files = depset([out]))]
+
+qemu_flash = rule(
+    implementation = _qemu_flash_impl,
+    attrs = {
+        "firmware_bin": attr.label(
+            allow_single_file = True,
+            mandatory = True,
+            doc = "Flat firmware binary to splice into the flash image at offset 0x0.",
+        ),
+        "firmware_elf": attr.label(
+            allow_single_file = True,
+            doc = "ELF counterpart for mtime sanity checks (optional; omit for unsigned builds).",
+        ),
+        "flashgen": attr.label(
+            executable = True,
+            cfg = "exec",
+            allow_files = True,
+            default = Label("//third_party/qemu:flashgen"),
+            doc = "flashgen.py py_binary target.",
+        ),
+    },
+)
diff --git a/target/earlgrey/tooling/qemu_cfg_gen.py b/target/earlgrey/tooling/qemu_cfg_gen.py
new file mode 100755
index 0000000..b3927be5
--- /dev/null
+++ b/target/earlgrey/tooling/qemu_cfg_gen.py
@@ -0,0 +1,271 @@
+#!/usr/bin/env python3
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+import argparse
+import json
+import sys
+from configparser import ConfigParser
+from typing import TextIO, Optional
+
+# Helper to match cfggen's alphanumeric-ish sorting if needed.
+# cfggen uses 'alphanum_key' from 'ot.util.misc'.
+# Let's see if standard sorting is enough.
+# The INI file had:
+#   digest_const = ...
+#   digest_iv = ...
+#   flash_addr_const = ...
+#   flash_addr_iv = ...
+#   flash_data_const = ...
+#   flash_data_iv = ...
+#   inv_default_part_5 = ...
+#   inv_default_part_6 = ...
+#   inv_default_part_7 = ...
+#   inv_default_part_8 = ...
+#   inv_default_part_9 = ...
+#   inv_default_part_10 = ...
+#   scrmbl_key = ...
+#   secret0_scramble_key = ...
+# Standard sorting of these keys:
+# ['digest_const', 'digest_iv', 'flash_addr_const', 'flash_addr_iv', 'flash_data_const', 'flash_data_iv', 'inv_default_part_10', 'inv_default_part_5', 'inv_default_part_6', 'inv_default_part_7', 'inv_default_part_8', 'inv_default_part_9', 'scrmbl_key', 'secret0_scramble_key']
+# Wait! Standard sorting puts 'inv_default_part_10' BEFORE 'inv_default_part_5'!
+# But in the generated INI, 'inv_default_part_10' was AFTER 'inv_default_part_9':
+#   inv_default_part_5 = ...
+#   inv_default_part_6 = ...
+#   ...
+#   inv_default_part_10 = ...
+# This is because cfggen uses 'alphanum_key' which does natural sorting (numeric sorting for numbers).
+# We should implement natural sorting to match exactly!
+
+
+def try_int(s: str) -> getattr(str, "__class__", object):  # type: ignore
+    try:
+        return int(s)
+    except ValueError:
+        return s
+
+
+def alphanum_key(s: str) -> list:
+    import re
+
+    return [try_int(c) for c in re.split(r"(\d+)", s)]
+
+
+def add_pair(cfg: ConfigParser, devname: str, kname: str, value: str) -> None:
+    if value:
+        if f'ot_device "{devname}"' not in cfg:
+            cfg[f'ot_device "{devname}"'] = {}
+        cfg[f'ot_device "{devname}"'][f"  {kname}"] = f'"{value}"'
+
+
+def generate_roms(
+    cfg: ConfigParser, rom_ctrl: dict, socid: Optional[str] = None, count: int = 1
+) -> None:
+    for cnt in range(count):
+        for rom, data in rom_ctrl.items():
+            nameargs = ["ot-rom_ctrl"]
+            if socid:
+                if count > 1:
+                    nameargs.append(f"{socid}{cnt}")
+                else:
+                    nameargs.append(socid)
+            if rom != "null":
+                nameargs.append(f"rom{rom}")
+            romname = ".".join(nameargs)
+            for kname in sorted(data.keys(), key=alphanum_key):
+                val = data[kname]
+                add_pair(cfg, romname, kname, val)
+
+
+def generate_otp(
+    cfg: ConfigParser, otp_ctrl: dict, variant: str, socid: Optional[str] = None
+) -> None:
+    nameargs = [f"ot-otp-{variant}"]
+    if socid:
+        nameargs.append(socid)
+    otpname = ".".join(nameargs)
+    for kname in sorted(otp_ctrl.keys(), key=alphanum_key):
+        val = otp_ctrl[kname]
+        add_pair(cfg, otpname, kname, val)
+
+
+def generate_lc_ctrl(
+    cfg: ConfigParser, lc_ctrl: dict, lc_states: dict, socid: Optional[str] = None
+) -> None:
+    nameargs = ["ot-lc_ctrl"]
+    if socid:
+        nameargs.append(socid)
+    lcname = ".".join(nameargs)
+
+    lcdata = {}
+    # In cfggen, states are added first, then other constants, then sorted.
+    # But ConfigParser preserves insertion order in Python 3.7+.
+    # If we want to match exact sorting in output, we should collect all pairs first, then sort them.
+    # Actually, cfggen did:
+    #   for name, states in self._lcconst.states.items():
+    #       self.add_pair(lcname, lcdata, f'{name}_first', states[0]) ...
+    #   for kname, value in lc_ctrl.items():
+    #       self.add_pair(lcname, lcdata, kname, value)
+    #   lcdata = dict(sorted(lcdata.items()))
+    # So the final keys are sorted.
+
+    pairs = {}
+    for name, states in lc_states.items():
+        pairs[f"{name}_first"] = states[0]
+        pairs[f"{name}_last"] = states[1]
+    for kname, value in lc_ctrl.items():
+        pairs[kname] = value
+
+    for kname in sorted(pairs.keys(), key=alphanum_key):
+        add_pair(cfg, lcname, kname, pairs[kname])
+
+
+def generate_key_mgr(
+    cfg: ConfigParser, keymgr: dict, socid: Optional[str] = None
+) -> None:
+    if not keymgr:
+        return
+    keymgr_name = keymgr.get("name", "keymgr")
+    values = keymgr.get("values", {})
+    nameargs = [f"ot-{keymgr_name}"]
+    if socid:
+        nameargs.append(socid)
+    kmname = ".".join(nameargs)
+    for kname in sorted(values.keys(), key=alphanum_key):
+        value = values[kname]
+        add_pair(cfg, kmname, kname, value)
+
+
+def generate_ast(
+    cfg: ConfigParser, top_clocks: dict, variant: str, socid: Optional[str] = None
+) -> None:
+    nameargs = [f"ot-ast-{variant}"]
+    if socid:
+        nameargs.append(socid)
+    astname = ".".join(nameargs)
+
+    topclockstr = ",".join(f'{name}:{c["frequency"]}' for name, c in top_clocks.items())
+    aonclockstr = ",".join(name for name, c in top_clocks.items() if c["aon"])
+    add_pair(cfg, astname, "topclocks", topclockstr)
+    add_pair(cfg, astname, "aonclocks", aonclockstr)
+
+
+def generate_clkmgr(
+    cfg: ConfigParser,
+    top_clocks: dict,
+    sub_clocks: dict,
+    clock_groups: dict,
+    socid: Optional[str] = None,
+) -> None:
+    nameargs = ["ot-clkmgr"]
+    if socid:
+        nameargs.append(socid)
+    clkname = ".".join(nameargs)
+
+    refclocks = [name for name, c in top_clocks.items() if c["ref"]]
+    if len(refclocks) > 1:
+        raise ValueError(f'Multiple reference clocks detected: {", ".join(refclocks)}')
+
+    clkrefname = refclocks[0] if refclocks else None
+    clfrefval = top_clocks.get(clkrefname) if clkrefname else None
+
+    topclockdefs = []
+    # Sort clocks to match cfggen output (which seems to match input order, but let's sort to be deterministic if cfggen sorted them?
+    # cfggen: `for ckname, ckval in self._top_clocks.items():`
+    # In cfggen, self._top_clocks is populated in load_config, preserving insertion order from HJSON.
+    # Our JSON also preserves insertion order (Python 3.7+ dict).
+    # Let's see if sorting is needed. The INI had: "main:500,io:480,usb:240,aon:1".
+    # In HJSON, clocks are defined in some order.
+    # If we want to match exactly, we should preserve order.
+    # In Python, dict preserves insertion order, so iterating over top_clocks.items() preserves order.
+    for ckname, ckval in top_clocks.items():
+        if clfrefval:
+            clkratio = ckval["frequency"] // clfrefval["frequency"]
+        else:
+            clkratio = 1
+        topclockdefs.append(f"{ckname}:{clkratio}")
+    topclockstr = ",".join(topclockdefs)
+
+    # subclocks in INI: "io_div2:io:2,io_div4:io:4,aes:main:1,hmac:main:1,kmac:main:1,otbn:main:1"
+    # This also preserves insertion order from HJSON.
+    subclockstr = ",".join(
+        f'{name}:{c["source"]}:{c["div"]}' for name, c in sub_clocks.items()
+    )
+
+    # groups in INI: "powerup:aon+io+io_div2+io_div4+main+usb,trans:aes+hmac+kmac+otbn,..."
+    # The group sources are sorted: `"+".join(sorted(g.sources))`
+    # But the groups themselves preserve insertion order.
+    groupstr = ",".join(
+        f'{name}:{"+".join(sorted(g["sources"], key=alphanum_key))}'
+        for name, g in clock_groups.items()
+    )
+
+    swcgstr = ",".join(name for name, g in clock_groups.items() if g["sw_cg"])
+    hintstr = ",".join(name for name, g in clock_groups.items() if g["hint"])
+
+    add_pair(cfg, clkname, "topclocks", topclockstr)
+    if clkrefname:
+        add_pair(cfg, clkname, "refclock", clkrefname)
+    add_pair(cfg, clkname, "subclocks", subclockstr)
+    add_pair(cfg, clkname, "groups", groupstr)
+    add_pair(cfg, clkname, "swcg", swcgstr)
+    add_pair(cfg, clkname, "hint", hintstr)
+
+
+def generate_pwrmgr(
+    cfg: ConfigParser, top_clocks: dict, socid: Optional[str] = None
+) -> None:
+    nameargs = ["ot-pwrmgr"]
+    if socid:
+        nameargs.append(socid)
+    pwrname = ".".join(nameargs)
+
+    clockstr = ",".join(name for name, c in top_clocks.items() if not c["aon"])
+    add_pair(cfg, pwrname, "clocks", clockstr)
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Regenerate QEMU config from JSON constants."
+    )
+    parser.add_argument("--json", required=True, help="Input JSON file with constants")
+    parser.add_argument("--out", required=True, help="Output INI file")
+    args = parser.parse_args()
+
+    with open(args.json, "r") as f:
+        data = json.load(f)
+
+    cfg = ConfigParser()
+
+    variant = data["variant"]
+
+    rom_ctrl = data.get("rom_ctrl", {})
+    generate_roms(cfg, rom_ctrl)
+
+    otp_ctrl = data.get("otp_ctrl", {})
+    generate_otp(cfg, otp_ctrl, variant)
+
+    lc_ctrl = data.get("lc_ctrl", {})
+    lc_states = data.get("lc_states", {})
+    generate_lc_ctrl(cfg, lc_ctrl, lc_states)
+
+    keymgr = data.get("keymgr", {})
+    generate_key_mgr(cfg, keymgr)
+
+    top_clocks = data.get("top_clocks", {})
+    generate_ast(cfg, top_clocks, variant)
+
+    sub_clocks = data.get("sub_clocks", {})
+    clock_groups = data.get("clock_groups", {})
+    generate_clkmgr(cfg, top_clocks, sub_clocks, clock_groups)
+
+    generate_pwrmgr(cfg, top_clocks)
+
+    with open(args.out, "w") as f:
+        if data.get("git_version"):
+            f.write(f'# Generated from OpenTitan commit: {data["git_version"]}\n\n')
+        cfg.write(f)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/target/earlgrey/tooling/qemu_constants_dumper.py b/target/earlgrey/tooling/qemu_constants_dumper.py
new file mode 100755
index 0000000..e33fe2f
--- /dev/null
+++ b/target/earlgrey/tooling/qemu_constants_dumper.py
@@ -0,0 +1,564 @@
+#!/usr/bin/env python3
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+"""OpenTitan QEMU configuration dumper to JSON.
+
+   Based on cfggen.py from lowRISC QEMU.
+"""
+
+from argparse import ArgumentParser
+from configparser import ConfigParser
+from logging import getLogger
+from os.path import (
+    abspath,
+    basename,
+    dirname,
+    isdir,
+    isfile,
+    join as joinpath,
+    normpath,
+)
+from traceback import format_exc
+from typing import NamedTuple, Optional, TextIO
+import sys
+import json
+import os
+
+# Robust runfiles path resolution for Bazel
+runfiles_dir = os.environ.get("RUNFILES_DIR")
+if runfiles_dir:
+    found = False
+    for root, dirs, files in os.walk(runfiles_dir):
+        if root.endswith(os.path.join("python", "qemu")) and "ot" in dirs:
+            sys.path.append(root)
+            found = True
+            break
+    if not found:
+        print("Warning: Could not find qemu python path in runfiles", file=sys.stderr)
+else:
+    # Fallback
+    QEMU_PYPATH = joinpath(
+        dirname(dirname(dirname(normpath(__file__)))), "python", "qemu"
+    )
+    sys.path.append(QEMU_PYPATH)
+
+try:
+    _HJSON_ERROR = None
+    from hjson import load as hjload
+except ImportError as hjson_exc:
+    _HJSON_ERROR = str(hjson_exc)
+
+    def hjload(*_, **__):  # noqa: E301
+        """dummy func if HJSON module is not available"""
+        return {}
+
+
+from ot.lc_ctrl.const import LcCtrlConstants
+from ot.otp.const import OtpConstants
+from ot.otp.secret import OtpSecretConstants
+from ot.top import OpenTitanTop
+from ot.util.arg import ArgError
+from ot.util.log import configure_loggers
+from ot.util.misc import alphanum_key, retrieve_git_version, to_bool
+
+
+OtParamRegex = str
+"""Definition of a parameter to seek and how to shorten it."""
+
+
+class OtClock(NamedTuple):
+    """Clock definition."""
+
+    name: str
+    """Clock signal name."""
+
+    frequency: int
+    """Clock frequency in Hz."""
+
+    aon: bool
+    """Whether the clock is always on."""
+
+    ref: bool
+    """Whether the clock is a reference clock."""
+
+
+class OtDerivedClock(NamedTuple):
+    """Clock derived from a top level clock definition."""
+
+    name: str
+    """Clock signal name."""
+
+    source: str
+    """Clock source signal name."""
+
+    div: int
+    """Divider."""
+
+
+class OtClockGroup(NamedTuple):
+    """Clock logicial group definition."""
+
+    name: str
+    """Group name."""
+
+    sources: list[str]
+    """Clock source signal names."""
+
+    sw_cg: bool
+    """Whether clock group can be managed by SW."""
+
+    hint: bool
+    """Whether clock group can be hinted by SW."""
+
+
+class OtConfiguration:
+    """QEMU configuration file generator."""
+
+    MODULES = {
+        "rom_ctrl": (True, r"RndCnstScr(.*)"),
+        "otp_ctrl": (False, r"RndCnst(.*)Init"),
+        "lc_ctrl": (False, r"RndCnstLcKeymgrDiv(.*)"),
+        "keymgr": (
+            False,
+            r"RndCnst((?:.*)Seed)",
+            # The CDI keymgr seed does not match 'RndCnst.*Seed'
+            r"RndCnst(Cdi)",
+        ),
+        "keymgr_dpe": (False, r"RndCnst((?:.*)Seed)"),
+    }
+
+    TRANSLATIONS = {"keymgr": {"cdi": "cdi_seed"}}
+
+    def __init__(self):
+        self._log = getLogger("cfggen.cfg")
+        self._otpconst = OtpConstants()
+        self._lcconst = LcCtrlConstants()
+        self._constants: dict[str, dict[Optional[int], dict[str, str]]] = {}
+        self._top_clocks: dict[str, OtClock] = {}
+        self._sub_clocks: dict[str, OtDerivedClock] = {}
+        self._clock_groups: dict[str, OtClockGroup] = {}
+        self._mod_clocks: dict[str, list[str]] = {}
+        self._top_name: Optional[str] = None
+        self._git_version: Optional[str] = None
+        self._exclusions: dict[str, set[str]] = {}
+
+    @property
+    def top_name(self) -> Optional[str]:
+        """Return the name of the top as defined in a configuration file."""
+        return self._top_name
+
+    def load_config(self, toppath: str) -> None:
+        """Load data from HJSON configuration file."""
+        assert not _HJSON_ERROR
+        with open(toppath, "rt") as tfp:
+            cfg = hjload(tfp, object_pairs_hook=dict)
+        self._top_name = cfg.get("name")
+        topbase = basename(toppath)
+
+        self._git_version = retrieve_git_version(toppath)
+
+        for module in cfg.get("module") or []:
+            modtype = module.get("type")
+            moddefs = self.MODULES.get(modtype)
+            if not moddefs:
+                continue
+            multi, regexes = moddefs[0], moddefs[1:]
+            consts = {}
+            OtpSecretConstants.load_values(module, consts, multi, *regexes)
+            if not consts:
+                continue
+            for cname, tname in self.TRANSLATIONS.get(modtype, {}).items():
+                if cname in consts:
+                    consts[tname] = consts.pop(cname)
+            self._log.debug("Constants for %s loaded from %s", modtype, topbase)
+            exist = modtype in self._constants
+            if multi:
+                if not exist:
+                    self._constants[modtype] = consts
+                else:
+                    self._constants[modtype].update(consts)
+            else:
+                if exist:
+                    raise ValueError(f"Redefinition of {modtype}")
+                self._constants[modtype] = {None: consts}
+
+        clocks = cfg.get("clocks", {})
+        for clock in clocks.get("srcs", []):
+            name = clock["name"]
+            aon = to_bool(clock["aon"], False)
+            ref = to_bool(clock["ref"], False)
+            freq = int(clock["freq"])
+            self._top_clocks[name] = OtClock(name, freq, aon, ref)
+        for clock in clocks.get("derived_srcs", []):
+            name = clock["name"]
+            src = clock["src"]
+            aon = to_bool(clock["aon"], False)
+            freq = int(clock["freq"])
+            div = int(clock["div"])
+            src_clock = self._top_clocks.get(src)
+            if not src_clock:
+                raise ValueError(f"Invalid top clock {src} " f"referenced from {name}")
+            if src_clock.frequency // div != freq:
+                raise ValueError(
+                    f"Incoherent derived clock {name} frequency: "
+                    f"{src_clock.frequency}/{div} != {freq}"
+                )
+            if aon and not src_clock.aon:
+                raise ValueError(f"Incoherent derived clock {name} AON")
+            self._sub_clocks[name] = OtDerivedClock(name, src, div)
+        clock_names = set(self._top_clocks.keys())
+        clock_names.update(set(self._sub_clocks.keys()))
+        for group in clocks.get("groups", []):
+            ext = group["src"] == "ext"
+            if ext:
+                continue
+            name = group["name"]
+            hint = group["sw_cg"] == "hint"
+            sw_cg = not hint and to_bool(group["sw_cg"], False)
+            clk_srcs = []
+            for clk_name, clk_src in group.get("clocks", {}).items():
+                if not hint:
+                    exp_name = f"clk_{clk_src}_{name}"
+                    if clk_name != exp_name:
+                        raise ValueError(
+                            f"Unexpected clock {clk_name} in group"
+                            f" {name} (exp: {exp_name})"
+                        )
+                    clk_srcs.append(clk_src)
+                else:
+                    exp_prefix = f"clk_{clk_src}_"
+                    if not clk_name.startswith(exp_prefix):
+                        raise ValueError(
+                            f"Unexpected clock {clk_name} in group" f" {name}"
+                        )
+                    src_name = clk_name.removeprefix(exp_prefix)
+                    clk_srcs.append(src_name)
+                    if src_name in self._sub_clocks:
+                        raise ValueError(f"Refinition of clock {src_name}")
+                    self._sub_clocks[src_name] = OtDerivedClock(src_name, clk_src, 1)
+            self._clock_groups[name] = OtClockGroup(name, clk_srcs, sw_cg, hint)
+        modules = cfg.get("module", [])
+        mod_clocks = {}
+        for module in modules:
+            type_ = module["type"]
+            if type_ in ("ast", "clkmgr"):
+                continue
+            name = module["name"]
+            clk_srcs = module.get("clock_srcs", {})
+            clk_grp = module.get("clock_group", "")
+            clocks = []
+            for clk in clk_srcs.values():
+                if isinstance(clk, dict):
+                    clocks.append(f'{clk["group"]}.{clk["clock"]}')
+                else:
+                    clocks.append(f"{clk_grp}.{clk}")
+            mod_clocks[name] = clocks
+        self._mod_clocks = mod_clocks
+
+    def load_lifecycle(self, svpath: str) -> None:
+        """Load LifeCycle data from RTL file."""
+        with open(svpath, "rt") as cfp:
+            self._log.debug("Loading LC constants from %s", svpath)
+            self._lcconst.load_sv(cfp)
+
+    def load_otp_constants(self, svpath: str) -> None:
+        """Load OTP data from RTL file."""
+        with open(svpath, "rt") as cfp:
+            self._log.debug("Loading OTP constants from %s", svpath)
+            self._otpconst.load_sv(cfp)
+
+    def load_constants(self, hjpath: Optional[str]) -> None:
+        """Load definitions from HJSON file."""
+        if not hjpath:
+            return
+        assert not _HJSON_ERROR
+        self._log.debug("Loading secrets from %s", hjpath)
+        hjbase = basename(hjpath)
+        with open(hjpath, "rt") as tfp:
+            cfg = hjload(tfp, object_pairs_hook=dict)
+        for module in cfg.get("module") or []:
+            modtype = module.get("type")
+            moddefs = self.MODULES.get(modtype)
+            if not moddefs:
+                continue
+            multi, regexes = moddefs[0], moddefs[1:]
+            consts = {}
+            OtpSecretConstants.load_values(module, consts, multi, *regexes)
+            if not consts:
+                continue
+            for cname, tname in self.TRANSLATIONS.get(modtype, {}).items():
+                if cname in consts:
+                    consts[tname] = consts.pop(cname)
+            self._log.debug("Constants for %s loaded from %s", modtype, hjbase)
+            exist = modtype in self._constants
+            if multi:
+                if not exist:
+                    self._constants[modtype] = consts
+                else:
+                    self._constants[modtype].update(consts)
+            else:
+                if exist:
+                    raise ValueError(f"Redefinition of {modtype}")
+                self._constants[modtype] = {None: consts}
+        self._otpconst.load_secrets(cfg)
+
+    def prepare(self) -> None:
+        """Prepare generation of data, aggregating several sources."""
+        digests = {
+            "cnsty_digest": "digest",
+            "flash_data_key": "flash_data",
+            "flash_addr_key": "flash_addr",
+            "sram_data_key": "sram",
+        }
+        avail_digests = self._otpconst.get_digests()
+        otp_ctrl = self._constants["otp_ctrl"][None]
+        for digest, prefix in digests.items():
+            if digest not in avail_digests:
+                continue
+            pair = self._otpconst.get_digest_pair(digest, prefix)
+            otp_ctrl.update(pair)
+        for key in self._otpconst.get_scrambling_keys():
+            key_value = self._otpconst.get_scrambling_key(key)
+            key = key.removesuffix("key") + "_scramble_key"
+            otp_ctrl[key] = key_value
+        idx = 0
+        while True:
+            try:
+                defaults = self._otpconst.get_partition_inv_defaults(idx)
+                if defaults:
+                    otp_ctrl[f"inv_default_part_{idx}"] = defaults
+                idx += 1
+            except ValueError:
+                break
+        lc_ctrl = self._constants["lc_ctrl"][None]
+        lc_ctrl.update(self._lcconst.tokens)
+
+    def exclude(self, exclusions: list[str]) -> None:
+        """Add property exclusions."""
+        for exclude in exclusions:
+            try:
+                dev, prop = exclude.split(".")
+            except ValueError as exc:
+                raise ArgError(f"Invalid exclusion format: {exclude}") from exc
+            if dev not in self._exclusions:
+                self._exclusions[dev] = set()
+            self._exclusions[dev].add(prop)
+
+    def dump_json(self, variant: str, ofp: TextIO) -> None:
+        """Dump all configuration constants to JSON format."""
+        data = {
+            "variant": variant,
+            "git_version": self._git_version,
+            "rom_ctrl": self._constants.get("rom_ctrl", {}),
+            "otp_ctrl": self._constants.get("otp_ctrl", {}).get(None, {}),
+            "lc_ctrl": self._constants.get("lc_ctrl", {}).get(None, {}),
+            "keymgr": {},
+            "lc_states": {
+                name: list(states) for name, states in self._lcconst.states.items()
+            },
+            "top_clocks": {
+                c.name: {"frequency": c.frequency, "aon": c.aon, "ref": c.ref}
+                for c in self._top_clocks.values()
+            },
+            "sub_clocks": {
+                c.name: {"source": c.source, "div": c.div}
+                for c in self._sub_clocks.values()
+            },
+            "clock_groups": {
+                g.name: {"sources": g.sources, "sw_cg": g.sw_cg, "hint": g.hint}
+                for g in self._clock_groups.values()
+            },
+        }
+
+        for keymgr_name in ("keymgr", "keymgr_dpe"):
+            if keymgr_name in self._constants:
+                data["keymgr"] = {
+                    "name": keymgr_name,
+                    "values": self._constants[keymgr_name].get(None, {}),
+                }
+                break
+
+        json.dump(data, ofp, indent=2)
+
+
+def main():
+    """Main routine"""
+    debug = True
+    try:
+        argparser = ArgumentParser(description="Dump OpenTitan constants to JSON.")
+        files = argparser.add_argument_group(title="Files")
+        files.add_argument(
+            "opentitan", nargs="?", metavar="OTDIR", help="OpenTitan root directory"
+        )
+        files.add_argument(
+            "-T", "--top", choices=OpenTitanTop.names, help="OpenTitan top name"
+        )
+        files.add_argument(
+            "-o",
+            "--out",
+            metavar="JSON",
+            required=True,
+            help="Filename of the JSON file to generate",
+        )
+        files.add_argument(
+            "-c",
+            "--otpconst",
+            metavar="SV",
+            help="OTP Constant SV file (default: auto)",
+        )
+        files.add_argument(
+            "-l", "--lifecycle", metavar="SV", help="LifeCycle SV file (default: auto)"
+        )
+        files.add_argument(
+            "-S", "--secrets", metavar="HJSON", help="Secret HJSON file (default: auto)"
+        )
+        files.add_argument(
+            "-t",
+            "--topcfg",
+            metavar="HJSON",
+            help="OpenTitan top HJSON config file " "(default: auto)",
+        )
+        mods = argparser.add_argument_group(title="Modifiers")
+        mods.add_argument(
+            "-x",
+            "--exclude",
+            action="append",
+            metavar="DEVICE.NAME",
+            default=[],
+            help="Discard any property from DEVICE that starts "
+            "with NAME (may be repeated)",
+        )
+        extra = argparser.add_argument_group(title="Extras")
+        extra.add_argument("-v", "--verbose", action="count", help="increase verbosity")
+        extra.add_argument(
+            "-d", "--debug", action="store_true", help="enable debug mode"
+        )
+        args = argparser.parse_args()
+        debug = args.debug
+
+        log = configure_loggers(args.verbose, "cfggen", "lc", "otp")[0]
+
+        if _HJSON_ERROR:
+            argparser.error(f"Missing HJSON module: {_HJSON_ERROR}")
+
+        cfg = OtConfiguration()
+
+        topcfg = args.topcfg
+        ot_dir = args.opentitan
+        if not topcfg:
+            if not args.opentitan:
+                argparser.error("OTDIR is required is no top file is specified")
+            if not isdir(ot_dir):
+                argparser.error("Invalid OpenTitan root directory")
+            ot_dir = abspath(ot_dir)
+            if not args.top:
+                argparser.error("Top name is required if no top file is " "specified")
+            top = f"top_{args.top}"
+            topvar = OpenTitanTop.short_name(args.top)
+            topcfg = joinpath(ot_dir, f"hw/{top}/data/autogen/{top}.gen.hjson")
+            if not isfile(topcfg):
+                argparser.error(f"No such file '{topcfg}'")
+            log.info("Top config: '%s'", topcfg)
+            cfg.load_config(topcfg)
+        else:
+            if not isfile(topcfg):
+                argparser.error(f"No such top file: {topcfg}")
+            cfg.load_config(topcfg)
+            ltop = cfg.top_name
+            if not ltop:
+                argparser.error("Unknown top name")
+            log.info("Top: '%s'", ltop)
+            ltop = ltop.lower()
+            topvar = OpenTitanTop.short_name(cfg.top_name)
+            if not topvar:
+                argparser.error(f"Unsupported top name: {cfg.top_name}")
+            top = f"top_{ltop}"
+            if not ot_dir:
+                check_dir = f"hw/{top}/data"
+                cur_dir = dirname(topcfg)
+                while cur_dir:
+                    check_path = joinpath(cur_dir, check_dir)
+                    if isdir(check_path):
+                        ot_dir = cur_dir
+                        break
+                    cur_dir = dirname(cur_dir)
+                if not ot_dir:
+                    argparser.error("Cannot find OT root directory")
+            elif not isdir(ot_dir):
+                argparser.error("Invalid OpenTitan root directory")
+            ot_dir = abspath(ot_dir)
+            log.info("OT directory: '%s'", ot_dir)
+        log.info("Variant: '%s'", topvar)
+        top_dir = joinpath(ot_dir, "hw", top)
+
+        lcfilename = "lc_ctrl_state_pkg.sv"
+        lcpath = args.lifecycle
+        if not lcpath:
+            lc_constant_locations = [
+                joinpath(top_dir, f"rtl/autogen/testing/{lcfilename}"),
+                joinpath(top_dir, f"rtl/autogen/dev/{lcfilename}"),
+                joinpath(top_dir, f"rtl/autogen/{lcfilename}"),
+                joinpath(ot_dir, f"hw/ip/lc_ctrl/rtl/{lcfilename}"),
+            ]
+            for maybe_lcpath in lc_constant_locations:
+                if isfile(maybe_lcpath):
+                    lcpath = maybe_lcpath
+                    break
+        if not lcpath:
+            argparser.error(f"Unknown location for '{lcfilename}'")
+        if not isfile(lcpath):
+            argparser.error(f"No such file '{lcpath}'")
+        log.debug(f"'{lcfilename}' location: '%s'", lcpath)
+
+        ocfilename = "otp_ctrl_part_pkg.sv"
+        ocpath = args.otpconst
+        if not ocpath:
+            otp_constant_locations = [
+                joinpath(top_dir, f"ip_autogen/otp_ctrl/rtl/{ocfilename}"),
+                joinpath(ot_dir, f"hw/ip/otp_ctrl/rtl/{ocfilename}"),
+            ]
+            for maybe_ocpath in otp_constant_locations:
+                if isfile(maybe_ocpath):
+                    ocpath = maybe_ocpath
+                    break
+        if not ocpath:
+            argparser.error(f"Unknown location for '{ocfilename}'")
+        if not isfile(ocpath):
+            argparser.error(f"No such file '{ocpath}'")
+        log.debug(f"'{ocfilename}' location: '%s'", ocpath)
+
+        secpath = args.secrets
+        if secpath:
+            if not isfile(secpath):
+                argparser.error("No such secret file: {secpath}")
+        else:
+            sec_constant_locations = [
+                joinpath(top_dir, f"data/autogen/{top}.secrets.testing.gen.hjson"),
+                joinpath(top_dir, f"data/autogen/{top}.secrets.dev.gen.hjson"),
+            ]
+            for maybe_secpath in sec_constant_locations:
+                if isfile(maybe_secpath):
+                    secpath = maybe_secpath
+                    break
+
+        cfg.load_lifecycle(lcpath)
+        cfg.load_otp_constants(ocpath)
+        cfg.load_constants(secpath)
+        cfg.prepare()
+        cfg.exclude(args.exclude)
+
+        with open(args.out, "wt") as ofp:
+            cfg.dump_json(topvar, ofp)
+
+    except (ArgError, IOError, ValueError, ImportError) as exc:
+        print(f"\nError: {exc}", file=sys.stderr)
+        if debug:
+            print(format_exc(chain=False), file=sys.stderr)
+        sys.exit(1)
+    except KeyboardInterrupt:
+        sys.exit(2)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/target/earlgrey/tooling/qemu_runner.py b/target/earlgrey/tooling/qemu_runner.py
new file mode 100644
index 0000000..1efdf7d
--- /dev/null
+++ b/target/earlgrey/tooling/qemu_runner.py
@@ -0,0 +1,416 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+"""QEMU runner for the ot-earlgrey machine.
+
+Launches firmware under the QEMU ot-earlgrey machine, pipes UART output through
+pw_tokenizer detokenization, and exits with a structured code:
+
+  0  success-regex matched
+  1  failure-regex matched
+  2  timeout (no match before --timeout-seconds elapsed)
+  3  QEMU process exited unexpectedly before any match
+"""
+
+import atexit
+import argparse
+import io
+import logging
+import os
+import re
+import shutil
+import signal
+import socket
+import subprocess
+import sys
+import tempfile
+import threading
+import time
+from pathlib import Path
+
+from pw_tokenizer import detokenize
+
+_LOG = logging.getLogger(__name__)
+
+_SPIFLASH_SIZE = 32 * 1024 * 1024  # W25Q256 = 32 MiB
+_SCRIPT_DIR = Path(__file__).resolve().parent
+
+EXIT_SUCCESS = 0
+EXIT_FAILURE = 1
+EXIT_TIMEOUT = 2
+EXIT_QEMU_CRASH = 3
+
+# Shared state for signal handlers — mutable list used as a ref cell.
+_active_pid: list[int] = []
+
+
+def _parse_args() -> argparse.Namespace:
+    p = argparse.ArgumentParser(
+        description=__doc__,
+        formatter_class=argparse.RawDescriptionHelpFormatter,
+    )
+    p.add_argument(
+        "--qemu-start",
+        type=Path,
+        default=_SCRIPT_DIR / "qemu_start.sh",
+        help="path to qemu_start.sh (default: sibling of this script)",
+    )
+    p.add_argument(
+        "--qemu-bin", required=True, type=Path, help="path to qemu-system-riscv32"
+    )
+    p.add_argument(
+        "--qemu-config",
+        required=True,
+        type=Path,
+        help="QEMU readconfig INI from qemu_cfg rule",
+    )
+    p.add_argument("--qemu-rom", required=True, type=Path, help="test ROM ELF")
+    p.add_argument(
+        "--qemu-otp",
+        required=True,
+        type=Path,
+        help="read-only OTP image; will be copied to a writable temp path",
+    )
+    p.add_argument(
+        "--qemu-flash",
+        required=True,
+        type=Path,
+        help="read-only flash image; will be copied to a writable temp path",
+    )
+    p.add_argument(
+        "--firmware-elf",
+        required=True,
+        type=Path,
+        help="firmware ELF for pw_tokenizer detokenization",
+    )
+    p.add_argument(
+        "--exit-success",
+        default=None,
+        type=str,
+        help="regex matched against detokenized UART output to signal pass",
+    )
+    p.add_argument(
+        "--exit-failure",
+        default=None,
+        type=str,
+        help="regex matched against detokenized UART output to signal fail",
+    )
+    p.add_argument(
+        "--timeout-seconds",
+        default=120,
+        type=int,
+        help="seconds before EXIT_TIMEOUT (0 = no timeout)",
+    )
+    p.add_argument(
+        "--icount", default=6, type=int, help="QEMU icount shift value (default 6)"
+    )
+    return p.parse_args()
+
+
+def _wait_for_pid(pidfile: Path, deadline: float) -> int:
+    while time.monotonic() < deadline:
+        try:
+            text = pidfile.read_text().strip()
+            pid = int(text)
+            if pid > 0:
+                return pid
+        except (FileNotFoundError, ValueError):
+            pass
+        time.sleep(0.05)
+    raise TimeoutError(f"QEMU PID file never appeared: {pidfile}")
+
+
+def _connect_unix(path: Path, deadline: float) -> socket.socket:
+    while True:
+        sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+        try:
+            sock.connect(str(path))
+            return sock
+        except OSError:
+            sock.close()
+            if time.monotonic() >= deadline:
+                raise TimeoutError(f"timed out connecting to {path}")
+            time.sleep(0.05)
+
+
+def _alive(pid: int) -> bool:
+    return Path(f"/proc/{pid}").exists()
+
+
+def _kill_qemu(pid: int) -> None:
+    """SIGTERM → wait 5 s → SIGKILL."""
+    if not _alive(pid):
+        return
+    try:
+        os.kill(pid, signal.SIGTERM)
+    except ProcessLookupError:
+        return
+    deadline = time.monotonic() + 5.0
+    while _alive(pid) and time.monotonic() < deadline:
+        time.sleep(0.1)
+    if _alive(pid):
+        try:
+            os.kill(pid, signal.SIGKILL)
+        except ProcessLookupError:
+            pass
+
+
+def _signal_handler(signum: int, _frame) -> None:
+    # sys.exit raises SystemExit, unwinding try/finally and TemporaryDirectory.
+    if _active_pid:
+        _kill_qemu(_active_pid[0])
+    sys.exit(EXIT_QEMU_CRASH)
+
+
+class _Watcher:
+    """Thread-safe regex watcher operating on detokenized text."""
+
+    def __init__(self, success: str | None, failure: str | None):
+        self._success = re.compile(success) if success else None
+        self._failure = re.compile(failure) if failure else None
+        self.result: int | None = None
+        self._lock = threading.Lock()
+
+    def feed(self, text: str) -> bool:
+        """Return True and record result on first pattern match."""
+        with self._lock:
+            if self.result is not None:
+                return True
+            if self._success and self._success.search(text):
+                self.result = EXIT_SUCCESS
+                return True
+            if self._failure and self._failure.search(text):
+                self.result = EXIT_FAILURE
+                return True
+        return False
+
+
+def _emit_line(det: detokenize.Detokenizer | None, line: str) -> str:
+    """Detokenize one line, write to stdout, return the decoded text."""
+    if det is None:
+        sys.stdout.write(line)
+        sys.stdout.flush()
+        return line
+    try:
+        buf = io.BytesIO()
+        det.detokenize_text_to_file(line, buf)
+        decoded = buf.getvalue().decode("utf-8", errors="replace")
+        sys.stdout.write(decoded)
+        sys.stdout.flush()
+        return decoded
+    except Exception as e:
+        _LOG.debug("detokenize error: %s", e)
+        sys.stdout.write(line)
+        sys.stdout.flush()
+        return line
+
+
+def _uart_thread(
+    sock: socket.socket,
+    watcher: _Watcher,
+    elf: Path,
+    done: threading.Event,
+) -> None:
+    try:
+        det: detokenize.Detokenizer | None = detokenize.Detokenizer(elf)
+    except Exception as e:
+        _LOG.warning("detokenizer init failed (%s); raw output only", e)
+        det = None
+
+    line_buf = b""
+    try:
+        while not done.is_set():
+            try:
+                chunk = sock.recv(4096)
+            except OSError:
+                break
+            if not chunk:
+                break
+
+            line_buf += chunk
+            while b"\n" in line_buf:
+                idx = line_buf.index(b"\n") + 1
+                line = line_buf[:idx]
+                line_buf = line_buf[idx:]
+                decoded = _emit_line(det, line.decode("utf-8", errors="replace"))
+                if watcher.feed(decoded):
+                    done.set()
+                    return
+
+        # Flush any partial line after the connection closes.
+        if line_buf:
+            decoded = _emit_line(det, line_buf.decode("utf-8", errors="replace"))
+            watcher.feed(decoded)
+    except Exception as e:
+        _LOG.warning("UART reader error: %s", e)
+    done.set()
+
+
+def _run(args: argparse.Namespace) -> int:
+    # Validate regex patterns before touching the filesystem.
+    for flag, val in (
+        ("--exit-success", args.exit_success),
+        ("--exit-failure", args.exit_failure),
+    ):
+        if val is not None:
+            try:
+                re.compile(val)
+            except re.error as e:
+                _LOG.error("invalid %s pattern: %s", flag, e)
+                return EXIT_FAILURE
+
+    signal.signal(signal.SIGINT, _signal_handler)
+    signal.signal(signal.SIGTERM, _signal_handler)
+
+    with tempfile.TemporaryDirectory() as _tmpdir:
+        tmp = Path(_tmpdir)
+
+        # Step 1: writable copies of read-only Bazel-built backing files.
+        # QEMU pflash/mtd drivers write back to the backing file on close.
+        # Use copyfile (content-only) not copy2, so Bazel's read-only mode bits
+        # are not propagated to the writable temp copy.
+        otp_rw = tmp / "otp.raw"
+        flash_rw = tmp / "flash.qemu_bin"
+        shutil.copyfile(args.qemu_otp, otp_rw)
+        shutil.copyfile(args.qemu_flash, flash_rw)
+
+        # Step 2: 32 MiB SPI flash backing store (sparse — W25Q256).
+        spiflash = tmp / "spiflash.bin"
+        with open(spiflash, "wb") as fh:
+            fh.seek(_SPIFLASH_SIZE - 1)
+            fh.write(b"\x00")
+
+        pidfile = tmp / "qemu.pid"
+        logfile = tmp / "qemu.log"
+        monitor_path = tmp / "monitor.sock"
+        uart_path = tmp / "uart0.sock"
+
+        # Step 3: launch qemu_start.sh.  -daemonize causes QEMU to fork to
+        # background; the script (and thus subprocess.run) returns once the
+        # child has written its PID file.
+        env = dict(os.environ)
+        env.update(
+            {
+                "QEMU_BIN": str(args.qemu_bin),
+                "QEMU_CONFIG": str(args.qemu_config),
+                "QEMU_ROM": str(args.qemu_rom),
+                "QEMU_OTP": str(otp_rw),
+                "QEMU_FLASH": str(flash_rw),
+                "QEMU_SPIFLASH": str(spiflash),
+                "QEMU_PIDFILE": str(pidfile),
+                "QEMU_LOG": str(logfile),
+                "QEMU_ICOUNT": str(args.icount),
+                "QEMU_MONITOR": str(monitor_path),
+                "QEMU_UART_SOCKET": str(uart_path),
+            }
+        )
+
+        cmd = [str(args.qemu_start)]
+        _LOG.info("starting QEMU via %s", args.qemu_start)
+        launch = subprocess.run(cmd, env=env, check=False)
+        if launch.returncode != 0:
+            _LOG.error("qemu_start.sh exited %d", launch.returncode)
+            return EXIT_QEMU_CRASH
+
+        deadline = (
+            time.monotonic() + args.timeout_seconds
+            if args.timeout_seconds > 0
+            else float("inf")
+        )
+
+        # Step 4a: poll for QEMU's PID file.
+        try:
+            qemu_pid = _wait_for_pid(pidfile, deadline)
+        except TimeoutError as e:
+            _LOG.error("%s", e)
+            return EXIT_TIMEOUT
+        _LOG.info("QEMU daemonized, pid=%d", qemu_pid)
+
+        # Register PID for signal-handler cleanup.
+        _active_pid.clear()
+        _active_pid.append(qemu_pid)
+        atexit.register(_kill_qemu, qemu_pid)
+
+        # Step 4b: connect to HMP monitor socket and resume the CPU.
+        # The -S flag holds the CPU paused; without `cont` the UART is silent.
+        try:
+            mon = _connect_unix(monitor_path, deadline)
+            try:
+                # Wait for the HMP `(qemu) ` prompt before sending cont.
+                mon.settimeout(5.0)
+                banner = b""
+                try:
+                    while b"(qemu)" not in banner:
+                        chunk = mon.recv(64)
+                        if not chunk:
+                            break
+                        banner += chunk
+                except socket.timeout:
+                    _LOG.warning(
+                        "monitor: no (qemu) prompt within 5s, sending cont anyway"
+                    )
+                mon.settimeout(None)
+                mon.sendall(b"cont\n")
+            finally:
+                mon.close()
+        except (TimeoutError, OSError) as e:
+            _LOG.error("monitor: %s", e)
+            _kill_qemu(qemu_pid)
+            return EXIT_QEMU_CRASH
+
+        # Step 5: connect to UART0 socket and watch for exit patterns.
+        try:
+            uart_sock = _connect_unix(uart_path, deadline)
+        except TimeoutError as e:
+            _LOG.error("%s", e)
+            _kill_qemu(qemu_pid)
+            return EXIT_QEMU_CRASH
+
+        watcher = _Watcher(args.exit_success, args.exit_failure)
+        done = threading.Event()
+        reader = threading.Thread(
+            target=_uart_thread,
+            args=(uart_sock, watcher, args.firmware_elf, done),
+            daemon=True,
+        )
+        reader.start()
+
+        timed_out = False
+        crashed = False
+        try:
+            while not done.is_set():
+                if args.timeout_seconds > 0 and time.monotonic() >= deadline:
+                    timed_out = True
+                    _LOG.error("test timed out after %ds", args.timeout_seconds)
+                    break
+                if not _alive(qemu_pid):
+                    crashed = True
+                    _LOG.error("QEMU pid=%d exited unexpectedly", qemu_pid)
+                    if logfile.exists():
+                        tail = logfile.read_text(errors="replace")[-2000:]
+                        _LOG.info("QEMU log tail:\n%s", tail)
+                    break
+                done.wait(timeout=0.1)
+        finally:
+            # Step 6: cleanup on all exit paths.
+            done.set()
+            try:
+                uart_sock.close()
+            except OSError:
+                pass
+            reader.join(timeout=2)
+            _kill_qemu(qemu_pid)
+            _active_pid.clear()
+
+        if watcher.result is not None:
+            return watcher.result
+        if timed_out:
+            return EXIT_TIMEOUT
+        if crashed:
+            return EXIT_QEMU_CRASH
+        # Reader set done without a watcher match (clean EOF, no sentinel).
+        return EXIT_TIMEOUT
+
+
+if __name__ == "__main__":
+    logging.basicConfig(format="%(levelname)s: %(message)s")
+    sys.exit(_run(_parse_args()))
diff --git a/target/earlgrey/tooling/qemu_start.sh b/target/earlgrey/tooling/qemu_start.sh
new file mode 100755
index 0000000..6d8b8e9
--- /dev/null
+++ b/target/earlgrey/tooling/qemu_start.sh
@@ -0,0 +1,114 @@
+#!/usr/bin/env bash
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+#
+# Launch qemu-system-riscv32 for the ot-earlgrey machine and daemonize it.
+# The CPU starts paused (-S); the runner must send `cont` to $QEMU_MONITOR
+# before any firmware output appears.
+#
+# Ported from opentitan/hw/top_earlgrey/sw/util/qemu.sh.
+# Stripped for v1: JTAG sockets, GPIO socket, I2C, USB, extra UARTs.
+# UART0 uses a Unix socket (not PTY) for Bazel sandbox hermeticity.
+#
+# Required environment variables:
+#   QEMU_BIN          path to qemu-system-riscv32
+#   QEMU_CONFIG       path to QEMU readconfig INI (from cfggen.py)
+#   QEMU_ROM          path to test ROM ELF
+#   QEMU_OTP          path to mutable raw OTP image (from otptool.py)
+#   QEMU_FLASH        path to mutable flash image (from flashgen.py)
+#   QEMU_SPIFLASH     path to mutable 32 MiB SPI-flash backing store
+#   QEMU_PIDFILE      path where QEMU writes its PID after daemonizing
+#   QEMU_LOG          path for QEMU's -D log output
+#   QEMU_ICOUNT       icount shift value (default: 6)
+#   QEMU_MONITOR      path for the QEMU monitor Unix socket (HMP mode)
+#   QEMU_UART_SOCKET  path for the UART0 Unix socket
+
+set -e
+
+_fail() {
+    echo "qemu_start.sh: $1" >&2
+    exit 1
+}
+
+# Mandatory checks — all must be non-empty strings pointing at real files.
+[ -n "$QEMU_BIN"         ] || _fail "QEMU_BIN is unset"
+[ -f "$QEMU_BIN"         ] || _fail "QEMU_BIN not found: $QEMU_BIN"
+[ -n "$QEMU_CONFIG"      ] || _fail "QEMU_CONFIG is unset"
+[ -f "$QEMU_CONFIG"      ] || _fail "QEMU_CONFIG not found: $QEMU_CONFIG"
+[ -n "$QEMU_ROM"         ] || _fail "QEMU_ROM is unset"
+[ -f "$QEMU_ROM"         ] || _fail "QEMU_ROM not found: $QEMU_ROM"
+[ -n "$QEMU_OTP"         ] || _fail "QEMU_OTP is unset"
+[ -f "$QEMU_OTP"         ] || _fail "QEMU_OTP not found: $QEMU_OTP"
+[ -n "$QEMU_FLASH"       ] || _fail "QEMU_FLASH is unset"
+[ -f "$QEMU_FLASH"       ] || _fail "QEMU_FLASH not found: $QEMU_FLASH"
+[ -n "$QEMU_SPIFLASH"    ] || _fail "QEMU_SPIFLASH is unset"
+[ -f "$QEMU_SPIFLASH"    ] || _fail "QEMU_SPIFLASH not found: $QEMU_SPIFLASH"
+[ -n "$QEMU_PIDFILE"     ] || _fail "QEMU_PIDFILE is unset"
+[ -n "$QEMU_LOG"         ] || _fail "QEMU_LOG is unset"
+[ -n "$QEMU_MONITOR"     ] || _fail "QEMU_MONITOR is unset"
+[ -n "$QEMU_UART_SOCKET" ] || _fail "QEMU_UART_SOCKET is unset"
+
+QEMU_ICOUNT="${QEMU_ICOUNT:-6}"
+
+qemu_args=(
+    # No GUI.
+    "-display" "none"
+
+    # Earlgrey 1.0.0 machine.
+    "-M" "ot-earlgrey"
+
+    # RTL constants from cfggen.
+    "-readconfig" "$QEMU_CONFIG"
+
+    # Daemonize after initialization; write PID to file.
+    "-daemonize"
+    "-pidfile" "$QEMU_PIDFILE"
+
+    # Start CPU paused — runner sends `cont` via monitor before tailing UART.
+    "-S"
+
+    # Log guest errors and unimplemented peripheral access (invaluable on failures).
+    "-D" "$QEMU_LOG"
+    "-d" "guest_errors,unimp"
+
+    # ROM image.
+    "-object" "ot-rom_img,id=rom,file=${QEMU_ROM}"
+
+    # OTP backing store (pflash).
+    "-drive" "if=pflash,file=${QEMU_OTP},format=raw"
+
+    # Firmware flash (mtd bus 2 = eflash).
+    "-drive" "if=mtd,id=eflash,bus=2,file=${QEMU_FLASH},format=raw"
+
+    # SPI Host 0 backing store — ot-earlgrey machine requires this drive even
+    # when the test does not exercise SPI.  W25Q256 = 32 MiB, matches the dd
+    # image created by the runner.
+    "-global" "ot-earlgrey-board.spiflash0=w25q256"
+    "-drive" "if=mtd,file=${QEMU_SPIFLASH},format=raw,bus=0"
+
+    # Virtual-time pacing: 1 GHz >> icount ns/insn ≈ wall-clock alignment.
+    "-icount" "shift=${QEMU_ICOUNT},align=on,sleep=on"
+
+    # Disable fatal-reset so tests that trigger a reset don't kill QEMU.
+    "-global" "ot-rstmgr.fatal_reset=0"
+
+    # Disable keymgr flash-seed check (info pages not spliced; would error).
+    "-global" "ot-keymgr.disable-flash-seed-check=true"
+
+    # Suppress test-status-register exit so multiple resets are possible.
+    "-global" "ot-ibex_wrapper.dv-sim-status-exit=off"
+
+    # Monitor socket in HMP (readline) mode — runner writes plain `cont\n`.
+    "-chardev" "socket,id=monitor,path=${QEMU_MONITOR},server=on,wait=off"
+    "-mon" "chardev=monitor,mode=readline"
+
+    # UART0 as a Unix socket (not PTY) for hermeticity inside Bazel sandboxes.
+    "-chardev" "socket,path=${QEMU_UART_SOCKET},server=on,wait=off,id=uart0"
+    "-serial" "chardev:uart0"
+
+    # UART1 — no external endpoint needed for software-loopback tests.
+    "-serial" "null"
+)
+
+echo "qemu_start.sh: launching ${QEMU_BIN} with ${#qemu_args[@]} args" >&2
+exec "$QEMU_BIN" "${qemu_args[@]}"
diff --git a/third_party/opentitan_rtl/BUILD.bazel b/third_party/opentitan_rtl/BUILD.bazel
new file mode 100644
index 0000000..86712aa
--- /dev/null
+++ b/third_party/opentitan_rtl/BUILD.bazel
@@ -0,0 +1,9 @@
+# Licensed under the Apache-2.0 license
+# SPDX-License-Identifier: Apache-2.0
+
+package(default_visibility = ["//target/earlgrey/tooling:__pkg__"])
+
+filegroup(
+    name = "qemu_constants",
+    srcs = ["qemu_constants.json"],
+)
diff --git a/third_party/opentitan_rtl/VENDORED_FROM.md b/third_party/opentitan_rtl/VENDORED_FROM.md
new file mode 100644
index 0000000..c26945b
--- /dev/null
+++ b/third_party/opentitan_rtl/VENDORED_FROM.md
@@ -0,0 +1,61 @@
+# Vendored RTL Constants
+
+These constants are used to generate QEMU input files. To avoid bringing in large SystemVerilog (`.sv`) and HJSON files from the OpenTitan repository into the build flow, the necessary constants have been extracted into a single JSON file: `qemu_constants.json`.
+
+This JSON file is a **static copy** of constants extracted from a specific upstream commit of OpenTitan. OpenTitan is not a Bazel dependency of openprot.
+
+## Upstream Source
+
+| Repository | URL |
+|---|---|
+| lowRISC/opentitan | https://github.com/lowRISC/opentitan |
+
+**Upstream commit:** `76d47da22f2310207ae72224f3835969f5a6d274`
+
+## File Provenance
+
+| File in this directory | Extracted From Upstream Paths | Notes |
+|---|---|---|
+| `qemu_constants.json` | `hw/ip/otp_ctrl/rtl/otp_ctrl_part_pkg.sv`<br>`hw/ip/lc_ctrl/rtl/lc_ctrl_state_pkg.sv`<br>`hw/top_earlgrey/data/autogen/top_earlgrey.gen.hjson` | Extracted using `qemu_constants_dumper.py` tool. |
+
+> **RMA OTP image:** `img_rma.vmem` is consumed directly from the devbundle as `@opentitan_devbundle//:earlgrey/otp/img_rma.24.vmem`. The devbundle pin in `MODULE.bazel` is the single source of truth.
+
+## Uprev and Regeneration Procedure
+
+The constants in `qemu_constants.json` are silicon-locked. Regenerate them only if the upstream RTL constants have meaningfully changed — typically they won't.
+
+When regeneration is genuinely needed:
+
+1.  Temporarily copy the upstream files verbatim to their historical locations (only needed for the dumper tool to find them, or you can pass their paths explicitly to the dumper tool):
+    *   `hw/ip/otp_ctrl/rtl/otp_ctrl_part_pkg.sv`
+    *   `hw/ip/lc_ctrl/rtl/lc_ctrl_state_pkg.sv`
+    *   `hw/top_earlgrey/data/autogen/top_earlgrey.gen.hjson`
+    (You can copy them anywhere, e.g. to a temporary `tmp/` directory).
+
+2.  Run the dumper tool `//target/earlgrey/tooling:qemu_constants_dumper` to generate the new JSON file. Pass the temporary paths explicitly:
+    ```bash
+    bazel run //target/earlgrey/tooling:qemu_constants_dumper -- \
+      --json \
+      --out $PWD/third_party/opentitan_rtl/qemu_constants.json \
+      --top earlgrey \
+      --topcfg /path/to/temporary/top_earlgrey.gen.hjson \
+      --otpconst /path/to/temporary/otp_ctrl_part_pkg.sv \
+      --lifecycle /path/to/temporary/lc_ctrl_state_pkg.sv \
+      /path/to/temporary/opentitan_root_if_needed
+    ```
+    *(Note: If you stage the files in a mock OpenTitan directory structure, you can omit the explicit paths and just pass the root directory path as positional argument).*
+
+3.  Delete the temporary `.sv` and `.hjson` files. Do **NOT** commit them to the repository.
+
+4.  Update the **Upstream commit** hash in this file (`VENDORED_FROM.md`) to capture the commit from which the new constants were obtained.
+
+5.  Commit the updated `qemu_constants.json` and `VENDORED_FROM.md`.
+
+## Why JSON Constants, Not RTL Files
+
+Previously, `cfggen.py` (from the QEMU release archive) consumed the raw `.sv` and `.hjson` files at build time to generate QEMU readconfig files. This required bringing large RTL files into the repository and parsing them during the build, which introduced a build-time dependency on python's `hjson` module and complicated the sandbox environment.
+
+By extracting these constants into `qemu_constants.json` once, we:
+1.  Avoid cluttering the repository with large, unused RTL files.
+2.  Simplify the target build flow, which now only needs to run a simple, dependency-free Python script (`qemu_cfg_gen.py`) to generate the QEMU INI file from the JSON.
+3.  Remove the build-time dependency on python's `hjson` module for target runs (it is only a tool dependency for the dumper now).
diff --git a/third_party/opentitan_rtl/qemu_constants.json b/third_party/opentitan_rtl/qemu_constants.json
new file mode 100644
index 0000000..5e003bd
--- /dev/null
+++ b/third_party/opentitan_rtl/qemu_constants.json
@@ -0,0 +1,184 @@
+{
+  "variant": "eg",
+  "git_version": "d6ec7c9-dirty",
+  "rom_ctrl": {
+    "null": {
+      "nonce": "fee457dee82b6e06",
+      "key": "663c291739ff0e7d644758fee1c58564"
+    }
+  },
+  "otp_ctrl": {
+    "scrmbl_key": "55d70063277642b5309a163990b966cd494444c3bcdf8087b7facd65e9654cd8",
+    "digest_const": "0e95f517cb98955b4d5a89aa9109294a",
+    "digest_iv": "bead91d5fa4e0915",
+    "flash_data_const": "277195fc471e4b26b6641214b61d1b43",
+    "flash_data_iv": "e048b657396b4b83",
+    "flash_addr_const": "d60822e1faec5c7290c7f21f6224f027",
+    "flash_addr_iv": "0b7474d640f8a7f5",
+    "sram_const": "4a22d4b78fe0266fbee3958332f2939b",
+    "sram_iv": "f98c48b1f9377284",
+    "secret0_scramble_key": "3ba121c5e097ddeb7768b4c666e9c3da",
+    "secret1_scramble_key": "effa6d736c5eff49ae7b70f9c46e5a62",
+    "secret2_scramble_key": "85a9e830bc059ba9286d6e2856a05cc3",
+    "inv_default_part_5": "7edc64361898f6ff7023461f42b4a5b3f7f71ea9a9507acf17c456385a48b963a629408e450e8458f3cd636e3c9a1140ae2905da9b31bb7ac60dd16b888838df2737bacf95ed7bf8",
+    "inv_default_part_6": "6969690000000000f254e78568a7f4bb",
+    "inv_default_part_7": "024c5fee0f5c8bb2c3080ad0531facb5370ad4f5b61d71b62203a5595f131d71a060cae9543819be",
+    "inv_default_part_8": "31dc5b08ced196d3b50ff2274384b9dbdd0d4e5d6bd0177ec2dce29f8327c8dbca99e674d3996d4260d2a0790332d0552ab6973f142947235a0c88a3ea33571096c8ddc72428751c29709bbd80960ee0a80ddce593c569c4",
+    "inv_default_part_9": "8a1cd4f5518b4d2a1978b594ed3dcd94671685f41086d5d3f6f8a097aad0585e4d857adfdebd0d2c0ece8ed011c5bad01988d871b8d25f316e627ccc51fc79029f45333ca4988068edfed1b3f0968cd628a94cbb02adbb8c",
+    "inv_default_part_10": "be6f2b4a67801b852a4529345af1cdbf37e97ab260e717e8e3c3363a666c130154d936fd1163e401fadd9f8c0ee9d1a056a2719fc2c345a4873c594f465e723e64ba4e17c79665cccb7943e751f0059633fbb917e41db693"
+  },
+  "lc_ctrl": {
+    "invalid": "5bc66d10208c4fb51b97bbf4d12c378c",
+    "test_unlocked": "cfd6a5a5bf3032db51fa1eb92f6ce10e",
+    "dev": "90f9c5c06f733dc911a321dd4c0ac240",
+    "production": "6d467215dab3f2b54a52e6728678af07",
+    "rma": "18068e344d2eae4d73c8fa54de8aa4a4",
+    "raw_unlock_token": "ea2b3f32cbe77554e43c8ea7ebf197c2"
+  },
+  "keymgr": {
+    "name": "keymgr",
+    "values": {
+      "lfsr_seed": "6ca8a8225c8e1705",
+      "revision_seed": "20bdaf59fe2ae209b8325d2c8c35fc960679b106a87e59e6aeb1b5302d334010",
+      "creator_identity_seed": "70251696fbb4ba169a6cd82fad46a0a5c04009bb934c7ef7b83b6e40610b4309",
+      "owner_int_identity_seed": "fcb9dc54d4276137f9901d09a76aeaa19de5c579fd38bdf38f0c21d6a52226b6",
+      "owner_identity_seed": "a4a7bdb05fe921615bf2ff984540f7d43ece76b4eb13363774ed2ed45545e927",
+      "soft_output_seed": "f6d9e4abac398d42c745eef646c1464dca86dafd7c7c71e6058ddfd871c51cac",
+      "hard_output_seed": "baf4410f06dcc036fcd16fcde97d171891105dd895e3a1d0a19a16d6dbd8b20f",
+      "aes_seed": "c9e662e1e1b4982b3e8eff63890dbeae926fd468a77efde3de5b4caf4776a247",
+      "kmac_seed": "baba4c9908ed16bc5415ec16d28c53551312fcedcf2832a66ceacf8ed4d5b616",
+      "otbn_seed": "177dd43b56fa754e1f53ad658193b563d9bdc6d2aee84852f0cc7371ed3a6faa",
+      "none_seed": "4b2276bec9fc1a7a5722a9aaca4db84a32e5c6985a1a6435118b5f5f3fd3b931",
+      "cdi_seed": "516c144b34c96dfabb6f6e79208a74f35c79f397c4e4c7e22b7581848a90a125"
+    }
+  },
+  "lc_states": {
+    "lc_state": [
+      "ee75b407d2314d2ef84185ac8c990f536071632c086d4c924070be92d2948d6228b2711e9b2d8c4d",
+      "ee75fe0ffe7b6f3ffc5f9ffd9ff96fdb7f736f6c9e6fdcd35277fef2d3bdcd6ffbb2f59fdf3fbedd"
+    ],
+    "lc_trscnt": [
+      "dfb6c45a241f85ce9f42229e8627462fdb02c6701242f14b41891180045c09c26c52744267c04aa055921b9461bb07da",
+      "dfb6f4fabf1fefcebf5ba2ffc677c6afdbabcefeb672f36b4fbdb3988dfe1be67e7e77ca77c76af7ddde3b9e7fbfe7de"
+    ],
+    "soc_dbg": [
+      "440af80d",
+      "6c9ef9cf"
+    ],
+    "ownership": [
+      "3c61398d626885931da660ed2ab18a0f",
+      "bc757dbdeaf9b7d35de7e9efabfbbecf"
+    ]
+  },
+  "top_clocks": {
+    "main": {
+      "frequency": 100000000,
+      "aon": false,
+      "ref": false
+    },
+    "io": {
+      "frequency": 96000000,
+      "aon": false,
+      "ref": false
+    },
+    "usb": {
+      "frequency": 48000000,
+      "aon": false,
+      "ref": false
+    },
+    "aon": {
+      "frequency": 200000,
+      "aon": true,
+      "ref": true
+    }
+  },
+  "sub_clocks": {
+    "io_div2": {
+      "source": "io",
+      "div": 2
+    },
+    "io_div4": {
+      "source": "io",
+      "div": 4
+    },
+    "aes": {
+      "source": "main",
+      "div": 1
+    },
+    "hmac": {
+      "source": "main",
+      "div": 1
+    },
+    "kmac": {
+      "source": "main",
+      "div": 1
+    },
+    "otbn": {
+      "source": "main",
+      "div": 1
+    }
+  },
+  "clock_groups": {
+    "powerup": {
+      "sources": [
+        "io_div4",
+        "aon",
+        "main",
+        "io",
+        "usb",
+        "io_div2"
+      ],
+      "sw_cg": false,
+      "hint": false
+    },
+    "trans": {
+      "sources": [
+        "aes",
+        "hmac",
+        "kmac",
+        "otbn"
+      ],
+      "sw_cg": false,
+      "hint": true
+    },
+    "infra": {
+      "sources": [
+        "io_div4",
+        "main",
+        "usb",
+        "io",
+        "io_div2"
+      ],
+      "sw_cg": false,
+      "hint": false
+    },
+    "secure": {
+      "sources": [
+        "io_div4",
+        "main",
+        "aon"
+      ],
+      "sw_cg": false,
+      "hint": false
+    },
+    "peri": {
+      "sources": [
+        "io_div4",
+        "io_div2",
+        "io",
+        "usb",
+        "aon"
+      ],
+      "sw_cg": true,
+      "hint": false
+    },
+    "timers": {
+      "sources": [
+        "io_div4",
+        "aon"
+      ],
+      "sw_cg": false,
+      "hint": false
+    }
+  }
+}
diff --git a/third_party/qemu/BUILD b/third_party/qemu/BUILD
new file mode 100644
index 0000000..ff21735
--- /dev/null
+++ b/third_party/qemu/BUILD
@@ -0,0 +1,52 @@
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Ported from opentitan/third_party/qemu/BUILD.
+# Uses @openprot_python_deps instead of @ot_python_deps.
+
+load("@openprot_python_deps//:requirements.bzl", "requirement")
+load("@rules_python//python:defs.bzl", "py_binary", "py_library")
+
+package(default_visibility = ["//visibility:public"])
+
+py_binary(
+    name = "cfggen",
+    srcs = ["@qemu_opentitan//:scripts/opentitan/cfggen.py"],
+    deps = [
+        ":ot",
+        requirement("hjson"),
+    ],
+)
+
+py_binary(
+    name = "flashgen",
+    srcs = ["@qemu_opentitan//:scripts/opentitan/flashgen.py"],
+    deps = [
+        ":ot",
+        requirement("pyelftools"),
+    ],
+)
+
+py_binary(
+    name = "otptool",
+    srcs = ["@qemu_opentitan//:scripts/opentitan/otptool.py"],
+    deps = [":ot"],
+)
+
+py_library(
+    name = "ot",
+    srcs = ["@qemu_opentitan//:ot"],
+)
+
+alias(
+    name = "qemu-system-riscv32",
+    actual = "@qemu_opentitan//:build/qemu-system-riscv32",
+)
+
+alias(
+    name = "qemu-img",
+    actual = "@qemu_opentitan//:build/qemu-img",
+)
+
+exports_files(["build_qemu.sh"])
diff --git a/third_party/qemu/BUILD.qemu_opentitan.bazel b/third_party/qemu/BUILD.qemu_opentitan.bazel
new file mode 100644
index 0000000..a55f9a4
--- /dev/null
+++ b/third_party/qemu/BUILD.qemu_opentitan.bazel
@@ -0,0 +1,19 @@
+# Copyright lowRISC contributors (OpenTitan project).
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+
+package(default_visibility = ["//visibility:public"])
+
+exports_files(glob(["**"]))
+
+filegroup(
+    name = "qemu_src",
+    srcs = glob(["**"]),
+)
+
+filegroup(
+    name = "ot",
+    srcs = glob([
+        "python/qemu/ot/**",
+    ]),
+)
diff --git a/third_party/qemu/README.md b/third_party/qemu/README.md
new file mode 100644
index 0000000..bb5c76c
--- /dev/null
+++ b/third_party/qemu/README.md
@@ -0,0 +1,71 @@
+# QEMU
+
+For instructions on setting up QEMU for local development and troubleshooting steps, see the [setup guide](./setup.md).
+
+## Introduction
+
+[QEMU](https://www.qemu.org/) is an open-source project providing fast, functional full-system emulation.
+OpenPRoT uses the lowRISC QEMU fork's `ot-earlgrey` machine to run earlgrey firmware tests without requiring Verilator or physical hardware.
+
+The QEMU binary is downloaded from the lowRISC GitHub releases as a pre-built archive:
+
+| Item | Value |
+|---|---|
+| Tag | `v10.2.0-2026-01-15` |
+| Archive | `qemu-ot-earlgrey-v10.2.0-2026-01-15-x86_64-unknown-linux-gnu.tar.gz` |
+| SHA-256 | `9e97f93b09912c904e84f06571e7b49023ccb405dd3caa232ad1e82a3f7b381c` |
+
+The Bazel targets `//third_party/qemu:cfggen`, `//third_party/qemu:flashgen`, and `//third_party/qemu:otptool` wrap the Python scripts bundled in the release archive.
+
+## Scope
+
+Only the Earlgrey machine (`ot-earlgrey`) is supported.
+Emulation is ongoing and incomplete — many peripherals are not yet fully emulated.
+Before relying on QEMU results, verify that the peripherals your test exercises are supported.
+
+## Key limitations
+
+- **Not cycle-accurate.** `mcycle` and hardware timers are approximate.
+  OpenPRoT uses `icount shift=6` to pace virtual time to wall-clock time.
+- **PMP granularity.** Misaligned PMP regions disable TLB caching, causing slowdowns.
+  Fine-grained PMP regions in earlgrey ROM/ROM_EXT can produce significant slowdowns.
+- **UART CharDev.** UART0 is wired via a Unix socket chardev; the runner opens it for output.
+  UART oversampling is not emulated.
+- **QEMU starts paused** (`-S` flag). The runner must send `cont\n` to the monitor socket
+  before any firmware output appears. Forgetting this produces silent hangs.
+
+## Files
+
+| File | Purpose |
+|---|---|
+| `extensions.bzl` | Bazel module extension — fetches the pinned QEMU release archive or builds from a local source override |
+| `BUILD.qemu_opentitan.bazel` | BUILD file placed inside the fetched QEMU repo |
+| `build_qemu.sh` | Shell script that configures + builds QEMU from a local source checkout |
+| `BUILD` | Bazel targets for `cfggen`, `flashgen`, `otptool`, `qemu-system-riscv32` |
+| `requirements.in` | Unlocked Python deps for cfggen/flashgen |
+| `requirements.txt` | Locked + hashed Python deps (input to `rules_python` pip.parse) |
+
+## Local development override
+
+To iterate on QEMU itself without waiting for a new release archive, pass an `--override_repository`
+flag pointing at your local source checkout:
+
+```sh
+bazelisk build \
+  --override_repository=qemu_opentitan_src=/path/to/your/qemu \
+  @qemu_opentitan//:build/qemu-system-riscv32
+```
+
+The `extensions.bzl` module extension checks for this override; when set it runs `build_qemu.sh`
+against the local tree instead of fetching the pinned archive.
+The resulting binary is used by all `//third_party/qemu:qemu-system-riscv32` consumers in the
+same build, including `ipc_runner_qemu_test`.
+
+For a full walkthrough (configuring build deps, setting `QEMU_CFLAGS`, etc.) see [setup.md](./setup.md).
+
+## Ported from
+
+These files were ported from `opentitan/third_party/qemu/` at the same upstream revision
+as the pinned QEMU tag above. When upgrading the QEMU version, re-port `extensions.bzl`
+(update the tag + sha256), update `requirements.txt` if script deps changed, and re-verify
+`cfggen`/`flashgen`/`otptool` still work against the new scripts.
diff --git a/third_party/qemu/build_qemu.sh b/third_party/qemu/build_qemu.sh
new file mode 100755
index 0000000..16c1234
--- /dev/null
+++ b/third_party/qemu/build_qemu.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+#
+# Copyright lowRISC contributors (OpenTitan project).
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+#
+set -e
+
+if [ "$#" -ne 2 ]; then
+    echo "usage: build_qemu.sh <path to QEMU source> <output archive path>"
+    exit 1
+fi
+
+QEMU_ROOT="$1"
+OUT_ARCHIVE="$(realpath "$2")"
+
+# Go to the QEMU source directory.
+cd "$QEMU_ROOT"
+# Create the build directory if needed.
+mkdir -p build
+cd build
+# Run configure if necessary.
+if [ -f config-host.mak ];
+then
+    echo "Skipping configure, delete $PWD/config-host.mak to reconfigure"
+else
+    config_log="$PWD/qemu_config.log"
+    echo "Configuring QEMU (output log in $config_log)..."
+    if ! "$QEMU_ROOT/configure" \
+        --target-list=riscv32-softmmu \
+        --without-default-features \
+        --enable-tcg \
+        --enable-tools \
+        --enable-trace-backends=log \
+        &> "$config_log" ;
+    then
+        echo "Failed (see $config_log)"
+        exit 1
+    fi
+fi
+
+# Build QEMU
+build_log="$PWD/qemu_build.log"
+echo "Building QEMU (output log in $build_log)..."
+if ! ninja qemu-system-riscv32 &> "$build_log" ; then
+    echo "Failed (see $build_log)"
+    exit 1
+fi
+if ! ninja qemu-img &>> "$build_log" ; then
+    echo "Failed (see $build_log)"
+    exit 1
+fi
+
+# Run the make release script to create the requested archive.
+# Some versions have a bug and except PWD to be the source directory.
+archive_log="$PWD/qemu_archive.log"
+echo "Creating archive (output log in $archive_log)..."
+cd "$QEMU_ROOT"
+if ! "./scripts/opentitan/make_release.sh" \
+        "$OUT_ARCHIVE" . "build" &> "$archive_log" ;
+then
+    echo "Failed (see $archive_log)"
+    exit 1
+fi
diff --git a/third_party/qemu/extensions.bzl b/third_party/qemu/extensions.bzl
new file mode 100644
index 0000000..464119f
--- /dev/null
+++ b/third_party/qemu/extensions.bzl
@@ -0,0 +1,110 @@
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+#
+# Ported from opentitan/third_party/qemu/extensions.bzl.
+# Pinned to QEMU tag v10.2.0-2026-01-15.
+
+load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
+
+_ARCHIVE_MARKER_FILE = ".this.is.the.archive"
+
+def _qemu_bazel_build_or_forward_impl(rctx):
+    qemu_root = rctx.path(rctx.attr.qemu_src).dirname
+
+    # Determine whether the source repo was overridden with a local source directory.
+    if qemu_root.get_child(_ARCHIVE_MARKER_FILE).exists:
+        # Symlink everything to the release directory. Also watch everything.
+        for child in qemu_root.readdir():
+            rctx.symlink(child, child.basename)
+            if not child.is_dir:
+                rctx.watch(child)
+            elif child.is_dir:
+                rctx.watch_tree(child)
+    else:
+        print(
+            "QEMU source archive override detected:",
+            "will be using {} as the source directory".format(qemu_root.realpath),
+        )
+
+        # Sanity checks.
+        configure_script = qemu_root.get_child("configure")
+        if not configure_script.exists:
+            fail("There is no configure script in the source directory, did you override the QEMU source correctly?")
+
+        # Run the build script which will produce a release archive
+        release_archive = "build_archive.tar.gz"
+        rctx.watch(rctx.attr._build_qemu)
+        res = rctx.execute(
+            [
+                rctx.attr._build_qemu,
+                qemu_root.realpath,
+                release_archive,
+            ],
+            quiet = False,
+        )
+        if res.return_code != 0:
+            fail("Failed to build QEMU")
+
+        # Extract archive here.
+        rctx.extract(rctx.path(release_archive))
+
+        # Watch everything in the source directory.
+        for child in qemu_root.readdir():
+            # Skip the build directory because it can cause weird bazel behaviour:
+            # this directory has symlinks and cycles.
+            if not child.is_dir:
+                rctx.watch(child)
+            elif child.is_dir and child.basename != "build":
+                rctx.watch_tree(child)
+
+        # Create a single BUILD file that exports everything.
+        rctx.file("BUILD", rctx.read(rctx.path(rctx.attr._build_file)))
+
+qemu_bazel_build_or_forward = repository_rule(
+    implementation = _qemu_bazel_build_or_forward_impl,
+    attrs = {
+        # This attribute is used to locate the source repository so that we don't have to guess
+        # its name.
+        "qemu_src": attr.label(doc = "This is a label to any file at the root of the QEMU source repository."),
+        "_build_file": attr.label(
+            default = ":BUILD.qemu_opentitan.bazel",
+        ),
+        "_build_qemu": attr.label(
+            default = ":build_qemu.sh",
+            executable = True,
+            cfg = "exec",
+        ),
+    },
+    # Allow the repository to be refetched by running
+    # bazel configure fetch --force
+    configure = True,
+    # Make sure to refetch on restart.
+    local = True,
+)
+
+def _qemu_opentitan_repos():
+    QEMU_VERSION = "v10.2.0-2026-01-15"
+
+    url = "/".join([
+        "https://github.com/lowRISC/qemu/releases/download",
+        QEMU_VERSION,
+        "qemu-ot-earlgrey-{}-x86_64-unknown-linux-gnu.tar.gz".format(QEMU_VERSION),
+    ])
+
+    http_archive(
+        name = "qemu_opentitan_src",
+        url = url,
+        build_file = Label(":BUILD.qemu_opentitan.bazel"),
+        sha256 = "9e97f93b09912c904e84f06571e7b49023ccb405dd3caa232ad1e82a3f7b381c",
+        patch_cmds = ["touch {}".format(_ARCHIVE_MARKER_FILE)],
+    )
+
+    qemu_bazel_build_or_forward(
+        name = "qemu_opentitan",
+        qemu_src = "@qemu_opentitan_src//:qemu_src",
+    )
+
+qemu = module_extension(
+    implementation = lambda _: _qemu_opentitan_repos(),
+)
diff --git a/third_party/qemu/requirements.in b/third_party/qemu/requirements.in
new file mode 100644
index 0000000..ab59f3e
--- /dev/null
+++ b/third_party/qemu/requirements.in
@@ -0,0 +1,4 @@
+# Python dependencies for QEMU tooling scripts (cfggen, flashgen, otptool).
+# cfggen.py requires hjson; flashgen.py requires pyelftools; otptool.py has no deps.
+hjson
+pyelftools==0.29
diff --git a/third_party/qemu/requirements.txt b/third_party/qemu/requirements.txt
new file mode 100644
index 0000000..b29322d
--- /dev/null
+++ b/third_party/qemu/requirements.txt
@@ -0,0 +1,14 @@
+#
+# This file is autogenerated by pip-compile with Python 3.11
+# by the following command:
+#
+#    pip-compile --generate-hashes --output-file=requirements.txt requirements.in
+#
+hjson==3.1.0 \
+    --hash=sha256:55af475a27cf83a7969c808399d7bccdec8fb836a07ddbd574587593b9cdcf75 \
+    --hash=sha256:65713cdcf13214fb554eb8b4ef803419733f4f5e551047c9b711098ab7186b89
+    # via -r requirements.in
+pyelftools==0.29 \
+    --hash=sha256:519f38cf412f073b2d7393aa4682b0190fa901f7c3fa0bff2b82d537690c7fc1 \
+    --hash=sha256:ec761596aafa16e282a31de188737e5485552469ac63b60cfcccf22263fd24ff
+    # via -r requirements.in
diff --git a/third_party/qemu/setup.md b/third_party/qemu/setup.md
new file mode 100644
index 0000000..97ba1f2
--- /dev/null
+++ b/third_party/qemu/setup.md
@@ -0,0 +1,65 @@
+# Setting up QEMU for local OpenPRoT development
+
+By default, the build system downloads a pre-built release of the [lowRISC QEMU fork](https://github.com/lowRISC/qemu/).
+For local development and debugging, you can build QEMU from source instead.
+
+## Building from source using Bazel
+
+Check out the lowRISC QEMU fork and switch to the correct branch:
+
+```bash
+git clone https://github.com/lowRISC/qemu
+cd qemu
+git checkout ot-10.2.0   # branch corresponding to the pinned release
+```
+
+Perform this setup step once at the root of your QEMU checkout:
+
+```bash
+touch REPO.bazel
+ln -s "/path/to/openprot/third_party/qemu/BUILD.qemu_opentitan.bazel" "BUILD.bazel"
+```
+
+Then tell Bazel to use your local checkout instead of the downloaded archive by passing
+`--override_repository` on every Bazel invocation:
+
+```bash
+bazelisk build --override_repository="+qemu+qemu_opentitan_src=/path/to/qemu" \
+    //third_party/qemu:qemu-system-riscv32
+```
+
+To avoid repeating this flag, add it to your `.bazelrc-site` file at the repo root:
+
+```
+common --override_repository=+qemu+qemu_opentitan_src=/path/to/qemu
+```
+
+## Troubleshooting
+
+### Finding the canonical repository name
+
+If Bazel reports that `+qemu+qemu_opentitan_src` is not a valid repository name, run:
+
+```bash
+bazelisk mod dump_repo_mapping "" | jq .qemu_opentitan_src
+```
+
+Use the reported canonical name in `--override_repository`.
+
+### How the override detection works
+
+`extensions.bzl`'s `qemu_bazel_build_or_forward` rule checks for a marker file
+(`.this.is.the.archive`) that is injected into the pre-built release archive.
+If the marker is absent (local checkout), the rule runs `build_qemu.sh` inside your
+source directory, which configures with:
+
+```
+--target-list=riscv32-softmmu
+--without-default-features
+--enable-tcg
+--enable-tools
+--enable-trace-backends=log
+```
+
+These flags are the minimum needed for the `ot-earlgrey` machine.
+Do not add other flags — they either bloat the build or break OT-specific TCG plugins.
diff --git a/workflows.json b/workflows.json
index 5d9ca2a..261827c 100644
--- a/workflows.json
+++ b/workflows.json
@@ -63,8 +63,8 @@
         "build_type": "bazel",
         "args": [
           "--keep_going",
-          "--build_tag_filters=-hardware,-disabled,-verilator,-emulator",
-          "--test_tag_filters=-hardware,-disabled,-verilator,-emulator",
+          "--build_tag_filters=-hardware,-disabled,-verilator,-emulator,-qemu",
+          "--test_tag_filters=-hardware,-disabled,-verilator,-emulator,-qemu",
           "--test_output=streamed",
           "--"
         ],
@@ -121,6 +121,27 @@
       ]
     },
     {
+      "name": "earlgrey_qemu_tests",
+      "build_config": {
+        "name": "earlgrey_qemu_tests_config",
+        "description": "Run Earlgrey QEMU-based tests",
+        "build_type": "bazel",
+        "args": [
+          "--keep_going",
+          "--build_tag_filters=+qemu",
+          "--test_tag_filters=+qemu",
+          "--test_output=streamed"
+        ],
+        "driver_options": {
+          "@type": "pw.build.proto.BazelDriverOptions",
+          "no_test": false
+        }
+      },
+      "targets": [
+        "//target/earlgrey/..."
+      ]
+    },
+    {
       "name": "ast10x0_qemu_tests",
       "build_config": {
         "name": "ast10x0_qemu_tests_config",
@@ -181,10 +202,11 @@
     },
     {
       "name": "ci",
-      "description": "Execute CI tests plus dedicated AST10x0 QEMU coverage",
+      "description": "Execute CI tests plus dedicated AST10x0 and Earlgrey QEMU coverage",
       "builds": [
         "ci_tests",
-        "ast10x0_qemu_tests"
+        "ast10x0_qemu_tests",
+        "earlgrey_qemu_tests"
       ]
     },
     {