summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm
diff options
context:
space:
mode:
authorArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-02 10:58:21 -0400
committerArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-02 10:58:21 -0400
commitf909a5f822c5c4a4e9526b861d4171f8925f5113 (patch)
treeed362021c83569f49b48079a774f05775e84b0bd /debug/risc-v-gsp-rm-vm
parent09f241601423b1ae98e97846ee451768ff8a0a9e (diff)
downloadopen-iov-f909a5f822c5c4a4e9526b861d4171f8925f5113.tar.gz
open-iov-f909a5f822c5c4a4e9526b861d4171f8925f5113.zip
debug: green-eye wave 3 - RM harness, local simulator, device map, MCP plugin, value-validation demos, CI pipeline, toolchain docs; add host RM tools rmtool/rmtop
Diffstat (limited to 'debug/risc-v-gsp-rm-vm')
-rw-r--r--debug/risc-v-gsp-rm-vm/README.md26
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md25
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md3
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md6
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md4
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md9
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/14-ADVERSARIAL-INTEROP-GUIDE.md7
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/16-CUSTOM-SIMULATOR.md156
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md164
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/18-HARNESS-AUTOMATION.md284
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/19-CONTROL-DISCOVERY.md445
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/20-MCP-SERVER.md277
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md320
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/22-PORTING-CUDA-AND-DEVICES.md353
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/23-RM610-READINESS.md259
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/24-DEVICE-MAP.md207
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/25-VALUE-VALIDATION.md337
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/26-ARCHITECTURE.md449
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/27-TOOL-AUDIT.md252
-rw-r--r--debug/risc-v-gsp-rm-vm/examples/scripts/sim_bench.py75
-rw-r--r--debug/risc-v-gsp-rm-vm/examples/scripts/sim_diff.py104
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/__init__.py41
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/analyze.py157
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/cli.py126
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/device_map.py555
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/ghidra.py2
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/session.py2
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/sim_backend.py390
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/symbols.py17
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/__init__.py11
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/ci_release.py444
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/common.py218
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/control_discovery.py1055
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/mutation_harness.py586
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/portability_matrix.py779
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/reach.py946
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/README.md83
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/__init__.py1
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/_common.py196
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/run_all.py123
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/s01_reply_consumer.py151
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/s02_counter_publication.py126
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/s03_unknown_command.py167
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/s04_run_until_function.py143
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/s05_snapshot_diff.py143
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/s06_inject_service.py124
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/s07_version_diff.py179
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/s08_mutation_triage.py161
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/s09_grabbing_a_ghidra_address.py146
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/value_demos/s10_full_session_evidence.py213
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/version_diff.py1509
-rw-r--r--debug/risc-v-gsp-rm-vm/plugins/README.md6
-rw-r--r--debug/risc-v-gsp-rm-vm/plugins/mcp_server.py1155
-rw-r--r--debug/risc-v-gsp-rm-vm/plugins/rmharness_plugin.py120
-rw-r--r--debug/risc-v-gsp-rm-vm/rmharness/README.md117
-rwxr-xr-xdebug/risc-v-gsp-rm-vm/rmharness/harness.sh147
-rwxr-xr-xdebug/risc-v-gsp-rm-vm/rmharness/rmq.sh21
-rwxr-xr-xdebug/risc-v-gsp-rm-vm/rmharness/rmtool.sh11
-rwxr-xr-xdebug/risc-v-gsp-rm-vm/rmharness/rmtop.sh11
-rw-r--r--debug/risc-v-gsp-rm-vm/rmharness/tests/test_rmharness_plugin.py95
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/__init__.py43
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/decode.py399
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/machine.py825
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/trace.py145
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_bridge.py9
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_core.py42
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_device_map.py325
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_discovery.py214
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_harness.py144
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_mcp.py431
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_sim_backend.py244
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_sim_trace.py224
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_value_demos.py107
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_version_diff.py333
74 files changed, 17676 insertions, 48 deletions
diff --git a/debug/risc-v-gsp-rm-vm/README.md b/debug/risc-v-gsp-rm-vm/README.md
index 4017cd4..eab691f 100644
--- a/debug/risc-v-gsp-rm-vm/README.md
+++ b/debug/risc-v-gsp-rm-vm/README.md
@@ -10,6 +10,10 @@ green-eye boots GSP/uproc firmware in `qemu-system-riscv64`, attaches over
QEMU's gdbstub with a GDB-free Python client, and exposes Frida-like
instrumentation: hooks, breakpoints, watchpoints, memory dumping, structure
decoding, snapshot diffing, session recording, plugins and a Ghidra bridge.
+An optional deterministic native RV64IMAC backend (M1) runs the same Session
+in-process: `--backend native` or `ge.launch(..., backend="native")`, with an
+in-process exec/access trace (M1.5/R4) written as a session blob and rendered
+by `analyze` (`--trace`, `--trace-start/--trace-end`, `--trace-exec on|off`).
Start with [`docs/00-OVERVIEW.md`](docs/00-OVERVIEW.md).
@@ -20,6 +24,8 @@ cd open-iov/debug/risc-v-gsp-rm-vm
make demo # build the bundled RV64 uproc demo (clang + lld)
./greeneye.sh demo # boot it, hook handlers, watch counters, record + analyze
+./greeneye.sh demo --backend native # same run on the deterministic native sim
+./greeneye.sh record --backend native --trace --duration 0.5 # exec/access trace
./greeneye.sh info # environment / tooling check
```
@@ -49,12 +55,17 @@ Everything else is Python stdlib.
```
greeneye/ Python package: RSP client, session, memory, symbols,
structures, recording, analysis, probes, plugins, Ghidra bridge
+sim/ deterministic native RV64IMAC simulator (M1: decode, RAM,
+ UART; M1.5: exec/access trace in sim/trace.py)
firmware/uproc_demo/RV64 bare-metal demo "uproc" (mailbox RPC, state machine, trap)
sdk/ injected-service SDK (position-independent RV64 blobs)
qemu/ launch helper + optional TCG plugin for full memory tracing
ghidra/ Ghidra scripts (export symbols, annotate hits, control bridge)
-plugins/ example plugin + authoring guide
+plugins/ example plugin, MCP server, RM-harness plugin + authoring guide
examples/ Python instrumentation scripts and declarative probe specs
+harness/ automation harness (mutation, portability, version diff,
+ control discovery/reachability, value-validation demos)
+rmharness/ host RM oracle wrappers + read-only capture harness
tests/ unit tests + QEMU integration smoke tests
docs/ full documentation set (design, scripting, plugins, accuracy,
portability playbooks, roadmap, service/injection guides)
@@ -81,5 +92,16 @@ dumps/ recorded sessions (gitignored)
| [13-GHIDRA-CONTROL](docs/13-GHIDRA-CONTROL.md) | driving green-eye from GhidraScripts |
| [14-ADVERSARIAL-INTEROP-GUIDE](docs/14-ADVERSARIAL-INTEROP-GUIDE.md) | navigating unpredictable/adversarial firmware change |
| [15-AGENTIC-GSP-IDE](docs/15-AGENTIC-GSP-IDE.md) | agentic workflows and runbooks |
-| [16-CUSTOM-SIMULATOR](docs/16-CUSTOM-SIMULATOR.md) | non-QEMU simulator design (far later) |
+| [16-CUSTOM-SIMULATOR](docs/16-CUSTOM-SIMULATOR.md) | non-QEMU simulator design; M1 native backend + M1.5 trace status/usage |
+| [17-RM-HARNESS](docs/17-RM-HARNESS.md) | host RM oracle harness (`rmharness`, wrappers, capture) |
+| [18-HARNESS-AUTOMATION](docs/18-HARNESS-AUTOMATION.md) | mutation/portability automation harness |
+| [19-CONTROL-DISCOVERY](docs/19-CONTROL-DISCOVERY.md) | unknown-call discovery and reachability |
+| [20-MCP-SERVER](docs/20-MCP-SERVER.md) | bundled MCP server plugin |
+| [21-CI-RELEASE-PIPELINE](docs/21-CI-RELEASE-PIPELINE.md) | release triage pipeline (`harness/ci_release.py`) |
+| [22-PORTING-CUDA-AND-DEVICES](docs/22-PORTING-CUDA-AND-DEVICES.md) | portability playbook for new CUDA/devices |
+| [23-RM610-READINESS](docs/23-RM610-READINESS.md) | RM 610 readiness checklist |
+| [24-DEVICE-MAP](docs/24-DEVICE-MAP.md) | versioned first-touch MMIO device map |
+| [25-VALUE-VALIDATION](docs/25-VALUE-VALIDATION.md) | value-validation demos (s01–s10) |
+| [26-ARCHITECTURE](docs/26-ARCHITECTURE.md) | full architecture/API surface map |
+| [27-TOOL-AUDIT](docs/27-TOOL-AUDIT.md) | tool audit: purpose, tests, usage, status |
| [GREEN-EYE-ROADMAP](GREEN-EYE-ROADMAP.md) | the long-term program (H1–H7) |
diff --git a/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md b/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md
index f1e1946..3b56512 100644
--- a/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md
+++ b/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md
@@ -42,11 +42,17 @@ whole stack scriptable, and QEMU is just the first execution backend.
| Session recording + `analyze` report | working, live-verified |
| Declarative probe specs | working, live-verified |
| Plugin loader + example plugin | working |
-| Test suite | 22 tests green (live QEMU smoke included) |
+| Native RV64IMAC simulator (M1, `--backend native`) | working; QEMU-parity differential test |
+| Native exec/access trace (M1.5, R4): `--trace`, analyzer coverage | working; trace blob + determinism test |
+| Test suite | 155 tests green (live QEMU + native differential included) |
| Ghidra bridge | scripts + docs |
| Real GSP `.fwimage` probe (first-fault report) | working (honest: it does not boot without a TU104 model — see 06) |
-| TCG full-memory tracing plugin | source + optional build |
-| MCP server / custom simulator / full device model | roadmap only (08) |
+| TCG full-memory tracing plugin | source + optional build (verified live) |
+| Automation harness (mutation/portability/version diff) + value demos | working (18/19/21/25) |
+| Host RM oracle harness (`rmharness`) | wrappers + capture working; device tables depend on driver permissions (17) |
+| First-touch device map + lenient native MMIO | working (24) |
+| MCP server (`plugins/mcp_server.py`) | working (20); stdlib-only, read-only by default |
+| Native M2+ device model | roadmap only (08, 16) |
## Quick start
@@ -84,7 +90,18 @@ Interactively:
| [13-GHIDRA-CONTROL](13-GHIDRA-CONTROL.md) | driving green-eye from Ghidra |
| [14-ADVERSARIAL-INTEROP-GUIDE](14-ADVERSARIAL-INTEROP-GUIDE.md) | adversarial firmware navigation |
| [15-AGENTIC-GSP-IDE](15-AGENTIC-GSP-IDE.md) | agentic workflows and runbooks |
-| [16-CUSTOM-SIMULATOR](16-CUSTOM-SIMULATOR.md) | non-QEMU simulator design (later) |
+| [16-CUSTOM-SIMULATOR](16-CUSTOM-SIMULATOR.md) | native simulator (M1/M1.5 implemented; M2+ design) |
+| [17-RM-HARNESS](17-RM-HARNESS.md) | host RM oracle harness (`rmharness`) |
+| [18-HARNESS-AUTOMATION](18-HARNESS-AUTOMATION.md) | mutation/portability automation |
+| [19-CONTROL-DISCOVERY](19-CONTROL-DISCOVERY.md) | unknown-call discovery and reachability |
+| [20-MCP-SERVER](20-MCP-SERVER.md) | bundled MCP server plugin |
+| [21-CI-RELEASE-PIPELINE](21-CI-RELEASE-PIPELINE.md) | release triage pipeline |
+| [22-PORTING-CUDA-AND-DEVICES](22-PORTING-CUDA-AND-DEVICES.md) | porting playbook |
+| [23-RM610-READINESS](23-RM610-READINESS.md) | RM 610 readiness checklist |
+| [24-DEVICE-MAP](24-DEVICE-MAP.md) | first-touch MMIO device map |
+| [25-VALUE-VALIDATION](25-VALUE-VALIDATION.md) | value-validation demos s01–s10 |
+| [26-ARCHITECTURE](26-ARCHITECTURE.md) | full architecture and API surface map |
+| [27-TOOL-AUDIT](27-TOOL-AUDIT.md) | tool audit: purpose, tests, usage, status |
| [GREEN-EYE-ROADMAP](../GREEN-EYE-ROADMAP.md) | long-term program H1–H7 |
## Where this fits in the Open-IOV workflow
diff --git a/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md b/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md
index c677c8d..89c61c4 100644
--- a/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md
+++ b/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md
@@ -4,7 +4,8 @@
green-eye scripts are ordinary Python programs run with
`./greeneye.sh run <script.py>` (or `python3 -m greeneye run …`). The
-names `ge`, `session`/`s`, `memory`, `symbols`, `recorder` are preloaded.
+names `ge`, `session`/`s`, `memory`, `symbols`, `recorder` are preloaded, and
+`__file__` points at the script path (use it to locate bundled assets).
## 1. Attach or launch
diff --git a/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md b/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md
index fa0fe0c..db9ee27 100644
--- a/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md
+++ b/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md
@@ -82,9 +82,9 @@ changes behind plugin-level flags and document them in the session metadata
(`s.recorder.meta`).
**(d) AI/harness integration** — export sessions/reports to external tools,
-generate probe specs, score runs. **MCP servers are planned as a plugin**
-(`docs/08-ROADMAP.md`), not part of v0.1; do not add MCP dependencies to the
-core package before that rung is scheduled.
+generate probe specs, score runs. The **MCP server is implemented as a bundled
+plugin** (`../plugins/mcp_server.py`, see [20-MCP-SERVER](20-MCP-SERVER.md)):
+stdlib-only, read-only by default, no dependency added to the core package.
## 4. Example plugin
diff --git a/debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md b/debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md
index 857102e..f7f46c3 100644
--- a/debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md
+++ b/debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md
@@ -83,6 +83,10 @@ $GHIDRA/support/analyzeHeadless <proj_dir> <project> -process <program> -noanaly
`ExportForGreenEye.java` is the reverse helper: it walks the current program
and writes a green-eye-compatible `addr<TAB>name<TAB>size` TSV.
+`greeneye.ghidra.render_ghidra_command(proj_dir, project, program, hits_tsv)`
+renders the `analyzeHeadless … MarkGreenEyeHits.java` command line above for
+scripts (the helper is not wired into the CLI).
+
## 5. Breakpoints from Ghidra
Two practical routes today:
diff --git a/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md b/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md
index 10f4f5f..5d8a9b1 100644
--- a/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md
+++ b/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md
@@ -68,6 +68,10 @@ only after the guest-bridge work (PLAN B) no longer depends on it.
## R3. MCP server plugin
+**Status: implemented.** The bundled plugin is `plugins/mcp_server.py`; see
+[20-MCP-SERVER](20-MCP-SERVER.md) for the live tool list, transports and
+capability flags. The text below is the original design sketch.
+
**Why:** expose green-eye sessions to AI harnesses and other agents under the
plugin model the user requested.
@@ -78,8 +82,9 @@ session/analysis API as MCP tools: `list_sessions`, `open_session`,
no network exposure by default. The plugin depends on an MCP library only at
plugin load time; the core stays stdlib-only.
-**Gate:** v0.1 must be stable (acceptance list in 00) and the session format
-frozen. Explicitly deferred by the owner; do not add MCP code before then.
+**Gate:** v0.1 stability and the session format. Implemented stdlib-only
+(no MCP library), stdio/loopback only, read-only unless `--allow-write` /
+`--allow-inject` are passed.
## R4. Full-memory trace (TCG plugin integration)
diff --git a/debug/risc-v-gsp-rm-vm/docs/14-ADVERSARIAL-INTEROP-GUIDE.md b/debug/risc-v-gsp-rm-vm/docs/14-ADVERSARIAL-INTEROP-GUIDE.md
index d310f22..fb78265 100644
--- a/debug/risc-v-gsp-rm-vm/docs/14-ADVERSARIAL-INTEROP-GUIDE.md
+++ b/debug/risc-v-gsp-rm-vm/docs/14-ADVERSARIAL-INTEROP-GUIDE.md
@@ -19,8 +19,11 @@ surface, plus items explicitly labelled roadmap; see also docs/06-ACCURACY.md.
|---|---|
| `probe`/`record`/`analyze`/`compare`/`symbols`/`inject`/`ghidra`, hooks, watchpoints, snapshots, sessions, `export_hits` | working (v0.1) |
| Ghidra control bridge (`run_until`, breakpoints, hits) | working (v0.1) |
-| Full TCG access trace (`qemu/greeneye_trace.c`) | source + optional build; not folded into sessions yet (R4) |
-| Mutation harness (AFL++ class), device model, MCP | roadmap (R5, H2/R1, H7/R3) |
+| Full TCG access trace (`qemu/greeneye_trace.c`) | source + optional build (verified live); native equivalent folds into sessions (`--trace`, R4); direct TCG JSONL import is still roadmap |
+| Mutation harness, control discovery/reach, portability/version diff | working (docs/18, 19, 21, 22) |
+| First-touch device map + lenient native MMIO (H2.1) | working (docs/24) |
+| MCP server (`plugins/mcp_server.py`, H7/R3) | working (docs/20) |
+| Full device model (H2.2) | roadmap (docs/16, 24) |
## 2. The adversary model (a moving target, not a malicious party)
diff --git a/debug/risc-v-gsp-rm-vm/docs/16-CUSTOM-SIMULATOR.md b/debug/risc-v-gsp-rm-vm/docs/16-CUSTOM-SIMULATOR.md
index 2b77ca3..2f99f7b 100644
--- a/debug/risc-v-gsp-rm-vm/docs/16-CUSTOM-SIMULATOR.md
+++ b/debug/risc-v-gsp-rm-vm/docs/16-CUSTOM-SIMULATOR.md
@@ -9,10 +9,116 @@ backend: a small deterministic RISC-V machine (`libgreenmachine`) implementing
the same `Session` contract as today's QEMU backend. It expands
`GREEN-EYE-ROADMAP.md` §H3 (H3.1–H3.4) and the §H6 soft-processor annex.
-**Nothing here exists today.** The only supported backend is `QemuTarget` +
-`RspClient` (`greeneye/target.py`, `greeneye/rsp.py`); native is gated behind
-H3 entry — H2.2, the QEMU device model that makes the real GSP image reach its
-message loop — and must not be started ad hoc. This document only scopes it.
+**Beyond M1.5 (§1.1), nothing here exists yet.** The only production backend is
+`QemuTarget` + `RspClient` (`greeneye/target.py`, `greeneye/rsp.py`); M2+ of
+the native backend is gated behind H3 entry — H2.2, the QEMU device model that
+makes the real GSP image reach its message loop — and must not be started ad
+hoc. This document scopes it.
+
+### 1.1 M1 status (implemented in this tree)
+
+M1 was explicitly commissioned and is implemented behind `--backend native`;
+the status marker above still applies to the remaining milestones (M2–M6) and
+to any use on real GSP images.
+
+* **What exists.** A pure-Python, stdlib-only RV64IMAC interpreter under
+ `sim/` (`sim/decode.py` decoder, `sim/machine.py` 256 MiB RAM + NS16550A
+ UART + execution loop) with an `RspClient`-shaped adapter in
+ `greeneye/sim_backend.py` (`SimClient`, `SimTarget`, `launch_native`).
+ `greeneye.session.Session` runs unchanged on it: registers by name, memory,
+ SW/HW breakpoints, watchpoints, step/continue/interrupt, `run_until`/`call`.
+* **Firmware parity.** The bundled `uproc_demo` boots unmodified; a scripted
+ hook/watch/leave scenario produces an event-for-event identical profile on
+ QEMU and native (`tests/test_sim_backend.py::DifferentialTests`), including
+ handler arguments and return values. Watchpoints stop *before* the accessing
+ instruction, which reproduces QEMU's `watch` stop and the Session
+ clear/step/re-arm dance exactly.
+* **Deviations from this design.** M1 here is the *reference* interpreter, not
+ the Rust product core of §12; no MMU, FPU, GC, snapshots/moments, map DSL,
+ scheduler, event bus or injection ABI (those are M2–M5). There is no
+ self-modifying code: the per-PC decode cache is invalidated by debugger
+ writes only.
+* **Time model.** Virtual time is instruction count: `continue_(timeout)`
+ executes at most `timeout * 1_000_000` instructions (SIM_IPS), then reports
+ a budget timeout. `Session.run(duration=...)` therefore bounds native runs
+ by virtual time, deterministically; QEMU's wall-clock execution naturally
+ reaches different heartbeat counts in the same duration (see §14).
+* **Measured performance** (one host thread, CPython 3.12, this machine):
+ ~2.3 MIPS tight RAM loop, ~1.5 MIPS sustained demo workload
+ (`examples/scripts/sim_bench.py`), ~1.25 MIPS with a hook + watchpoint
+ active. The ≥10 MIPS M1 target in §14 is a compiled-core target and is not
+ met by the Python reference; the demo self-test completes in ~2k
+ instructions, so the parity suite still runs in seconds.
+* **Stop semantics.** `ebreak` (the demo's `rpc_fault`) stops *at* the trap as
+ a SIGTRAP like the gdbstub, and `step` executes over it; `wfi`, `fence` and
+ `fence.i` are no-ops (single hart, no ordering model); unmapped accesses and
+ illegal instructions stop as SIGSEGV/SIGILL stops instead of raising.
+
+### 1.1a M1.5 status - native exec/access trace (implemented in this tree)
+
+The roadmap R4 full-trace path now exists natively, without the QEMU TCG
+plugin and without leaving the process:
+
+* **Engine.** `sim/trace.py` (`Trace`, `make_trace`) collects compact JSONL
+ lines from the execution loop in `sim/machine.py`:
+ `{"t":"exec","vcpu":0,"pc":"0x...","vt":N}` and
+ `{"t":"read"|"write","vcpu":0,"addr":"0x...","size":S,"pc":"0x...","vt":N}`,
+ ending in the plugin's `{"t":"summary",...}` line. `vt` is the machine's
+ deterministic instruction count; nothing reads the host clock.
+* **Knobs (same semantics as `qemu/greeneye_trace.c`).** `exec=on|off`
+ (default on in the CLI), `start=`/`end=` inclusive PC filters applied to the
+ executing instruction - so memory accesses are filtered by *instruction* PC
+ exactly as the plugin filters at translation time - and a ring budget
+ `max_events` after which lines are counted as `dropped` (default 1,000,000,
+ `0` = unlimited).
+* **Delivery.** The trace is written at Session close as the session blob
+ `blobs/trace.jsonl` (referenced from `session.json` as `trace_blob`, with
+ `trace` config/counts in the metadata), so it never swamps `events.jsonl`;
+ folded `exec`/`access`/`read`/`write` events in `events.jsonl` are also
+ accepted by the analyzer.
+* **Analyzer.** `greeneye analyze` renders an `## Execution trace` section:
+ exec/read/write totals, unique PCs grouped by symbol, and accessed 4 KiB
+ pages with read/write counts. `greeneye compare` adds a trace-stream totals
+ delta and includes trace writes in the written-page delta.
+* **API.** `ge.launch(..., backend="native", trace=...)` and
+ `launch_native(trace=...)` take `True`, a dict
+ `{"exec", "start", "end", "max_events"}`, or a `sim.trace.Trace`.
+* **Measured cost.** On the tight one-instruction loop microbenchmark the
+ always-present `if trace` guard costs ~7% versus an unguarded copy; on the
+ demo workload (best-of runs) untraced throughput is unchanged within noise
+ (1.53 MIPS before, 1.55 MIPS after). Tracing itself costs ~2.4x on the demo
+ workload and ~3x on the microbenchmark. `examples/scripts/sim_bench.py
+ --trace` reports both.
+
+### 1.2 Usage
+
+```bash
+./greeneye.sh demo --backend native # the canned demo, native backend
+./greeneye.sh run examples/scripts/trace_rpc.py --backend native
+printf 'bp rpc_sum\nc 0.2\nquit\n' | ./greeneye.sh attach --backend native
+./greeneye.sh record --backend native --trace --duration 0.5
+./greeneye.sh record --backend native --trace --trace-exec off \
+ --trace-start 0x80000000 --trace-end 0x80010000
+./greeneye.sh analyze dumps/<stamp>-record # renders the trace coverage section
+python3 examples/scripts/sim_bench.py --trace # throughput + trace overhead
+python3 examples/scripts/sim_diff.py # QEMU/native profile diff
+```
+
+```python
+import greeneye as ge
+s = ge.launch(kernel="firmware/uproc_demo/build/uproc_demo.elf", backend="native",
+ record="dumps/traced", trace={"exec": True, "start": 0x80000000})
+s.hook("rpc_sum", on_enter=lambda hit: print(hit.arg(0), hit.arg(1)))
+s.watch(s.symbols.lookup("gsp_ctrl") + 0x10, size=8, kind="w")
+s.run(duration=1.0)
+s.close() # writes blobs/trace.jsonl
+```
+
+`--backend` is accepted by `demo`, `run`, `attach`, `record`, `inject` and
+`ghidra`; the default stays `qemu` and the QEMU path is behaviourally
+unchanged. `attach --port` stays QEMU-only (there is no server to attach to),
+and `--trace` is native-only (QEMU uses the TCG plugin). Everything below
+remains the design for M2+.
## 2. Why a custom simulator
@@ -147,6 +253,13 @@ class Backend(Protocol):
Compatibility decisions:
+* **M1 implementation note.** Instead of the `Backend` protocol and typed
+ `Session(backend=...)` sketched below, M1 implements the narrower, already
+ exercised `RspClient` method surface (`greeneye/sim_backend.py`) and leaves
+ `Session` untouched. The `Backend` refactor is deferred until a second
+ non-RSP backend (e.g. Verilator, §16) actually needs it; §5's table is
+ still the M2+ target.
+
* `Session(client=...)` keeps working; `Session(backend=...)` is added and
`client=` becomes an alias for `RspBackend(client)`. `Memory` is re-typed
against `Backend`; its public methods do not change.
@@ -291,16 +404,17 @@ single producer for stop-based and full-access events:
|---|---|---|
| `session_start`, `image_loaded`, `hook`, `watch`, `note`, `exit`, `session_end` | existing | unchanged |
| `bp_hit`, `leave`, `watch_hit`, `stop`, `step`, `mem_read`, `mem_write`, `snapshot*`, `probe*`, `plugin*` | existing | unchanged; `mem_*` may now come from the bus, not only tooling |
-| `exec` | new (M1, optional) | `pc`, `insn`, `vt`, `core`, `symbol` |
-| `access` | new (M2) | `pc`, `addr`, `size`, `rw`, `region`, `vt`, `core` |
+| `exec` | native M1.5 (blob) | `pc`, `vt` (+ `symbol` when folded into an event) |
+| `access` | native M1.5 (`read`/`write` lines or folded events) | `pc`, `addr`, `size`, `vt`; `rw` only for the folded `access` form |
| `device` | new (M2) | `device`, `name`, `rw`, `value`, `vt` |
| `irq` / `schedule` | new (M3) | `line`, `state`, `core`, `vt`, `reason` |
| `inject_*` | existing shape (docs/12) | `service_injected`, `call_enter`, `call_return` unchanged |
`t` stays the recorder's monotonic seconds **filled from virtual time**, so
the same run yields the same file. `exec`/`access` volume is controlled by
-profile logging and a ring budget; the analyzer gains coverage/heatmap
-rendering (roadmap R4). `greeneye compare` treats backend identity, `t` and
+profile logging and a ring budget; for the native backend that is implemented
+(M1.5, §1.1a): the stream is a session blob and the analyzer renders coverage
+and accessed pages. `greeneye compare` treats backend identity, `t` and
host paths as non-semantic, so cross-backend comparison is first-class.
## 11. Injection ABI
@@ -352,12 +466,20 @@ familiar with the codebase; milestones are sequential.
| # | Scope | Exit criteria | Effort |
|---|---|---|---|
| M1 | RV64IMAC interpreter, RAM, NS16550A-compatible UART, loader; `Session` adapter with `Backend` | bundled demo uproc boots unmodified; the canned instrumentation script (`03 §12`) produces the same hook/watch/stop timeline and memory snapshots as QEMU (canonical comparison); determinism test green | 4–6 wk |
+| M1.5 | Native exec/access trace (R4 equivalent): `--trace`, filters, blob + analyzer coverage | trace determinism test green; analyzer renders unique PCs/pages; non-trace path unregressed (`sim_bench.py --trace`) | 1–2 wk |
| M2 | Memory-map DSL + `falcon`/`csb`/`ptimer` devices + profile validation; native region snapshots; moments v1 | a re-hosted uproc build boots from a profile with no core changes; moment save/restore round-trips; restoring reproduces the event-stream hash | 4–6 wk |
| M3 | Deterministic scheduler (budgets, timers, IRQ events) + event log/replay | multi-task test profile replays identically from the log on two hosts; tie-break and replay tests in CI | 3–4 wk |
| M4 | Device-model parity with H2.2 + `dma`/`pmu_stub` + boot shims | real GSP image reaches the same stable state as the QEMU H2 exit under the same profile; differential suite passes for probe and boot fixtures; L3 parity documented, L2 still not claimed | 6–10 wk |
| M5 | Injection ABI + service registry + `log`/`stub`/`forward` interception | an SDK v1 blob loads and serves a control end-to-end; each interception mode has a recorded acceptance session; fuzzing seed/restore demonstrated | 4–6 wk |
| M6 | Performance/ergonomics: profiling, fast paths, `--backend native`, docs + flag-removal criteria | §14 targets met; `greeneye demo --backend native` in the differential CI job; user docs updated | 3–4 wk |
+> **M1 status: done (pure-Python reference implementation).** Ported without
+> the `Backend` refactor (see §5 note); differential parity and a determinism
+> test are in `tests/test_sim_backend.py`; performance is §1.1. **M1.5 (native
+> trace) is done** in §1.1a: `sim/trace.py`, `tests/test_sim_trace.py`,
+> analyzer coverage rendering and `examples/scripts/sim_diff.py`. M2–M6 above
+> are unchanged.
+
Total: roughly 24–36 engineering-weeks, consistent with the roadmap's
"months; explicitly later" classification; none of it starts before the H3
entry gate, and if H2 slips this plan waits.
@@ -389,6 +511,24 @@ harness, trended in CI):
| MMIO access vs RAM access | ≤5× | M2 |
| Event bus throughput | ≥1 M events/s buffered, lossless under budget | M3 |
+> **M1 measured (pure-Python reference).** 2.3 MIPS tight RAM loop, 1.5 MIPS
+> sustained demo workload, 1.25 MIPS with a hook + watchpoint active
+> (`examples/scripts/sim_bench.py`, CPython 3.12). The ≥10 MIPS M1 row is a
+> compiled-core gate and remains open for the Rust core; the Python reference
+> exists to pin semantics and differentially test the firmware, which it does
+> at parity. QEMU executes comparatively more instructions per wall second on
+> this host, so equal wall-clock `run` durations produce different heartbeat
+> counts across backends — the hook/watch/leave event profile is identical.
+>
+> **M1.5 trace overhead (same host).** The tracing guard is a single local
+> boolean test per instruction plus one per memory access; A/B against an
+> otherwise identical loop copy: ~2.4 MIPS vs ~2.6 MIPS on a pathological
+> one-instruction loop (~7%), while the demo workload is unchanged within
+> run-to-run noise (1.53 vs 1.55 MIPS best-of). With `exec=on` the demo
+> workload runs at ~0.54 MIPS (~2.4x slower); an access-only trace is
+> proportionally cheaper. Use `--trace-start/--trace-end` to bound the logged
+> region, and the default 1M-event ring budget to bound memory.
+
## 15. CI strategy
* The Python package and QEMU path remain the default, always-on suite; no
diff --git a/debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md b/debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md
new file mode 100644
index 0000000..59db437
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md
@@ -0,0 +1,164 @@
+# 17 — RM host oracle harness (`rmharness`)
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+> The RM tools are read-only host diagnostics. They are development tooling:
+> never shipped, never present in a guest, never a driver dependency.
+
+## 1. Purpose
+
+The GSP emulation can only be trusted against ground truth. Two of the
+metrics in [06-ACCURACY](06-ACCURACY.md) need the real host RM as a reference:
+
+* **M1 — inventory coverage**: which controls/engines the real RM exposes;
+* **M6 — memory side-effects / fixed points**: real VRAM/heap/BAR1 numbers
+ and engine inventory at a moment in time.
+
+`rmharness` packages the host-side oracle tools and wires their output into
+green-eye sessions, so a simulator/emulation session and a host RM capture
+can be recorded, diffed and cited together. It is the host half of the
+accuracy loop (H4.5), not a boot path or a replacement for PLAN B traces.
+
+## 2. Tool inventory
+
+| tool | source | role in the loop |
+|---|---|---|
+| `rmtool` | `tools/rmtool/` (new) | CLI oracle: `gpu list/topology/info`, `engine list/info`, `mem stats`, `nv2080 class list`, allowlisted `nv2080 method call`, `sample timer`; `--format=table\|json\|csv` |
+| `rmtop` | `tools/rmtop/` (new) | ncurses dashboard over the same query layer; `--once` renders table/json/csv for capture |
+| `rmq` | `tools/rmq/` (pre-existing, unmodified) | FIFO/INTERNAL device info tables for TU104 static data |
+
+Both new tools share one ioctl layer (`tools/rmtool/src/rm_api.c`,
+`query.c`): version check, `NV01_ROOT_CLIENT` -> `NV01_DEVICE_0` ->
+`NV20_SUBDEVICE_0` alloc ladder, and `NV_ESC_RM_CONTROL` queries. The
+device-open protocol follows what `nvidia-smi` does: open
+`/dev/nvidia<N>`, `NV_ESC_WAIT_OPEN_COMPLETE`, then allocate on
+`/dev/nvidiactl`. The ABI structs match the running driver (the in-tree
+userspace SDK snapshot is older and its 40-byte `NVOS64` is rejected).
+
+## 3. Build
+
+```bash
+make -C tools/rmtool # builds rmtool + lib/librmapi.a
+make -C tools/rmtop # builds rmtop (ncursesw)
+make -C tools/rmq # unchanged, still builds
+```
+
+Requirements: gcc, ncursesw for rmtop. No external SDK, NVML or
+libnvidia-ml linkage. `make clean` removes all artifacts.
+
+## 4. Running
+
+```bash
+tools/rmtool/rmtool --help
+tools/rmtool/rmtool gpu list --format=json
+tools/rmtool/rmtool gpu info -d 0
+tools/rmtool/rmtool engine list
+tools/rmtool/rmtool mem stats
+tools/rmtool/rmtool sample timer --interval=100ms --count=20
+tools/rmtop/rmtop --once
+tools/rmtop/rmtop -g 0 --interval=250 # interactive (needs a TTY)
+```
+
+`gpu list`/`gpu topology` need no device access (they read
+`/proc/driver/nvidia/gpus` and PCI sysfs). Everything else opens a
+short-lived RM client and issues read-only controls; this is the same
+access model as `nvidia-smi` and is safe alongside a running CUDA job.
+
+`--device-spec`/`-d` accepts a minor (`0`) or a bus id (`0000:01:00.0`) and
+maps to the RM `deviceId`; `--device PATH` overrides the `/dev/nvidiaN`
+node.
+
+## 5. green-eye integration
+
+Wrappers `rmharness/rmtool.sh`, `rmharness/rmtop.sh`, `rmharness/rmq.sh`
+locate and build the tools and pass arguments through. `rmharness/harness.sh`
+adds capture:
+
+```bash
+cd debug/risc-v-gsp-rm-vm
+./rmharness/harness.sh --session dumps/real-rm-before --name before \
+ -- rmtool engine list --format=json
+./rmharness/harness.sh --session dumps/real-rm-before -- rmtop --once
+```
+
+The harness appends one `rm_harness_run` event per run and stores
+stdout/stderr under `<session>/blobs/` with sizes and SHA-256. Existing
+sessions are appended to, never rewritten; a missing `session.json` is
+created with the `greeneye-session/1` format. Because these runs are
+external, the event `t` field is `0.0`; `start`/`end` (epoch seconds) carry
+the wall clock. `greeneye analyze` tolerates the session; the run count shows
+up in the event summary.
+
+`plugins/rmharness_plugin.py` exposes the same wrappers in the REPL
+(`rmtool`, `rmtop`, `rmq`), recording through `ge.recorder.blob()` +
+`ge.recorder.event()`. Write-looking arguments are refused and recorded as
+`rm_harness_skip`. The plugin is stdlib-only and is exercised without QEMU
+by `rmharness/tests/test_rmharness_plugin.py` (fake `ge`).
+
+## 6. Safety contract (absolute)
+
+* Host-side read-only queries only. The tools allocate only an RM client /
+ device / subdevice handle pair; they never create engine, channel or memory
+ objects.
+* Never load/unload kernel modules, bind/unbind the driver, create/destroy
+ mdevs, claim the physical GPU, reboot, or touch `/sys/fs/pstore`.
+* Write paths are enforced in code, not by convention:
+ * `rmtool` refuses `mem alloc/map/unmap/pin`, `engine allocate/start/stop/
+ config`, and `channel/context/resserve/cudasw/resman/gsp` with exit 4/3;
+ * `nv2080 method call` only sends controls on a read-only allowlist
+ (`GPU_GET_INFO_V2`, `GPU_GET_NAME_STRING`, `GPU_GET_ENGINES_V2`,
+ `MC_GET_ARCH_INFO`, `FB_GET_INFO_V2`, `BUS_GET_PCI_INFO`,
+ `TIMER_GET_TIME`, `FIFO_GET_DEVICE_INFO_TABLE`,
+ `GPU_GET_CLASSLIST_V2`, ...) and refuses anything else before the ioctl;
+ * `harness.sh` only accepts `rmtool`, `rmtop`, `rmq`;
+ * the plugin blocks write-looking arguments.
+* Another agent may be running a live CUDA/PLAN B job; the tools do not
+ touch its context and all smoke checks above run without a GPU claim.
+
+## 7. What was and was not run
+
+Smoke checks executed on the development host (driver 570.211.01, TU104
+RTX 2080 Super Max-Q, one GPU at `0000:01:00.0`):
+
+* `--help`/`--version` for both tools; `gpu list`, `gpu topology` (proc/sysfs
+ only);
+* `gpu info`, `engine list`, `mem stats`, `sample timer`, `rmtop --once`
+ (read-only RM controls) — all returned live data (`TU100`, 10 engines GR +
+ 5 CE + NVDEC + NVENC + SW + SEC2, 8 GiB VRAM, BAR1 256 MiB);
+* `harness.sh` session recording and the plugin test.
+
+Not run, and why: `rmq` device info tables and `nv2080 class list` return
+`NV_ERR_INSUFFICIENT_PERMISSIONS` / `NV_ERR_NOT_SUPPORTED` for an
+unprivileged client on this driver (reported, not escalated); interactive
+`rmtop` was not driven (no TTY in the build shell); nothing here exercises
+migrations, MIG or a second GPU.
+
+## 8. Roadmap tie-ins
+
+* **docs/06-ACCURACY** — recorded `rm_harness_run` blobs are the host
+ reference for M1/M6; keep the JSON captures in the session directories
+ cited by the metric table.
+* **GREEN-EYE-ROADMAP H3** — the custom simulator's device map and RM-core
+ interception need real control semantics (IDs, sizes, status values) to
+ validate against; `rmtool` supplies that inventory before the H2.2 boot is
+ reached, and the shared `rm_api.c` can become the simulator's host-RM
+ client for differential tests.
+* **GREEN-EYE-ROADMAP H4.5** — the canonical observe -> compare -> implement
+ loop can now include a host capture (`harness.sh --session`) beside the
+ simulator session, giving every emulation change an evidence pair.
+* **R4 (full-memory trace)** — the session-event schema already carries
+ external runs; when the TCG trace lands, host `rm_harness_run` events and
+ firmware `access` events can be interleaved for a complete real-vs-sim
+ picture.
+* **H1.3** — injected services that emulate RM controls should be diffed
+ field-by-field against the `rmtool nv2080 method call` reply for the same
+ control id.
+
+## 9. Extending safely
+
+1. Add new read-only controls to the allowlist **and** to `rmtool.h`
+ structs, with the exact kernel-side size (the userspace SDK snapshot is
+ not authoritative).
+2. Never add an override that bypasses the allowlist/refusals.
+3. Record any new query in the session and document the skip reason for
+ anything privilege-gated.
+4. Keep `tools/rmq/` untouched; call it from the harness.
diff --git a/debug/risc-v-gsp-rm-vm/docs/18-HARNESS-AUTOMATION.md b/debug/risc-v-gsp-rm-vm/docs/18-HARNESS-AUTOMATION.md
new file mode 100644
index 0000000..ecd0614
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/18-HARNESS-AUTOMATION.md
@@ -0,0 +1,284 @@
+# 18 — Harness automation: portability matrix and mutation harness
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+
+Two stdlib-only orchestration tools in `harness/` turn the manual playbooks
+into repeatable runs:
+
+| Tool | Roadmap | What it does |
+|---|---|---|
+| `harness/portability_matrix.py` | **R6**, H4.1 | Runs probe + scripted demo + recorded sessions against several firmware ELFs and emits a cross-target delta report with the M1–M6 skeleton. |
+| `harness/mutation_harness.py` | **R5**, H4.3 | Deterministically mutates the demo firmware's mailbox input surface, classifies abnormal outcomes, and records every attempt as a session plus a compact finding. |
+
+Both use the **QEMU backend only** (the same `probe`/`demo` machinery as the
+CLI). They intentionally do not depend on `greeneye.sim_backend`, and they
+never attach to an existing gdbstub, hardware, a guest or a kernel module.
+
+Requirements: `qemu-system-riscv64`, Python 3.10+, and (for the bundled demo)
+`make demo`. All commands run from `open-iov/debug/risc-v-gsp-rm-vm/`.
+
+---
+
+## 1. Portability matrix
+
+### 1.1 Usage
+
+Self-comparison of one build (a determinism check):
+
+```bash
+python3 harness/portability_matrix.py \
+ --target firmware/uproc_demo/build/uproc_demo.elf \
+ --target firmware/uproc_demo/build/uproc_demo.elf \
+ --out dumps/matrix-demo \
+ --probe-instructions 200 --scenario-duration 0.2
+```
+
+Old vs new firmware with the project's symbol DBs (one DB applies to every
+target in the invocation; run separate invocations for per-version DBs):
+
+```bash
+python3 harness/portability_matrix.py \
+ --target firmware/57X-GSP/570.144/gsp-570.144.fwimage.bin \
+ --target firmware/57X-GSP/570.211/gsp-570.211.fwimage.bin \
+ --ghidra-index analysis/570.144/decompiled_index.tsv --ghidra-bias 0 \
+ --symbols symbols-570.144.json \
+ --out dumps/matrix-570
+```
+
+Replacing the builtin scenario with a fixed green-eye script:
+
+```bash
+python3 harness/portability_matrix.py --target <rehosted.elf> \
+ --scenario-script examples/scripts/trace_rpc.py --out dumps/matrix-rehosted
+```
+
+Flags: `--probe-instructions N` (0 = wall-clock `--probe-duration`),
+`--scenario-duration`, `--max-stops`, `--max-rows`, `--symbols`,
+`--ghidra-names/--ghidra-index/--ghidra-exports/--ghidra-callgraph`
+(`--ghidra-bias`), and repeatable `--target` (positional targets also work).
+
+### 1.2 What each target does
+
+1. **Probe** — the ELF is flattened and relocated to `0x80000000` exactly as
+ `greeneye probe` does, then run for a fixed instruction budget (default
+ 2000) or a wall-clock budget. The first exception is parsed with
+ `greeneye.analyze.parse_qemu_faults`; the stopped PC is annotated from the
+ symbol DB. Raw `.fwimage` targets fault early here; that is the expected
+ first-touch data, not a harness failure.
+2. **Symbols** — ELF `.symtab` plus optional Ghidra exports and/or an
+ existing JSON DB, merged and saved to `<out>/symbols/<target>.json`
+ (runtime addresses; the relocation `bias` is recorded in the DB meta).
+3. **Scenario** — the target boots under QEMU with hooks on every `rpc_*`
+ handler that resolves and a write watchpoint on `gsp_ctrl.rpc_count`, runs
+ its self-test, then receives one deterministic host stimulus
+ (`mailbox = PING (1)`, `arg0 = 0x1234`). Everything is recorded as a
+ normal `greeneye-session/1` directory. A re-hosted ELF without `rpc_*`/
+ `gsp_ctrl` symbols still runs; it simply produces an empty profile.
+
+### 1.3 Output format
+
+```text
+<out>/
+ portability-report.json stable machine-readable report
+ portability-report.md rendered report
+ symbols/<target>.json merged symbol DB per target
+ sessions/<target>-probe/ recorded probe session
+ sessions/<target>-scenario/ recorded scripted session
+ compare/<a>--<b>.md greeneye compare report per target pair
+```
+
+JSON shape (stable keys, targets in CLI order, tables sorted):
+
+```json
+{
+ "format": "greeneye-portability-matrix/1",
+ "tool": "harness/portability_matrix.py",
+ "backend": "qemu",
+ "targets": [{
+ "name": "...", "path": "...", "sha256": "...", "entry": "0x...",
+ "status": "ok",
+ "probe": {"status": "...", "stopped_pc": 0, "stopped_symbol": "...",
+ "fault_count": 0, "first_fault": null},
+ "symbols": {"count": 13, "sources": ["elf"], "path": "symbols/x.json"},
+ "scenario": {"session": "sessions/x-scenario", "event_counts": {},
+ "bp": {}, "watch": {}, "writes": {}, "ctrl": {}},
+ "metrics": [{"id": "M1", "status": "not measured", "observed": {}}]
+ }],
+ "comparisons": [{"a": "...", "b": "...", "deltas": {"bp": [], "watch": [], "writes": []},
+ "all_zero": true, "metrics": []}],
+ "skipped": []
+}
+```
+
+Determinism: fixed probe instructions, fixed scenario phases and the same
+stimulus make hit/watch/write profiles reproducible, so an identical target
+compared with itself reports **zero deltas**. The M6 control-block snapshot
+hash is *not* a fixed point while the firmware heartbeats; compare snapshots
+only between quiesced runs. Metrics are marked `"not measured"` unless a
+reference capture exists (docs/06 §5); `observed` carries the local facts the
+matrix could gather.
+
+Targets that are missing, not RISC-V ELF, or too large are reported with
+`status: "skipped"` and a `skip_reason`; a failing step yields `"error"` for
+that step and does not abort the matrix.
+
+---
+
+## 2. Mutation harness
+
+### 2.1 Usage
+
+```bash
+python3 harness/mutation_harness.py --out dumps/mutation-7 \
+ --iterations 64 --seed 7 --timeout 0.25 --settle 0.2
+```
+
+Targeting the command/args surface plus an optional RAM window:
+
+```bash
+python3 harness/mutation_harness.py --out dumps/mutation-ram \
+ --iterations 128 --seed 11 --memory-region 0x80000900:0x100
+```
+
+Budgets: `--iterations` (1..10000), `--seed`, `--timeout` (0.01..10 s total
+observation per mutation; a short first window plus one confirmation window
+for slow handlers), `--settle` (per attempt, until `SELFTEST DONE` is seen),
+and `--max-log-bytes` (QEMU log window per fault check; trap loops flood
+`-d int`). `--kernel` defaults to the bundled demo; a non-demo ELF is refused
+unless `--allow-foreign-elf` is given, and remains QEMU-only.
+
+### 2.2 Strategies
+
+| Strategy | Input surface |
+|---|---|
+| `cmd-invalid` | command IDs outside the demo's valid range (`0, 7..15, 24+, 0x100, 0xffff, 2^64-1`) |
+| `arg-boundary` | one of arg0/arg1/arg2 set to `0, 1, 2, 0xff, 0x100, 0x7fffffff, 2^63, 2^64-1`, ... |
+| `bit-flip` | one bit flipped in the command or an argument of a valid command |
+| `buffer-length` | ECHO/FILL/SUM against `gsp_ctrl.buf` with lengths `0, 1, 31, 32, 33, 255, 256, 257, 4096, 2^32, ...` |
+| `mem-region` | one byte bit-flip inside `--memory-region` (when supplied) |
+
+Each iteration uses a **fresh QEMU session**, settles the demo through its
+self-test, applies exactly one mutation, runs bounded, then reads back the
+control block. No hardware watchpoints are installed, so the only stops are
+budget interrupts and real traps.
+
+### 2.3 Outcome classes
+
+| Class | Detection |
+|---|---|
+| `ok` | command dispatched (or `cmd=0`) and the demo oracle, when one exists, matched |
+| `trap` | first new exception is a breakpoint (`ebreak`), or control transferred below `0x100` |
+| `fault` | first new QEMU exception is a load/store/fetch/illegal fault (`parse_qemu_faults`) |
+| `no-progress` | command written but `rpc_count` did not advance within both windows (no fault) |
+| `unexpected-result` | result/last_rpc contradict the documented demo oracle for `cmd ∈ {0,1,3,5, invalid}` |
+| `exit` / `settle-failed` / `error` | target exited; self-test never observed; iteration exception |
+
+The oracle is deliberately small: handlers that dereference `arg0` (ECHO,
+SUM) and injected slots 0–5 have no oracle, so their successful executions
+are `ok` with `expected: null`.
+
+### 2.4 Output format
+
+```text
+<out>/
+ findings.jsonl one compact finding per iteration (deterministic order)
+ summary.md unique outcome classes, strategy coverage, examples, replay command
+ sessions/iter-0000/ recorded greeneye session per iteration
+```
+
+Finding shape:
+
+```json
+{"iteration": 5, "strategy": "arg-boundary",
+ "mutation": {"cmd": 4, "arg0": 0, "arg1": 255, "arg2": 0, "region": null},
+ "outcome": "fault",
+ "detail": {"observed": {"result": 48, "last_rpc": 5, "rpc_count_delta": 0},
+ "fault": {"cause": 5, "desc": "fault_load", "epc": 2147483860,
+ "tval": 255, "access": {"rw": "read", "access_addr": 255,
+ "size": 1, "reason": "rejected"}}},
+ "session": "sessions/iter-0005"}
+```
+
+Replay is re-running the same command: the per-iteration RNG is seeded from
+`(seed, index)`, finding fields avoid wall-clock data, and `findings.jsonl`
+is byte-identical for the same seed/budgets on the same host. Session
+directories under `sessions/` contain the usual `events.jsonl`, `session.json`
+and `summary.md`; they are the evidence behind each finding.
+
+---
+
+## 3. Tests
+
+```bash
+python3 -m unittest tests.test_harness -v
+make test # includes the harness tests with the rest of the suite
+```
+
+The integration tests boot the demo twice for a self-comparison (must show
+zero deltas) and run 12 seeded iterations twice (findings file, at least one
+outcome class, byte-identical replay). They skip cleanly when QEMU or the
+demo ELF is absent.
+
+---
+
+## 4. Attaching reports to a portability change
+
+For the docs/07 P1/P2/P3 loop and the docs/14 §6 evidence pack:
+
+1. Run one matrix invocation per version/device/architecture family with the
+ same scenario options, and keep `<out>/portability-report.json`, the
+ per-pair compare reports and the session directories (or their sha256
+ list).
+2. Record the firmware hash (`sha256` in each target entry), the symbol
+ source list and `bias` from `<out>/symbols/*.json` meta.
+3. Fill the M1–M6 table using `observed` from the matrix plus the real
+ reference capture; the matrix marks agreement metrics "not measured" on
+ purpose. Cite docs/06 L2/L3 limits in any claim.
+4. For an input-surface change, run the mutation harness before and after the
+ change and diff the outcome-class tables; attach `findings.jsonl` as the
+ corpus entry list, with each finding's session as evidence.
+5. Re-run the unchanged stimulus after the emulation change: profiles must
+ improve or stay equal (the docs/06 accuracy loop).
+
+---
+
+## 5. Safety
+
+* Demo / re-hosted firmware under QEMU only. Never physical hardware, never
+ a guest, never a kernel module, never an existing gdbstub or the native
+ simulator.
+* The mutation harness refuses non-demo ELFs unless `--allow-foreign-elf` is
+ passed explicitly, and still refuses non-ELF images.
+* Memory-region mutation is bounds-checked against the QEMU RAM window; no
+ host memory is touched outside the emulated target.
+* Every write is recorded in a session (`mem_write`, `note`, mutation fields);
+ budgets (`duration`/`max_stops`/`--timeout`) bound every run.
+* Only temporary work directories and `<out>/` are written on the host.
+
+---
+
+## 6. Limits and fidelity
+
+* L2/L3 claims are out of scope: no timing, interrupt or device-model
+ conclusions (docs/06). The matrix observes RAM-resident behaviour and the
+ first unmapped access, nothing more.
+* A QEMU log window is bounded; a fault not in the window is recorded as its
+ secondary signal (`trap_loop`/`no-progress`) rather than guessed.
+* Mutation covers one mailbox write per iteration; multi-write races and
+ concurrency are future work (H2/H4.4).
+* The demo oracle is small and documented; "unexpected-result" is only
+ meaningful for the listed commands.
+
+---
+
+## 7. Roadmap mapping
+
+| Item | Where this doc/tool contributes |
+|---|---|
+| **R5 — fuzzing and mutation harness** | `mutation_harness.py`: bounded seeded mutation, outcome classes, recorded corpus (`findings.jsonl` + sessions) |
+| **R6 — multi-version orchestration** | `portability_matrix.py`: probe + rehost/demo + compare + M1–M6 for a target set |
+| **H4.1 — semantic diffing** | identical stimulus per target, `compare_sessions` plus hit/watch/write deltas in one report |
+| **H4.3 — mutation/fuzzing** | error-path corpus with symbol-annotated sessions, feeding fail-closed rules |
+| **H4.5 — reimplementation loop** | reports are the attachable evidence for the docs/07 checklist |
+| **V1 — metrics/golden data** | deterministic profiles and seeded findings support golden comparisons |
+| **H7 — agentic harness** | self-describing JSON/markdown artifacts designed for an agent to drive and review |
diff --git a/debug/risc-v-gsp-rm-vm/docs/19-CONTROL-DISCOVERY.md b/debug/risc-v-gsp-rm-vm/docs/19-CONTROL-DISCOVERY.md
new file mode 100644
index 0000000..0f9afbe
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/19-CONTROL-DISCOVERY.md
@@ -0,0 +1,445 @@
+# 19 — Control discovery and hot-path reachability
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status and
+> [`GREEN-EYE-ROADMAP.md`](../GREEN-EYE-ROADMAP.md) H4 for the program.
+
+Two harness tools close the loop between "a new firmware image does something
+we do not model" and "here is the code path, its trigger and its effects":
+
+| Tool | What it answers |
+|---|---|
+| `harness/control_discovery.py` | Which command IDs exist, which code path each one takes, and what it changes. New/undiscovered controls fall out of the sweep instead of waiting for documentation. |
+| `harness/reach.py` | Given one decompiled symbol/address: is it reachable, what triggers it, what arguments does it need, what happens. |
+
+Both are simulator-only, bounded, evidence-producing, and depend only on the
+public `Session`/hook/watch primitives (stdlib-only Python). They are the
+practical start of the "Sandsifter for RM" idea in
+[`GREEN-EYE-ROADMAP.md`](../GREEN-EYE-ROADMAP.md) H4 and reuse the contracts
+in [06-ACCURACY](06-ACCURACY.md) and [14-ADVERSARIAL-INTEROP-GUIDE](14-ADVERSARIAL-INTEROP-GUIDE.md).
+
+---
+
+## 1. The problem
+
+The GSP's RM is a message/method system: services own objects and answer
+controls dispatched by ID. NVIDIA ships new images on a hidden schedule,
+stripped and signed, and new services, messages and controls appear between
+versions. Static diffing (docs/18 portability matrix, docs/14 section 4.1)
+tells us the code changed; it does not tell us which input activates the new
+path, what the path needs, or what it changes. A sweep of the input surface
+plus dynamic hooks answers exactly those questions, and it is safe to run in a
+simulator where the firmware cannot be damaged.
+
+## 2. The discovery model
+
+### 2.1 Sweep spaces
+
+A "call surface" is a finite input space with an observable effect:
+
+| Surface (today / future) | Space | Probe action |
+|---|---|---|
+| demo mailbox (`--surface demo`) | command IDs `1..--max-id`, with argument patterns | write `gsp_ctrl.arg0..2`, then `gsp_ctrl.mailbox` |
+| RM control/method entry (`--surface rm`, H1.3) | control IDs, classes and method opcodes | call the RM method entry with `(control, a0, a1, a2)` |
+| future CSB/TRANSCOM message functions | message function IDs | write the message buffer and ring the doorbell |
+
+ID 0 is the demo's idle mailbox sentinel (the polling loop ignores it), so the
+sweep starts at 1; surface classes with a meaningful 0 can start there instead.
+
+The same machinery applies to any of them because the sweep is described by a
+:class:`Surface` subclass: a probe function, an effect observer, and an
+entry-point table.
+
+### 2.2 Deterministic argument patterns
+
+Every ID is probed with `--patterns` (default all three):
+
+* `zero` — `(0, 0, 0)` (the "no arguments" baseline);
+* `sentinel` — three distinct pseudo-random 64-bit values from a stream keyed
+ by `(seed, id)`, so an ID that misuses an argument is caught;
+* `boundary` — `(2^64-1, 1, 2^63-1)`, the usual off-by-one/size boundaries.
+
+Patterns are deterministic: the same `--seed`/`--max-id` replays the same
+findings byte-for-byte (verified by `tests/test_discovery.py`).
+
+### 2.3 Entry-point modelling
+
+Before probing, the tool reads the surface's declared entry points:
+
+* the dispatcher symbol (`rpc_dispatch` on the demo) is hooked for every
+ probe, capturing the command and the raw `arg0..arg2` at dispatch entry;
+* every non-zero handler-table slot (`gsp_ctrl.handlers[0..7]`) is hooked so a
+ hit can be attributed to a function even when the command is not one of the
+ documented switch cases.
+
+On real RM the same role is played by the RM-core dispatch table / object
+class table: hook the dispatcher, then resolve the handler the simulator
+routes to.
+
+### 2.4 Effect capture
+
+Each probe observes:
+
+* control-block fields before/after (`state`, `rpc_count`, `last_rpc`,
+ `result`, `buf` hash) plus write watchpoints on the scalar fields, which
+ also record the *store PC* (`effects.watch`);
+* the new QEMU exception window (`-d guest_errors,unimp,int`), parsed with
+ `greeneye.analyze.parse_qemu_faults`, so traps (`cause 3`) and faults are
+ distinguished from "no progress";
+* stop signals as a backend-independent fallback (a native backend has no
+ QEMU log: SIGTRAP maps to trap, SIGSEGV/SIGBUS/SIGILL to fault; budget
+ SIGINTs are deliberately excluded from findings).
+
+### 2.5 Classification
+
+One probe yields one outcome, in priority order:
+
+| Outcome | Meaning |
+|---|---|
+| `trap` | breakpoint exception (`ebreak`) or trap loop below `0x100` |
+| `fault` | load/store/fetch/illegal exception |
+| `method-return` | an RM-style call entry returned a value |
+| `no-progress` | the mailbox was never consumed in the budget |
+| `injected-slot` | command routed through the injected-service handler table |
+| `known-handler` | a declared handler ran |
+| `default-path` | the dispatcher catch-all ran (no dedicated handler) |
+| `settle-failed` / `error` | the pre-probe state or the probe itself failed |
+
+`default-path`, `injected-slot` and `trap/fault` are the interesting classes:
+they are where new behaviour, injection wiring and input bugs live.
+
+---
+
+## 3. `control_discovery.py` — unknown-call discovery
+
+### 3.1 Usage
+
+Full demo mailbox sweep (189 probes: IDs 1..63 times three patterns):
+
+```bash
+python3 harness/control_discovery.py --out dumps/discovery --seed 7
+```
+
+A quick sweep for CI or bring-up (IDs 1..32, one pattern):
+
+```bash
+python3 harness/control_discovery.py --surface demo --out dumps/discovery-min \
+ --max-id 32 --patterns zero --seed 5 --timeout 0.25
+```
+
+Other surfaces / budgets:
+
+```bash
+# RM surface (documented future interface; see section 5)
+python3 harness/control_discovery.py --surface rm --out dumps/discovery-rm
+
+# cap the probe count and keep every recorded session
+python3 harness/control_discovery.py --out dumps/discovery-cap --iterations 40 --record-all
+```
+
+Flags: `--max-id` (1..4096), `--patterns zero,sentinel,boundary`, `--seed`,
+`--iterations` (0 = all probes), `--timeout` (per-probe observation window),
+`--settle` (self-test budget), `--backend qemu|native` (QEMU is the supported
+default; native drops the QEMU fault log and uses signals), `--kernel`
+(bundled demo by default; a foreign ELF needs `--allow-foreign-elf`),
+`--record-classes` and `--record-all`, `--max-log-bytes`.
+
+Each probe uses a **fresh session**, settles through the self-test, then
+applies one command. Fresh sessions are the safe strategy: a trap or fault can
+leave the demo in a state that would corrupt the next probe.
+
+### 3.2 Output
+
+```text
+<out>/
+ findings.jsonl one compact finding per probe (deterministic order)
+ discovery-report.json format + options + per-ID aggregate + findings
+ control-discovery.md rendered report grouped by class
+ sessions/<idx>-id<NNN>-<pattern>/ kept evidence for interesting classes
+```
+
+Finding shape (stable keys, no wall-clock data):
+
+```json
+{"probe": 15, "cmd": 16, "pattern": "zero", "args": [0, 0, 0],
+ "outcome": "injected-slot",
+ "handler": "rpc_ping", "handler_entry": 2147483720, "documented_handler": null,
+ "dispatch_seen": true, "dispatch_pc": 2147483912, "dispatch_args": [16, 0, 0, 0],
+ "rpc_count_delta": 1,
+ "effects": {"changed": [{"field": "result", "before": 48, "after": 48879}],
+ "watch": [{"field": "result", "pc": 2147484066, "symbol": "rpc_dispatch"}]},
+ "fault": null, "signals": [], "settle_ok": true,
+ "session": "sessions/0015-id016-zero", "reason": null}
+```
+
+### 3.3 Reading the report
+
+The markdown report opens with the outcome counts and an "Ids by class" line,
+then sections that matter:
+
+* **Known handler ids** — the documented dispatch table (by the firmware's own
+ control block, not the docs);
+* **Injected-slot dispatch ids** — commands whose path is the injected-service
+ table, with the handler each aliases;
+* **Unknown / undiscovered ids** — the explicit list that answers "what is
+ not handled here yet";
+* **Traps and faults** — IDs that reach error paths under some input;
+* **All findings by id** — the join key for `findings.jsonl`.
+
+### 3.4 What the demo sweep already shows
+
+Measured on 2026-10-02, demo ELF sha256 `c038a04a...`, 189 probes in ~36 s:
+
+* IDs 1..6 hit `rpc_ping`/`rpc_echo`/`rpc_fill`/`rpc_sum`/`rpc_state`/
+ `rpc_fault`; the ID 6 path ends in the `ebreak` trap and never writes
+ `result` (the dispatcher's post-handler stores do not run);
+* **discovered class:** IDs 16..23 fall through the switch into the
+ injected-service handler table. IDs 16..21 alias the *built-in* handlers
+ (`16 -> rpc_ping`, ..., `21 -> rpc_fault`); IDs 22..23 are NULL slots and
+ return `~id`. This is exactly the kind of wiring a new firmware can add
+ silently;
+* IDs 7..15 and 24..63 take the catch-all and return `~id` with
+ `rpc_count + 1` — the unknown-ID space;
+* argument patterns change the class where the handler uses the argument:
+ `rpc_echo` and `rpc_sum` fault on sentinel/boundary pointers, while
+ `rpc_ping`/`rpc_state`/`rpc_fill` stay on the handler path.
+
+---
+
+## 4. `reach.py` — hot-path reachability
+
+### 4.1 Usage
+
+```bash
+# Directly callable target, with an argument
+python3 harness/reach.py --target rpc_ping --method call --args 0x41 \
+ --out dumps/reach-ping
+
+# Natural boot/self-test path
+python3 harness/reach.py --target rpc_sum --method run --out dumps/reach-sum
+
+# Only reachable through a mailbox command: discover the trigger from the
+# handler table and apply it
+python3 harness/reach.py --target rpc_fault --method trigger --out dumps/reach-fault
+
+# Replay a saved probe spec; emit one for CI
+python3 harness/reach.py --probe dumps/reach-fault/probe.json --emit-probe /tmp/fault-probe.json
+```
+
+Flags: `--method auto|stimulus|trigger|run|call`, `--all-methods`, `--args`,
+`--arg-count` with `--arg-fill zero|sentinel`, `--timeout`, `--settle`,
+`--stimulus script.py`, `--probe spec.json`, `--emit-probe`, `--record [DIR]`,
+`--max-prior`, `--kernel`, `--backend`, `--allow-foreign-elf`.
+
+### 4.2 Methods
+
+`auto` tries methods in this order and stops at the first hit (use
+`--all-methods` to compare routes):
+
+1. `stimulus` — run a green-eye script (or a probe spec's stimulus) with the
+ target hook and watchpoints armed. The script drives `session.run` itself.
+2. `trigger` — read the surface's handler table, find the command ID(s) that
+ resolve to the target, settle the firmware, apply that command and run
+ bounded. This is the trigger discovery for IDs 1..6 and 16..23 on the demo.
+3. `run` — `Session.run_until(target, timeout)` from launch, which catches the
+ natural boot/self-test path.
+4. `call` — `Session.call(target, args)`, the Frida-like direct call, for
+ unit-testing a function in isolation.
+
+A method reports a hit when the target-entry hook fires, even if execution
+immediately traps (`rpc_fault`): reachability and success are different
+questions, and the report distinguishes them.
+
+### 4.3 Output
+
+```text
+<out>/
+ reach-report.json machine-readable report (stable keys)
+ reach-report.md rendered report
+ probe.json suggested repeatable probe spec (on a hit)
+ sessions/<method>/ optional recorded session (--record)
+```
+
+The report records:
+
+* `reachable`, `method`, and every `methods_tried` with its miss reason;
+* `entry`: the register state at the target (args `a0..a3`, `ra`, `sp`) and
+ `dispatch_args` — the authoritative command arguments when the target was
+ reached through the dispatcher;
+* `prior_events`: the ordered hook events that led to the hit (bounded by
+ `--max-prior`);
+* `required_args`: discovered arguments plus their provenance (`call-args` or
+ `firmware-dispatch`) and the caller `ra`;
+* `effects`: field deltas, watchpoint stores, the return value, and whether a
+ trap/fault followed (`rpc_fault`: trap `ebreak` at `pc 0`);
+* `trace_window`: hook counts and, when recorded, session event counts;
+* `probe_spec`: the exact spec to replay the route.
+
+### 4.4 Demo results
+
+Measured on 2026-10-02 (each run ~0.5 s, QEMU):
+
+| Target | Method | Result |
+|---|---|---|
+| `rpc_ping` | `call` with `--args 0x41` | reachable; returns `0x41 ^ 0xbeef = 0xbeae` |
+| `rpc_sum` | `run` | reachable in the self-test; entry args `(buf, 16, 32, ...)`; path passes through `rpc_ping` -> `rpc_echo` -> `rpc_fill` first |
+| `rpc_fault` | `trigger` | reachable via mailbox command 6 (also aliased at 21); dispatch args `cmd=6, a0=0, a1=0`; immediate `ebreak` trap |
+
+The self-test path is a useful reminder that compile-time inlining can bypass
+the dispatcher: the `run` route reaches `rpc_sum` from an inlined call site,
+so there are no `rpc_dispatch` hits before it. The `trigger` route does pass
+through the dispatcher and exposes the control arguments.
+
+---
+
+## 5. Sandsifter for RM (the future surface)
+
+A Sandsifter-style tool for RM needs three more capabilities than the demo
+has today, in this order.
+
+### 5.1 The `Surface` abstraction (already in place)
+
+`harness/control_discovery.Surface` separates:
+
+* `probe(session, id, args)` — how one call is applied (mailbox write today;
+ RM method call / message write later);
+* `observer()` — the effect model (control-block fields today; RM reply
+ buffer and object state later);
+* `handler_map()` / `command_for_addr()` — how IDs map to code, which drives
+ both discovery and reach's `trigger` method.
+
+Adding a real RM surface is a new subclass, not a rewrite of the sweep,
+classification or reporting.
+
+### 5.2 RM sweep design
+
+1. **Control/method inventory.** Start from `rmtool`/`rmq` (docs/17) for the
+ host-visible control list and class IDs; import the Ghidra RM-core dispatch
+ table into the symbol DB. The sweep ranges are then control IDs by class
+ (`--rm-class`) and method opcodes within a control, mirroring the
+ `nv2080 method call` surface.
+2. **Entry-point modelling.** Hook the RM dispatch routine, the object
+ constructor/method tables and the status/notifier writes. For each swept
+ ID, capture the handler entry, the object it selects and the status it
+ returns.
+3. **Effect observation.** Point `--rm-reply` at the reply buffer for the
+ control and hash/snapshot it; add watchpoints on the object handle table
+ and status queue. Compare the reply field-by-field against the host oracle
+ (`rmtool` JSON) for M2 (docs/06).
+4. **Branch exploration.** The native simulator's snapshots (H3) make it cheap
+ to fork at the dispatcher and sweep IDs without re-booting; the QEMU
+ surface uses fresh sessions exactly like today.
+5. **Corpus and gates.** Findings become an inventory corpus (`findings.jsonl`)
+ that CI diffs between firmware versions; new IDs, new handlers, or changed
+ default-path results are the regression signal (docs/21).
+
+### 5.3 Rollout gates
+
+* Entry gate: H2.2/H1.3 — a simulator session reaches the RM message loop and
+ exposes the dispatch table (otherwise `--surface rm` exits cleanly with the
+ message below).
+* Exit gate: sweep a real control space, reach one previously unknown control,
+ and replay its probe spec through `reach.py` with a recorded session.
+
+When unavailable the tool is explicit, not mysterious:
+
+```text
+$ python3 harness/control_discovery.py --surface rm --out dumps/discovery-rm
+[discovery] skipped: surface 'rm' unavailable: no RM control/method entry point is exposed
+by the simulator or a live session yet (roadmap H1.3). Pass --rm-entry SYMBOL once the
+RM method dispatcher exists; the demo surface works today with --surface demo.
+```
+
+---
+
+## 6. Feeding the simulator/device model and CI
+
+* **Device model.** Each discovered class is a candidate row in the
+ simulator's dispatch/control model: the command ID, the entry symbol, the
+ argument pattern that reaches it, and the effect snapshot are exactly the
+ data the H2/H3 device map needs. Unknown IDs (`default-path`) document the
+ fail-closed/default behaviour the emulation must reproduce.
+* **Accuracy metrics.** The `dispatch_pc`/`handler_entry`/effects fields feed
+ M1 (inventory coverage) and M4/M6 (state and memory side effects,
+ docs/06); compare two firmware versions by diffing `discovery-report.json`
+ (new handler IDs, moved handler entries, changed default results).
+* **CI (docs/21).** The intended gates are:
+ 1. `control_discovery.py --max-id 32 --patterns zero` as a fast inventory
+ smoke test: known handlers must resolve, `rpc_fault` must trap, and the
+ default-path result must stay `~id`;
+ 2. `reach.py` on a pinned list of targets, asserting `reachable` and the
+ method (`call` for `rpc_ping`, `run` for `rpc_sum`, `trigger` for
+ `rpc_fault`) so a compiler/inlining or dispatch change is caught;
+ 3. diff `findings.jsonl`/`discovery-report.json` against a golden run.
+ Findings contain no wall-clock data, so the diff is stable for a fixed
+ seed/params (the same property the mutation harness documents in docs/18).
+* **Probe specs are the interface.** `reach.py --emit-probe` and
+ `--probe` turn a discovered route into an artifact that any agent or CI job
+ can replay without re-deriving it.
+
+---
+
+## 7. Tests
+
+```bash
+python3 -m unittest tests.test_discovery -v
+make test # includes the discovery tests with the rest of the suite
+```
+
+`tests/test_discovery.py` covers the pure functions (pattern determinism,
+probe ordering/caps, classification priority), a 32-probe demo sweep that
+must attribute IDs 1..6, find an unknown ID and an injected-slot alias and
+write the report, byte-identical replay for a fixed seed, the clean RM skip,
+and the three reach demonstrations (`rpc_ping` call, `rpc_sum` run,
+`rpc_fault` trigger). It skips cleanly when QEMU or the demo ELF is absent.
+The suite completes in well under a minute.
+
+---
+
+## 8. Safety and ethics
+
+* **Simulator only.** The tools launch their own QEMU (or the native
+ simulator with `--backend native`) against the bundled demo or an
+ explicitly allowed re-hosted ELF. They never attach to hardware, a guest, a
+ kernel module or an existing gdbstub. Ctrl-C leaves no target running: each
+ session is closed in a `finally`.
+* **Bounded probes.** Every probe has a settle budget and an observation
+ window; `--max-id`/`--iterations` cap the sweep; QEMU log reads are capped.
+* **No hardware writes.** All memory writes go through the debug stub into
+ emulated RAM; the only host writes are under `--out`.
+* **Evidence, not truth.** A `default-path` result is evidence about this
+ firmware at this fidelity (L0/L1, docs/06), not a statement about silicon.
+ Never use a simulator observation to bypass a hardware protection; escalate
+ anything that would change the product to a physical capture (docs/14
+ sections 6-7).
+* **No injected payloads here.** Discovery only *observes* injection slots; it
+ never loads a blob. Injection itself stays behind `greeneye inject` and the
+ docs/12 contract.
+
+---
+
+## 9. Limits and fidelity
+
+* QEMU wall-clock slicing bounds when a handler is interrupted, so a very long
+ handler may be classified from its entry hit without its final result; the
+ finding says `known-handler` and leaves `effects` empty rather than guessing.
+* The demo is not RM: there is no object model, no status queue and no
+ per-control reply. The RM design in section 5 is deliberately unbuilt until
+ the simulator exposes an RM dispatch table (H1.3/H2.2).
+* `trap` vs `fault` uses the guest exception cause; QEMU and the native
+ simulator agree on the demo, but x86/RISC-V mapping differences are a
+ documented fidelity gap (docs/06 section 8).
+* The sweep is single-threaded and deterministic; concurrent/multi-core
+ dispatch interleavings (H2.4) are future work.
+
+---
+
+## 10. Roadmap mapping
+
+| Item | Contribution |
+|---|---|
+| **H4.adversarial navigation** | unknown-call discovery and trigger discovery over control surfaces |
+| **H4.1 semantic diffing** | `discovery-report.json` is a version-diffable inventory (new/changed IDs, handlers, default paths) |
+| **H4.2 capability inference** | `effects.watch` store PCs are access evidence for the capability table |
+| **H4.5 reimplementation loop** | `reach.py` output plus probe spec is the observe -> reproduce artifact for a new behaviour |
+| **H3 native simulator** | `--backend native` reuses the same sweep once parity lands; snapshots will remove the fresh-session cost |
+| **V1 golden data / docs/21 CI** | stable findings and the fast 32-probe gate are the golden corpus |
diff --git a/debug/risc-v-gsp-rm-vm/docs/20-MCP-SERVER.md b/debug/risc-v-gsp-rm-vm/docs/20-MCP-SERVER.md
new file mode 100644
index 0000000..a3b6111
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/20-MCP-SERVER.md
@@ -0,0 +1,277 @@
+# 20 — MCP server (green-eye agent interface)
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+
+## 1. What it is
+
+`plugins/mcp_server.py` is the green-eye **MCP (Model Context Protocol)
+server** — roadmap [`R3`](08-ROADMAP.md#r3-mcp-server-plugin) and
+[`H7`](../GREEN-EYE-ROADMAP.md). It exposes the *active* `Session` as MCP
+tools so an AI agent (Claude Code, opencode, any MCP client) can inspect
+symbols, set breakpoints/watchpoints, run the target, read memory, snapshot,
+analyse/compare recordings, inject services, and drive the harnesses.
+
+* **Stdlib-only**: JSON-RPC 2.0 over newline-delimited stdin/stdout, no MCP
+ library, no network listener unless explicitly asked.
+* **Plugin first**: `register(ge)` adds an `mcp-serve` REPL/CLI command that
+ serves the session already loaded by green-eye.
+* **Standalone too**: the same file runs as a script that launches its own
+ session (`--kernel`, `--backend`, `--record`) and then serves MCP.
+* **Read-only by default**: memory writes, remote calls and injection require
+ explicit startup flags.
+* **Defensive capabilities**: tools owned by other components (mutation and
+ portability harnesses, native trace, injection) return a structured
+ `not_available` tool error when the module/file is absent instead of
+ crashing. The tool list always advertises every capability.
+
+The core green-eye package is untouched; everything lives in the plugin.
+
+## 2. Running it
+
+All commands run from `open-iov/debug/risc-v-gsp-rm-vm/`.
+
+### 2.1 Standalone (the usual MCP client mode)
+
+```bash
+# bundled demo, QEMU, recorded session
+python3 plugins/mcp_server.py --stdio --record
+
+# native simulator, recorded
+python3 plugins/mcp_server.py --stdio --backend native --kernel firmware/uproc_demo/build/uproc_demo.elf --record
+
+# enable writes / injection (never on by default)
+python3 plugins/mcp_server.py --stdio --allow-write --allow-inject
+
+# local loopback TCP instead of stdio (for clients that cannot spawn a process)
+python3 plugins/mcp_server.py --tcp 127.0.0.1:8765 --record
+```
+
+Flags: `--stdio` (default), `--tcp HOST:PORT` (loopback only),
+`--kernel ELF`, `--backend qemu|native`, `--record [PATH]`, `--name NAME`,
+`--plugins PATH` (repeatable), `--allow-write`, `--allow-inject`.
+Logs go to stderr; **stdout carries protocol frames only**.
+
+### 2.2 Plugin mode (serve a live session)
+
+```bash
+./greeneye.sh attach --kernel firmware/uproc_demo/build/uproc_demo.elf \
+ --plugin plugins/mcp_server.py
+greeneye> mcp-serve # read/observe
+greeneye> mcp-serve --allow-write # + memory writes / remote calls
+greeneye> mcp-serve --allow-inject # + service injection
+```
+
+`mcp-serve` blocks until the MCP client disconnects or calls the `close`
+tool, then returns to the REPL. In plugin mode `close` stops the server but
+**does not** close the session (the REPL still owns it); in standalone mode
+`close` also closes the launched session.
+
+Both `--stdio` and `--tcp` behave identically. The bundled plugin directory is
+loaded by default, so `mcp_server.py` is auto-discovered; the command only
+serves when invoked.
+
+## 3. Client configuration
+
+The MCP server must be started **before** the session it should serve, so
+client configs use standalone mode. Use absolute paths.
+
+### 3.1 Claude Code / `.mcp.json`
+
+```json
+{
+ "mcpServers": {
+ "green-eye": {
+ "command": "python3",
+ "args": [
+ "/home/rmcore/Git/integdev_gpu_drv/open-iov/debug/risc-v-gsp-rm-vm/plugins/mcp_server.py",
+ "--stdio",
+ "--backend", "native",
+ "--kernel", "/home/rmcore/Git/integdev_gpu_drv/open-iov/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/build/uproc_demo.elf",
+ "--record"
+ ]
+ }
+ }
+}
+```
+
+Or, one line: `claude mcp add green-eye -- python3
+<tool-root>/plugins/mcp_server.py --stdio --record`.
+
+### 3.2 opencode (`opencode.json`)
+
+```json
+{
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "green-eye": {
+ "type": "local",
+ "command": [
+ "python3",
+ "/home/rmcore/Git/integdev_gpu_drv/open-iov/debug/risc-v-gsp-rm-vm/plugins/mcp_server.py",
+ "--stdio", "--backend", "native", "--record"
+ ],
+ "enabled": true
+ }
+ }
+}
+```
+
+For an agent that must write memory or inject, add `--allow-write` /
+`--allow-inject` to `args` and keep the session recorded (the recording is
+the audit trail).
+
+## 4. Protocol
+
+| Request | Behaviour |
+|---|---|
+| `initialize` | returns `protocolVersion` (echoes `2024-11-05`, `2025-03-26` or `2025-06-18`; otherwise the server default), `serverInfo`, `capabilities.tools` |
+| `notifications/initialized` | accepted silently (notifications never get a response) |
+| `ping` | `{}` |
+| `tools/list` | the 24-tool catalogue with JSON input schemas |
+| `tools/call` | `{"name": ..., "arguments": {...}}` → `{"content":[{"type":"text","text":"<json>"}]}`, or `isError: true` for tool errors |
+
+JSON-RPC errors: `-32700` parse error, `-32600` invalid request, `-32601`
+unknown method, `-32602` unknown tool / bad params. Tool-level failures
+(bad address, symbol not found, gating, capability absent) are returned as
+`isError: true` results whose text is a JSON object with `error` and
+`message` — the server never dies on a bad call.
+
+Tool result payloads are always a single JSON text block so agents can parse
+them deterministically.
+
+## 5. Tool catalogue
+
+| Tool | Arguments | Class | Notes |
+|---|---|---|---|
+| `session_info` | — | read | backend, PC, symbols, recording, breakpoints/watches, enabled capabilities |
+| `symbols_resolve` | `target` | read | symbol, `symbol+0xNN` or hex address |
+| `bp_set` | `target`, `kind=sw\|hw` | observe | hits recorded as `bp_hit` |
+| `bp_clear` | `target` | observe | removes every breakpoint at that address |
+| `watch_set` | `target`, `size=8`, `kind=w\|r\|a` | observe | hits recorded as `watch_hit` |
+| `run` | `duration=1.0`, `max_stops`, `timeout` | observe | bounded; services hooks/watchpoints |
+| `run_until` | `target`, `timeout=5.0` | observe | temporary breakpoint |
+| `step` | `count=1` (max 10000) | observe | dispatches hooks/watchpoints |
+| `read_mem` | `addr`, `size=64` (max 1 MiB) | read | lowercase hex |
+| `write_mem` | `addr`, `hex` | **write** | requires `--allow-write`; recorded as `mem_write` |
+| `hexdump` | `addr`, `size=64` (max 1 MiB) | read | symbol-annotated text |
+| `snapshot` | `addr`, `size` (max 16 MiB), `label=mcp` | read | zlib + sha256 into the session |
+| `call_function` | `target`, `args=[]`, `timeout=5.0` | **write** | requires `--allow-write`; Frida-like remote call |
+| `inject_service` | `service`, `slot=6`, `load_addr`, `cmd=1`, `arg0`, `arg1`, `path=remote\|firmware\|both`, `settle=0.25` | **inject** | requires `--allow-inject`; service ELF built with `make sdk` |
+| `trace_start` | `exec=true`, `start`, `end`, `max_events` | trace | native backend + recording session only |
+| `trace_stop` | — | trace | flushes `blobs/trace.jsonl`, returns counts |
+| `analyze_current` | `max_rows=40` | read | `render_summary` for the active recording |
+| `compare_sessions` | `a`, `b`, `symbols=true`, `max_rows=40` | read | A/B recorded sessions |
+| `export_hits` | `session`, `out` | read | Ghidra `greeneye_hits.tsv` |
+| `probe_firmware` | `elf`, `instructions=2000`, `duration=1.0`, `no_relocate`, `symbols`, `timeout=300` | harness | QEMU-only probe subprocess; first divergence |
+| `mutation_run` | `kernel`, `out`, `iterations=10`, `seed=1`, `timeout=0.25`, `settle=0.2`, `memory_region`, `allow_foreign_elf` | harness | `harness/mutation_harness.py`, QEMU-only |
+| `portability_matrix_run` | `targets[]`, `out`, `probe_instructions=2000`, `scenario_duration=0.4`, `symbols`, `max_rows` | harness | `harness/portability_matrix.py`, QEMU-only |
+| `plugins_list` | — | read | bundled plugins, `GREENEYE_PLUGINS`, REPL commands |
+| `close` | — | session | stop serving; standalone also closes the session |
+
+Capability classification follows the manifest in
+[15-AGENTIC-GSP-IDE](15-AGENTIC-GSP-IDE.md) §5: read / observe are
+unattended-safe; write and inject require the explicit startup flags and a
+recorded session. `bp_set`, `watch_set`, `run` and `step` mutate target
+*observation state* only and stay in the observe class.
+
+Harness and probe tools shell out to the existing CLIs with bounded budgets
+and return their stdout/stderr; they never touch hardware, a guest or a
+kernel module.
+
+## 6. Example agent sessions
+
+### 6.1 Monitor an automated firmware-release run
+
+```json
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"session_info","arguments":{}}}
+{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"symbols_resolve","arguments":{"target":"rpc_ping"}}}
+{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"bp_set","arguments":{"target":"rpc_ping"}}}
+{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run","arguments":{"duration":0.5,"max_stops":100}}}
+{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"analyze_current","arguments":{"max_rows":20}}}
+```
+
+The agent watches `bp_hit`/`watch_hit` counts in the report, snapshots the
+control block, runs the release stimulus, then diffs the new recording
+against the golden one:
+
+```json
+{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"snapshot","arguments":{"addr":"gsp_ctrl","size":392,"label":"release"}}}
+{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"compare_sessions","arguments":{"a":"dumps/golden","b":"dumps/release","symbols":true}}}
+```
+
+### 6.2 Dump memory
+
+```json
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hexdump","arguments":{"addr":"0x80000840","size":128}}}
+{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_mem","arguments":{"addr":"0x80000840","size":16}}}
+{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"snapshot","arguments":{"addr":"0x80000000","size":4096,"label":"ram-window"}}}
+```
+
+### 6.3 Inject a service
+
+Start the server with `--allow-write --allow-inject --record`, then:
+
+```json
+{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"inject_service","arguments":{"service":"sdk/build/injected_demo.elf","slot":6,"cmd":1,"arg0":65,"arg1":66,"path":"both"}}}
+```
+
+The result contains the load address/entry and both the direct (`remote`) and
+firmware-mailbox (`firmware`) return values; every segment write and call is
+recorded, and the session can be compared like any other run.
+
+## 7. Security rules
+
+1. **Local only.** Default transport is stdin/stdout. `--tcp` binds loopback
+ addresses only (`127.0.0.1`, `localhost`, `::1`); any other host is
+ refused at argument parse time. There is no auth, so never expose it.
+2. **No guest, no hardware.** The server drives the simulator/QEMU session
+ only. It does not touch `/dev`, a kernel module, an mdev, a guest or a
+ physical GPU; the agent runs against the simulator by default
+ ([H7](../GREEN-EYE-ROADMAP.md), [15-AGENTIC-GSP-IDE](15-AGENTIC-GSP-IDE.md) §5).
+3. **Read-only by default.** `--allow-write` enables `write_mem` and
+ `call_function`; `--allow-inject` enables `inject_service`. The agent's
+ client config decides which manifest class it gets; a read/observe agent is
+ configured without those flags.
+4. **Sessions are the audit trail.** Writes, calls, injections and snapshots
+ land in `events.jsonl`; run with `--record` (the trace tools require it).
+ A finding without a session is not a finding.
+5. **Budgets everywhere.** `run` defaults to 1 s, `step` is capped, reads and
+ snapshots have size caps, and harness subprocesses have timeouts. A wedged
+ target is left stopped and restarted clean, never poked.
+6. **Protocol hygiene.** stdout is reserved for JSON-RPC; diagnostics go to
+ stderr. One bad tool call returns `isError` and the server keeps serving.
+7. **No product changes.** MCP can propose evidence, not merge emulation
+ changes; human approval still gates `open-iov-vfio`.
+
+## 8. Absent capabilities
+
+`probe_firmware`, `mutation_run`, `portability_matrix_run`, `trace_*`,
+`inject_service`, `analysis` and the Ghidra export are checked at call time.
+If a module/file is missing the tool returns:
+
+```json
+{"content":[{"type":"text","text":"{\"error\": \"not_available\", \"message\": \"mutation harness not present: ...\"}"}],"isError": true}
+```
+
+so an agent can plan around a capability instead of seeing the server crash.
+The tool list is stable regardless of what is present.
+
+## 9. Testing
+
+```bash
+python3 -m unittest tests.test_mcp -v # 34 tests, no external deps
+```
+
+The suite drives `initialize`/`tools/list`/`tools/call` over in-memory
+streams (including unknown method/tool, parse errors, write/inject gating,
+no-stdout-pollution and structured `not_available`), plus a standalone
+subprocess test on the native backend that skips cleanly when the native
+simulator is absent.
+
+## 10. Roadmap
+
+* **R3 (done)** — MCP plugin exposing sessions as agent tools; local stdio,
+ no network by default; capability gating.
+* **H7 remaining** — named harness entry points and budgets, a session index
+ for agents, enforced capability manifest files, and the physical-escalation
+ gate. Those build on this server, not around it.
diff --git a/debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md b/debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md
new file mode 100644
index 0000000..d687cca
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md
@@ -0,0 +1,320 @@
+# 21 — CI and release pipeline (version diff + evidence bundle)
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+
+## 1. Purpose
+
+This is the release-triage path for a new GSP firmware image (roadmap R6 /
+H4.1, docs/23 C5/C7). Two stdlib-only tools turn the manual docs/07 P1/P2
+playbook into one repeatable run and one attachable bundle:
+
+| Tool | What it does |
+|---|---|
+| `harness/version_diff.py` | old vs new target, section by section: image/shape, symbol inventory, decompiled corpus hints, bounded QEMU probe, shared-scenario behavior, M1–M6 skeleton |
+| `harness/ci_release.py` | runs version_diff → portability matrix → mutation smoke and hashes every artifact into `bundle-manifest.json` |
+
+`.github/workflows/greeneye.yml` runs the tool's test suite on pushes/PRs that
+touch `debug/risc-v-gsp-rm-vm/**` or `tools/rm*/**`. The repository-level
+`.github/workflows/ci.yml` is independent and unchanged.
+
+Everything here is local: the tools never download firmware, never attach to
+hardware, a guest or a kernel module, and use their own QEMU processes.
+
+---
+
+## 2. Version diff
+
+### 2.1 Usage
+
+Self-diff of the demo (a determinism/regression check; must be all-zero):
+
+```bash
+python3 harness/version_diff.py \
+ --old firmware/uproc_demo/build/uproc_demo.elf \
+ --new firmware/uproc_demo/build/uproc_demo.elf \
+ --out dumps/vdiff-selfcheck \
+ --probe-instructions 200 --behavior-duration 0.2
+```
+
+Old vs new firmware with per-version symbol DBs and Ghidra exports:
+
+```bash
+python3 harness/version_diff.py \
+ --old firmware/57X-GSP/570.144/gsp-570.144.fwimage.bin \
+ --new firmware/57X-GSP/570.211/gsp-570.211.fwimage.bin \
+ --old-label 570.144 --new-label 570.211 \
+ --old-ghidra-exports analysis/570.144/symbols.tsv \
+ --new-ghidra-exports analysis/570.211/symbols.tsv \
+ --corpus-diff analysis/570.144/decompiled_index.tsv analysis/570.211/decompiled_index.tsv \
+ --out dumps/vdiff-570
+```
+
+Attaching a fixed stimulus and a pre-produced corpus report:
+
+```bash
+python3 harness/version_diff.py --old <rehosted-old.elf> --new <rehosted-new.elf> \
+ --stimulus examples/scripts/trace_rpc.py \
+ --corpus-report run/corpus-delta-570.144-570.211.txt \
+ --out dumps/vdiff-rehosted
+```
+
+Targets are RISC-V ELF files or raw images (`.fwimage`). A raw target is
+relocated to `0x80000000` as one implicit load; its firmware-vaddr translation
+is `runtime - 0x80000000`. Symbols may be absent for raw images; the symbol
+section then reports `skipped` with a reason unless Ghidra/JSON sources are
+supplied.
+
+Flags: `--old/--new` (required), `--out` (required), `--old-label/--new-label/
+--release`, `--old-symbols/--new-symbols` (green-eye JSON), `--old-ghidra-names`,
+`--new-ghidra-names`, `--old-ghidra-index`, `--new-ghidra-index`,
+`--old-ghidra-exports`, `--new-ghidra-exports` (`--old-ghidra-bias`/
+`--new-ghidra-bias`), `--corpus-diff OLD NEW`, `--corpus-report FILE`,
+`--stimulus`, `--probe-instructions` (`0` = `--probe-duration` wall clock),
+`--behavior-duration`, `--max-stops`, `--max-rows`, `--approval-token`.
+
+### 2.2 Sections
+
+Every section reports `status: ok | skipped | error`, and a skipped section
+never aborts the others. A failing QEMU probe or scenario is recorded as
+`error` for that section only.
+
+1. **Image / shape.** Entry, span, file size/hash, PT_LOAD layout (vaddr,
+ filesz/memsz, flags), and the ELF section table (name/type/addr/size).
+ Raw images have no section table; that is stated, not guessed.
+2. **Symbol inventory.** ELF `.symtab` plus Ghidra `names.tsv`,
+ `decompiled_index.tsv`/bucket TSVs, 4-column `exports.tsv` and the
+ 3-column `ExportForGreenEye` form (`addr<TAB>name<TAB>size`), plus
+ green-eye `greeneye-symbols/1` JSON DBs. Matching follows
+ `userspace/cuda/tools/diff_corpus.py`: named functions match by name
+ (duplicates in address order), the address shift is the median of named
+ deltas, and `FUN_*` functions match at the shifted address. The report
+ lists added/removed/moved/resized (named and anonymous), the shift and its
+ dominance, match rates and the named-transfer rate. Merged per-side DBs are
+ saved under `<out>/symbols/`.
+3. **Decompiled-change hints.** `--corpus-diff A B` runs the same matching
+ discipline over two corpus indexes and writes `<out>/corpus/corpus-diff.json`;
+ `--corpus-report FILE` attaches a previously produced report (hash +
+ preview). With neither flag the section is `skipped` with reason
+ `not supplied: ...`.
+4. **Probe first-touch.** Both targets are flattened, relocated to
+ `0x80000000`, stepped for the bounded budget, and their QEMU logs parsed
+ with `greeneye.analyze.parse_qemu_faults`. The first fault is reported with
+ cause/desc, `epc` **and** `epc_firmware`, the access address **and**
+ `access_firmware` (translated only when the access address falls inside the
+ image). The diff says whether the stopped PC/first fault changed.
+5. **Behavior.** The shared demo scenario from `portability_matrix` (hooks on
+ every resolvable `rpc_*`, a write watchpoint on `gsp_ctrl.rpc_count`, the
+ self-test, one deterministic PING) or a `--stimulus` script is run on both
+ targets and recorded as normal `greeneye-session/1` directories. The report
+ includes `analyze.compare_sessions` output under `<out>/compare/`, explicit
+ hit/watch/written-page deltas, `all_zero`, and the docs/06 M1–M6 skeleton
+ with every agreement metric marked `not measured` (a reference capture is
+ required, docs/06 §5).
+
+### 2.3 Output
+
+```text
+<out>/
+ version-diff.json stable machine-readable report
+ version-diff.md rendered report
+ symbols/<target>.json merged symbol DB per side (when non-empty)
+ sessions/<target>-probe/ recorded probe session
+ sessions/<target>-behavior/ recorded scenario session
+ compare/<old>--<new>.md compare_sessions report
+ corpus/corpus-diff.json when --corpus-diff is used
+```
+
+Exit status: `0` for a normal report (skips included), `2` for a tool error,
+`3` for an RM 610 gate refusal.
+
+---
+
+## 3. RM 610 gate (hard, testable)
+
+The gate is enforced by `version_diff` and `ci_release` **before any target
+file is opened** and before `--out` is created:
+
+* a target/release label matching `rm610` / `RM-610` / `rm_610` etc. (the
+ `--release`, `--old-label`, `--new-label` values and the target basenames)
+ is refused unless
+ * `--approval-token RM610-APPROVED` is passed, or
+ * `GREENEYE_RM610_APPROVED=1` is set;
+* the refusal text points at [`docs/23-RM610-READINESS.md`](23-RM610-READINESS.md)
+ and states that nothing was downloaded and no target file was opened;
+* the same check runs in `--dry-run` mode.
+
+Nothing in either tool performs network access, and `--dry-run` never touches
+a target. Current-version rehearsal runs use `570.144`-labelled inputs; 610
+inputs require the §6 approval message in docs/23.
+
+---
+
+## 4. Release pipeline
+
+### 4.1 Dry run first
+
+```bash
+python3 harness/ci_release.py --old <old> --new <new> --out dumps/release-570 \
+ --old-label 570.144 --new-label 570.211 --dry-run
+```
+
+`--dry-run` prints a deterministic JSON plan (the three step commands and the
+artifact paths) and exits `0` without creating `--out` or opening a target.
+This is the docs/23 §4 step 6 rehearsal.
+
+### 4.2 Real run
+
+```bash
+python3 harness/ci_release.py --old <old> --new <new> --out dumps/release-570 \
+ --old-label 570.144 --new-label 570.211 \
+ --old-symbols symbols-570.144.json --new-symbols symbols-570.211.json \
+ --stimulus examples/scripts/trace_rpc.py \
+ --mutation-kernel firmware/uproc_demo/build/uproc_demo.elf \
+ --mutation-iterations 8 --mutation-seed 7
+```
+
+Steps, in order:
+
+1. **version_diff** — writes `<out>/version-diff/` (section flags above);
+2. **portability_matrix** — old and new as two targets, same budgets, writes
+ `<out>/portability-matrix/portability-report.json|.md` and per-pair compare
+ reports; the self-comparison must show `all_zero`;
+3. **mutation_smoke** — `mutation_harness` on the demo (or `--mutation-kernel`,
+ with `--allow-foreign-mutation` for a re-hosted ELF) with a small budget;
+ writes `<out>/mutation-smoke/findings.jsonl|summary.md`.
+
+A step that raises is recorded as `status: error` and the later steps still
+run; the process exits `1` when any step errored, `0` otherwise (`skipped`
+steps do not fail the run, but their reasons are in the report). Exit `3` is
+the RM 610 refusal.
+
+`ci_release` flags: the `version_diff` inputs above plus `--matrix-symbols`
+(one JSON DB applied to both matrix targets), `--mutation-kernel` (default:
+the bundled demo), `--mutation-iterations`/`--mutation-seed`/
+`--mutation-timeout`/`--mutation-settle`, and `--allow-foreign-mutation` for a
+non-demo re-hosted ELF.
+
+### 4.3 Evidence bundle format
+
+```text
+<out>/
+ release-report.json pipeline summary (steps, gate, labels)
+ bundle-manifest.json sha256 + byte size of every other file
+ version-diff/ docs/21 §2.3 tree
+ portability-matrix/ portability-report.json|.md, symbols/, sessions/, compare/
+ mutation-smoke/ findings.jsonl, summary.md, sessions/
+```
+
+`bundle-manifest.json`:
+
+```json
+{
+ "format": "greeneye-release-bundle/1",
+ "tool": "harness/ci_release.py",
+ "files": [{"path": "version-diff/version-diff.json", "bytes": 12345, "sha256": "..."}]
+}
+```
+
+`release-report.json` shape:
+
+```json
+{
+ "format": "greeneye-ci-release/1",
+ "dry_run": false,
+ "rm610_gate": {"status": "passed", "labels": ["570.144", "570.211"]},
+ "old": {"label": "570.144", "path": "gsp-570.144.fwimage.bin"},
+ "new": {"label": "570.211", "path": "gsp-570.211.fwimage.bin"},
+ "steps": [{"name": "version_diff", "status": "ok",
+ "sections": {"image": "ok", "symbols": "ok", "corpus": "ok",
+ "probe": "ok", "behavior": "ok"}}],
+ "errors": [],
+ "manifest": "bundle-manifest.json"
+}
+```
+
+The manifest is written last, so its hashes cover the final release report.
+Attach the directory (or its manifest) to the docs/07 evidence checklist and
+the docs/14 §6 pack; the docs/23 §4 step 7 expected-artifact list maps 1:1.
+
+---
+
+## 5. CI workflow
+
+`.github/workflows/greeneye.yml` (new; `ci.yml` is untouched):
+
+* triggers on push/PR paths `debug/risc-v-gsp-rm-vm/**` and `tools/rm*/**`;
+* `ubuntu-latest`, no secrets, no hardware;
+* installs `qemu-system-misc clang lld-18`;
+* builds `make -C debug/risc-v-gsp-rm-vm sdk demo`;
+* runs `python3 -m unittest discover -s tests -t .` from the tool root (QEMU
+ integration tests run; if QEMU were absent they skip cleanly).
+
+The intended release gates this workflow covers are the fast inventory smoke
+from docs/19 §6 (`control_discovery --max-id 32 --patterns zero`), the harness
+suites, and the docs/21 dry-run. A full `ci_release` run against real firmware
+is driven manually/agentically because it needs the versioned inputs and
+symbol DBs; CI-adjacent automation should call it with `--dry-run` first.
+
+---
+
+## 6. MCP monitoring
+
+`plugins/mcp_server.py` (docs/20) exposes the same machinery to an agent:
+`probe_firmware`, `portability_matrix_run`, `mutation_run`, `compare_sessions`
+and `analyze_current`. The agent pattern for a release:
+
+1. `portability_matrix_run` with the old/new targets and fixed budgets;
+2. `mutation_run` on the demo with the pinned seed and budget;
+3. `compare_sessions` for the recorded evidence pair;
+4. read `version-diff.json` / `release-report.json` / `bundle-manifest.json`
+ from the run directory (agent reads the bundle, it does not recompute it).
+
+The MCP server remains read/observe by default and simulator-only (docs/20 §7).
+
+---
+
+## 7. Determinism, limits and safety
+
+* Fixed probe budgets, fixed scenario phases and the same stimulus make the
+ self-diff zero: an unchanged target reports zero symbol/probe/behavior
+ deltas (verified by `tests/test_version_diff.py`).
+* Agreement metrics M1–M6 are **not measured** without a reference capture
+ (docs/06 §5); the skeleton carries local `observed` facts only. L2/L3 claims
+ (timing, interrupts, device model) are out of scope.
+* Raw `.fwimage` targets execute as flat blobs; a firmware linked differently
+ may fault earlier than on silicon — that is first-touch data, not a boot
+ (docs/06 §3).
+* Only temporary work directories and `<out>/` are written; QEMU is always
+ launched and killed by the tools; log reads are bounded
+ (`--log-bytes`).
+* No downloads, no `/dev`, no kernel module, no existing gdbstub, no physical
+ GPU. The 610 gate is part of both CLIs, not documentation-only.
+
+---
+
+## 8. Tests
+
+```bash
+python3 -m unittest tests.test_version_diff -v
+make test # includes these with the rest of the suite
+```
+
+`tests/test_version_diff.py` covers the gate (default refusal, token/env
+approval, gate-before-open), the `diff_corpus.py` matching discipline
+(moved/resized/added/removed, anonymous shift), the 3-column export parser,
+missing/incompatible/raw target handling, corpus hints, `ci_release --dry-run`
+side-effect freedom, clean `--help`, and a demo self-diff asserting zero
+inventory and behavior deltas. QEMU integration tests skip cleanly when QEMU
+or the demo ELF is absent.
+
+---
+
+## 9. Roadmap mapping
+
+| Item | Where this doc/tool contributes |
+|---|---|
+| **R6 — multi-version orchestration** | `version_diff` + `ci_release` prefix the matrix with a version-scoped diff |
+| **H4.1 — semantic diffing** | symbol/corpus inventory deltas and behavior profiles for two firmware versions |
+| **V1 — metrics/golden data** | deterministic self-diff and stable JSON reports support golden comparisons |
+| **docs/23 C5/C7** | release pipeline implemented and exercisable; dry-run rehearsal path |
+| **H7 — agentic harness** | self-describing bundle + docs/20 MCP tools for monitored runs |
diff --git a/debug/risc-v-gsp-rm-vm/docs/22-PORTING-CUDA-AND-DEVICES.md b/debug/risc-v-gsp-rm-vm/docs/22-PORTING-CUDA-AND-DEVICES.md
new file mode 100644
index 0000000..30f4720
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/22-PORTING-CUDA-AND-DEVICES.md
@@ -0,0 +1,353 @@
+# 22 — Porting with green-eye: CUDA userspace, GSP firmware and new GPU devices
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00-OVERVIEW for status.
+> This is the combined porting guide for the three churn axes the project
+> faces. It joins the existing CUDA/GSP/firmware procedures with green-eye's
+> runtime layer. Items marked "planned (docs/xx)" do not exist yet; do not
+> invent commands for them.
+
+## 1. The three churn axes and the anchor discipline
+
+NVIDIA ships three things on independent schedules, and any one of them can
+break `open-iov-vfio` while the other two stay still:
+
+| Axis | Examples | Primary evidence path |
+|---|---|---|
+| **CUDA userspace version** (`libcuda`) | 570.144 → 570.211.01; future 580/610 families | CUDA corpus tooling + guest smoke (`userspace/cuda/docs/07`) |
+| **GSP firmware / RM version** | 570.144 → 570.211 GSP image; RM dispatcher and control-set changes | `greeneye probe` + `compare`, host oracle (`docs/05-operations/02` §4.1) |
+| **GPU device / architecture** | TU104 → GB202 (RTX 5090), GB10 (Grace-Blackwell), aarch64 hosts | Device profile + pairing check + portability matrix (docs/07 P2/P3, docs/18) |
+
+They interact:
+
+* a userspace bump usually implies an emulation-anchor decision (guest driver
+ and emulated firmware move together) even when the firmware binary is
+ byte-identical;
+* a device change can drag a different firmware family (TU10x vs GB100 vs
+ `gb10`) and a different partition/boot model (FMC/MP-SK; Init → ACR → RM for
+ confidential compute) with it;
+* an architecture change (aarch64 host, newer NV-RISCV core) can invalidate
+ environment assumptions that the other two axes assume are stable
+ (PMU/SOE provisioning, coherent memory, ISA extensions).
+
+**Anchor discipline.** The lock-step that must stay coherent is the
+**guest driver ↔ emulated firmware profile** (the GSP generation whose protocol
+the host emulates). The host's distribution driver is **not** part of that
+lock-step: it is the oracle used for A/B comparisons, and the optional OpenRM
+host modules are only for native vGPU-management workflows. The current anchor
+is **570.144**; a version bump moves the guest driver and the emulation, not
+the host distro driver (see `docs/05-operations/02-debugging-and-maintenance-workflows.md`
+§4.1). Porting rules that follow:
+
+1. One anchor per port; never evaluate a new `libcuda` against an old emulated
+ firmware profile and call a mismatch an emulation bug.
+2. Every input is versioned and hashed: `firmware/<SERIES>-GSP/<VER>/`,
+ `userspace/cuda/<version>/`, `config/versions/<MAJOR>.yaml`. The `config/`
+ YAML files are **design targets, not live configuration** until the module
+ load path reads them (`docs/06-portability/hardware-software-matrix.md` §6).
+3. New versions live in parallel directories; the previous anchor stays
+ untouched so rollback never destroys a working setup.
+4. A port is a claim with evidence attached (M1–M6, §4), not a merged patch.
+
+## 2. Joint workflow A — CUDA userspace version port (`libcuda` bump)
+
+### 2.1 Decide what kind of port it is (minutes)
+
+Run the cheap triage before any decompilation or emulation work:
+
+```bash
+cd userspace/cuda
+tools/cudatool.py info <new>/run/libcuda.so.<new>
+tools/cudatool.py delta <old>/run/libcuda.so.<old> <new>/run/libcuda.so.<new> > <new>/run/delta.txt
+```
+
+| `delta` reading | Port class | What it implies |
+|---|---|---|
+| no export moves, small `.text` growth | patch | transfer the name map and spot-check touched subsystems |
+| new/removed exports | feature | decode the new API surface before touching the emulation |
+| many moves | reshuffle (toolchain/linker) | plan a full re-analysis; a fraction of names will not transfer |
+
+### 2.2 Static analysis vs dynamic confirmation
+
+| Step | Existing project tooling | green-eye addition |
+|---|---|---|
+| Export/interface diff | `tools/cudatool.py delta` | — |
+| Function matching / name-transfer measurement | `tools/cudatool.py fdematch --names <names.tsv>` | — |
+| Corpus-level diff (added/removed/resized functions) | `tools/diff_corpus.py <old_index> <new_index>` | — |
+| Name transfer + corpus annotation | `ApplyNameMap.java` (`--dry-run` first), `tools/annotate_corpus.py` | `./greeneye.sh symbols export` builds a runtime-address symbol DB from the same index/names |
+| Driver-API smoke | `tools/test/cuda_drv_test.c` compiled as `/tmp/cdt` (prints `ALL OK`) | `./greeneye.sh symbols resolve --db ... <addr>` spot-checks transferred addresses per call path |
+| Runtime workload | `tools/cuda-ab/ab.sh all` (`vector_add.cu`; `RESULT`/`STATUS` must match host) | session-format evidence and `./greeneye.sh compare` when firmware-side sessions exist |
+| Guest-side control observation | `tools/guest-diag/ioctl_trace.c`, host module trace (`gsp-resman-vfio:`) | annotation of the firmware-side RPC path with the per-version symbol DB |
+| Firmware-side confirmation | — | `./greeneye.sh probe` / `record` / `compare` against the paired firmware |
+
+**Be honest about the execution boundary.** `cdt` and `vectorAdd` are x86_64
+guest binaries; the RISC-V simulator does not execute them. green-eye's
+contribution to a userspace port is the **symbol/evidence layer**: a
+runtime-address DB per `libcuda` version, per-address resolution checks for
+transferred names, and a recorded comparison format for the GSP-side behaviour
+the driver drives. The print-and-verify path (`cdt`, `ab.sh`, ioctl trace)
+remains the dynamic oracle.
+
+### 2.3 Step-by-step runbook
+
+1. **Acquire and version.** Fetch the new `libcuda.so.<version>`
+ (`570.144/scripts/fetch_from_guest.sh`, or the driver package with
+ `--extract-only`). Work in a new `userspace/cuda/<new>/` tree copied from
+ `570.144/`; never overwrite an analysed version.
+2. **Cheap triage.** `cudatool.py info` + `delta`; classify the port (§2.1);
+ record `.text` growth, build-id and ABI note.
+3. **Measure transfer before copying.** Run `cudatool.py fdematch` with the
+ old `names.tsv`. The known result from 570.144 → 570.211.01: all 659
+ exports transfer exactly, verified internals 5/7, wrapper-callee internals
+ 21/29, but only **~1% of generated `wrapper_*` addresses** transfer. Never
+ move wrapper names by address arithmetic; re-resolve them with
+ `cudatool.py wrappers` and re-run `annotate_corpus.py`.
+4. **Full analysis when needed.** For a feature/reshuffle port run
+ `ghidra_full_analysis.sh`, `ghidra_export_index.sh`,
+ `ghidra_batch_decompile.sh`, `ghidra_index_merge.sh` per
+ `userspace/cuda/docs/07-version-upgrade-playbook.md` §2, then
+ `diff_corpus.py` for the measured corpus delta.
+5. **Transfer names and knowledge.** Apply the map with `ApplyNameMap.java`
+ `--dry-run` first, then for real; translate the subsystem index; re-validate
+ entries the delta marked as moved.
+6. **Build the green-eye symbol DB** for the new version so runtime addresses
+ resolve:
+
+ ```bash
+ cd debug/risc-v-gsp-rm-vm
+ ./greeneye.sh symbols export \
+ --names ../../userspace/cuda/<new>/annotations/names.tsv \
+ --index ../../userspace/cuda/<new>/run/analysis/decompiled/decompiled_index.tsv \
+ --callgraph ../../userspace/cuda/<new>/run/analysis/elf-index/callgraph.tsv \
+ --bias 0x100000 -o symbols-<new>.json
+ ./greeneye.sh symbols resolve --db symbols-<new>.json 0x<transferred-addr>
+ ```
+
+ `--bias 0x100000` is the Ghidra image base convention for `libcuda`
+ (docs/02 §6); use `0` for firmware linked at its load address.
+7. **Dynamic confirmation on the emulated stack.** Compile and run `cdt`
+ (`tools/test/cuda_drv_test.c`), with the PTRACE ioctl tracer attached, on
+ the new userspace version against the **same** emulated firmware profile:
+ every `CHECK(...)` line and the final `ALL OK` is a per-entry-point
+ assertion (`cuInit`, `cuDeviceGetCount`, `cuDeviceGet`, `cuCtxCreate`,
+ `cuMemAlloc`, `cuMemcpyHtoD`, `cuMemcpyDtoH`, `cuMemFree`, `cuCtxDestroy`).
+ Then run the runtime workload end-to-end:
+
+ ```bash
+ tools/cuda-ab/ab.sh all # host oracle + guest run + compare
+ ```
+
+ `DEVICE` lines are normalised; `RESULT`/`STATUS` must match exactly. Any
+ new or reordered control in the ioctl trace is the interesting delta.
+8. **Compare across versions.** Diff `delta.txt`, the `fdematch` report, the
+ corpus diff, and the ioctl/control sequences old vs new. Where the firmware
+ side is recorded, use the session compare primitive:
+
+ ```bash
+ ./greeneye.sh compare dumps/<old-fw-run> dumps/<new-fw-run> --symbols
+ ```
+
+ For multi-target orchestration use the portability matrix (docs/18); note
+ it targets firmware ELFs, not `libcuda`.
+9. **Only then touch the emulation** — and only if the behaviour diff requires
+ it, with the docs/07 P4 escalation rule: any change that would change
+ emulation behaviour is reproduced against the physical/vfio-mdev reference
+ first.
+10. **Update documents**: the per-version decompilation map, `names.tsv` /
+ `subsystems.tsv`, the version-upgrade playbook notes, and the interface
+ doc only if controls/capability bits changed.
+
+### 2.4 Acceptance evidence (CUDA userspace port)
+
+* [ ] `delta` report (exports added/removed/moved, `.text` growth, build-id);
+* [ ] `fdematch` transfer table with counts per source class;
+* [ ] wrapper re-resolution evidence; no wrapper transferred by address;
+* [ ] `ApplyNameMap.java --dry-run` then real run; corpus diff report;
+* [ ] `symbols-<version>.json` with provenance/bias and spot-check resolutions;
+* [ ] `cdt` output ending `ALL OK`, with the ioctl trace attached;
+* [ ] `ab.sh` host/guest comparison with `RESULT`/`STATUS` matched;
+* [ ] ioctl/control diff old vs new explained line by line;
+* [ ] firmware anchor unchanged (or explicitly changed in the same PR);
+* [ ] M1–M6 table when firmware-side behaviour moved (§4);
+* [ ] docs updated; unexplained-delta list empty (empty is a claim).
+
+## 3. Joint workflow B — new GPU device (RTX 5090 / GB10-class)
+
+### 3.1 Device profile collection
+
+| Field | Where it comes from | Artifact |
+|---|---|---|
+| PCI IDs | `lspci`, vendor docs, `config/hardware/*.yaml` (design target) | device profile |
+| BAR layout / sizes | guest `lspci -vvv`, host sysfs | device profile |
+| FB geometry (VRAM, BAR1) | host oracle `rmtool mem stats --format=json` | capture in session |
+| Engine inventory | host oracle `rmtool engine list --format=json`, guest `guest_engines.c` | inventory table |
+| Interrupt topology | MSI-X tables, guest `lspci -vvv` | profile notes |
+| ROM/VBIOS quirks | ROM read, laptop/Surface variants | profile overrides |
+| Probe-time register values | guest driver init trace (`gsp-resman-vfio:`) | expected-value list |
+
+The host oracle (`rmharness`, docs/17) is read-only and safe alongside a live
+CUDA job; capture it into a session so the numbers carry SHA-256 provenance:
+
+```bash
+cd debug/risc-v-gsp-rm-vm
+./rmharness/harness.sh --session dumps/real-<device> --name before \
+ -- rmtool engine list --format=json
+./rmharness/harness.sh --session dumps/real-<device> -- rmtool mem stats --format=json
+```
+
+### 3.2 Firmware pairing first
+
+Verify the image family (TU10x / GH100 / LS10 / `gb10`), the FMC partition
+manifest and the version string **before** debugging anything. A mismatched
+pairing presents as an emulation bug — the project learned this with 570.144
+vs 570.211. Record firmware hash, family, version string and pairing notes at
+the top of the evidence pack.
+
+### 3.3 Probe first-touch diff and symbol inventory
+
+```bash
+./greeneye.sh symbols export --index <new-export.tsv> --bias 0 -o symbols-<new>.json
+./greeneye.sh probe <new-fwimage> --symbols symbols-<new>.json --record
+./greeneye.sh probe <reference-fwimage> --symbols symbols-<ref>.json --record
+```
+
+The probe loads the segments, runs bounded and reports the first divergence
+(faulting instruction, access address/size, stopped PC annotated from the
+symbol DB). Raw `.fwimage` targets fault early by design — that first-touch
+map is the data, not a failure. Changed first-touch addresses across devices
+usually point at profile data (register windows, boot args), not logic
+(docs/07 P2.2). The symbol inventory is the merged ELF/Ghidra DB per target;
+provenance (export file, bias) is recorded in the DB meta.
+
+### 3.4 Device-model implications (H2/H5)
+
+What the probe reveals feeds the roadmap, not the product directly:
+
+* **H2 (platform device model)** — falcon/SEC2 windows, CSB/TRANSCOM mailboxes,
+ WPR2/FWSEC read-back state, PTIMER, PMU stub, DMA/IOMMU. A device that
+ touches a different first register set changes the modelled register map
+ before the image can reach its message loop.
+* **H5 (adjacent microcontrollers)** — PMU/SEC2/ACR/SOE mailbox protocols and
+ boot ordering; a new device family may change identity/clock provisioning or
+ the Init → ACR → RM boot sequence.
+* Entry gate for device-model work is the portability task itself (docs/08 R1);
+ until then use probe + rehosted uproc paths.
+
+### 3.5 Folding into the hardware-software matrix
+
+The project's support ladder is `docs/06-portability/hardware-software-matrix.md`
+(Reference / Supported / Experimental / Target / Out of scope). A device is
+"tested and documented", not "should work". The fold-in:
+
+1. Run `harness/portability_matrix.py` across the reference and candidate
+ device firmware with the same scenario options (docs/18 §1); keep
+ `<out>/portability-report.json`, per-pair compare reports and sessions.
+2. The planned version-diff/CI pipeline (docs/21-CI-RELEASE-PIPELINE.md,
+ planned) is where those reports become release-gated artifacts; until it
+ exists, attach them to the matrix process manually.
+3. Update the device profile (documentation today; `config/hardware/*.yaml` is
+ not read by code — do not claim configuration-driven portability).
+4. Validate on the physical device with the supervised harness before claiming
+ support; simulator-only evidence never justifies the claim (docs/06 §6).
+
+### 3.6 aarch64 host / GB10-class design notes
+
+* **Host tooling** — green-eye is Python + QEMU; on aarch64 install
+ `qemu-system-misc` (provides `qemu-system-riscv64`) and the same commands
+ run. Keep host-specific paths out of session metadata (docs/07 P3.5).
+ `GREENEYE_QEMU` is the existing escape hatch if the binary lives elsewhere.
+* **Firmware family** — GB10-class devices pair a different firmware family
+ (`gb10`/GB100-era) than TU104's `57X-GSP`; treat the fwimage, FMC manifest
+ and PMU/SOE images as a new family, not a re-badged TU10x image.
+* **Boot/partition model** — expect the newer FMC/manifest partition model and,
+ for confidential-compute paths, the Init → ACR → RM flow; the rehost shim
+ needs the matching entry/return convention (docs/09 §4, docs/07 P3).
+* **PMU/SOE deltas** — identity and clock provisioning flow through PMU/SOE;
+ any change there invalidates prior environment assumptions and must be
+ recorded before probe results are interpreted.
+* **ISA/core** — confirm the image is still RV64IMAC or has moved to RV64GC
+ (`file`/`readelf`); update rehost `-march` flags accordingly. QEMU supports
+ RV64GC even where the bundled demo uses IMAC.
+* **Memory model** — Grace/GB10 coherence and page-size/cache-attribute
+ differences are memory-model risks (hardware-software-matrix §8); test on
+ aarch64 early rather than assuming x86 results transfer.
+
+## 4. Evidence and metrics contract for any port
+
+The metric contract is `docs/06-ACCURACY.md` §5; it is authoritative for every
+port type:
+
+| # | Metric | How it is measured in a port |
+|---|---|---|
+| M1 | Inventory coverage | observed controls/RPCs vs emulated handled set; host oracle inventory |
+| M2 | Reply agreement | field-by-field reply diff, canonical-size and consumed-field checks |
+| M3 | Ordering agreement | sequence alignment of the control/event stream (LCS/edit distance) |
+| M4 | State agreement | firmware state transitions vs the emulation state model |
+| M5 | Error-path agreement | injected faults take the same status/notifier path |
+| M6 | Memory side-effects | read/write map vs shadow state; snapshot hashes at fixed points |
+| M7 | Timing | **not claimed**; order-of-events budgets only |
+
+Rules: at least an A/B pair per conclusion; every claim carries its fidelity
+level (L0–L3) and provenance; a simulator observation is evidence about the
+firmware, not silicon; any product change is confirmed against a physical
+capture first. The evidence pack is docs/07's checklist plus docs/14 §6.
+
+**Checklist per port type**
+
+CUDA userspace (A):
+* [ ] §2.4 items complete; firmware anchor documented.
+
+GSP firmware version (P1):
+* [ ] firmware hash + version string + pairing notes;
+* [ ] symbol DB hash and Ghidra export provenance;
+* [ ] probe first-touch old vs new, every changed address explained;
+* [ ] portability matrix report + per-pair `compare`;
+* [ ] M1–M6 table with reference-capture method and L2/L3 limits;
+* [ ] existing guest ladder has no new regression;
+* [ ] every new/changed control has a version-keyed handler or explicit
+ "not used" note.
+
+Device (P2):
+* [ ] profile fields (§3.1) recorded with host-oracle captures;
+* [ ] pairing verified before any debugging;
+* [ ] probe first-touch diff explained as profile vs logic;
+* [ ] engine inventory complete and validated against the host oracle;
+* [ ] matrix entry and evidence published; physical validation done before the
+ support claim.
+
+Architecture (P3):
+* [ ] ISA/image shape confirmed (`file`/`readelf`), rehost flags updated;
+* [ ] partition/boot model identified; shim convention matched;
+* [ ] PMU/SOE deltas recorded; host portability verified on aarch64;
+* [ ] all P2 items.
+
+## 5. Common failure modes and decided responses
+
+| Failure mode | Signature | Decided response | Source |
+|---|---|---|---|
+| Mismatched guest driver ↔ firmware pairing | failures indistinguishable from emulation bugs (e.g. 570.144 image vs 570.211 driver) | verify hash/version string/pairing before debugging; fix the pairing, not the emulation | docs/07 P2.2; docs/05-operations §4.1 |
+| Runfile/metapackage version drift | a different major resolves than requested | check the candidate version; prefer the exact-version runfile | docs/05-operations §4.1 pitfalls |
+| Silent control-shape change | message layout changes break the emulator with no error | M2 canonical-size + consumed-field checks; per-control probes; decode against the exact-version headers | docs/05-operations §4.1 table |
+| Engine-inventory change blocking context creation | `cuCtxCreate` fails because an expected engine (e.g. `COPY14`) is absent | complete the inventory per device/driver profile, validate against `rmtool engine list`; treat inventory as profile data | hardware-software-matrix §6.2 |
+| Wrapper re-layout on a patch bump | wrapper names resolve to wrong addresses | transfer exports/internals only; re-resolve wrappers with `cudatool.py wrappers`; never add a delta to old wrapper addresses | userspace/cuda/docs/07 §3 |
+| Firmware address drift per build | stale Ghidra addresses produce nonsense | re-derive addresses per firmware build; never carry addresses into a new configuration | docs/05-operations §4.1 pitfalls |
+| Host driver assumed to be in lock-step | A/B oracle "updated" and treated as the target | host distro driver is the oracle only; the anchor is guest driver ↔ emulated firmware | docs/05-operations §4.1 |
+| Simulator-only divergence | `compare` delta that physical capture cannot reproduce | record as a fidelity gap; do not change the product from it | docs/06 §6; docs/07 P4.5 |
+| QEMU missing on an aarch64 host | `qemu-system-riscv64 not found` | install `qemu-system-misc` or set `GREENEYE_QEMU` | docs/02 §9; docs/07 P3.5 |
+
+## 6. Roadmap hooks
+
+| Port activity | Roadmap item | What it adds | Gate |
+|---|---|---|---|
+| First-touch map and boot of real firmware | H2 / R1 | device model so ports boot beyond the first unmapped access | portability/accuracy task requires it |
+| Semantic diff of dispatch/handler/control sets | H4.1 | automated portability delta report; per-version symbol corpora | H1+H2 |
+| Capability inference across new devices | H4.2 | access map vs capability model; least-privilege findings | watchpoints (done); TCG-session fold (R4) |
+| Mutation corpus for new input surfaces | H4.3 / R5 | fail-closed rules validated per version | R1/R2 |
+| Differential execution and reimplementation loop | H4.5 | every port lands with A/B sessions and the M1–M6 table | reference-capture pipeline |
+| Adjacent-controller work for GB10-class bring-up | H5 | PMU/SEC2/ACR/SOE models, cross-controller sessions | H2.2 |
+| Multi-target orchestration | R6 / docs/18 | one report across firmware/device targets | v0.1 stable |
+| Version diff and release gating | planned (docs/21) | CI pipeline that gates a port on the evidence bundle | pipeline implemented |
+| Control discovery | planned (docs/19) | systematic control/class inventory across versions | document exists |
+
+Program-level success criterion served here: a new GSP version (or device) can
+be triaged — inventory diff + behaviour diff + metric table — in **hours**,
+with recorded evidence.
diff --git a/debug/risc-v-gsp-rm-vm/docs/23-RM610-READINESS.md b/debug/risc-v-gsp-rm-vm/docs/23-RM610-READINESS.md
new file mode 100644
index 0000000..18d93bd
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/23-RM610-READINESS.md
@@ -0,0 +1,259 @@
+# 23 — RM 610 port readiness and approval gate
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00-OVERVIEW for status.
+> This document defines the gate, not a claim: readiness is **not** met today
+> (2026-10-02). It exists so no one starts the port early.
+
+**The RM 610 port begins only after explicit human approval.** That is a hard
+gate, not a scheduling preference: until a named human approves it in writing,
+nothing in this repository fetches, downloads, extracts or analyses a 610 GSP
+firmware image, and no code or document treats 610 as an anchor or a supported
+target. The gate is repeated in the sign-off checklist (§6) and is the first
+thing the approval message must acknowledge.
+
+## 1. Hard gate
+
+| Rule | Meaning |
+|---|---|
+| No acquisition | No 610 runfile, GSP `.fwimage`, driver package or firmware blob is fetched, extracted, copied or mirrored before approval. |
+| No analysis | No decompilation, symbol export, probe, corpus diff or pairing comparison against a 610 image before approval. |
+| No emulation edits | No version-keyed handler, signature entry or profile edit justified by "610" before approval; changes require current-version evidence. |
+| No physical-GPU work | No unsupervised physical-GPU cycle, mdev claim or driver bind/unbind for 610; the project rule is no unattended physical-GPU cycles (GREEN-EYE-ROADMAP H7). |
+| No support claims | 610 is not entered in the hardware/software matrix or the version matrix until it passes the docs/22 port checklists with evidence. |
+
+Precision notes:
+
+* The pinned `nvidia-open-gpu-kernel-modules` checkout used as build glue
+ (recorded in `SBOM.md`) is **not** a GSP firmware image and is **not** a 610
+ port; it neither authorises acquisition nor counts as analysis.
+* This document, docs/22 and the current-version test runs are the only
+ 610-adjacent work permitted today. Everything else waits for approval.
+* Approval is scoped and revocable: it covers named inputs and named
+ activities, on a named date, by a named approver.
+
+### 1.1 What approval does and does not cover
+
+| Approval covers | Approval does not cover |
+|---|---|
+| Acquiring the named 610 inputs through the named channel | Any other 610 artifact or channel not listed in the approval message |
+| Analysis, probing, diffing and evidence capture on the 610 inputs | Merging an emulation change without the docs/22 A/B evidence |
+| Running the docs/22 port workflow and the pipeline dry-run | Physical-GPU execution without a separate supervised plan |
+| Publishing 610 evidence into the port log | Entering 610 as "Supported" in the matrix before validation |
+| Re-asking for a scope extension | Retroactively blessing work done before approval |
+
+An approval with an open-ended scope is invalid; ask again with the specific
+inputs and activities. A denied or expired approval leaves the repo in the
+state described in §5.
+
+## 2. Readiness criteria (all must be true)
+
+Every row must pass on the **current anchor** (570.144) before approval is even
+requested. "All must be true" is literal: a single open item keeps the gate
+closed.
+
+| # | Criterion | How it is verified | Evidence | Status today |
+|---|---|---|---|---|
+| C1 | All tools implemented and tested on the current driver/firmware | full test suites green (documents/demo/probe/inject/Ghidra/harness) | test logs attached to the request | partial (MCP/CI missing) |
+| C2 | Tests green | `make test` and the harness/rmharness suites pass | transcript with date and commit | green at v0.1 (55 tests) |
+| C3 | Demo, probe, inject, Ghidra work | `./greeneye.sh demo`, `probe`, `inject`, `ghidra` all demonstrated | session dirs + transcript | working per docs/00 |
+| C4 | MCP monitoring works | MCP plugin implemented and exercised (roadmap R3, docs/08; docs/15) | tool list + a monitored run | **not implemented (planned R3)** |
+| C5 | CI pipeline works | release/CI pipeline implemented and exercised (planned docs/21) | pipeline run with artifacts | **not implemented (planned docs/21)** |
+| C6 | Docs complete and accurate | doc review checklist (§3.2) signed | review record | to complete |
+| C7 | Discovery/version-diff/CI exercised on the current version | control discovery (planned docs/19) + `harness/portability_matrix.py` + `compare` + pipeline dry-run on 570.144 | report + session hashes | partial (matrix/compare exist; docs/19 planned) |
+| C8 | Harness produces a full evidence bundle | docs/07 checklist + docs/14 §6 pack produced end to end for a current-version port rehearsal | bundle directory with SHA-256 inventory | to complete |
+| C9 | Human has approved | signed message (§6) | approval ID recorded in the request thread | pending |
+
+C4 and C5 are the long poles. Do not silently downgrade them: if the human
+chooses to waive a criterion, the waiver is written into the approval message
+with its risk and the affected scope.
+
+## 3. What "ready" looks like concretely
+
+### 3.1 Exact commands that must pass
+
+Run from `open-iov/debug/risc-v-gsp-rm-vm/` (docs/02 §8 has the make targets):
+
+```bash
+make demo
+make test
+python3 -m unittest tests.test_harness -v
+python3 rmharness/tests/test_rmharness_plugin.py
+./greeneye.sh info
+./greeneye.sh demo
+./greeneye.sh demo --backend native
+./greeneye.sh probe
+./greeneye.sh inject
+./greeneye.sh symbols export --index <export.tsv> --bias 0 -o symbols-<ver>.json
+./greeneye.sh analyze dumps/<run> --symbols symbols-<ver>.json
+./greeneye.sh compare dumps/<a> dumps/<b> --symbols
+```
+
+Plus, on the current version:
+
+```bash
+python3 harness/portability_matrix.py \
+ --target firmware/uproc_demo/build/uproc_demo.elf \
+ --target firmware/uproc_demo/build/uproc_demo.elf \
+ --out dumps/matrix-selfcheck
+python3 harness/mutation_harness.py --out dumps/mutation-selfcheck \
+ --iterations 12 --seed 7
+python3 harness/mutation_harness.py --out dumps/mutation-replay \
+ --iterations 12 --seed 7
+```
+
+Required results:
+
+* every suite green, with installed QEMU; skips are explained, not ignored;
+* `portability_matrix` self-comparison reports **zero deltas** (`all_zero`);
+* mutation `findings.jsonl` is byte-identical between the two seeded runs;
+* `greeneye compare` on two identical runs is empty;
+* `analyze` renders a coherent session (hook/watch/breakpoint events, UART
+ banner liveness per docs/06 §7).
+
+### 3.2 Doc review checklist
+
+* [ ] 00–18 match the code on the current commit (commands, flags, status
+ tables, test counts);
+* [ ] `docs/22` porting guide is accurate for the current tools; planned items
+ are labelled `planned (docs/xx)`;
+* [ ] `docs/19-CONTROL-DISCOVERY.md` (planned) exists and has been run once on
+ the current version, or C7 is explicitly waived;
+* [ ] `docs/21-CI-RELEASE-PIPELINE.md` (planned) exists and has run once on the
+ current version, or C5 is explicitly waived;
+* [ ] `GREEN-EYE-ROADMAP.md` and `docs/08` status claims match reality;
+* [ ] no local source paths, no invented command names, no unsupported
+ "supported/accurate" claims.
+
+### 3.3 Golden sessions and regression data in place
+
+* [ ] golden demo session(s) checked in or reproducible by a documented seed;
+* [ ] matrix self-comparison zero-delta output archived;
+* [ ] mutation replay corpus archived with its seed and budget;
+* [ ] rmharness host-oracle captures for the current anchor archived
+ (`rmtool engine list --format=json`, `mem stats --format=json`);
+* [ ] a full current-version evidence bundle (docs/07 checklist + docs/14 §6)
+ produced once, as the rehearsal for the 610 run.
+
+## 4. Dry-run plan for the day of approval (no execution today)
+
+Executed only after the approval in §6 exists. Steps 1–2 are acquisition and
+must not happen beforehand.
+
+1. **Confirm the approval.** Record approval ID, approver, date, scope and any
+ waived criterion in the port log.
+2. **Obtain the 610 inputs through the approved channel.** The 610 driver
+ runfile and matching GSP `.fwimage` (or the approved firmware source) are
+ fetched through the channel named in the approval. Record the runfile
+ version, firmware hash (MD5/SHA-256), version string and image family
+ immediately; extract the `.fwimage` per docs/05-operations §4.1 step 1.
+3. **Lay down versioned directories.** `firmware/<SERIES>-GSP/610/`,
+ `userspace/cuda/610/` (copied skeleton), `config/versions/610.yaml`
+ (documentation until the load path reads it). Never overwrite a working
+ version; keep 570.144 untouched.
+4. **Pair and probe.** Build the 610 symbol DB (`analyze_fwimage.py`,
+ `symbols export --bias 0`), probe the 610 image and the 570.144 reference
+ with the same bounded settings, and diff the first-touch maps. Pairing
+ mismatch stops the run here.
+5. **Baseline vs candidate orchestration.** Run
+ `harness/portability_matrix.py` with the 610 and 570.144 targets and
+ identical scenario options; run the mutation harness once per version with
+ the same seed; archive both reports and every session.
+6. **Pipeline dry-run, then real.** Run the release pipeline (planned
+ docs/21-CI-RELEASE-PIPELINE.md) in its dry-run mode first; inspect the
+ planned artifact list without publishing. Only after the dry-run is clean,
+ run the real pipeline on the 610 evidence bundle.
+7. **Expected artifacts.**
+ * approval record and input hashes;
+ * 610 symbol DB + Ghidra export provenance;
+ * probe reports (610 and reference) and the first-touch diff;
+ * `portability-report.json`/`.md`, per-pair compare reports, sessions;
+ * mutation `findings.jsonl` + summary for both versions;
+ * M1–M6 table with reference-capture method and L2/L3 limits;
+ * doc updates per docs/22 §3.5/§4 and the matrix entry (if and only if the
+ support claim is earned).
+8. **Decision points.** Stop and keep the current anchor if: pairing is
+ ambiguous; any unexplained probe delta remains; the engine/RPC inventory
+ diff is not fully explained; M1–M6 regress on the current workload; the
+ evidence bundle is incomplete. Forward only when every delta has a decided
+ response.
+9. **Rollback / keep-old-version rules.** The 570.144 directories,
+ configuration and guest driver stay untouched; rollback = return to the old
+ anchor commit/tag and the old guest driver (docs/05-operations §4.1 step 6).
+ A forward attempt never destroys the working setup; do not delete the old
+ version tree and do not reuse 570.144-derived addresses for 610.
+
+## 5. What must NOT happen before approval
+
+| Prohibited action | Why it is prohibited |
+|---|---|
+| Download/fetch 610 blobs or runfiles | Version discipline and evidence integrity: an unapproved input hash cannot anchor a later claim; acquisition is the act the gate exists to control. |
+| Probe or decompile a 610 image | Analysis before readiness contaminates the evidence chain and tempts ad-hoc emulation edits. |
+| Edit emulation code "for 610" | No current-version evidence justifies it; version-keyed handlers must be driven by a measured inventory diff (docs/05-operations §4.1 step 4). |
+| Touch the physical GPU for 610 | Project hard rule: no unattended physical-GPU cycles; physical validation is a supervised step after approval. |
+| Try the 610 driver in a guest | The guest never contains Open-IOV code and the anchor is fixed; an unpaired driver failure looks like an emulation bug (docs/07 P2.2). |
+| Record 610 in the support/version matrix | Support means tested and documented; an untested entry violates the matrix contract (hardware-software-matrix §1/§6). |
+| Treat the pinned open-module tag as approval | Build glue is not a GSP image and not an authorised port input (SBOM.md). |
+
+Permitted before approval: reading this document and docs/22, running all
+current-version tests, producing and archiving the current evidence bundle,
+and preparing the approval request.
+
+## 6. Sign-off checklist and approval message
+
+### 6.1 Human sign-off (one page)
+
+* [ ] **The RM 610 port begins only after explicit human approval.**
+* [ ] C1–C9 all true, or each waiver is listed with its scope and risk.
+* [ ] Test transcript attached (date, commit, `make test` green).
+* [ ] `portability_matrix` self-comparison zero deltas; mutation replay
+ byte-identical.
+* [ ] Doc review checklist (§3.2) complete.
+* [ ] Current-version evidence bundle complete with SHA-256 inventory.
+* [ ] Golden sessions/regression data in place (§3.3).
+* [ ] Approval message (§6.2) sent and answered with an approval ID.
+* [ ] Named approver accepts the acquisition scope and the permitted channel.
+
+```
+Approver: ______________________ Role: ______________________
+Approval ID: ____________________ Date: ______________________
+Scope limits / waivers: ________________________________________
+Signature: ______________________
+```
+
+### 6.2 Approval request message (exact format)
+
+Send exactly this shape; fill every `<...>` and leave "Waivers" as "none" if
+empty:
+
+```
+RM 610 PORT APPROVAL REQUEST
+
+Anchor today: 570.144 (firmware <sha256>; guest driver <md5>)
+Target: RM 610 (requested input: <runfile version / approved source>)
+Requested scope: acquire the 610 driver + GSP firmware through <approved
+channel>; lay down versioned dirs; analyse, probe, diff, and run the docs/22
+port workflow; no product change before A/B evidence.
+
+Readiness, docs/23 §2:
+ C1 tools tested .......... <pass / waived>
+ C2 tests green ........... <pass / waived>
+ C3 demo/probe/inject/Ghidra <pass / waived>
+ C4 MCP monitoring ........ <pass / waived>
+ C5 CI pipeline ........... <pass / waived>
+ C6 docs complete ......... <pass / waived>
+ C7 discovery/diff/CI ..... <pass / waived>
+ C8 evidence bundle ....... <pass / waived>
+ C9 human approval ........ requested by this message
+
+Waivers: <none / list with reason>
+Evidence bundle: <path>
+Commit: <git rev>; transcripts: <paths>
+Signed: <name/role>, <date>
+
+Decision (human): APPROVED / DENIED
+Approval ID: <id>; Date: <date>; Scope limits: <text>
+```
+
+After approval, the 610 port proceeds only through the docs/22 workflow and
+the docs/05-operations §4.1 version discipline. Until that message exists,
+this repository is a 610 no-fly zone.
diff --git a/debug/risc-v-gsp-rm-vm/docs/24-DEVICE-MAP.md b/debug/risc-v-gsp-rm-vm/docs/24-DEVICE-MAP.md
new file mode 100644
index 0000000..4430bf9
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/24-DEVICE-MAP.md
@@ -0,0 +1,207 @@
+# 24 — Device map: first-touch MMIO and lenient native execution
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+> This is the H2.1 data layer and the first H2.2 building block, not a device
+> model. It carries the same fidelity rules as `06-ACCURACY.md`: L3 remains
+> **absent**; nothing here is a hardware, timing or security claim.
+
+## 1. Purpose and status
+
+`greeneye-device-map/1` records which MMIO addresses a firmware image touched
+first, at which width and direction, with observed values, hit counts and
+provenance. It is JSON data, versioned with the firmware that produced it, and
+consumed by the native simulator's lenient MMIO mode. It exists so the R1
+device work (H2.2) starts from measured addresses instead of guesses, and so
+the real GSP image can be stepped past its first unmapped access to collect
+the *next* ones.
+
+| Piece | Where |
+|---|---|
+| map model, harvest, query, CLI | `greeneye/device_map.py` |
+| lenient / fault MMIO policy | `sim/machine.py` (`Machine(device_map=..., unknown_mmio=...)`) |
+| backend plumbing | `greeneye/sim_backend.py` (`launch_native(..., device_map=..., unknown_mmio=..., learn_mmio=..., bias=...)`) |
+| tests | `tests/test_device_map.py` |
+
+## 2. The model
+
+An entry is one `(address, width, direction)` observation:
+
+| field | meaning |
+|---|---|
+| `address` | the MMIO address as accessed (64-bit; 61-bit GSP addresses fit without allocating memory) |
+| `width` | access size in bytes (1/2/4/8) |
+| `kind` | `"r"` or `"w"` |
+| `values` | observed values (first-seen order, deduplicated); empty for probe-harvested reads because the QEMU log records the fault, not the value |
+| `hits` | number of times observed at this key |
+| `first_epc` | the first observing PC as executed (relocated) |
+| `first_epc_fw` | `first_epc - bias`: the firmware's own virtual address; a PC of 0 (trap-vector fetch) stays 0 |
+| `source` | `probe` (harvested), `manual` (hand-written), `trace` (learned at runtime) |
+| `notes` | fault/exception provenance, e.g. `fault_load reason=rejected` |
+
+The file carries `meta` with the schema version, harvest/learn source, the
+relocation `bias`, `entry`, the firmware path, its `sha256` (when the file is
+readable) and `gsp_version.txt` (sanitised), plus `qemu_fault_count` when
+harvested. Entries are always sorted by `(address, width, kind)` and the file
+is written with `sort_keys=True`, so harvesting the same log twice is
+byte-identical; there are no timestamps.
+
+`DeviceMap.lookup(addr, size, write)` returns a behaviour spec:
+
+* `value:0x...` — a read observed to return this value;
+* `zero` — the address is known (or the direction is unknown) and reads back 0;
+* `unimplemented` — no evidence at this address.
+
+Exact `(address, width, kind)` wins; a different width at the same address is
+still evidence for the register. A write to a read-observed address is
+absorbed; a read of a write-only register reads back zero.
+
+## 3. Harvesting from `greeneye probe`
+
+`greeneye probe` (unchanged) records `blobs/qemu.log` and the parsed
+`qemu_first_fault` in `session.json`. Harvest reads either:
+
+```bash
+# 1) run a bounded probe against the real image (QEMU path, existing command)
+./greeneye.sh probe # writes dumps/<stamp>-probe-<image>/
+
+# 2) turn that session into a device map
+python3 -m greeneye.device_map harvest dumps/<stamp>-probe-<image> \
+ --out dumps/570.144-device-map.json
+
+# 3) inspect / merge maps later, e.g. across firmware versions
+python3 -m greeneye.device_map show dumps/570.144-device-map.json
+python3 -m greeneye.device_map merge a.json b.json --out merged.json
+```
+
+Harvesting the recorded TU104 570.144 probe session
+(`dumps/20261002-091222-probe-gsp-570.144.fwimage`) gives two entries — the
+first-touch MMIO read and the `mtvec=0` fetch loop QEMU falls into afterwards:
+
+```json
+{"schema": "greeneye-device-map/1", "entries": [
+ {"address": 0, "width": 2, "kind": "r", "hits": 367551, "first_epc": 0,
+ "first_epc_fw": 0, "source": "probe", "notes": "fault_fetch reason=rejected"},
+ {"address": 2305843009213696512, "width": 4, "kind": "r", "hits": 1,
+ "first_epc": 2214629396, "first_epc_fw": 67145748, "source": "probe",
+ "notes": "fault_load reason=rejected"}]}
+```
+
+`2305843009213696512 == 0x2000000000000A00`, `67145748 == 0x4009014`; `meta`
+also carries the firmware sha256
+(`2d1bb5c6…`), `firmware_version: 570.144W` and `qemu_fault_count: 367552`.
+Programmatic forms: `harvest_probe_session(session_dir)` and
+`harvest_from_text(log_text, bias=...)` for arbitrary logs; `DeviceMap.save`,
+`DeviceMap.load`, `merge`, `lookup`.
+
+## 4. Lenient MMIO in the native simulator
+
+`sim.machine.Machine` gained three knobs (all default to today's behaviour):
+
+```python
+Machine(memory_mb=256, ram_base=0x80000000,
+ device_map=None, unknown_mmio="fault", learn_mmio=False, bias=0)
+```
+
+* `unknown_mmio="fault"` (default) — **byte-for-byte M1 parity**: an access
+ that is neither RAM nor UART faults exactly as before, and nothing is logged
+ or counted.
+* `device_map=...` — mapped entries are served in *either* mode; a mapped
+ address that is unknown to the map is still `unimplemented`.
+* `unknown_mmio="lenient"` — an unmodelled access returns the mapped
+ behaviour, else zero; it is appended to `machine.mmio_log` as
+ `{pc, addr, size, rw}` and counted in `machine.mmio_unknown`. No logging
+ occurs when lenient mode is off. Debugger `read_mem`/`write_mem` stay
+ strict.
+* `learn_mmio=True` — lenient observations are written back into the map
+ (`source: "trace"`, read value `0`, write value as written); with no map
+ given, one is created lazily. Values recorded with `pc` and `bias`.
+
+Through the backend:
+
+```python
+session = ge.launch(kernel=..., backend="native",
+ device_map="dumps/570.144-device-map.json",
+ unknown_mmio="lenient")
+session.target.mmio_log # [{pc, addr, size, rw}, ...]
+session.target.mmio_unknown # int
+```
+
+`launch_native(..., device_map=..., unknown_mmio=..., learn_mmio=..., bias=...)`
+takes the same options; `device_map` may be a `DeviceMap` or a saved path, and
+`bias` relocates images linked for GPU physical addresses. The lenient path is
+only entered on a miss, so RAM/UART/trace throughput is unchanged; recording
+is a plain list append and the map lookup is a dict hit.
+
+**What lenient proves, honestly:** an unmodelled address can be served a
+mapped/zero value and execution continues deterministically; a bounded run
+lists every unmodelled access and their PCs. It does **not** prove what a real
+device returns, does not model read-back, side effects, interrupts or timing,
+and a zero that keeps the firmware running is a hypothesis to be checked
+against the H2.2 device model (and eventually hardware), not a finding about
+silicon.
+
+## 5. Real-image demonstration (bounded, measured 2026-10-02)
+
+```bash
+python3 -m greeneye.device_map demo --instructions 200000
+```
+
+```text
+image: /home/rmcore/Git/integdev_gpu_drv/open-iov/firmware/57X-GSP/570.144/gsp-570.144.fwimage.bin
+sha256: 2d1bb5c60b208cfd115f015b17dd04a6fece05fd2c0dcba3625d9d48cce3ceea
+elf: entry=0x4009000 vaddr=[0x0,0x5bb9000)
+bias: 0x80000000 relocated entry: 0x84009000
+budget: 200000 instructions
+stopped: reason=budget pc=0x84009070 (fw 0x4009070)
+unknown mmio accesses: 1
+ first unknown: pc=0x84009014 (fw 0x4009014) addr=0x2000000000000a00 size=4 rw=r
+[device-map] qemu probe first fault: fw vaddr 0x4009014 reading 0x2000000000000a00 (load access fault)
+[device-map] verdict: first-touch address reproduced; lenient execution continued for 200000 instructions (199994 past the qemu trap)
+```
+
+Comparison against the QEMU probe (`docs/06 §3`, still current): QEMU executes
+six instructions and traps at firmware vaddr `0x4009014` on the 4-byte read of
+`0x2000000000000A00`, then loops on `mtvec=0` fetch faults. The lenient native
+run reproduces the same first access at the same PC (the `pc` in `mmio_log` is
+the accessing instruction, not the run start), returns 0, and continues
+through the entire 200 000-instruction budget, ending at firmware vaddr
+`0x4009070` with **exactly one** unmodelled access logged. It does not fault.
+
+**61-bit addresses.** `0x2000000000000A00` is a valid 64-bit value in the
+interpreter's address computation; no memory is allocated for it. RAM is
+bounds-checked by `addr - ram_base`, the UART window by range, and everything
+else goes to the device map / lenient handler. (`1 << 61` is well inside the
+RV64 address width; the only care needed is that no map or device code ever
+treats MMIO addresses as host indices.) `--out map.json` saves the learned
+map; `python3 -m greeneye.device_map show map.json` renders it.
+
+## 6. How this feeds H2.2
+
+* The harvested entries are the first test vectors for the `falcon`/`CSB`/
+ `pmu_stub` windows in the docs/16 profile DSL: group entries by page
+ (`address & ~0xFFF`), take `lookup()` behaviour as the initial read-back,
+ and replace `zero` with real semantics one window at a time.
+* `learn_mmio=True` is the bring-up loop: run lenient, inspect the learned
+ map + `mmio_log`, promote hand-verified entries to `source: "manual"`, and
+ re-run strict to see the next unmapped wall. Each promotion should carry a
+ note and the firmware hash, so the map is a reviewable artifact.
+* The map's `bias`, firmware sha256/version and schema make it profile data
+ keyed by `(device, firmware-family, profile-version)`, as required by
+ docs/16 §7.
+* The same maps are the YAML/JSON seed for the QEMU `-device` model, keeping
+ the two backends on one source of truth.
+
+## 7. API and commands
+
+| Call | Purpose |
+|---|---|
+| `harvest_probe_session(dir)` | session dir -> `DeviceMap` |
+| `harvest_from_text(text, bias=0)` | arbitrary QEMU log -> `DeviceMap` |
+| `DeviceMap.lookup(addr, size, write)` | `zero` / `value:0x...` / `unimplemented` |
+| `DeviceMap.record(...)`, `merge(...)`, `save(path)`, `load(path)` | authoring/persistence |
+| `python3 -m greeneye.device_map harvest\|show\|merge\|demo` | CLI |
+| `python3 -m pytest tests/test_device_map.py` | 20 unit/parity/real-image tests |
+
+Testing honesty: the unit tests are synthetic (no QEMU); the real-image test
+is bounded to 200 000 instructions and skips when the fwimage is absent.
+`tests/test_sim_backend.py` remains the M1 parity guard and passes unchanged.
diff --git a/debug/risc-v-gsp-rm-vm/docs/25-VALUE-VALIDATION.md b/debug/risc-v-gsp-rm-vm/docs/25-VALUE-VALIDATION.md
new file mode 100644
index 0000000..d47b8ea
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/25-VALUE-VALIDATION.md
@@ -0,0 +1,337 @@
+# 25 — Value validation: measurable time-to-value evidence
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See
+> [00-OVERVIEW](00-OVERVIEW.md) for status. Every number in this document is
+> either **measured** on the bundled demo analog (recorded in
+> `harness/value_demos/artifacts/`) or explicitly labelled a **projection**
+> for the real case. No demo claims a real-GSP result the simulator cannot
+> reach; device-model-blocked scenarios say so in their result.
+
+## 1. Why this document exists
+
+green-eye is only worth its maintenance if it turns real firmware questions
+into answers faster than static analysis and supervised hardware cycles. The
+project handoffs record those questions and their costs in wall-clock and
+cycles: the 0x59 RM-init chase (PART 91, 2026-10-01), the 20 GR counters
+(PARTs 46–64, 2026-09-24…29), the GPFIFO GET fault (PART 91 ADDENDUM 9), the
+P6 service wiring (PART 92 ADDENDA 5–11, 2026-10-02), and the version pairing
+question (570.144 vs 570.211). This document maps each of them to a runnable
+demonstration under [`harness/value_demos/`](../harness/value_demos/), states
+the baseline cost from the handoffs, and measures the green-eye path.
+
+## 2. Methodology
+
+### 2.1 What is measured, what is projected
+
+| Class | Meaning | Where it appears |
+|---|---|---|
+| **Measured (analog)** | Wall-clock of the green-eye path on the bundled `uproc_demo` firmware, native RV64 interpreter or QEMU; answers, counts, session bytes, trace slowdown | `result.json` per demo, `artifacts/results.json` |
+| **Recorded (real baseline)** | Cost actually paid on the physical path, as recorded in the handoffs (dates, cycles, PARTs, wall spans) | `real_case.static_baseline` in each `result.json`, §5 below |
+| **Projected** | What the same tool path would save on the real firmware once H2.2 executes it; labelled `projected` | `real_case.projection`; never presented as a result |
+| **Blocked** | Real firmware access requires the H2.2 QEMU device model; `blocked_on_device_model: true` | every real `.fwimage` scenario here |
+
+Fidelity is exactly docs/06: L0 instruction semantics and L1 memory behaviour
+are exercised; **L2 time/concurrency is not modelled** and **L3 device
+environment is absent**. A green-eye observation is evidence about the
+firmware, and anything that would change `open-iov-vfio` is escalated to a
+physical capture before the product changes (docs/06 §6, docs/14 §7).
+
+### 2.2 Run contract
+
+* Each demo is a stdlib-only script run from the tool root; it prints the
+ question, commands, answer, evidence and elapsed time, then one
+ `RESULT_JSON: {...}` line.
+* `run_all.py` executes each demo in a subprocess with a hard timeout,
+ aggregates `artifacts/results.json`, and enforces the suite budget
+ (per-demo ≤ 45 s, suite ≤ 180 s).
+* Demos are deterministic: native sessions use virtual time (instruction
+ count), stimuli are fixed, and the version-diff patch site is verified
+ unique before use. Artifacts contain no host paths beyond the repo.
+
+### 2.3 Scenario selection and “why not edge cases”
+
+Every class below recurs across multiple handoff PARTs and sits on the
+critical path to `vectorAdd`. The mapping is direct:
+
+| Demo | Recurs in | Why it is on the vectorAdd path |
+|---|---|---|
+| s01 reply consumer | PART 91 cycles 2–13; status-queue decode cycles 22–25 | RM init must complete (`0x1001`, `rpc_result=0`) before any RPC; a generic failure hides the first error |
+| s02 counter publication | PARTs 46–64 | `cuCtxCreate` completes on per-channel counters; 4/20 blocked the client |
+| s03 unknown commands | every new image; docs/19 | control inventory (M1) is the input surface the emulation must handle |
+| s04 run-until | PART 91 ADDENDUM 9 (cycles 18–25) | each ladder rung (VAS, PDB, channel, token, GPFIFO, semaphore) is a reachability question |
+| s05 snapshot diff | PART 91 ADDENDUM 6/7 (identity, static config) | static-config fields feed guest wiring; a zeroed field kept the device-id read at zero |
+| s06 inject service | PART 92 ADDENDA 5–11 (P6a–P6c) | guest-originated work executing on the engine is the shape of the load |
+| s07 version diff | PART 91 cycles 2–3; every GSP release | the emulation must track firmware changes without regressing |
+| s08 mutation triage | docs/18 R5; docs/19 §3.4; PARTs 3–9 | fail-closed rules come from error paths, not happy paths |
+| s09 Ghidra address | PART 91 ADDENDUM 9 cycle 24 | the GPFIFO GET fix was accepted only after the runtime fault PC existed |
+| s10 evidence pack | docs/14 §6; every milestone | a finding without sessions/hashes/M1–M6 is not actionable |
+
+None of these is a bizarre edge case: they are the same observe → decode →
+change loop the project runs on every firmware version, and each one is
+cited more than once in the handoffs.
+
+## 3. Scenario → tool → baseline → measured
+
+Measured on the reference host (CPython 3.12); numbers regenerate with
+`python3 harness/value_demos/run_all.py`. Times below are from the
+`run_all` aggregate (~18.4 s total).
+
+| # | Real scenario (source, date) | Green-eye tool | Static/Ghidra-only baseline (recorded) | Analog (measured) | Real-case projection |
+|---|---|---|---|---|---|
+| s01 | 0x59 RM-init chase; fn72/fn73 (PART 91 + ADDENDA 2–7, 2026-10-01) | hook dispatcher, watch `result`, decode reply | cycles 2–13, ~11:40→16:52 ≈ 5 h 10 m; 10+ supervised boots (~5–6 min each); reply was generic `0x59` | **1.14 s**; 3/3 commands consumed and decoded; FAULT traps before the reply store | one hook + one watchpoint per command once H2.2 boots the image (blocked) |
+| s02 | 20 GR per-channel counters (PARTs 46–64, 2026-09-24…29) | watch publication field, per-slot readback | ~5 days; PART 49 “only 4 of 20 counters ever moved”; ~2–3 s pin vs ~2 s client deadline | **1.34 s**; 20/20 slots published, 28 stores, store PCs captured | per-channel page watchpoints on real counter pages (blocked) |
+| s03 | unknown control IDs / injected slots (docs/19, 2026-10-02) | sweep + dispatcher/handler hooks + classify | 189 real probes in ~36 s; static-only requires the indirect table per image | **5.96 s** for 24 IDs: 6 known, 8 injected-slot (aliases shown), 10 default | RM control/method sweep once H1.3/H2.2 exposes the table (blocked) |
+| s04 | RM-client ladder reachability (PART 91 ADDENDUM 9, 2026-10-01) | `run_until`, trigger, direct `call` | cycles 18–25 = 19:12→20:53 ≈ 1 h 41 m, 8 supervised boots | **0.24 s**; natural hit / timeout / trigger / direct call | every ladder rung becomes a probe spec (blocked) |
+| s05 | fn72 identity + 1656-B static config (PART 91 ADDENDUM 6/7, 2026-10-01) | before/after snapshot + schema diff | malformed fields suspected statically; acceptance required a live boot per iteration (10 NOCAT cycles) | **0.50 s**; 6 named fields changed, 7 byte runs fully attributed | same diff over real RM control/reply pages (blocked) |
+| s06 | P6 service wiring / guest CE copies (PART 92 ADDENDA 5–11, 2026-10-02) | service injection SDK, both invocation routes | kernel rebuild + supervised cycle (~5–8 min) per rung; P6a→P6c spanned ~02:20–08:30 | **0.63 s**; blob loaded/registered; remote and mailbox results agree | RM service registry (M5) once H2.2 exists (blocked) |
+| s07 | 570.144 vs 570.211 pairing (PART 91 cycles 2–3, 2026-10-01) | same stimulus on two builds, `compare_sessions` | one full boot (~5–6 min) per version plus manual log comparison | **1.14 s**; only the patched reply field moved; decision-table row = “prioritise” | real versions triaged on re-hosted firmware; symbols/sessions are the artifact (blocked for the raw image) |
+| s08 | mutation / fail-closed corpus (R5; docs/18 §2; docs/19 §3.4) | seeded one-field mutations, signal classification | hand-derived asserts/NOCAT across PARTs 3–9; a 64-iteration QEMU run is the tracked harness baseline | **1.82 s**; 7 mutations → {ok:4, fault:2, no-progress:1}, all expectations matched | real controls land in the same harness (H1.3 surface, blocked) |
+| s09 | GPFIFO GET fault `0x40000` vs `0x101000` (PART 91 ADDENDUM 9 cycle 24) | Ghidra TSV + bias + raw-address hook + `export_hits` | static hypothesis unproven; the decoded fault ended 8 supervised cycles (~1 h 41 m) | **0.72 s**; biased address resolves, hook fires at the exact PC, TSV annotated | names.tsv + bias loop once the image executes (blocked) |
+| s10 | milestone evidence pack (docs/14 §6; cycles 11–13, 2026-10-01) | one recorded session → analyze/compare/hits/manifest/M1–M6 | hashes, sessions, logs and compare gathered by hand across boots | **4.92 s**; 12,276-byte session, A/B deterministic, all artifacts + overhead numbers | same pack against real sessions (blocked) |
+
+## 4. Exact commands (per demo)
+
+All from `open-iov/debug/risc-v-gsp-rm-vm`. `VALUE_DEMO_BACKEND` selects the
+backend (`native` default, `qemu` where noted).
+
+```bash
+# s01 — reply consumer (native; QEMU also supported)
+python3 harness/value_demos/s01_reply_consumer.py
+# hook("rpc_dispatch"); watch(gsp_ctrl+0x40, 8, 'w')
+# mailbox cmd=1 arg0=0x1234 -> 0xacdb; cmd=7 -> ~7; cmd=6 -> trap, no result store
+
+# s02 — counter publication
+python3 harness/value_demos/s02_counter_publication.py
+# watch(gsp_ctrl+0x40, 8, 'w'); 20 x (mailbox cmd=5, run) -> 20/20 published
+
+# s03 — unknown command discovery
+python3 harness/value_demos/s03_unknown_command.py
+# fresh session per ID 1..24; hook dispatcher + 6 handlers; classify
+# (findings.jsonl, control-discovery.md)
+
+# s04 — run-until / reachability
+python3 harness/value_demos/s04_run_until_function.py
+# run_until("rpc_sum"); run_until("rpc_fault", timeout=0.2) -> RspTimeout;
+# mailbox cmd=6 + run_until; call("rpc_ping", (0x41,))
+
+# s05 — snapshot diff
+python3 harness/value_demos/s05_snapshot_diff.py
+# snapshot before/after ECHO; Memory.diff; decode with gsp_ctrl.schema.json
+
+# s06 — service injection (needs `make sdk`)
+python3 harness/value_demos/s06_inject_service.py
+# load_service(...slot=6); invoke_remote; invoke_via_firmware
+
+# s07 — version diff
+python3 harness/value_demos/s07_version_diff.py
+# byte-patch the PING key (0xbeef -> 0xbef0) into version B; identical
+# stimulus; decoded field diff + compare_sessions
+
+# s08 — mutation triage
+python3 harness/value_demos/s08_mutation_triage.py
+# 7 seeded one-field mutations; classify by stop signal + rpc_count
+
+# s09 — Ghidra address join (QEMU by default)
+python3 harness/value_demos/s09_grabbing_a_ghidra_address.py
+# names.tsv (program = runtime - 0x1000); load_names_tsv(bias=0x1000);
+# hook(ghidra + bias); export_hits -> greeneye_hits.tsv
+
+# s10 — full evidence pack (QEMU by default)
+python3 harness/value_demos/s10_full_session_evidence.py
+# two identical recorded sessions; analyze + compare + hits + manifest + M1–M6
+# + startup/session/trace overhead
+
+# all of them
+python3 harness/value_demos/run_all.py
+python3 -m unittest tests.test_value_demos -v
+```
+
+## 5. Per-scenario detail
+
+### s01 — reply consumer (0x59 first-error visibility)
+
+The physical path sent `GSP_SET_SYSTEM_INFO` (fn72) and `SET_REGISTRY`
+(fn73), and the only visible reply was `GSP_INIT_DONE rpc_result=0x59`
+(`NV_ERR_OPERATING_SYSTEM`), wrapped generically by `RmStartup`; the RM log
+ring was not mapped until the LOGRM id fix (`0x4c4f474d` “LOGM” vs
+`0x4c4f47524d` “LOGRM”), so the failing collector step was invisible
+(PART 91 ADDENDA 2–6). Ten-plus supervised boots later, the identity fix
+(raw config-space dwords instead of unpacked fields) made init-done succeed
+in 950 ms. The analog consumes each posted command, watches the result
+field, and decodes the exact reply — including the case where the fault
+handler traps before any reply store. **Success:** 3/3 dispatches, replies
+match the ABI, FAULT has no result store. **Failure:** a command is not
+consumed or a reply disagrees.
+
+### s02 — counter publication (20 slots)
+
+`cuCtxCreate` waits on 20 GR per-channel counters; the host saw the client’s
+releases but could not translate the counter page, and for days only 4 of 20
+client-visible slots moved (PART 49, 2026-09-29). The page VA
+(`0x20400f000`, slots at `+0xec0`, 16-byte stride) and the different GPA per
+boot were only proven by a raw live page dump (PARTs 51, 64). The analog
+publishes 20 slots and attributes each store to a PC. **Success:** 20/20
+published with one `rpc_count` increment per slot. **Failure:** any slot
+missing or count delta disagreeing.
+
+### s03 — unknown command discovery
+
+New firmware can silently add or re-point dispatch entries; static reading of
+an indirect table is per-image work. The 2026-10-02 sweep (189 probes, ~36 s)
+found IDs 16–23 falling through to the injected-service handler table
+(16–21 alias built-ins, 22–23 NULL returning `~id`) and 7–15/24–63 on the
+default path (docs/19 §3.4). The analog sweeps 24 IDs with fresh sessions
+and the same hook shape. **Success:** expected classes per ID. **Failure:**
+a misclassification or an unreplayable probe.
+
+### s04 — run-until reachability
+
+The RM-client ladder asked per rung whether symbolized code executed under
+the current stimulus; cycles 18–25 cost ~1 h 41 m of supervised boots
+(PART 91 ADDENDUM 9). The analog shows three answers in one session: reached
+on the natural path, timed out (not reached under this stimulus — docs/14 §5
+explicitly says this is not unreachability), reached via a trigger, and a
+direct Frida-like call. **Success:** all four behaviors agree with the ABI.
+**Failure:** a route reports reachable without a hit, or a direct call
+disagrees.
+
+### s05 — snapshot diff
+
+The fn72 payload carried unpacked identity fields where the reference sends
+raw config-space dwords; the server read the device id back as zero, and the
+fix was confirmed by rejecting hypotheses field-by-field (PART 91 ADDENDUM 6,
+cycle 12), then by decoding the full 1656-byte static-config reply (cycle
+13). The analog diffs two snapshots and attributes every changed byte run to
+a schema field. **Success:** `rpc_count`/`last_rpc`/`result`/`buf` change,
+`result` equals the ECHO length, no unattributed run. **Failure:** an
+unattributed changed region.
+
+### s06 — service injection
+
+P6 wired guest-originated copies through the real engine; every rung was a
+kernel rebuild plus a supervised cycle with fail-safe release discipline
+(PART 92 ADDENDA 5–11, 2026-10-02). The analog loads `injected_demo.elf`,
+registers it in the dispatch table, and invokes it directly and through the
+mailbox. **Success:** both routes agree with the SDK ABI. **Failure:** a
+route disagrees, the slot is not registered, or the blob cannot load
+(skipped with a reason when `make sdk` has not run).
+
+### s07 — version diff
+
+Cycles 2 (570.211) and 3 (570.144) both failed init with `0x59`, disproving
+the image-pairing hypothesis at the cost of one full boot each, and the
+project’s H4.1 decision table exists to separate “the code changed” from
+“the behaviour moved” (docs/14 §4.1). The analog patches the PING reply
+constant into a labelled version B and replays an identical stimulus; only
+the patched field moves. **Success:** exactly the patched reply moves.
+**Failure:** unrelated movement or an unreplayable version.
+
+### s08 — mutation triage
+
+The fail-closed rules come from error paths; the tracked harness is bounded
+seeded mutation with outcome classes (docs/18 §2), and the discovery sweep
+found the sentinel/boundary pointer faults (docs/19 §3.4). The analog runs 7
+one-field mutations in fresh sessions with `max_stops=1` (a faulted target
+re-faults at the same PC, so the run stops at the first signal) and
+classifies ok/trap/fault/no-progress. **Success:** every mutation matches
+its case table. **Failure:** a contradicting class.
+
+### s09 — grabbing a Ghidra address
+
+The GPFIFO GET hypothesis (`0x101000`) was only settled when the decoded
+status queue showed the fetch at `0x40000`; fix `6f9cbfa` followed
+(PART 91 ADDENDUM 9, cycle 24). The analog exports a Ghidra-style
+`names.tsv` with a deliberate base offset, loads it with the matching bias,
+hooks the raw static address, triggers the path, and writes
+`greeneye_hits.tsv` back for Ghidra. **Success:** biased address equals the
+real function, hook records the hit, TSV annotates it. **Failure:** a hit at
+a different address or an empty TSV.
+
+### s10 — full session evidence
+
+The evidence pack contract (docs/14 §6) was satisfied by hand for every
+milestone (cycle evidence dirs, P6 dirs). The analog produces it in one run:
+two deterministic sessions, analyze, compare, hits TSV, firmware/artifact
+hashes, the M1–M6 skeleton (M2/M5 marked not measured without a physical
+reference), and the measured overhead. **Success:** ABI reply, A/B
+agreement, all artifacts, empty unexplained-delta list. **Failure:**
+nondeterminism or a missing artifact.
+
+## 6. Overhead analysis (measured, with numbers)
+
+| Overhead | Measured on the analog | Real-path comparison |
+|---|---|---|
+| Session startup | native ≈ 0.001 s; QEMU ≈ 0.13 s per launch | one supervised hardware cycle ≈ 5–6 min (PART 91/92) |
+| Session size | 12,276 B per recorded session (s10), including 2 snapshots and 16 event types | per-cycle evidence dirs hold multi-MB logs, rings and decodes |
+| Trace slowdown | 3.1× run-only on the demo workload; 13.2× end-to-end including writing `blobs/trace.jsonl` (~200 k exec lines) | docs/16: ~2.4× exec trace on the demo workload (1.53 → 0.54 MIPS); guard ~7% |
+| CI cost | `tests/test_value_demos.py`: 5 tests in ≈ 4.5 s; `run_all.py`: 10 demos in ≈ 18.4 s (budget 180 s) | a single physical cycle is 300–400× the entire demo suite |
+| Host footprint | stdlib only; artifacts under `harness/value_demos/artifacts/`; no hardware, no guest, no kernel module | physical cycles hold D0/D3cold locks, mdev and a QEMU guest |
+
+**Why the overhead is dominated by the time saved.** The expensive baselines
+are per-question hardware cycles and multi-hour manual campaigns (s01 ≈ 5 h,
+s02 ≈ 5 days, s04/s09 ≈ 1 h 41 m each, s06 ≈ 6 h). The tool path for the same
+question class costs 0.2–6.0 s on the analog plus ≤0.13 s of launch — under
+7 s end to end for every demo, or ~0.02 % of one 5–6-minute cycle even
+before the campaign time. Trace logging at 3–13× of a sub-second run is
+still seconds; a 12 KB session replaces a multi-MB manual capture. The only
+place the tools add real cost is the once-per-question scripting in
+`harness/value_demos/` and the 4.5 s test, which run in CI and are reused by
+every later version.
+
+## 7. OODA-loop compression
+
+| Stage | Before (real path, as recorded) | After (green-eye analog, measured) |
+|---|---|---|
+| **Observe** | one supervised boot + capture per hypothesis: 5–6 min, fail-safe discipline, one device-write experiment per cycle | launch 0.001–0.13 s, then hooks/watchpoints and bounded runs: 0.2–5.8 s per question |
+| **Orient** | manual LOGINIT/LOGRM/NOCAT ring decode, static reports (REPORT3–10), hours per campaign; the ring was invisible until cycle 11 | `analyze`/`compare`/`Memory.diff` with named symbols: seconds, e.g. s05 attributes every changed byte to a schema field |
+| **Decide** | one candidate fix per supervised cycle, accepted/rejected by another boot | version diff (s07), mutation matrix (s08) and per-slot publication (s02) test the whole candidate set in one deterministic run |
+| **Act** | rebuild + supervised boot; escalate every conclusion to a physical capture | re-run the session (seconds); product change still gated on physical confirmation (docs/06 §6, docs/14 §7) |
+
+The compression is not “the simulator replaces the hardware”; it is the loop
+around each physical experiment. green-eye reduces the number of physical
+cycles needed to reach a confident hypothesis and produces the evidence pack
+that makes the eventual physical confirmation cheap (s10).
+
+## 8. Success/failure criteria summary
+
+| Demo | Pass | Fail |
+|---|---|---|
+| s01 | 3/3 dispatches; PING/unknown replies match; FAULT traps before the store | command not consumed or reply disagrees |
+| s02 | 20/20 slots published, `rpc_count` +20 | any slot missing / count mismatch |
+| s03 | expected class per ID (6 known, 8 injected, 10 default) | misclassification or unreplayable probe |
+| s04 | natural hit, timeout, trigger hit, direct call value | false reachability or wrong call value |
+| s05 | required fields change, result = ECHO length, no unattributed run | unattributed changed region |
+| s06 | remote and mailbox invocation agree with the SDK ABI | route disagreement or unloadable blob (skipped if SDK absent) |
+| s07 | only the patched field moves | unrelated movement / unreplayable version |
+| s08 | every mutation matches its expected class | contradicting class |
+| s09 | biased address resolves; hook fires; TSV annotated | wrong address / empty TSV |
+| s10 | ABI reply; A/B agreement; artifacts complete; no unexplained deltas | nondeterminism / missing artifact |
+
+## 9. Reproducing and extending
+
+```bash
+cd open-iov/debug/risc-v-gsp-rm-vm
+make demo && make sdk
+python3 harness/value_demos/run_all.py # writes artifacts/results.json
+python3 -m unittest tests.test_value_demos -v # fast gate
+```
+
+`artifacts/results.json` is the machine-readable source for the table in §3:
+per-demo `elapsed_s`, `success`, `real_case`, answers, and artifact paths.
+When the H2.2 device model lands, each demo's `projected` note becomes the
+runbook for replacing its analog with the real image; the result format and
+evidence contract do not change.
+
+## 10. Limits and fidelity statement
+
+* The analog firmware is not GSP-RM: no RM objects, status queue, object
+ model or GPU device model. Demos reproduce the *shape* of each scenario
+ (hook the dispatcher, watch the field, decode the reply, diff versions,
+ mutate, inject) and are labelled as such.
+* L2 timing/concurrency is not modelled; no demo makes a timing claim.
+* L3 device environment is absent; every real-firmware result is
+ `blocked_on_device_model`.
+* Absence of a hit is a fact about that run at that fidelity, not proof of
+ dead code (docs/14 §2).
+* Full suite status and per-demo times are regenerated by
+ `run_all.py`; do not quote a stale number without re-running it.
diff --git a/debug/risc-v-gsp-rm-vm/docs/26-ARCHITECTURE.md b/debug/risc-v-gsp-rm-vm/docs/26-ARCHITECTURE.md
new file mode 100644
index 0000000..326533b
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/26-ARCHITECTURE.md
@@ -0,0 +1,449 @@
+# 26 — green-eye architecture: full picture, workflow and API surfaces
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!**
+> The architecture map for the green-eye suite: how the pieces fit, which
+> surface to call, and which document specifies each part. Every claim here is
+> implemented in the tree or explicitly marked as a gated roadmap design.
+> Development-only: never shipped, never a driver dependency, never in a guest.
+
+## 1. What green-eye is (and is not)
+
+green-eye runs GSP/uproc firmware in a controllable machine, observes it with
+Frida-like primitives, records a durable session, changes the machine's inputs
+(including injected code), and compares runs as evidence for `open-iov-vfio`
+emulation changes.
+
+| It is | It is not |
+|---|---|
+| A firmware runtime microscope (execution + memory, L0/L1) | A time, interrupt or device model (L2/L3 absent — [06](06-ACCURACY.md)) |
+| A scriptable `Session` API with two backends | A GDB wrapper; there is no GDB dependency |
+| A recorded-evidence system with a versioned format | A product component or a guest agent |
+| A static/dynamic join with Ghidra | A decompiler or a CUDA-corpus replacement |
+| An automation layer (harnesses, MCP, CI) | A fuzzing service against hardware; simulator/re-hosted code only |
+
+## 2. The suite as a whole
+
+```text
+ front doors control plane evidence
+ CLI/REPL/scripts ─┐
+ MCP (stdio) ──────┼─► GreenEyeAPI ─► Session ─┬─► QEMU-RSP backend ──┐
+ Ghidra bridge ────┤ (plugins) Memory │ qemu-system-riscv64│
+ harness/ ─────────┤ SymbolDB └─► native backend ────┼─► session dir
+ rmharness ────────┘ Recorder sim/machine.py │ events.jsonl
+ └─► analyze / compare /
+ export_hits / bundles
+```
+
+```mermaid
+flowchart LR
+ CLI["CLI/REPL/scripts"] --> CORE
+ MCP["MCP server"] --> CORE
+ GH["Ghidra bridges"] --> CORE
+ HAR["harness/*.py"] --> CORE
+ RMH["rmharness (read-only)"] --> CORE
+ CORE["GreenEyeAPI + Session<br/>hooks, watchpoints, call, run_until<br/>Memory, SymbolDB, Recorder"]
+ CORE --> QB["QEMU-RSP backend"] --> SESS
+ CORE --> NB["native backend (M1/M1.5)"] --> SESS
+ SESS["session dir, greeneye-session/1"] --> REP["analyze / compare / export_hits / MCP / bundles"]
+ RMH --> SESS
+```
+
+Status: transport, core, recording, both backends (native M1 trace included),
+injection ABI v1, both Ghidra bridges, traces, device map, harnesses, MCP (R3)
+and `rmharness` are implemented and exercised. The `Backend` protocol
+refactor (16 §5), the H2.2 QEMU device model, RM-core injection (H1.3) and the
+H3 M2–M6 milestones are design/roadmap only ([16](16-CUSTOM-SIMULATOR.md)).
+
+## 3. Session core: hooks, watchpoints and dispatch
+
+`Session` ([greeneye/session.py](../greeneye/session.py)) owns all stop-driven
+state and is backend-agnostic: every stop reads registers, resolves the PC,
+records an event, invokes matching hooks/watchpoints, then resumes.
+
+```mermaid
+sequenceDiagram
+ S->>FW: set breakpoint / watchpoint
+ loop run(duration, max_stops)
+ FW-->>S: stop reply (T/S/W)
+ S->>S: resolve PC via SymbolDB
+ S->>R: bp_hit / watch_hit / stop / step
+ alt symbol entry
+ S->>H: on_enter(hit) with a0..a7
+ S->>S: arm return breakpoint from ra (LIFO)
+ S->>FW: continue
+ else return breakpoint
+ S->>H: on_leave(hit), a0 as retval
+ else watchpoint (Z2/Z3/Z4)
+ S->>FW: clear watch, step over store, re-arm
+ S->>H: callback + store PC
+ end
+ S->>R: on_event(kind, hit)
+ end
+ S->>R: session_end on close
+```
+
+| Primitive | Semantics / limits |
+|---|---|
+| `breakpoint(addr, kind=sw\|hw, temporary=)` | sw is stepped over transparently; hw uses the backend's `Z1` |
+| `hook(target, on_enter, on_leave)` | leave is a return breakpoint on `ra`; tail calls/interrupt returns are not tracked |
+| `watch(target, size, kind=w\|r\|a, callback)` | QEMU stops *at* the access; Session clears/steps/re-arms; store PC is recorded |
+| `run(duration, max_stops)` | services stops until a budget or exit; the deadline path interrupts once |
+| `run_until(target, timeout)` | temporary breakpoint; raises `RspTimeout` when not reached |
+| `call(target, args, timeout)` | Frida-like remote call; restores `RESTORE_REGS` around it |
+| `step(n)`, `note(text)`, `on_event(kind, fn)` | single-step with dispatch; annotate and observe the stream |
+
+Instructions between stops are invisible unless the TCG plugin
+(`qemu/greeneye_trace.c`) or the native trace (`sim/trace.py`) is on
+(01 §11, 16 §1.1a).
+
+## 4. Backend abstraction: QEMU-RSP and native
+
+`Session` assumes a small RSP-shaped primitive set; `run_until` and `call`
+live above it and work on every backend. M1 implements the same method
+surface (`greeneye/sim_backend.py`) without the planned `Backend` protocol
+refactor (16 §5); the table is the M2+ target.
+
+```mermaid
+flowchart LR
+ S["Session (backend-independent)"]
+ S -->|"read/write reg+mem, step, continue, Z0-Z4"| RC
+ S -->|"same surface"| ST
+ subgraph QEMU["QEMU-RSP backend (default)"]
+ QT["QemuTarget<br/>qemu-system-riscv64 virt"] --- RC["RspClient gdbstub packets"]
+ end
+ subgraph NAT["Native backend (--backend native)"]
+ ST["SimTarget / SimClient"] --- MC["sim.machine.Machine<br/>RAM + UART + decode loop"]
+ MC --- TR["sim.trace.Trace exec/access"]
+ MC --- DM["DeviceMap lenient MMIO"]
+ end
+```
+
+| Session need | QEMU-RSP | Native |
+|---|---|---|
+| registers/memory | `g`/`p`/`P`, `m`/`M`/`X` chunked to `PacketSize` | direct register file and memory map |
+| breakpoints/watchpoints | `Z0`–`Z4` | dispatch-loop tables; same stop semantics |
+| snapshots | region copies over RSP | direct map copy; machine moments are M2+ |
+| virtual time | host wall clock | deterministic instruction count (`SIM_IPS`) |
+| full-access trace | TCG plugin (`-plugin`) | in-process `sim/trace.py`, `--trace` |
+| device environment | QEMU `virt` only (no TU104) | device map + lenient MMIO; real devices H2.2 |
+| injection | debugger writes + `Session.call` | same SDK ABI; RM interception is M5 |
+
+`--backend qemu|native` is accepted by `demo`, `run`, `attach`, `record`,
+`inject` and `ghidra`; `attach --port` and QEMU `--trace` are QEMU-only;
+`--trace` is native-only (16 §1.2).
+
+## 5. Recording and the evidence format
+
+A session directory is the durable artifact (`greeneye-session/1`,
+[greeneye/record.py](../greeneye/record.py)). The event stream is the map; the
+snapshot and blob files are the dump.
+
+```text
+dumps/<stamp>-<name>/
+ session.json # format, name, created, counts, snapshot inventory, meta
+ events.jsonl # append-only JSON lines: {"t": monotonic, "type": ...}
+ snapshots/*.bin.z # zlib-compressed region captures (sha256 recorded)
+ blobs/* # QEMU logs, trace.jsonl, rmharness stdout, plugin dumps
+ summary.md # rendered analysis
+```
+
+```mermaid
+flowchart LR
+ E["Session stops, memory ops,<br/>probe/plugin/injection events"] --> R["Recorder.event()"]
+ R --> J["events.jsonl (append-only)"]
+ M["Memory.snapshot()"] --> Z["snapshots/*.bin.z + sha256"]
+ B["Recorder.blob()"] --> BD["blobs/*: trace.jsonl, qemu.log"]
+ X["rmharness external runs"] --> J
+ J --> A["analyze: summary.md"]
+ J --> C["compare: A/B delta report"]
+ J --> G["export_hits: greeneye_hits.tsv"]
+ J --> S10["value_demos s10: manifest + M1-M6 skeleton"]
+```
+
+Event classes: core stop/memory events (`session_start`, `image_loaded`,
+`hook`, `watch`, `bp_hit`, `leave`, `watch_hit`, `stop`, `step`, `mem_read`,
+`mem_write`, `snapshot*`, `probe*`, `plugin*`, `note`, `exit`, `session_end` —
+01 §6); injection (`service_injected`, `call_enter`, `call_return` — 12 §6);
+traces (`exec`/`access`); M2/M3 native bus (`device`/`irq`/`schedule` — 16
+§10); `rmharness` (`rm_harness_run`, `t=0.0` with epoch `start`/`end` — 17 §5).
+
+Format invariants: append-only (partial sessions stay readable); additive
+versioning with migration notes (V2); `compare` treats `t`, host paths and
+backend identity as non-semantic; a claim without a session is not evidence
+(06 §7, 25 §1).
+
+## 6. Injection SDK lifecycle
+
+The SDK (`sdk/greeneye_service.h`, `sdk/injected_demo.c`) builds
+position-independent ELF64 `EM_RISCV` blobs; `greeneye/inject.py` loads,
+registers and invokes them. ABI v1: `ge_u64 (*)(ge_u64 cmd, ge_u64 a0,
+ge_u64 a1)`, magic `0x47455356`, entry `ge_demo_service_dispatch`.
+
+```mermaid
+sequenceDiagram
+ Dev->>Inj: load_service(elf, slot=6, load_addr)
+ Inj->>FW: write PT_LOAD segments into RAM
+ Inj->>FW: patch gsp_ctrl.handlers[slot] entry
+ Inj->>Rec: service_injected + mem_write events
+ Dev->>Inj: invoke_remote(cmd, a0, a1)
+ Inj->>FW: Session.call(entry, args)
+ Rec-->>Rec: call_enter / call_return
+ Dev->>FW: mailbox = 16 + slot (arg0=cmd, arg1, arg2)
+ FW->>FW: dispatch handlers[slot]
+ FW-->>Rec: hook/watch events + result field
+```
+
+Demo registration: mailbox commands 16..23 select slots 0..7; slot 6 is the
+SDK default; control-block offsets (`mailbox +0x20`, `handlers +0x150`) are
+demo-specific and must come from `greeneye/inject.py` constants (12 §4).
+Cleanup is not automatic in v1; record the injection so runs start from a
+known state. Roadmap: RM-core shim, service registry and `log`/`stub`/
+`forward` interception at M5/H1.3 (12 §8, 16 §11).
+
+## 7. Ghidra bridges (two directions)
+
+The static/dynamic join uses three address spaces — file vaddr, Ghidra program
+addr, runtime addr — with a bias per source (01 §5, 05 §1).
+
+```mermaid
+sequenceDiagram
+ G->>B: {"id":n,"cmd":"run_until","args":{"target":"0x..."}}
+ B->>S: run_until(addr) (temporary breakpoint)
+ S->>T: continue; service intervening stops
+ T-->>S: stop at addr
+ S-->>B: {addr, symbol, kind}
+ B-->>G: JSON response
+ G->>B: read_mem / read_reg / call / snapshot / hits / export_hits
+ B->>R: hits and notes recorded in the session
+ R-->>G: greeneye_hits.tsv (via MarkGreenEyeHits.java)
+```
+
+| Tool | Purpose | Doc |
+|---|---|---|
+| `symbols export` + `SymbolDB` loaders | Ghidra TSV/JSON → runtime symbol DB | [05](05-GHIDRA-BRIDGE.md) |
+| `greeneye.ghidra.export_hits` | session hits → `addr<TAB>hits<TAB>symbol<TAB>annotation` | 05 |
+| `ExportForGreenEye.java` | Ghidra → `addr<TAB>name<TAB>size` TSV | 05 |
+| `MarkGreenEyeHits.java` | annotate functions with runtime hits + bookmarks | 05 |
+| `GreenEyeControl.java` + `greeneye.sh ghidra` | drive a live session from GhidraScripts over localhost JSON-lines | [13](13-GHIDRA-CONTROL.md) |
+
+Bridge commands: `ping`, `info`, `resolve`, `bp_set`, `bp_clear`, `bp_list`,
+`watch_set`, `run_until`, `run`, `step`, `read_mem`, `write_mem`, `read_reg`,
+`write_reg`, `call`, `snapshot`, `note`, `hits`, `export_hits`, `close`
+(13 §3). Unauthenticated by design; localhost only.
+
+## 8. MCP server tool surface
+
+`plugins/mcp_server.py` serves the active `Session` over JSON-RPC 2.0 on
+stdio (or loopback TCP) with no MCP library; read/observe by default,
+`write_mem`/`call_function` require `--allow-write`, `inject_service`
+requires `--allow-inject` ([20](20-MCP-SERVER.md)).
+
+```mermaid
+flowchart LR
+ AG["AI agent (Claude Code, opencode)"] -->|"JSON-RPC 2.0 over stdio"| M["plugins/mcp_server.py"]
+ M --> READ["read: session_info, symbols_resolve,<br/>read_mem, hexdump, snapshot, plugins_list"]
+ M --> OBS["observe: bp_set/clear, watch_set,<br/>run, run_until, step (recorded)"]
+ M --> WR["write (gated): write_mem, call_function"]
+ M --> INJ["inject (gated): inject_service"]
+ M --> TR["trace: trace_start/stop"]
+ M --> AN["analysis: analyze_current, compare_sessions, export_hits"]
+ M --> HN["harness: probe_firmware, mutation_run,<br/>portability_matrix_run"]
+ M --> CL["session: close"]
+ READ & OBS & WR & INJ & TR & AN & HN --> S["Session + Recorder"]
+```
+
+All results are one JSON text block; absent modules return a structured
+`not_available` error; stdout carries protocol frames only (20 §4, §8). Full
+argument schemas for all 24 tools are in 20 §5.
+
+## 9. Automation harness
+
+`harness/` turns the playbooks into repeatable, self-describing runs. Modules
+bootstrap the tool root and use the QEMU backend by default (18, 19, 21);
+`common.py` provides flat-image relocation, session helpers and bounded log
+reads.
+
+```mermaid
+flowchart TB
+ FW["firmware ELF / .fwimage"] --> PM & MU & CD & RE & VD & LV
+ SYM["Ghidra exports + symbol DBs"] --> PM & MU & CD & RE & VD & LV
+ STIM["stimulus / probe spec"] --> PM & MU & CD & RE & VD & LV
+ SEED["seed + budgets"] --> PM & MU & CD & RE & VD & LV
+ subgraph H["harness/"]
+ PM["portability_matrix.py R6/H4.1"]
+ MU["mutation_harness.py R5/H4.3"]
+ CD["control_discovery.py H4"]
+ RE["reach.py H4.1/H4.5"]
+ VD["version_diff.py H4.1"]
+ CR["ci_release.py"]
+ LV["value_demos/ s01..s10"]
+ end
+ VD --> CR
+ PM --> CR
+ MU --> CR
+ PM & MU & CD & RE & VD & LV & CR --> OUT["reports + sessions + findings.jsonl<br/>+ compare/ + bundle-manifest.json"]
+```
+
+| Entry point | Purpose | Outputs | Doc |
+|---|---|---|---|
+| `portability_matrix.py` | probe + symbols + fixed scenario + compare across targets; M1–M6 skeleton | `portability-report.json/.md`, `symbols/`, `sessions/`, `compare/` | 18 §1 |
+| `mutation_harness.py` | seeded one-input mutation, outcome classes, per-attempt session | `findings.jsonl`, `summary.md`, `sessions/` | 18 §2 |
+| `control_discovery.py` | sweep command/call surfaces; classify handler/default/trap/fault | `findings.jsonl`, `discovery-report.json`, `control-discovery.md` | 19 §3 |
+| `reach.py` | reachability, trigger discovery, required args and effects for one symbol | `reach-report.json/.md`, `probe.json`, sessions | 19 §4 |
+| `version_diff.py` | old vs new image: shape, symbols, corpus hints, probe, behavior, M1–M6 | `version-diff.json/.md`, `corpus/`, `compare/` | 21 §2 |
+| `ci_release.py` | version_diff → matrix → mutation smoke, then hash the bundle | `release-report.json`, `bundle-manifest.json` | 21 §4 |
+| `value_demos/run_all.py` | ten fixed scenarios (reply, counters, discovery, reach, diff, inject, version, mutation, Ghidra, evidence) | `artifacts/results.json` + per-demo JSON | 25 |
+| `common.py` | shared setup (relocate to `0x80000000`, open session, bounded logs) | library | 18 §1.2 |
+
+Harness limits are intentional: QEMU/re-hosted/demo code only; no hardware,
+guest, kernel module or existing gdbstub; bounded budgets; deterministic
+findings for a fixed seed (18 §5, 19 §8, 21 §7).
+
+## 10. Device map and lenient MMIO data flow
+
+`greeneye-device-map/1` is versioned JSON keyed by
+`(address, width, direction)` with values, hit counts and firmware PCs
+([24](24-DEVICE-MAP.md)). It records what the firmware touched first and lets
+the native interpreter step past unmodelled registers to collect the next
+ones; it feeds the H2.2 QEMU device model and the H3 profile DSL.
+
+```mermaid
+flowchart LR
+ P["greeneye probe (raw .fwimage in QEMU)"] -->|"blobs/qemu.log + qemu_first_fault"| H["device_map.py harvest"]
+ H --> MAP["greeneye-device-map/1<br/>entries + meta (bias, sha256)"]
+ MAP --> LOOK["DeviceMap.lookup<br/>zero / value / unimplemented"]
+ LOOK --> LEN["Native Machine(unknown_mmio=lenient)"]
+ LEN --> LOG["machine.mmio_log / mmio_unknown"]
+ LEN --> LEARN["learn_mmio=True: source trace entries"]
+ LEARN --> MAP
+ MAP --> PROF["docs/16 profile DSL<br/>falcon/csb/ptimer/pmu stubs"]
+ PROF --> H22["H2.2 QEMU device model"]
+ LOG --> NEXT["next unmapped wall; promote to manual"]
+ NEXT --> MAP
+```
+
+Honest scope: lenient execution proves an unmodelled access *can* be served
+deterministically; it does not model read-back, side effects, interrupts or
+timing, and a zero that keeps the firmware running is a hypothesis, not a
+silicon fact (24 §4).
+
+## 11. Workflow architecture: scenario → evidence → emulation change
+
+The canonical loop (H4.5, 06 §5, 07 P4, 25 §7). Each stage names its gate.
+
+| Stage | Action / instrument | Success criterion | Failure signal |
+|---|---|---|---|
+| 1. Scenario | state the firmware question and the smallest stimulus (one command/control/boot phase) | question maps to a symbol or surface | no observable exists → add one (probe/plugin) |
+| 2. Choose instrument | hooks/watchpoints for semantics; TCG/native trace for coverage; `run_until`/`call` for reachability; injection for new code | stop set answers the question without flooding | session too large/noisy → filter PCs, watch fields only |
+| 3. Run | fresh bounded session; fixed stimulus, seed and budgets | run completes; UART/banner liveness holds | timeout, trap loop, wedged target → capture and retry clean |
+| 4. Record | `greeneye-session/1` with snapshots and notes | events + snapshots + hashes written | missing `session.json`/empty analyzer → recording off |
+| 5. Compare | `compare` against a reference (old firmware, hardware/VFIO capture, self-diff) | deltas explained symbolically; self-diff is zero | unexplained hit/watch/write delta → investigate first |
+| 6. Decide | fill M1–M6 with the reference method (06 §5); mutation/triage for error paths | metric table improves or stays equal | metric claimed without a reference capture |
+| 7. Implement | change `open-iov-vfio` (never the simulator for a product fix); keep the stimulus | physical confirmation for behaviour changes (06 §6, 14 §7) | product change from a simulator-only observation |
+| 8. Regression | re-run step 3 with an unchanged stimulus; golden sessions/CI (`make test`, `greeneye.yml`) | profile improves or stays equal; CI green | regression → revert and re-derive |
+
+```mermaid
+flowchart LR
+ Q["Question"] --> OBS["Observe<br/>probe / hooks / watch / trace"]
+ OBS --> ORI["Orient<br/>analyze + symbol decode + snapshots"]
+ ORI --> DEC["Decide<br/>compare + M1-M6 + mutation"]
+ DEC --> ACT["Act<br/>implement in open-iov-vfio"]
+ ACT --> REG["Regression<br/>same stimulus, same budgets"]
+ REG -->|"improved / equal"| SHIP["Evidence pack<br/>sessions + report + hashes"]
+ REG -->|"regressed"| Q
+ SHIP --> OBS
+```
+
+OODA compression evidence (analog, measured) is in 25 §7: hardware cycles are
+minutes and campaigns hours/days; the green-eye path for the same question
+class is sub-second to seconds plus scripting.
+
+## 12. API surfaces by tool
+
+### 12.1 CLI (`greeneye.sh`, `greeneye/cli.py`; usage in [02](02-USAGE.md))
+
+| Command | Purpose | Not for |
+|---|---|---|
+| `info` | environment/tooling check | validating a firmware image |
+| `demo` | canned boot + hooks + watch + record on the bundled uproc | booting real GSP (use `probe`) |
+| `run <script.py>` | run a Python instrumentation session | shell pipelines (write Python) |
+| `attach` / `launch` | interactive REPL / keep a target alive by port | injecting into a live product |
+| `record` | boot and record with watches/snapshots/trace | post-processing an existing session |
+| `probe <image>` | bounded run of a raw image; first divergence report | booting the image (no TU104 model) |
+| `inject` | load/register/invoke an SDK service | shipping or hot-patching signed firmware |
+| `ghidra` | serve a live session to GhidraScripts | network exposure (localhost only) |
+| `analyze <session>` / `compare <a> <b>` | markdown report / symbolic A/B delta | proving accuracy without a reference |
+| `symbols export\|resolve` | build/inspect a symbol DB | recovering types/decompilation |
+| `plugins` | list discoverable plugins | loading untrusted code into the core |
+| `python3 -m greeneye.device_map harvest\|show\|merge\|demo` | MMIO first-touch map data | a device model or timing source |
+
+### 12.2 Python entry points (recipes in [03](03-SCRIPTING.md))
+
+| Entry point | Purpose | Not for |
+|---|---|---|
+| `ge.launch/attach`, `Session.hook/watch/run/run_until/call/step/note` | session lifecycle and instrumentation | in-process C instrumentation |
+| `Memory.read/write/hexdump/search_*/snapshot/diff/render_diff` | memory inspection and region diff | full-machine snapshots (M2+) |
+| `SymbolDB.load_elf/*_tsv/*_json`, `resolve/annotate` | address-space-aware symbols | type recovery from Ghidra |
+| `structs.schema_from_json/decode/render` | declarative struct decoding | automatic struct discovery |
+| `probes.load_spec/install` | JSON probe specs (no arbitrary code) | complex control flow (write a script) |
+| `inject.load_service/invoke_remote/invoke_via_firmware` | ABI v1 service load/invoke | product features or persistence |
+| `ghidra.build_symbol_db/export_hits/corpus_location` | Ghidra TSV in/out and corpus location | replacing Ghidra analysis |
+| `analyze.render_summary/compare_sessions/parse_qemu_faults/load_trace` | offline reports and QEMU fault parsing | live control |
+| `record.Recorder/load_events/load_meta` | session writing/reading | a database (files are the interface) |
+| `device_map.harvest_*/DeviceMap.*` | MMIO map authoring/merge | device semantics |
+| `sim_backend.launch_native`, `sim.trace.Trace/make_trace` | native backend + trace knobs | timing/concurrency claims |
+| `errors.*` (`GreenEyeError`, `RspTimeout`, ...) | typed failure surface | retrying a wedged target blindly |
+
+### 12.3 Plugin API (`register(ge)`, [04](04-PLUGINS.md))
+
+`ge` exposes live objects (`session`, `memory`, `symbols`, `recorder`),
+instrumentation (`hook`, `watch`, `on_event`, `resolve`), CLI extension
+(`add_command(name, fn, help)`, `commands`) and audit logging (`log`). A
+raising plugin is recorded as `plugin_error` and skipped. Not for: shipping
+dependencies, assuming a specific firmware, or hiding unrecorded state.
+
+## 13. Extensibility and invariants
+
+Where extension lives:
+
+* **Plugins** (`plugins/`, `GREENEYE_PLUGINS`) — runtime RE tools, harnesses,
+ environment updates, agent integrations (04).
+* **Harness modules** (`harness/`) — new sweeps/reports over the public
+ `Session`/analysis primitives; a new call surface is a `Surface` subclass
+ (19 §5.1).
+* **SDK services** (`sdk/`) — PIC blobs behind ABI v1; v2 adds an RM-core call
+ table (12 §8).
+* **Device maps/profiles** — versioned data (`greeneye-device-map/1`, the
+ `greenmachine-profile/1` DSL in 16 §7), never core special cases.
+* **MCP tools** (`plugins/mcp_server.py`) — add tools in the plugin, keep the
+ core stdlib-only (20 §10).
+
+Invariants (enforced by tests or review):
+
+1. **stdlib-only core**: `greeneye/` and `sim/` import only the standard
+ library; optional deps live in plugins/harnesses.
+2. **Session format**: `greeneye-session/1` is append-only and evolves
+ additively with migration notes (V2).
+3. **Backend parity**: every backend satisfies the same `Session` primitives,
+ so scripts, probes, plugins, recordings and comparisons survive backend
+ changes (16 §4, R0).
+4. **ABI**: blobs declare an ABI version and capability metadata; breaking
+ changes need a shim and a doc note (12 §8, V3).
+5. **Markers**: the `TOOL IN DEVELOPMENT` banner stays until the acceptance
+ list in 00 passes; never shipped, never in a guest, never a driver
+ dependency.
+6. **Evidence**: no accuracy, portability or security claim without a recorded
+ A/B session and an explicit L2/L3 fidelity statement (06 §5–6, 25 §10).
+7. **Provenance**: tracked docs never name local source trees; public
+ descriptions first, local provenance in the local notes (V6).
+
+## 14. Diagram and document index
+
+Section diagrams and their backing docs: §2 suite (00, 01, README); §3
+dispatch (01, 03); §4 backends (01 §8, 16 §4–5); §5 recording (01 §6, 09, 12
+§6, 16 §10, 17 §5); §6 injection (11, 12); §7 Ghidra (05, 13); §8 MCP (20);
+§9 harness (18, 19, 21); §10 device map (24, 16 §7); §11 OODA (06, 07 P4, 25
+§7). Roadmap keys: R3 = MCP (08), R5 = mutation (18), R6 = multi-version (18,
+21), H1.3 = RM service injection (GREEN-EYE-ROADMAP), H2.2 = QEMU device
+model, H3/M1–M6 = native simulator (16), H4 = adversarial navigation (14),
+H7 = agentic platform (15, 20).
diff --git a/debug/risc-v-gsp-rm-vm/docs/27-TOOL-AUDIT.md b/debug/risc-v-gsp-rm-vm/docs/27-TOOL-AUDIT.md
new file mode 100644
index 0000000..e208253
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/27-TOOL-AUDIT.md
@@ -0,0 +1,252 @@
+# 27 — green-eye tool audit
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See [00-OVERVIEW](00-OVERVIEW.md)
+> for status and [06-ACCURACY](06-ACCURACY.md) for fidelity limits.
+> Development-only: never shipped, never a driver dependency, never in a guest.
+
+Audit date: 2026-10-02. Scope: every runnable surface in
+`debug/risc-v-gsp-rm-vm/` (CLI, Python package, `sim/`, `harness/`,
+`plugins/`, `qemu/`, `ghidra/`, `rmharness/`, `sdk/`, firmware demo,
+`examples/`, `tests/`, docs). The goal is that every tool is either exercised
+somewhere, documented well enough to be unmistakable, or explicitly marked
+`not used yet`.
+
+## 0. Method (what was actually run)
+
+Environment: QEMU 8.2.2 (`qemu-system-riscv64`), Python 3.12, clang/lld.
+Every safe smoke below was executed from the tool root; device/WPR paths were
+not touched.
+
+* all 13 `./greeneye.sh <cmd> --help` parsers, `--version`;
+* `info`, `demo --backend native`, `record --backend native --trace`,
+ `analyze`, `compare`, `plugins`, `inject --backend native`;
+* `probe --instructions 400` against the real 570.144 `.fwimage`;
+* `launch` (bounded, QEMU killed afterwards) and the attach REPL path via
+ `tests/test_injection.py`;
+* `symbols export` from a synthetic Ghidra index TSV, then `symbols resolve`;
+* `run` of every `examples/scripts/*.py` as documented, plus the declarative
+ probe spec `examples/probes/demo_probes.json`;
+* `make demo`, `make sdk`, `make -C qemu` (plugin already built) and a live
+ QEMU run with `qemu/greeneye-trace.so` (8,012,129 JSONL events in ~1 s);
+* `harness/*.py --help`; `harness/value_demos/run_all.py --only s02,s05`;
+ `examples/scripts/sim_bench.py`, `sim_diff.py`;
+* `plugin --help` / MCP suite; `rmharness/*.sh --help`,
+ `rmharness/harness.sh` capture with `rmtool gpu list` (read-only `/proc`);
+ `python3 rmharness/tests/test_rmharness_plugin.py` (3 tests);
+* `python3 -m greeneye.device_map --help` and `demo` (bounded 50k
+ instructions, lenient MMIO);
+* full suite: `python3 -m unittest discover -s tests -t .` → **155 tests, OK**;
+* `python3 scripts/check-docs-links.py` from `open-iov/` → **OK, 55 files**;
+* `git check-ignore -v` for the local-only ignore patterns.
+
+Status vocabulary: `verified useful` (exercised and/or wired into
+tests/demos/docs), `not used yet` (no caller found; decision recorded),
+`needs improvement` (concrete weakness; fixed where safe), `prune candidate`
+(clearly dead; none found).
+
+## 1. Master table — `greeneye.sh` CLI
+
+| surface | purpose | API / entry points | tests | used by | status | notes/actions |
+|---|---|---|---|---|---|---|
+| `info` | environment/tooling check | `greeneye/cli.py:173`, `greeneye.sh info` | indirect (smoke) | README, 00, 02 | verified useful | ran; native sim + fwimage + CUDA names detection all correct |
+| `demo` | canned demo boot + hooks/watch/diff/trap | `cli.py:209` | smoke via `test_smoke_qemu.py` | README, 00, 02; `make demo-run` | verified useful | ran on `--backend native`; 17 hook lines, diff + trap work |
+| `run` | run a Python instrumentation script | `cli.py:272` | `test_injection.py` (REPL), `test_sim_trace.py` | 02, 03, examples scripts | needs improvement (fixed) | the exec namespace lacked `__file__`, so the documented `dump_diff.py` failed; namespace now sets `__file__`/`__name__`, doc 03 notes it |
+| `attach` | interactive REPL | `cli.py:486`, `GreenEyeRepl` | `test_injection.py:68` | README, 02 | verified useful | REPL commands all back onto tested Session APIs |
+| `launch` | start target, keep alive for attach | `cli.py:509`; `qemu/launch.sh` | smoke (bounded run) | README, 02 | verified useful | prints gdbstub endpoint; QEMU survives Ctrl-C by design |
+| `probe` | run arbitrary/real firmware, report first divergence | `cli.py:551`; `test_probe.py` unit | `test_probe.py` (ELF/flat/fault parse) | README, 00, 06, 24 (`harvest` input) | verified useful | ran vs real fwimage: first fault `fault_fetch @0x0`, recorded session evidence |
+| `record` | record a memory-activity session (+ native trace) | `cli.py:529` | `test_sim_trace.py` | README, 02, 03 | verified useful | ran with `--trace`; trace blob + watch events rendered by `analyze` |
+| `analyze` | session → symbol-annotated report | `cli.py:690`, `greeneye/analyze.py` | `test_sim_trace.py`, `test_smoke_qemu.py` | 02, 03, 05, 13, 14, 25 | verified useful | ran on recorded session |
+| `compare` | A/B recorded sessions | `cli.py:704`, `analyze.compare_sessions` | `test_sim_backend.py`, `test_value_demos.py` | 02, 03, 14, 25 | verified useful | ran demo-vs-record; correct profile deltas |
+| `symbols export\|resolve` | build/inspect symbol DB | `cli.py:711` | `test_core.py` (loaders) | 05, README | needs improvement (fixed) | bad `--db` JSON produced a traceback; `SymbolDB.load_json` now raises a clean `GreenEyeError` (exit 2) |
+| `plugins` | list discoverable plugins | `cli.py:733` | smoke | README, 04 | verified useful | lists all three bundled plugins |
+| `inject` | load + call an injected RV64 service | `cli.py:630`; `greeneye/inject.py` | `test_injection.py` | 11, 12, 14 | verified useful | ran native: direct call and firmware mailbox both `0xbeae` |
+| `ghidra` | serve session to GhidraScripts | `cli.py:660`; `greeneye/gdbridge.py` | `test_bridge.py` (full protocol) | 05, 13, 14 | verified useful | bridge protocol covered live over a socket; Java side needs Ghidra |
+
+## 2. Master table — Python API (`greeneye/*.py`)
+
+| surface | purpose | API / entry points | tests | used by | status | notes/actions |
+|---|---|---|---|---|---|---|
+| `__init__.py` | public API: `launch`, `attach`, exports | `ge.launch(backend=, trace=, ...)` | all integration tests | README, 02, 03 | verified useful | stable-ish v0.1 surface, documented |
+| `errors.py` | exception taxonomy | `GreenEyeError` + subclasses | everywhere | 03 §13 | verified useful | small and meaningful |
+| `rsp.py` | GDB-free RSP client | `RspClient`, `StopReply` | `test_rsp.py`, `test_mcp.py` | 01, 03 | verified useful | fake-stub unit tests |
+| `elf.py` | ELF64/32 reader, flat kernel writer | `load_elf`, `write_flat_kernel`, `write_relocated_elf`, `FirmwareImage` | `test_core.py` (`write_relocated_elf` test added), `test_probe.py`, `test_device_map.py` | 06, 12 | verified useful | `write_relocated_elf` had no caller; a relocation test now locks its entry/PT_LOAD shift |
+| `symbols.py` | symbol DB, address translation, TSV/JSON loaders | `SymbolDB.get/lookup/resolve/annotate/by_subsystem`, `Symbol` | `test_core.py` (`by_subsystem` + list/bad JSON tests added), `test_mcp.py`, `test_memory_record.py` | 03, 05 | verified useful | `load_json` list payloads worked by accident? no — they crashed; fixed and tested; parse errors now `GreenEyeError` |
+| `memory.py` | read/write/hexdump/search/snapshot/diff | `Memory`, `Diff` | `test_memory_record.py` | 03 §5/§7 | verified useful | full docs in 03 |
+| `structs.py` | schema-driven struct decode/render | `schema_from_json`, `Schema`, `render` | `test_core.py` | 03 §6 | verified useful | docs example matches demo schema |
+| `record.py` | durable session: events.jsonl/snapshots/summary | `Recorder`, `NullRecorder`, `load_events` | `test_memory_record.py` | 01, 02, 03 | verified useful | `rmharness/harness.sh` also appends compatible events |
+| `session.py` | hooks/bp/watchpoints/events/step/call | `Session`, `hook`, `watch`, `run`, `call` | `test_injection.py`, `test_mcp.py`, `test_bridge.py` | 01, 03, everywhere | verified useful | `Session.symbol_at` is a thin alias of `symbols.resolve` with no caller yet; docstring added, kept for API symmetry |
+| `target.py` | QEMU process lifecycle + UART/dlog | `QemuTarget`, `find_qemu`, `free_port` | `test_smoke_qemu.py`, `test_injection.py` | 01, 02 | verified useful | `launch`/`probe` both exercise it |
+| `sim_backend.py` | in-process native backend (M1) | `launch_native`, `SimClient`, `SimTarget` | `test_sim_backend.py`, `test_sim_trace.py`, `test_device_map.py`, `test_mcp.py` | README, 16, 24 | verified useful | QEMU-parity differential test |
+| `analyze.py` | report renderer, fault parser, A/B compare | `render_summary`, `compare_sessions`, `describe_fault` | `test_sim_trace.py`, `test_probe.py`, `test_smoke_qemu.py` | 02, 03, 06, 25 | verified useful | consumed by CLI and value demos |
+| `device_map.py` | versioned first-touch MMIO map + lenient policy | `python3 -m greeneye.device_map {harvest,show,merge,demo}` | `test_device_map.py` | 24 | verified useful | `demo` reproduced the documented first GSP access (lenient run 50k insns) |
+| `gdbridge.py` | JSON-lines Ghidra control server | `GhidraBridge`, `cmd_*` | `test_bridge.py` | 05, 13 | verified useful | 25 commands; protocol tested live |
+| `ghidra.py` | symbol import / hit export | `build_symbol_db`, `export_hits`, `corpus_location`, `render_ghidra_command` | `test_core.py` (TSV loaders), `test_value_demos.py` s09 (`export_hits`), `test_bridge.py` (`render_ghidra_command` test added) | 05, 13, 25 | verified useful | `render_ghidra_command` now has a test and is cited from 05 §4 |
+| `inject.py` | position-independent service load/call | `load_service`, `invoke_remote`, `invoke_via_firmware` | `test_injection.py` | 11, 12 | verified useful | both call paths run in tests and in the CLI smoke |
+| `plugins.py` | plugin discovery/API | `PluginManager`, `GreenEyeAPI` | `test_injection.py`; plugin smoke | 04, plugins/README | verified useful | bundled + `GREENEYE_PLUGINS` paths |
+| `probes.py` | declarative JSON probes | `load_spec`, `install`, `compile_condition` | `test_smoke_qemu.py`; `demo_probes.json` smoke | 03 §9, 14 | verified useful | 3 probes installed, fired as expected; no standalone runner needed (documented snippet) |
+| `cli.py` | argparse entry points (section 1) | `main`, `build_parser` | CLI smokes + integration tests | everywhere | needs improvement (fixed) | docstring now lists `launch`/`inject`/`ghidra`; `run` sets `__file__` |
+
+## 3. Master table — simulator, harness, plugins, host tooling
+
+| surface | purpose | API / entry points | tests | used by | status | notes/actions |
+|---|---|---|---|---|---|---|
+| `sim/decode.py` | RV64IMAC decoder | `decode32`, `decode16` | `test_sim_backend.py` | 16 | verified useful | compressed canonicalisation tested |
+| `sim/machine.py` | deterministic machine, UART, lenient MMIO | `Machine`, `Uart16550` | `test_sim_backend.py`, `test_sim_trace.py`, `test_device_map.py` | 16, 24 | verified useful | no wall-clock claims (06) |
+| `sim/trace.py` | native exec/access trace (R4) | `Trace` | `test_sim_trace.py` | 16, README | verified useful | trace blob rendered by `analyze` |
+| `harness/common.py` | shared harness helpers | `load_target`, `write_json`, `read_faults_window`, … | `test_harness.py`, `test_discovery.py`, `test_version_diff.py` | 18–23 | verified useful | internal, well factored |
+| `harness/ci_release.py` | release triage orchestration | `python3 harness/ci_release.py --old … --new …` | `test_version_diff.py` | 21 | verified useful | dry-run supported; manifest/mini-gate |
+| `harness/control_discovery.py` | unknown-call discovery ("Sandsifter for RM") | CLI, `--surface demo\|rm` | `test_discovery.py` | 19, 21 | verified useful | deterministic probe generation + effect observer |
+| `harness/mutation_harness.py` | bounded deterministic mutation | CLI `--iterations/--seed` | `test_harness.py` (integration, seed replay byte-identical) | 18, 20, 23 | verified useful | replayed identically in test |
+| `harness/portability_matrix.py` | multi-target portability runs | CLI `--target` (repeatable) | `test_harness.py` (self-comparison all-zero deltas) | 18, 22 | verified useful | includes probe/scenario/compare sections |
+| `harness/reach.py` | reachability answers for a target | CLI `--method auto\|stimulus\|trigger\|run\|call` | `test_discovery.py` | 19 | verified useful | emits suggested probe spec |
+| `harness/version_diff.py` | old-vs-new firmware diff | CLI `--old/--new` | `test_version_diff.py` | 21, 25 | verified useful | composes ci_release |
+| `harness/value_demos/*` | s01–s10 value validation + `run_all.py` | `python3 harness/value_demos/run_all.py [--only]` | `test_value_demos.py` | 25, MCP tools | verified useful | ran s02/s05 from the CLI; artifacts under `value_demos/artifacts/` |
+| `plugins/example_plugin.py` | plugin authoring reference | `register(ge)` | plugin smoke (`--plugin`) | 04, plugins/README | verified useful | loaded via `greeneye run --plugin` |
+| `plugins/mcp_server.py` | MCP server (stdio/loopback) | `--stdio`, `--tcp`, `--allow-write`, `--allow-inject` | `test_mcp.py` (431 lines) | 08/R3, 15, 20 | verified useful | read-only default; structured `not_available` fallbacks |
+| `plugins/rmharness_plugin.py` | REPL commands for host RM oracle | `register(ge)`; commands `rmtool/rmtop/rmq` | `rmharness/tests/test_rmharness_plugin.py` (3 tests) | 17, 23 | verified useful | write-named args refused (`rm_harness_skip`) |
+| `qemu/greeneye_trace.c` | full TCG exec/access JSONL trace | `make -C qemu`; `-plugin greeneye-trace.so,path=…` | none automated; **live-verified in this audit** | 10, 16, 14 | verified useful | 8M exec events in 1 s on the demo; vendored `qemu-plugin.h` builds out of the box |
+| `qemu/launch.sh` | one-line `greeneye launch` wrapper | `qemu/launch.sh --kernel <elf>` | smoke | qemu/README | verified useful | thin `exec` of `greeneye.sh launch` |
+| `ghidra/*.java` (Export/Mark/Control) | static/dynamic join inside Ghidra | `analyzeHeadless -postScript …` | protocol counterpart covered by `test_bridge.py`; no Ghidra installed here | 05, 13 | verified useful (static) | README documents exact command lines; Java not executed in this environment |
+| `rmharness/rmtool.sh` | RM oracle wrapper | passthrough to `tools/rmtool` | `harness.sh` capture smoke (`gpu list`) | 17, 23 | verified useful | builds tool on first use; read-only |
+| `rmharness/rmtop.sh` | dashboard wrapper | passthrough to `tools/rmtop` | `--help` | 17 | verified useful | `--once` for captures |
+| `rmharness/rmq.sh` | pre-existing rmq wrapper | passthrough to `tools/rmq` | `--help` | 17 | needs improvement (fixed) | `--help` used to fall through and open the RM; wrapper now answers help itself |
+| `rmharness/harness.sh` | capture tool run into a green-eye session | `--session DIR -- TOOL ARGS` | capture smoke (verified event + blobs) | 17, 22 | verified useful | additive recording; read-only allowlist enforced |
+| `rmharness/tests/` | plugin registration check | `python3 rmharness/tests/test_rmharness_plugin.py` | 3 tests, run and green | 17 | needs improvement (open) | not part of `tests/` discovery nor CI; kept separate by design, documented in 17/README — consider CI step when the workflow is next edited |
+| `sdk/injected_demo.c` | position-independent service SDK | `make sdk` | `test_injection.py`; `inject` smoke | 11, 12 | verified useful | direct + firmware call paths |
+| `firmware/uproc_demo/` | bundled RV64 uproc (mailbox RPC/state/trap) | `make demo` | many integration tests | README, 00, 02 | verified useful | self-test + scripted stimulus work |
+| `examples/scripts/dump_diff.py` | schema decode + snapshot diff demo | `./greeneye.sh run examples/scripts/dump_diff.py` | verified after `__file__` fix | 02 | needs improvement (fixed) | was broken by the `run` namespace gap; now prints decoded ctrl + changed bytes |
+| `examples/scripts/trace_rpc.py` | hook every RPC handler | `./greeneye.sh run …` | ran | 02, 16, 18 | verified useful | 17 hook events from the demo |
+| `examples/scripts/sim_bench.py` | native throughput benchmark | standalone CLI | ran (0.92 MIPS untraced, pure Python) | 16 | verified useful | self-bootstraps `sys.path`, works from anywhere |
+| `examples/scripts/sim_diff.py` | QEMU-vs-native differential | standalone CLI | ran: `PARITY: OK` | 16 | verified useful | exit 1 on divergence, 0 on skip |
+| `examples/probes/demo_probes.json` | declarative probe spec sample | loaded by `greeneye.probes.install` | installed + fired via `greeneye run` | 03 §9 | verified useful | 3 probes, hits `[1, 3, 0]` on the demo |
+| `tests/` (15 files) | unit + live QEMU integration | `python3 -m unittest discover -s tests -t .` | **155 tests OK** | CI (`greeneye.yml`) | verified useful | includes live QEMU, native parity, MCP, bridge, harness, value demos |
+
+## 4. Scripting API purpose review
+
+* **Entry points and lifetime.** `ge.launch()` (QEMU or `backend="native"`)
+ and `ge.attach()` return a `Session`; `close()` clears breakpoints and kills
+ owned QEMU unless `keep_alive=True`. Documented in 03 §1 and the
+ `__init__` docstring.
+* **`greeneye run` namespace.** Preloads `ge`, `session`/`s`, `memory`,
+ `symbols`, `recorder`, plus `__file__`/`__name__` (fixed here so scripts can
+ locate bundled assets; `examples/scripts/dump_diff.py` depends on it).
+ `docs/03-SCRIPTING.md` §intro now states this.
+* **Static/dynamic join.** `SymbolDB` understands ELF, Ghidra name/index/
+ export/callgraph TSVs and native JSON; `resolve()/annotate()` power every
+ report. `by_subsystem` now has a unit test; `Session.symbol_at` is an
+ untested-but-documented alias of `symbols.resolve`.
+* **Safety.** Probe conditions are whitelisted (`compile_condition`), and the
+ RSP/MCP surfaces never eval user text except through that whitelist.
+* **Errors.** All library errors derive from `GreenEyeError`; `load_json` now
+ reports file/parse failures as `GreenEyeError` instead of leaking
+ `JSONDecodeError` through the CLI.
+* **Gaps (deliberate).** No session rewind/replay API; TCG JSONL import into
+ sessions is still roadmap (R4).
+
+## 5. Plugin API purpose review
+
+* **Contract.** `register(ge)`; `ge` exposes `session`, `memory`, `symbols`,
+ `recorder`, `hook`, `watch`, `on_event`, `add_command`, `log`
+ (`greeneye/plugins.py:36`, `plugins/README.md`). A raising plugin is
+ recorded as `plugin_error` and cannot break the session.
+* **Discovery order.** `--plugin PATH` → `GREENEYE_PLUGINS` → bundled
+ `plugins/`. Documented in `plugins/README.md` and 04.
+* **Bundled plugins.**
+ * `example_plugin.py` — authoring reference; loaded in this audit.
+ * `mcp_server.py` — the AI/agent surface; tested by `tests/test_mcp.py`,
+ documented in 20. The old "MCP is planned/do not add it" notes in
+ `plugins/README.md`, 04 and 08 are now corrected to point at 20.
+ * `rmharness_plugin.py` — host oracle REPL commands; tested separately.
+* **Gaps.** Plugins cannot yet declare tool capabilities for MCP discovery
+ beyond `add_command`; `run_command` is REPL-only. Noted, not blocking.
+
+## 6. Unused tools and decisions
+
+No `prune candidate` was found: every unreferenced item was a small, coherent
+public helper. Three were cheaply wired into unit tests; one remains
+`not used yet` with a docstring. Nothing was deleted.
+
+| item | decision | rationale |
+|---|---|---|
+| `greeneye.symbols.SymbolDB.by_subsystem` | **wired**: docstring + `test_core.test_by_subsystem` | natural read-only query alongside `names()` |
+| `greeneye.elf.write_relocated_elf` | **wired**: `test_core.test_write_relocated_elf` verifies entry/PT_LOAD shift | complete ELF utility next to `write_flat_kernel` |
+| `greeneye.ghidra.render_ghidra_command` | **wired**: `test_bridge.GhidraCommandTests` + cited in 05 §4 | renders the `MarkGreenEyeHits.java` command for scripts |
+| `greeneye.session.Session.symbol_at` | `not used yet`: docstring only; kept | thin alias of `symbols.resolve`; external scripts may prefer the Session namespace |
+
+Nothing was deleted.
+
+## 7. Portability / extensibility / discoverability checks
+
+* **Portability.** Stdlib-only Python; `greeneye.sh` pins `PYTHONPATH` to the
+ tool root. Harness tools and `run_all.py` bootstrap their own `sys.path`.
+ QEMU is optional (integration tests skip cleanly); the native backend makes
+ the suite largely hardware-free. `qemu-plugin.h` is vendored so the TCG
+ plugin builds without a QEMU source tree (verified).
+* **Extensibility.** Three documented extension axes with stable contracts:
+ Python scripts (`run`, doc 03), JSON probe specs (`probes`, doc 03 §9), and
+ plugins (`register(ge)`, docs 04/20). Device behaviour is data, not code
+ (`greeneye-device-map/1`, doc 24).
+* **Discoverability.** `README.md` + `docs/00-OVERVIEW.md` doc maps were
+ completed for docs 17–27 (previously they stopped at 16); `plugins/` and
+ `harness/` are now listed in the README layout. `greeneye.sh --help` and
+ `plugins/mcp_server.py --help` are self-describing.
+* **CI.** `.github/workflows/greeneye.yml` builds SDK+demo and runs the main
+ suite on Linux; `rmharness/tests` is the only suite the workflow misses.
+
+## 8. How to tell if a tool is misbehaving (symptom → check → fix)
+
+| tool | symptom | check | fix |
+|---|---|---|---|
+| `greeneye.sh` / CLI | `greeneye error: …` on a valid path | `./greeneye.sh info` shows resolved paths; `--help` for arguments | correct the path/arg; check `GREENEYE_QEMU`/`GREENEYE_DUMPS`; bad symbol DBs now fail with a clear message |
+| `run` scripts | `NameError: __file__` (pre-fix) or names missing | `./greeneye.sh run` preloads `ge/session/s/memory/symbols/recorder/__file__`; run with `--backend native` to remove QEMU | keep scripts inside the tool tree and invoke through `greeneye.sh run`, not bare `python3` |
+| hooks/watchpoints | callback never fires | hook an obviously-hit symbol (`rpc_sum`), `record` + `analyze`; check the session event counts | re-run demo to confirm firmware self-test; ensure the target is resumed (`run`) and the symbol exists (`symbols.get`) |
+| `probe` | 0 faults reported | check `dumps/<run>/blobs/qemu.log` and `recorder.meta["qemu_fault_count"]` | expected for images that stay in mapped memory; use `--instructions` for a bounded step instead of `--duration` |
+| native backend / trace | QEMU-vs-native diff fails | `python3 examples/scripts/sim_diff.py`; `analyze` on the trace blob | divergence means a real backend bug: capture both sessions and diff `exec`/`access` counts (see 16) |
+| `inject` | service not found or call returns garbage | `make sdk`; check slot (`--slot 0..7`) and load address; hook `rpc_dispatch` | rebuild the SDK, restore the default slot, verify `dispatch` ABI (12 §) |
+| harness mutation/portability | findings not reproducible | `--seed` must be fixed; compare `findings.jsonl` byte-for-byte | if a seed diverges, the target or environment changed: capture a session and diff the baseline |
+| discovery / reach | everything "unreachable" | run `--surface demo` on the built demo first; check `--timeout/--settle` | raise settle time; confirm the stimulus hook matches the firmware's mailbox |
+| version diff / CI release | huge spurious diff | check symbol biases/TSVs (`--*-symbols`, `--bias`) and labels | fix the bias first; use `--dry-run` to inspect the plan before writing artifacts |
+| MCP server | client sees no tools / writes refused | `python3 plugins/mcp_server.py --help`; stdout must carry only protocol frames (logs go to stderr) | remove stdout prints from plugins; pass `--allow-write`/`--allow-inject` only when intended |
+| `rmharness` wrappers | `--help` opens the device (pre-fix) or permission errors | `--help` is now device-free; `rmtool gpu list/topology` read `/proc`/sysfs only | run `rmtool.sh --help` to check the build; device-table queries need the driver's permission model (17 §Not implemented) |
+| `qemu/greeneye-trace.so` | empty trace file | run `make -C qemu`; QEMU must reach the `start/end` window | widen `start/end`, set `exec=on`, and resume the target (plugin records only executed code) |
+| device map | unexpected `unimplemented` read | `greeneye.device_map show <map>`; check `bias` and schema version | harvest from a `probe` session with the same bias; use `unknown_mmio="lenient"` for first-touch exploration |
+
+## 9. Provenance discipline
+
+Tracked tool-tree docs describe components and behaviour; no non-public
+paths, filenames or source locations appear in them. The audit re-ran the
+marker scan across this tree and found none. No further action.
+
+## 10. Summary
+
+| status | count | items |
+|---|---:|---|
+| `verified useful` | 57 | all 13 CLI commands (2 with a fixed gap); all 19 package modules; all `sim/`; all `harness/` incl. value demos; 3 plugins; QEMU plugin + launch helper; Ghidra scripts (static); all `rmharness/` (1 fixed gap); SDK, demo firmware; 5 example assets; tests |
+| `not used yet` | 1 | `session.symbol_at` — docstring added, kept (section 6) |
+| `needs improvement` | 5 | `run` `__file__` namespace (**fixed**), `symbols resolve --db` traceback (**fixed**), `rmq.sh --help` device touch (**fixed**), stale doc maps/MCP/status text in README/00/04/08/14 + `plugins/README` (**fixed**), `rmharness/tests` outside main suite/CI (**open**, documented) |
+| `prune candidate` | 0 | nothing deleted |
+
+Files changed by this audit:
+
+* `greeneye/cli.py` — command docstring completed; `run` namespace sets
+ `__file__`/`__name__`.
+* `greeneye/symbols.py` — `load_json` raises `GreenEyeError` and now handles
+ list payloads; docstrings for `by_subsystem`, `nearest`.
+* `greeneye/session.py` — docstring for `symbol_at`.
+* `greeneye/ghidra.py` — docstring for `render_ghidra_command`.
+* `tests/test_core.py` — tests for list/bad JSON payloads, `by_subsystem`,
+ `write_relocated_elf`.
+* `tests/test_bridge.py` — test for `render_ghidra_command`.
+* `rmharness/rmq.sh` — device-free `--help`.
+* `README.md`, `docs/00-OVERVIEW.md` — doc maps completed (17–27), layout
+ rows for `harness/`/`rmharness/`, status-table corrections.
+* `docs/03-SCRIPTING.md` — documents `__file__`.
+* `docs/04-PLUGINS.md`, `docs/08-ROADMAP.md`, `plugins/README.md` — MCP is
+ implemented, not planned; point at 20.
+* `docs/05-GHIDRA-BRIDGE.md` — documents `render_ghidra_command`.
+* `docs/14-ADVERSARIAL-INTEROP-GUIDE.md` — capability table updated to
+ implemented harness/device-map/MCP status.
+* `docs/27-TOOL-AUDIT.md` — this document.
+
+Verification after the changes: 155/155 unit tests OK; docs-link check OK
+(55 files); every smoke in section 0 re-run green.
diff --git a/debug/risc-v-gsp-rm-vm/examples/scripts/sim_bench.py b/debug/risc-v-gsp-rm-vm/examples/scripts/sim_bench.py
new file mode 100644
index 0000000..fa805ad
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/examples/scripts/sim_bench.py
@@ -0,0 +1,75 @@
+"""Measure native (M1) simulator throughput on the bundled demo firmware.
+
+Run standalone (from anywhere):
+
+ python3 examples/scripts/sim_bench.py # untraced baseline
+ python3 examples/scripts/sim_bench.py --trace # baseline + R4 trace overhead
+
+Reports MIPS for a fixed instruction budget -- the budget
+``SimClient.continue_(timeout=1.0)`` derives from SIM_IPS = 1e6. With
+``--trace`` a second run enables the exec/access trace and reports the
+per-instruction slowdown, so the non-trace path can be checked for regressions.
+"""
+from __future__ import annotations
+
+import argparse
+import sys
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+import greeneye as ge
+from greeneye.errors import RspTimeout
+from sim.trace import Trace
+
+DEMO = ROOT / "firmware" / "uproc_demo" / "build" / "uproc_demo.elf"
+
+
+def measure(budget: int, *, trace: bool = False) -> tuple[int, float]:
+ """Run a fixed instruction budget; return (instructions, seconds)."""
+ session = ge.launch(kernel=str(DEMO), backend="native", name="sim-bench")
+ machine = session.client.machine
+ if trace:
+ session.client.trace = Trace() # recorder stays null: nothing is written
+ try:
+ machine.insns = 0
+ started = time.perf_counter()
+ try:
+ session.client.continue_(timeout=budget / 1_000_000.0)
+ except RspTimeout:
+ pass # budget exhausted: the benchmark workload completed
+ elapsed = time.perf_counter() - started
+ banner = session.target.read_uart().splitlines()
+ print(f"uart: {banner[0] if banner else '<empty>'!r}")
+ return machine.insns, elapsed
+ finally:
+ session.close()
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("--budget", type=int, default=1_000_000)
+ parser.add_argument("--trace", action="store_true", help="also measure traced throughput")
+ parser.add_argument("--trace-budget", type=int, default=200_000)
+ args = parser.parse_args(argv)
+
+ insns, elapsed = measure(args.budget)
+ mips = insns / elapsed / 1e6 if elapsed else float("inf")
+ print(f"native M1: {insns} instructions in {elapsed:.3f}s = {mips:.2f} MIPS (untraced)")
+
+ if args.trace:
+ t_insns, t_elapsed = measure(args.trace_budget, trace=True)
+ t_mips = t_insns / t_elapsed / 1e6 if t_elapsed else float("inf")
+ overhead = (mips / t_mips - 1.0) if t_mips else float("inf")
+ print(
+ f"native M1 trace: {t_insns} instructions in {t_elapsed:.3f}s = {t_mips:.2f} MIPS "
+ f"({overhead * 100:+.1f}% per-instruction vs untraced)"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/examples/scripts/sim_diff.py b/debug/risc-v-gsp-rm-vm/examples/scripts/sim_diff.py
new file mode 100644
index 0000000..f79b822
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/examples/scripts/sim_diff.py
@@ -0,0 +1,104 @@
+"""Differential harness: run one demo scenario on QEMU and native, then diff.
+
+ python3 examples/scripts/sim_diff.py [--out DIR] [--keep]
+
+Both backends execute the same scripted profile: enter/leave hooks on every
+uproc RPC handler, a write watchpoint on ``gsp_ctrl->rpc_count``, and a fixed
+0.3 s serviced run. The scenario is deterministic on both backends (the demo
+self-test finishes and call counts are fixed), so the hook/watch/breakpoint
+profiles must match exactly. Sessions are recorded under ``--out`` (or a
+temporary directory) and ``analyze.compare_sessions`` renders the delta report.
+
+Exit status: 0 when the profiles match (or QEMU is unavailable: skip),
+1 when they diverge.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[2]
+if str(ROOT) not in sys.path:
+ sys.path.insert(0, str(ROOT))
+
+import greeneye as ge
+from greeneye import analyze
+
+DEMO = ROOT / "firmware" / "uproc_demo" / "build" / "uproc_demo.elf"
+RPC_HANDLERS = ["rpc_ping", "rpc_echo", "rpc_fill", "rpc_sum", "rpc_state"]
+DURATION = 0.3
+
+
+def run_scenario(backend: str, session_dir: str) -> dict:
+ """Record one scripted run and return its canonical profile."""
+ session = ge.launch(kernel=str(DEMO), backend=backend, record=session_dir, name=f"diff-{backend}")
+ events: list = []
+ watch_hits: list = []
+ try:
+ for name in RPC_HANDLERS:
+ session.hook(
+ name,
+ on_enter=lambda hit, n=name: events.append(["enter", n, hit.arg(0), hit.arg(1)]),
+ on_leave=lambda hit, n=name: events.append(["leave", n, hit.retval]),
+ )
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ session.watch(ctrl + 16, size=8, kind="w", callback=lambda hit: watch_hits.append(hit.addr))
+ session.run(duration=DURATION)
+ uart = session.target.read_uart()
+ bp_counts = [handle.hits for handle in session._bps.values()]
+ finally:
+ session.close()
+ return {
+ "hook_events": events,
+ "watch_hits": watch_hits,
+ "bp_counts": bp_counts,
+ "uart_first_line": uart.splitlines()[0] if uart else "",
+ }
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("--out", help="directory for the two recorded sessions")
+ parser.add_argument("--keep", action="store_true", help="keep a temporary --out directory")
+ args = parser.parse_args(argv)
+
+ if not DEMO.exists():
+ print(f"skip: demo firmware not built ({DEMO}); run `make demo`")
+ return 0
+ if not shutil.which(os.environ.get("GREENEYE_QEMU", "qemu-system-riscv64")):
+ print("skip: qemu-system-riscv64 not found; native profile not compared")
+ return 0
+
+ out = args.out or tempfile.mkdtemp(prefix="greeneye-simdiff-")
+ os.makedirs(out, exist_ok=True)
+ qemu = run_scenario("qemu", os.path.join(out, "qemu"))
+ native = run_scenario("native", os.path.join(out, "native"))
+ report = analyze.compare_sessions(
+ os.path.join(out, "qemu"), os.path.join(out, "native")
+ )
+
+ if not args.out and not args.keep:
+ shutil.rmtree(out, ignore_errors=True)
+ location = "<temporary; use --out/--keep to keep>"
+ else:
+ location = out
+
+ print(f"# QEMU vs native differential profile ({location})")
+ print(json.dumps(qemu, sort_keys=True))
+ print(json.dumps(native, sort_keys=True))
+ print(report)
+
+ if qemu != native:
+ print("PARITY: FAIL")
+ return 1
+ print("PARITY: OK (hook/watch/bp profiles identical)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/__init__.py b/debug/risc-v-gsp-rm-vm/greeneye/__init__.py
index 8b1c1e5..717160c 100644
--- a/debug/risc-v-gsp-rm-vm/greeneye/__init__.py
+++ b/debug/risc-v-gsp-rm-vm/greeneye/__init__.py
@@ -85,13 +85,46 @@ def launch(
name: str = "session",
load_symbols: bool = True,
keep_alive: bool = False,
+ backend: str = "qemu",
+ trace=None,
) -> Session:
- """Launch QEMU (paused), attach, and return a ready Session.
+ """Launch a target (paused), attach, and return a ready Session.
- The target starts paused at its reset vector; use Session.load_image() to
- load an ELF (which also sets PC), or QemuTarget.run_kernel() style helper
- launch with kernel= for the fast `-kernel` path.
+ ``backend="qemu"`` (default) starts ``qemu-system-riscv64`` exactly as
+ before. ``backend="native"`` uses the in-process deterministic RV64IMAC
+ simulator (M1) via ``greeneye.sim_backend``; the Session contract is the
+ same. ``trace`` (native only) enables the R4 exec/access trace: ``True``,
+ a dict of ``{"exec", "start", "end", "max_events"}``, or a
+ ``sim.trace.Trace``; it is saved to the session as ``blobs/trace.jsonl``.
+ The target starts paused at its reset vector; use
+ Session.load_image() to load an ELF (which also sets PC), or launch with
+ kernel= for the fast `-kernel` path.
"""
+ if backend in ("native", "sim"):
+ from .sim_backend import launch_native
+
+ return launch_native(
+ kernel=kernel,
+ memory_mb=memory_mb,
+ record=record,
+ symbols=symbols,
+ name=name,
+ load_symbols=load_symbols,
+ keep_alive=keep_alive,
+ trace=trace,
+ )
+ if trace is not None:
+ from .errors import TargetError
+
+ raise TargetError(
+ "trace= is native-only; for QEMU build qemu/greeneye-trace.so and "
+ "pass -plugin (see qemu/README.md)"
+ )
+ if backend != "qemu":
+ from .errors import TargetError
+
+ raise TargetError(f"unknown backend {backend!r} (use 'qemu' or 'native')")
+
target = QemuTarget(
machine=machine,
memory_mb=memory_mb,
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/analyze.py b/debug/risc-v-gsp-rm-vm/greeneye/analyze.py
index 66429e9..1c7e930 100644
--- a/debug/risc-v-gsp-rm-vm/greeneye/analyze.py
+++ b/debug/risc-v-gsp-rm-vm/greeneye/analyze.py
@@ -6,12 +6,14 @@ offline comparison between runs (bare-metal vs VFIO, old firmware vs new).
"""
from __future__ import annotations
+import json
import os
import re
from collections import Counter, defaultdict
from .record import load_events, load_meta
+_TRACE_KINDS = ("exec", "access", "read", "write")
_ACCESS_RE = re.compile(
r"Invalid (read|write) at addr (0x[0-9a-fA-F]+), size (\d+), region '([^']*)', reason: (\w+)"
)
@@ -90,13 +92,56 @@ def describe_fault(fault: dict, bias: int = 0) -> str:
return "\n".join(lines)
+def _trace_kind(record: dict) -> str | None:
+ """Native/TCG trace records use ``t``; folded session events use ``type``."""
+ return record.get("type") or record.get("t")
+
+
+def _number(value, default: int = 0) -> int:
+ if isinstance(value, int):
+ return value
+ if isinstance(value, str):
+ try:
+ return int(value, 0)
+ except ValueError:
+ return default
+ return default
+
+
+def load_trace(session_dir: str, meta: dict | None = None) -> list[dict]:
+ """Load the trace blob referenced by ``meta['trace_blob']`` (R4)."""
+ meta = meta if meta is not None else load_meta(session_dir)
+ rel = meta.get("trace_blob")
+ if not rel:
+ return []
+ path = rel if os.path.isabs(rel) else os.path.join(session_dir, rel)
+ records: list[dict] = []
+ try:
+ with open(path, "r", encoding="utf-8") as fh:
+ for line in fh:
+ line = line.strip()
+ if not line:
+ continue
+ record = json.loads(line)
+ if _trace_kind(record) != "summary":
+ records.append(record)
+ except (OSError, ValueError):
+ return []
+ return records
+
+
def render_summary(session_dir: str, symbols=None, max_rows: int = 40) -> str:
meta = load_meta(session_dir)
events = load_events(session_dir)
- return render_summary_from(meta, events, symbols=symbols, max_rows=max_rows)
+ return render_summary_from(
+ meta, events, symbols=symbols, max_rows=max_rows, trace=load_trace(session_dir, meta)
+ )
-def render_summary_from(meta: dict, events: list[dict], symbols=None, max_rows: int = 40) -> str:
+def render_summary_from(meta: dict, events: list[dict], symbols=None, max_rows: int = 40,
+ trace: list[dict] | None = None) -> str:
+ if trace is None:
+ trace = [e for e in events if _trace_kind(e) in _TRACE_KINDS]
lines: list[str] = []
lines.append(f"# green-eye session summary: {meta.get('name', '?')}")
lines.append("")
@@ -194,6 +239,79 @@ def render_summary_from(meta: dict, events: list[dict], symbols=None, max_rows:
lines.append(f"| `{page:#x}` | {count} | `{sym.name if sym else ''}` |")
lines.append("")
+ # Execution/access trace coverage (native R4 or folded TCG trace)
+ if trace:
+ def access_kind(record):
+ kind = _trace_kind(record)
+ if kind == "access":
+ return "write" if str(record.get("rw", "")).startswith("w") else "read"
+ return kind
+
+ execs = [r for r in trace if access_kind(r) == "exec"]
+ reads = [r for r in trace if access_kind(r) == "read"]
+ writes = [r for r in trace if access_kind(r) == "write"]
+ lines.append("## Execution trace")
+ lines.append("")
+ blob = meta.get("trace_blob")
+ if blob:
+ lines.append(f"* trace blob: `{blob}`")
+ pcs = Counter()
+ for record in execs:
+ pcs[_number(record.get("pc"))] += 1
+ suffix = f", unique PCs: {len(pcs)}" if execs else " (exec=off)"
+ lines.append(f"* exec events: {len(execs)}{suffix}")
+ read_pages = Counter()
+ for record in reads:
+ read_pages[(_number(record.get("addr")) >> 12) << 12] += 1
+ write_pages = Counter()
+ for record in writes:
+ write_pages[(_number(record.get("addr")) >> 12) << 12] += 1
+ lines.append(
+ f"* accesses: {len(reads)} reads ({len(read_pages)} pages), "
+ f"{len(writes)} writes ({len(write_pages)} pages)"
+ )
+ config = meta.get("trace") or {}
+ if config:
+ bits = [f"exec={'on' if config.get('exec') else 'off'}"]
+ for key in ("start", "end"):
+ if config.get(key) is not None:
+ bits.append(f"{key}={config[key]:#x}" if isinstance(config[key], int) else f"{key}={config[key]}")
+ if config.get("max_events"):
+ bits.append(f"max_events={config['max_events']}")
+ if config.get("dropped"):
+ bits.append(f"dropped={config['dropped']}")
+ lines.append("* trace config: " + ", ".join(bits))
+ lines.append("")
+
+ if execs:
+ by_sym = defaultdict(lambda: [0, 0]) # unique PCs, exec count
+ for pc, count in pcs.items():
+ sym = symbols.resolve(pc) if symbols else None
+ entry = by_sym[sym.name if sym else f"{pc:#x}"]
+ entry[0] += 1
+ entry[1] += count
+ lines.append("### Unique PCs by symbol")
+ lines.append("")
+ lines.append("| unique PCs | execs | symbol |")
+ lines.append("|---:|---:|---|")
+ for name, (unique, count) in sorted(by_sym.items(), key=lambda kv: -kv[1][1])[:max_rows]:
+ lines.append(f"| {unique} | {count} | `{name}` |")
+ lines.append("")
+
+ if read_pages or write_pages:
+ lines.append("### Accessed pages")
+ lines.append("")
+ lines.append("| page | reads | writes | symbol |")
+ lines.append("|---|---:|---:|---|")
+ for page in sorted(set(read_pages) | set(write_pages),
+ key=lambda p: -(read_pages.get(p, 0) + write_pages.get(p, 0)))[:max_rows]:
+ sym = symbols.resolve(page) if symbols else None
+ lines.append(
+ f"| `{page:#x}` | {read_pages.get(page, 0)} | {write_pages.get(page, 0)} "
+ f"| `{sym.name if sym else ''}` |"
+ )
+ lines.append("")
+
# Coverage
steps = [e for e in events if e.get("type") == "step"]
if steps:
@@ -277,9 +395,16 @@ def compare_sessions(a_dir: str, b_dir: str, symbols=None, max_rows: int = 40) -
"""Diff two recorded sessions at the symbolic event level (A/B tooling)."""
a_meta, b_meta = load_meta(a_dir), load_meta(b_dir)
a_events, b_events = load_events(a_dir), load_events(b_dir)
+ a_trace, b_trace = load_trace(a_dir, a_meta), load_trace(b_dir, b_meta)
lines = [f"# green-eye session comparison", "", f"* A: {a_meta.get('name')} ({a_dir})", f"* B: {b_meta.get('name')} ({b_dir})", ""]
- def profile(events):
+ def trace_kind(record: dict) -> str:
+ kind = _trace_kind(record)
+ if kind == "access":
+ return "write" if str(record.get("rw", "")).startswith("w") else "read"
+ return kind
+
+ def profile(events, trace):
bp = Counter()
watch = Counter()
writes = Counter()
@@ -290,10 +415,13 @@ def compare_sessions(a_dir: str, b_dir: str, symbols=None, max_rows: int = 40) -
watch[(event.get("addr"), event.get("kind"))] += 1
elif event.get("type") == "mem_write":
writes[(event.get("addr", 0) >> 12) << 12] += 1
+ for record in trace:
+ if trace_kind(record) == "write":
+ writes[(_number(record.get("addr")) >> 12) << 12] += 1
return bp, watch, writes
- a_bp, a_watch, a_writes = profile(a_events)
- b_bp, b_watch, b_writes = profile(b_events)
+ a_bp, a_watch, a_writes = profile(a_events, a_trace)
+ b_bp, b_watch, b_writes = profile(b_events, b_trace)
lines.append("## Breakpoint profile delta (A vs B)")
lines.append("")
@@ -318,4 +446,23 @@ def compare_sessions(a_dir: str, b_dir: str, symbols=None, max_rows: int = 40) -
for page in sorted(set(a_writes) | set(b_writes), key=lambda k: -(a_writes.get(k, 0) + b_writes.get(k, 0)))[:max_rows]:
lines.append(f"| `{page:#x}` | {a_writes.get(page, 0)} | {b_writes.get(page, 0)} |")
lines.append("")
+
+ def trace_totals(trace):
+ totals = Counter()
+ for record in trace:
+ kind = trace_kind(record)
+ if kind in ("exec", "read", "write"):
+ totals[kind] += 1
+ return totals
+
+ a_totals, b_totals = trace_totals(a_trace), trace_totals(b_trace)
+ if a_trace or b_trace:
+ lines.append("## Trace stream totals (A vs B)")
+ lines.append("")
+ lines.append("| stream | A | B | delta |")
+ lines.append("|---|---:|---:|---:|")
+ for kind in ("exec", "read", "write"):
+ a_count, b_count = a_totals.get(kind, 0), b_totals.get(kind, 0)
+ lines.append(f"| {kind} | {a_count} | {b_count} | {b_count - a_count:+d} |")
+ lines.append("")
return "\n".join(lines) + "\n"
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/cli.py b/debug/risc-v-gsp-rm-vm/greeneye/cli.py
index 94102a4..e7023ec 100644
--- a/debug/risc-v-gsp-rm-vm/greeneye/cli.py
+++ b/debug/risc-v-gsp-rm-vm/greeneye/cli.py
@@ -1,13 +1,17 @@
"""green-eye command line interface.
Commands:
- info environment/tooling check
+ info environment/tooling check (incl. native simulator availability)
demo boot the bundled uproc demo and run a canned instrumentation pass
run run a Python instrumentation script against a target
- attach interactive REPL against a QEMU gdbstub
+ attach interactive REPL over a gdbstub or the native simulator
+ launch start a target and keep it alive for a later attach
probe run an arbitrary firmware (default: the real GSP fwimage) for N
instructions and report the first divergence
record boot a firmware and record a memory-activity session
+ (native: --trace collects an exec/access trace blob)
+ inject load and invoke an injected RV64 service (Frida-like)
+ ghidra serve a live session to GhidraScripts over the JSON-lines bridge
analyze render a recorded session to a symbol-annotated report
compare A/B two recorded sessions at the symbolic level
symbols build/inspect a green-eye symbol database from Ghidra exports
@@ -82,14 +86,20 @@ def _start_session(
record,
name: str,
plugins: list[str] | None = None,
+ backend: str = "qemu",
+ trace=None,
):
if port:
+ if backend != "qemu":
+ raise GreenEyeError("--backend native cannot attach to an existing gdbstub port")
+ if trace is not None:
+ raise GreenEyeError("--trace is native-only; use the qemu/greeneye-trace.so plugin for QEMU")
db = SymbolDB()
if kernel:
db.load_elf(kernel)
session = ge.attach(port=port, record=record, symbols=db, name=name)
else:
- session = ge.launch(kernel=kernel, record=record, name=name)
+ session = ge.launch(kernel=kernel, record=record, name=name, backend=backend, trace=trace)
manager = PluginManager(session)
manager.load_all(plugins)
return session, manager
@@ -101,6 +111,63 @@ def _print_session_dir(session) -> None:
print(f"[green-eye] session: {directory}")
+def _add_backend_arg(parser) -> None:
+ """Select the execution backend; qemu stays the default reference path."""
+ parser.add_argument(
+ "--backend",
+ choices=("qemu", "native"),
+ default="qemu",
+ help="execution backend: qemu (default) or the native RV64IMAC simulator",
+ )
+
+
+def _add_trace_args(parser) -> None:
+ """Native exec/access tracing (R4), mirroring qemu/greeneye_trace.c."""
+ parser.add_argument(
+ "--trace",
+ action="store_true",
+ help="native backend: record an exec/access trace into the session",
+ )
+ parser.add_argument(
+ "--trace-exec",
+ choices=("on", "off"),
+ default="on",
+ help="trace instruction exec events (default on; 'off' records accesses only)",
+ )
+ parser.add_argument(
+ "--trace-start",
+ type=lambda v: int(v, 0),
+ default=None,
+ help="only trace PCs >= this address (inclusive)",
+ )
+ parser.add_argument(
+ "--trace-end",
+ type=lambda v: int(v, 0),
+ default=None,
+ help="only trace PCs <= this address (inclusive)",
+ )
+ parser.add_argument(
+ "--trace-max",
+ type=int,
+ default=None,
+ help="cap buffered trace events (0 = unlimited)",
+ )
+
+
+def _trace_spec(args):
+ """Build a launch_native trace spec from parsed args (None when disabled)."""
+ if not getattr(args, "trace", False):
+ return None
+ spec = {"exec": args.trace_exec == "on"}
+ if args.trace_start is not None:
+ spec["start"] = args.trace_start
+ if args.trace_end is not None:
+ spec["end"] = args.trace_end
+ if args.trace_max is not None:
+ spec["max_events"] = args.trace_max
+ return spec
+
+
# ---------------------------------------------------------------------------
# commands
# ---------------------------------------------------------------------------
@@ -121,6 +188,13 @@ def cmd_info(args) -> int:
version = subprocess.run([resolved, "--version"], capture_output=True, text=True).stdout.strip()
print(f"qemu version: {version}")
+ try:
+ from .sim_backend import SIM_IPS # noqa: F401
+
+ print("native simulator: available (deterministic RV64IMAC, pure-Python reference)")
+ print("native trace: available (--trace exec/access, --trace-start/--trace-end filters)")
+ except Exception as exc: # pragma: no cover - only on a broken install
+ print(f"native simulator: unavailable ({exc})")
for label, path in [
("demo firmware", DEMO_ELF),
("demo schema", DEMO_SCHEMA),
@@ -142,7 +216,10 @@ def cmd_demo(args) -> int:
raise GreenEyeError(f"demo firmware not built: {elf}")
schema = _load_schema(DEMO_SCHEMA)
record = True if args.record is None else args.record
- session, _manager = _start_session(kernel=str(elf), port=None, record=record, name="demo")
+ session, _manager = _start_session(
+ kernel=str(elf), port=None, record=record, name="demo", backend=args.backend,
+ trace=_trace_spec(args),
+ )
hits: list[str] = []
try:
ctrl = session.symbols.lookup("gsp_ctrl")
@@ -204,7 +281,13 @@ def cmd_run(args) -> int:
if not kernel and not args.port:
kernel = str(_build_demo())
session, manager = _start_session(
- kernel=kernel, port=args.port, record=args.record or True, name=script.stem, plugins=args.plugin
+ kernel=kernel,
+ port=args.port,
+ record=args.record or True,
+ name=script.stem,
+ plugins=args.plugin,
+ backend=args.backend,
+ trace=_trace_spec(args),
)
namespace = {
"ge": ge,
@@ -214,6 +297,8 @@ def cmd_run(args) -> int:
"symbols": session.symbols,
"recorder": session.recorder,
"greeneye": ge,
+ "__file__": str(script),
+ "__name__": "__main__",
}
try:
source = script.read_text(encoding="utf-8")
@@ -411,7 +496,13 @@ def cmd_attach(args) -> int:
elif not args.port:
kernel = str(_build_demo())
session, manager = _start_session(
- kernel=kernel, port=args.port, record=args.record, name="repl", plugins=args.plugin
+ kernel=kernel,
+ port=args.port,
+ record=args.record,
+ name="repl",
+ plugins=args.plugin,
+ backend=args.backend,
+ trace=_trace_spec(args),
)
try:
GreenEyeRepl(session, manager).cmdloop()
@@ -443,7 +534,10 @@ def cmd_launch(args) -> int:
def cmd_record(args) -> int:
_ensure_dumps_env()
elf = args.kernel or str(_build_demo())
- session, _manager = _start_session(kernel=str(elf), port=None, record=args.record or True, name=args.name)
+ session, _manager = _start_session(
+ kernel=str(elf), port=None, record=args.record or True, name=args.name,
+ backend=args.backend, trace=_trace_spec(args),
+ )
try:
for spec in args.watch or []:
addr_or_sym, size = (spec.split(":", 1) + ["8"])[:2]
@@ -550,7 +644,8 @@ def cmd_inject(args) -> int:
)
kernel = args.kernel or str(_build_demo())
session, _manager = _start_session(
- kernel=str(kernel), port=args.port, record=args.record or True, name="inject", plugins=args.plugin
+ kernel=str(kernel), port=args.port, record=args.record or True, name="inject",
+ plugins=args.plugin, backend=args.backend, trace=_trace_spec(args),
)
try:
service = load_service(session, str(service_path), load_addr=args.load_addr, slot=args.slot)
@@ -578,7 +673,8 @@ def cmd_ghidra(args) -> int:
elif not args.target_port:
kernel = str(_build_demo())
session, _manager = _start_session(
- kernel=kernel, port=args.target_port, record=args.record or True, name="ghidra", plugins=args.plugin
+ kernel=kernel, port=args.target_port, record=args.record or True, name="ghidra",
+ plugins=args.plugin, backend=args.backend, trace=_trace_spec(args),
)
bridge = GhidraBridge(session, port=args.bridge_port)
port = bridge.start()
@@ -669,6 +765,8 @@ def build_parser() -> argparse.ArgumentParser:
demo.add_argument("--no-build", action="store_true", help="do not rebuild the demo")
demo.add_argument("--record", nargs="?", const=True, default=None, help="record to a path (or default dumps/)")
demo.add_argument("--duration", type=float, default=1.0)
+ _add_backend_arg(demo)
+ _add_trace_args(demo)
demo.set_defaults(fn=cmd_demo)
run = sub.add_parser("run", help="run a Python instrumentation script")
@@ -677,6 +775,8 @@ def build_parser() -> argparse.ArgumentParser:
run.add_argument("--port", type=int)
run.add_argument("--record", nargs="?", const=True, default=None)
run.add_argument("--plugin", action="append", default=[])
+ _add_backend_arg(run)
+ _add_trace_args(run)
run.set_defaults(fn=cmd_run)
attach = sub.add_parser("attach", help="interactive REPL over a gdbstub (launches if no --port)")
@@ -684,6 +784,8 @@ def build_parser() -> argparse.ArgumentParser:
attach.add_argument("--kernel", help="firmware ELF (default: built demo)")
attach.add_argument("--record", nargs="?", const=True, default=None)
attach.add_argument("--plugin", action="append", default=[])
+ _add_backend_arg(attach)
+ _add_trace_args(attach)
attach.set_defaults(fn=cmd_attach)
launch = sub.add_parser("launch", help="start a target and keep it alive for attach")
@@ -713,6 +815,8 @@ def build_parser() -> argparse.ArgumentParser:
inject.add_argument("--no-firmware", action="store_true", help="only make the direct debugger-side call")
inject.add_argument("--record", nargs="?", const=True, default=None)
inject.add_argument("--plugin", action="append", default=[])
+ _add_backend_arg(inject)
+ _add_trace_args(inject)
inject.set_defaults(fn=cmd_inject)
record = sub.add_parser("record", help="boot and record a memory-activity session")
@@ -722,6 +826,8 @@ def build_parser() -> argparse.ArgumentParser:
record.add_argument("--watch", action="append", help="addr|symbol[:size]")
record.add_argument("--snapshot", action="append", help="addr|symbol:len")
record.add_argument("--record", nargs="?", const=True, default=None)
+ _add_backend_arg(record)
+ _add_trace_args(record)
record.set_defaults(fn=cmd_record)
ghidra = sub.add_parser("ghidra", help="serve a live session to GhidraScripts (JSON-lines bridge)")
@@ -730,6 +836,8 @@ def build_parser() -> argparse.ArgumentParser:
ghidra.add_argument("--bridge-port", type=int, default=7000)
ghidra.add_argument("--record", nargs="?", const=True, default=None)
ghidra.add_argument("--plugin", action="append", default=[])
+ _add_backend_arg(ghidra)
+ _add_trace_args(ghidra)
ghidra.set_defaults(fn=cmd_ghidra)
analyze = sub.add_parser("analyze", help="render a recorded session report")
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/device_map.py b/debug/risc-v-gsp-rm-vm/greeneye/device_map.py
new file mode 100644
index 0000000..11ac656
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/device_map.py
@@ -0,0 +1,555 @@
+"""Versioned first-touch device map (roadmap H2.1 -> H2.2 start).
+
+A device map is JSON data, not code: it records which MMIO addresses a
+firmware image touched first, at which width and direction, with observed
+values, hit counts and provenance. ``harvest_*`` builds one from the QEMU
+logs a ``greeneye probe`` session records; the native simulator
+(:class:`sim.machine.Machine`) consults it so unmodelled accesses return a
+mapped behaviour instead of faulting (``unknown_mmio="lenient"``).
+
+Schema ``greeneye-device-map/1``::
+
+ {
+ "schema": "greeneye-device-map/1",
+ "meta": {"bias": 2147483648, "firmware": "...", "firmware_sha256": "..."},
+ "entries": [
+ {"address": 2305843009213696512, "width": 4, "kind": "r",
+ "values": [], "hits": 1, "first_epc": 2214629396,
+ "first_epc_fw": 67145716, "source": "probe", "notes": "fault_load"},
+ ...
+ ]
+ }
+
+Entries are emitted in a deterministic order (address, width, kind) and the
+file contains no timestamps or host-specific paths in semantic fields, so two
+harvests of the same log are byte-identical.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import sys
+from dataclasses import dataclass
+
+from .errors import GreenEyeError
+
+SCHEMA = "greeneye-device-map/1"
+KINDS = ("r", "w")
+SOURCES = ("probe", "manual", "trace")
+_RAM_BASE = 0x80000000
+#: QEMU probe first fault for the TU104 570.144 image (docs/06-ACCURACY.md §3).
+QEMU_REFERENCE = {
+ "firmware_vaddr": 0x4009014,
+ "access_addr": 0x2000000000000A00,
+ "size": 4,
+ "rw": "read",
+ "cause": 5,
+ "desc": "load access fault",
+}
+
+_BEHAVIOUR_ZERO = "zero"
+_BEHAVIOUR_VALUE = "value:"
+
+
+def _fw_pc(pc: int, bias: int) -> int:
+ """Translate a relocated PC back to the firmware's own vaddr.
+
+ A PC of 0 is a trap-vector fetch (not relocated code) and stays 0; PCs
+ below the relocation bias are not attributable and stay untranslated.
+ """
+ if not bias or pc == 0 or pc < bias:
+ return pc
+ return pc - bias
+
+
+@dataclass(frozen=True)
+class DeviceEntry:
+ """One observed (address, width, direction) MMIO touch."""
+
+ address: int
+ width: int
+ kind: str # "r" | "w"
+ values: tuple[int, ...] = ()
+ hits: int = 1
+ first_epc: int = 0 # PC as observed in the run (relocated)
+ first_epc_fw: int = 0 # PC - bias: the firmware's own virtual address
+ source: str = "manual" # probe | manual | trace
+ notes: str = ""
+
+ def key(self) -> tuple[int, int, str]:
+ return (self.address, self.width, self.kind)
+
+ def to_dict(self) -> dict:
+ return {
+ "address": self.address,
+ "width": self.width,
+ "kind": self.kind,
+ "values": list(self.values),
+ "hits": self.hits,
+ "first_epc": self.first_epc,
+ "first_epc_fw": self.first_epc_fw,
+ "source": self.source,
+ "notes": self.notes,
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "DeviceEntry":
+ return cls(
+ address=int(data["address"]),
+ width=int(data["width"]),
+ kind=str(data["kind"]),
+ values=tuple(int(v) for v in data.get("values", ())),
+ hits=int(data.get("hits", 1)),
+ first_epc=int(data.get("first_epc", 0)),
+ first_epc_fw=int(data.get("first_epc_fw", 0)),
+ source=str(data.get("source", "manual")),
+ notes=str(data.get("notes", "")),
+ )
+
+
+class DeviceMap:
+ """A versioned collection of :class:`DeviceEntry` records."""
+
+ def __init__(self, *, meta: dict | None = None):
+ self.meta: dict = dict(meta or {})
+ self._entries: dict[tuple[int, int, str], DeviceEntry] = {}
+
+ # -- construction ------------------------------------------------------
+
+ def record(
+ self,
+ addr: int,
+ size: int,
+ kind: str,
+ value: int | None = None,
+ *,
+ pc: int = 0,
+ bias: int = 0,
+ source: str = "manual",
+ notes: str = "",
+ ) -> DeviceEntry:
+ """Add or merge one observation; returns the resulting entry."""
+ if kind not in KINDS:
+ raise GreenEyeError(f"device map: access kind must be r or w, got {kind!r}")
+ if size <= 0:
+ raise GreenEyeError(f"device map: width must be positive, got {size}")
+ key = (addr, size, kind)
+ entry = self._entries.get(key)
+ if entry is None:
+ entry = DeviceEntry(
+ address=addr,
+ width=size,
+ kind=kind,
+ values=(value,) if value is not None else (),
+ hits=1,
+ first_epc=pc,
+ first_epc_fw=_fw_pc(pc, bias),
+ source=source,
+ notes=notes,
+ )
+ else:
+ values = entry.values
+ if value is not None and value not in values:
+ values = values + (value,)
+ entry = DeviceEntry(
+ address=entry.address,
+ width=entry.width,
+ kind=entry.kind,
+ values=values,
+ hits=entry.hits + 1,
+ first_epc=entry.first_epc or pc,
+ first_epc_fw=entry.first_epc_fw or _fw_pc(pc, bias),
+ source=entry.source,
+ notes=entry.notes or notes,
+ )
+ self._entries[key] = entry
+ return entry
+
+ def add(self, entry: DeviceEntry) -> DeviceEntry:
+ """Merge a whole entry (used by :meth:`merge`)."""
+ key = entry.key()
+ current = self._entries.get(key)
+ if current is None:
+ self._entries[key] = entry
+ return entry
+ values = current.values
+ for value in entry.values:
+ if value not in values:
+ values = values + (value,)
+ merged = DeviceEntry(
+ address=current.address,
+ width=current.width,
+ kind=current.kind,
+ values=values,
+ hits=current.hits + entry.hits,
+ first_epc=current.first_epc or entry.first_epc,
+ first_epc_fw=current.first_epc_fw or entry.first_epc_fw,
+ source=current.source,
+ notes=current.notes or entry.notes,
+ )
+ self._entries[key] = merged
+ return merged
+
+ def merge(self, other: "DeviceMap") -> "DeviceMap":
+ """Merge ``other`` into this map (deterministic given call order)."""
+ for key in sorted(other._entries):
+ self.add(other._entries[key])
+ for key, value in other.meta.items():
+ self.meta.setdefault(key, value)
+ return self
+
+ # -- queries -----------------------------------------------------------
+
+ def entries(self) -> list[DeviceEntry]:
+ """All entries, sorted by ``(address, width, kind)``."""
+ return [self._entries[key] for key in sorted(self._entries)]
+
+ def __len__(self) -> int:
+ return len(self._entries)
+
+ def _find(self, addr: int, size: int, kind: str) -> DeviceEntry | None:
+ exact = self._entries.get((addr, size, kind))
+ if exact is not None:
+ return exact
+ for key in sorted(self._entries): # width fallback: smallest width first
+ k_addr, _k_size, k_kind = key
+ if k_addr == addr and k_kind == kind:
+ return self._entries[key]
+ return None
+
+ def lookup(self, addr: int, size: int, write: bool = False) -> str:
+ """Behaviour spec for an access: ``zero``, ``value:0x...`` or ``unimplemented``.
+
+ Exact (address, width, direction) wins; a different width at the same
+ address is still evidence for the register. A write to an address
+ known only for reads is absorbed (``zero``); a read of a write-only
+ register reads back zero. An address with no entry at all is
+ ``unimplemented``.
+ """
+ kind = "w" if write else "r"
+ entry = self._find(addr, size, kind)
+ if entry is None:
+ if self._find(addr, size, "w" if not write else "r") is not None:
+ return _BEHAVIOUR_ZERO # known address, opposite direction
+ return "unimplemented"
+ if not write and entry.values:
+ return f"{_BEHAVIOUR_VALUE}0x{entry.values[-1]:x}"
+ return _BEHAVIOUR_ZERO
+
+ # -- persistence -------------------------------------------------------
+
+ def to_dict(self) -> dict:
+ return {
+ "schema": SCHEMA,
+ "meta": dict(self.meta),
+ "entries": [entry.to_dict() for entry in self.entries()],
+ }
+
+ def to_json(self) -> str:
+ return json.dumps(self.to_dict(), indent=1, sort_keys=True) + "\n"
+
+ @classmethod
+ def from_dict(cls, data: dict) -> "DeviceMap":
+ schema = data.get("schema")
+ if schema != SCHEMA:
+ raise GreenEyeError(f"device map: unsupported schema {schema!r} (want {SCHEMA!r})")
+ dm = cls(meta=data.get("meta") or {})
+ for raw in data.get("entries", ()):
+ dm.add(DeviceEntry.from_dict(raw))
+ return dm
+
+ @classmethod
+ def from_json(cls, text: str) -> "DeviceMap":
+ try:
+ data = json.loads(text)
+ except ValueError as exc:
+ raise GreenEyeError(f"device map: invalid JSON ({exc})") from exc
+ if not isinstance(data, dict):
+ raise GreenEyeError("device map: top level must be an object")
+ return cls.from_dict(data)
+
+ def save(self, path: str) -> str:
+ with open(path, "w", encoding="utf-8") as fh:
+ fh.write(self.to_json())
+ return path
+
+ @classmethod
+ def load(cls, path: str) -> "DeviceMap":
+ try:
+ with open(path, "r", encoding="utf-8") as fh:
+ text = fh.read()
+ except OSError as exc:
+ raise GreenEyeError(f"device map: cannot read {path}: {exc}") from exc
+ return cls.from_json(text)
+
+
+def firmware_sha256(path: str) -> str:
+ digest = hashlib.sha256()
+ with open(path, "rb") as fh:
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+# -- harvest ---------------------------------------------------------------
+
+
+def harvest_from_text(log_text: str, bias: int = 0, source: str = "probe") -> DeviceMap:
+ """Build a map from an arbitrary QEMU ``guest_errors,unimp,int`` log.
+
+ Only records that pair an invalid access with its exception contribute an
+ entry, using the faulting PC (translated by ``bias``) and the access
+ address/width/direction. Fetch-loop faults (no access) are skipped.
+ """
+ from .analyze import parse_qemu_faults
+
+ dm = DeviceMap(meta={"bias": bias, "source": source})
+ for fault in parse_qemu_faults(log_text):
+ access = fault.get("access")
+ if not access:
+ continue
+ kind = "r" if access.get("rw") == "read" else "w"
+ desc = fault.get("desc") or ""
+ reason = access.get("reason") or ""
+ dm.record(
+ int(access["access_addr"]),
+ int(access["size"]),
+ kind,
+ pc=int(fault.get("epc", 0)),
+ bias=bias,
+ source=source,
+ notes=f"{desc} reason={reason}".strip(),
+ )
+ return dm
+
+
+def _fault_to_map(fault: dict, bias: int, source: str = "probe") -> DeviceMap:
+ dm = DeviceMap(meta={"bias": bias, "source": source})
+ access = fault.get("access")
+ if access:
+ kind = "r" if access.get("rw") == "read" else "w"
+ dm.record(
+ int(access["access_addr"]),
+ int(access["size"]),
+ kind,
+ pc=int(fault.get("epc", 0)),
+ bias=bias,
+ source=source,
+ notes=str(fault.get("desc") or ""),
+ )
+ return dm
+
+
+def harvest_probe_session(session_dir: str, bias: int | None = None) -> DeviceMap:
+ """Harvest a ``greeneye probe`` session directory into a device map.
+
+ Reads ``session.json`` for the relocation bias and firmware identity, then
+ the ``qemu_log_blob`` (falling back to ``blobs/qemu.log``, ``qemu.log`` or
+ the stored ``qemu_first_fault`` record when no blob was kept). ``bias``
+ overrides the session's recorded relocation bias when given.
+ """
+ session_file = os.path.join(session_dir, "session.json")
+ try:
+ with open(session_file, "r", encoding="utf-8") as fh:
+ session = json.load(fh)
+ except FileNotFoundError as exc:
+ raise GreenEyeError(f"not a greeneye session directory: {session_dir}") from exc
+ except ValueError as exc:
+ raise GreenEyeError(f"{session_file}: invalid JSON ({exc})") from exc
+
+ if bias is None:
+ bias = int(session.get("bias", 0) or 0)
+ log_text: str | None = None
+ blob = session.get("qemu_log_blob")
+ candidates = [blob] if blob else []
+ candidates += ["blobs/qemu.log", "qemu.log"]
+ log_path: str | None = None
+ for candidate in candidates:
+ if candidate and os.path.exists(os.path.join(session_dir, candidate)):
+ log_path = os.path.join(session_dir, candidate)
+ break
+ if log_path is not None:
+ with open(log_path, "r", encoding="utf-8", errors="replace") as fh:
+ log_text = fh.read()
+
+ first = session.get("qemu_first_fault")
+ if log_text is not None:
+ dm = harvest_from_text(log_text, bias=bias)
+ elif isinstance(first, dict):
+ dm = _fault_to_map(first, bias)
+ else:
+ dm = DeviceMap(meta={"bias": bias})
+
+ meta = dm.meta
+ meta["session"] = os.path.basename(os.path.abspath(session_dir))
+ if session.get("firmware"):
+ meta["firmware"] = session["firmware"]
+ if session.get("entry") is not None:
+ meta["entry"] = int(session["entry"])
+ if session.get("qemu_fault_count") is not None:
+ meta["qemu_fault_count"] = int(session["qemu_fault_count"])
+ fw = session.get("firmware")
+ if fw and os.path.isfile(fw):
+ meta["firmware_sha256"] = firmware_sha256(fw)
+ version_file = os.path.join(os.path.dirname(fw), "gsp_version.txt")
+ if os.path.isfile(version_file):
+ with open(version_file, "r", encoding="utf-8", errors="replace") as fh:
+ meta["firmware_version"] = "".join(
+ ch for ch in fh.read() if ch.isprintable()
+ ).strip()
+ return dm
+
+
+# -- CLI -------------------------------------------------------------------
+
+
+def _default_image() -> str:
+ tool_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ return os.path.join(
+ os.path.dirname(os.path.dirname(tool_root)),
+ "firmware", "57X-GSP", "570.144", "gsp-570.144.fwimage.bin",
+ )
+
+
+def _cmd_harvest(args) -> int:
+ dm = harvest_probe_session(args.session_dir, bias=args.bias)
+ print(f"[device-map] harvested {len(dm)} entries from {args.session_dir}", file=sys.stderr)
+ if args.out:
+ dm.save(args.out)
+ print(f"[device-map] wrote {args.out}", file=sys.stderr)
+ else:
+ sys.stdout.write(dm.to_json())
+ return 0
+
+
+def _cmd_show(args) -> int:
+ dm = DeviceMap.load(args.map)
+ if args.json:
+ sys.stdout.write(dm.to_json())
+ return 0
+ print(f"map: {args.map}")
+ for key in sorted(dm.meta):
+ print(f"meta {key}: {dm.meta[key]}")
+ print(f"{'address':>20} {'w':>2} {'rw':>3} {'hits':>7} {'first_epc_fw':>14} {'values':>10} source")
+ for entry in dm.entries():
+ values = ",".join(f"{v:#x}" for v in entry.values) or "-"
+ print(
+ f"{entry.address:#20x} {entry.width:>2} {entry.kind:>3} {entry.hits:>7} "
+ f"{entry.first_epc_fw:#14x} {values:>10} {entry.source}"
+ )
+ return 0
+
+
+def _cmd_merge(args) -> int:
+ merged = DeviceMap()
+ for path in args.maps:
+ merged.merge(DeviceMap.load(path))
+ merged.save(args.out)
+ print(f"[device-map] merged {len(args.maps)} maps -> {args.out} ({len(merged)} entries)")
+ return 0
+
+
+def _run_real_image(args) -> tuple:
+ """Load the fwimage into a lenient native Machine and run a fixed budget."""
+ from .elf import load_elf
+ from sim.machine import Machine
+
+ image = load_elf(args.image)
+ bias = args.bias
+ end = image.max_vaddr + bias
+ need = end - _RAM_BASE + (1 << 20)
+ memory_mb = max(256, (need + (1 << 20) - 1) // (1 << 20))
+ dm = DeviceMap(meta={"source": "learn", "image": os.path.basename(args.image), "bias": bias})
+ machine = Machine(
+ memory_mb=memory_mb, device_map=dm, unknown_mmio="lenient", learn_mmio=True, bias=bias
+ )
+ machine.load_segments(image.flat_load_plan(bias=bias), image.relocated_entry(bias=bias))
+ reason, pc, a, b = machine.run(args.instructions)
+ return machine, image, dm, reason, pc, a, b
+
+
+_REASONS = {
+ 0: "budget", 1: "breakpoint", 2: "watch", 3: "ebreak", 4: "fault", 5: "step", 6: "ecall",
+}
+
+
+def _cmd_demo(args) -> int:
+ if not os.path.isfile(args.image):
+ print(f"[device-map] image not found: {args.image}", file=sys.stderr)
+ return 2
+ machine, image, dm, reason, pc, a, b = _run_real_image(args)
+ digest = firmware_sha256(args.image)
+ print(f"image: {args.image}")
+ print(f"sha256: {digest}")
+ print(f"elf: entry={image.entry:#x} vaddr=[{image.min_vaddr:#x},{image.max_vaddr:#x})")
+ print(f"bias: {args.bias:#x} relocated entry: {image.relocated_entry(args.bias):#x}")
+ print(f"budget: {args.instructions} instructions")
+ print(f"stopped: reason={_REASONS.get(reason, reason)} pc={pc:#x} (fw {pc - args.bias:#x})")
+ print(f"unknown mmio accesses: {machine.mmio_unknown}")
+ for line in machine.mmio_log[: args.first]:
+ print(
+ f" first unknown: pc={line['pc']:#x} (fw {line['pc'] - args.bias:#x}) "
+ f"addr={line['addr']:#x} size={line['size']} rw={line['rw']}"
+ )
+ if machine.mmio_log and machine.mmio_log[0]["addr"] == QEMU_REFERENCE["access_addr"]:
+ delta = machine.insns - 6 # QEMU traps on the sixth instruction
+ print(
+ "[device-map] qemu probe first fault: "
+ f"fw vaddr {QEMU_REFERENCE['firmware_vaddr']:#x} reading "
+ f"{QEMU_REFERENCE['access_addr']:#x} ({QEMU_REFERENCE['desc']})"
+ )
+ print(
+ "[device-map] verdict: first-touch address reproduced; lenient execution "
+ f"continued for {machine.insns} instructions ({delta} past the qemu trap)"
+ )
+ else:
+ print("[device-map] verdict: no unknown access at the documented first-touch address")
+ if args.out:
+ dm.save(args.out)
+ print(f"[device-map] learned map ({len(dm)} entries) -> {args.out}")
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="python3 -m greeneye.device_map",
+ description="Harvest, inspect and merge green-eye first-touch device maps.",
+ )
+ sub = parser.add_subparsers(dest="cmd", required=True)
+
+ harvest = sub.add_parser("harvest", help="harvest a greeneye probe session directory")
+ harvest.add_argument("session_dir")
+ harvest.add_argument("--out")
+ harvest.add_argument("--bias", type=lambda s: int(s, 0), default=None)
+ harvest.set_defaults(func=_cmd_harvest)
+
+ show = sub.add_parser("show", help="print a device map")
+ show.add_argument("map")
+ show.add_argument("--json", action="store_true", help="emit the raw JSON")
+ show.set_defaults(func=_cmd_show)
+
+ merge = sub.add_parser("merge", help="merge maps into one (self, then others)")
+ merge.add_argument("maps", nargs="+")
+ merge.add_argument("--out", required=True)
+ merge.set_defaults(func=_cmd_merge)
+
+ demo = sub.add_parser("demo", help="bounded lenient run of a real fwimage")
+ demo.add_argument("--image", default=_default_image())
+ demo.add_argument("--bias", type=lambda s: int(s, 0), default=_RAM_BASE)
+ demo.add_argument("--instructions", type=int, default=200_000)
+ demo.add_argument("--first", type=int, default=8)
+ demo.add_argument("--out", help="save the learned device map here")
+ demo.set_defaults(func=_cmd_demo)
+
+ args = parser.parse_args(argv)
+ try:
+ return args.func(args)
+ except GreenEyeError as exc:
+ print(f"[device-map] error: {exc}", file=sys.stderr)
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py b/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py
index 6986d74..399a355 100644
--- a/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py
+++ b/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py
@@ -73,6 +73,8 @@ def corpus_location(symbol, corpus_root: str) -> str | None:
def render_ghidra_command(project_dir: str, project_name: str, program: str, hits_tsv: str) -> str:
+ """Render the ``analyzeHeadless`` command that runs MarkGreenEyeHits.java
+ against ``hits_tsv`` (the file written by :func:`export_hits`)."""
return (
"analyzeHeadless {proj} {name} -process {program} -noanalysis "
"-scriptPath <open-iov>/debug/risc-v-gsp-rm-vm/ghidra "
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/session.py b/debug/risc-v-gsp-rm-vm/greeneye/session.py
index fa54f77..fffc60e 100644
--- a/debug/risc-v-gsp-rm-vm/greeneye/session.py
+++ b/debug/risc-v-gsp-rm-vm/greeneye/session.py
@@ -227,6 +227,8 @@ class Session:
raise GreenEyeError(f"cannot resolve target {target!r}")
def symbol_at(self, addr: int) -> Symbol | None:
+ """Return the symbol containing ``addr``, or the nearest one (alias of
+ ``symbols.resolve``); ``None`` when the database is empty."""
return self.symbols.resolve(addr)
# -- breakpoints / hooks ----------------------------------------------
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/sim_backend.py b/debug/risc-v-gsp-rm-vm/greeneye/sim_backend.py
new file mode 100644
index 0000000..d17076f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/sim_backend.py
@@ -0,0 +1,390 @@
+"""Native green-eye execution backend (M1): deterministic RV64IMAC in Python.
+
+``ge.launch(kernel=..., backend="native")`` builds a :class:`SimTarget` around
+:class:`sim.machine.Machine` and exposes a :class:`SimClient` whose method
+surface matches :class:`greeneye.rsp.RspClient`, so ``greeneye.session.Session``
+runs unchanged against it. ``trace=`` enables the M1.5 exec/access trace
+(:mod:`sim.trace`), written to the session as ``blobs/trace.jsonl``.
+``device_map=`` and ``unknown_mmio="lenient"`` (H2.1/H2.2 start) serve
+unmodelled MMIO accesses from a harvested first-touch map instead of faulting;
+lenient accesses are logged to ``target.mmio_log`` and counted in
+``target.mmio_unknown``.
+
+Virtual time is instruction count: ``continue_(timeout)`` executes at most
+``timeout * SIM_IPS`` instructions (SIM_IPS = 1 MIPS), deterministically. The
+wall-clock budgets ``Session.run`` derives from its deadline therefore bound
+the run; the stop/watch/hook sequence within a fixed instruction budget is
+reproducible on every host.
+"""
+from __future__ import annotations
+
+import os
+import sys
+
+from .elf import load_elf
+from .errors import GreenEyeError, RspProtocolError, RspTimeout
+from .rsp import Register, StopReply
+from .session import Session
+from .symbols import SymbolDB
+
+_TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _TOOL_ROOT not in sys.path: # `sim/` lives beside the greeneye package
+ sys.path.insert(0, _TOOL_ROOT)
+
+from sim.machine import ( # noqa: E402
+ R_BUDGET,
+ R_FAULT,
+ R_WATCH,
+ Machine,
+)
+from sim.trace import make_trace # noqa: E402
+#: Virtual instructions per second used to convert wall timeouts to budgets.
+SIM_IPS = 1_000_000
+#: Safety valve for ``continue_(timeout=None)``: never run unbounded.
+MAX_UNBOUNDED_BUDGET = 20_000_000
+
+#: Canonical register names, as exposed by QEMU's target.xml for riscv:rv64.
+ABI_NAMES = (
+ ["zero", "ra", "sp", "gp", "tp", "t0", "t1", "t2", "s0", "s1"]
+ + [f"a{i}" for i in range(8)]
+ + [f"s{i}" for i in range(2, 12)]
+ + [f"t{i}" for i in range(3, 7)]
+)
+#: xN -> ABI-name aliases (x0 == zero, x1 == ra, ...).
+_X_TO_ABI = {f"x{i}": ABI_NAMES[i] for i in range(32)}
+_X_TO_ABI["fp"] = "s0"
+_X_TO_ABI["return"] = "ra"
+_ABI_TO_INDEX = {name: i for i, name in enumerate(ABI_NAMES)}
+
+
+class SimClient:
+ """Drop-in replacement for :class:`RspClient` over a native Machine."""
+
+ def __init__(self, machine: Machine, *, sim_ips: int = SIM_IPS, trace=None):
+ self.machine = machine
+ self.sim_ips = max(1, int(sim_ips))
+ self.timeout = 10.0
+ self.packet_size = 0x1000
+ self.trace = trace # sim.trace.Trace | None
+ self._recorder = None
+ self.registers = [
+ Register(number=i, name=name, bitsize=64, offset=i * 8)
+ for i, name in enumerate(ABI_NAMES)
+ ] + [Register(number=32, name="pc", bitsize=64, offset=32 * 8)]
+ self._by_name = {r.name: r for r in self.registers}
+ self._by_name.update(_X_TO_ABI)
+ self._by_number = {r.number: r for r in self.registers}
+ self.features = "PacketSize=1000;swbreak+;hwbreak+;greeneye-native+"
+ self.connected = True
+ self._bps: set[int] = set()
+ self._hw_bps: set[int] = set()
+ self._watches: dict[tuple[int, int, str], None] = {}
+
+ # -- lifecycle ---------------------------------------------------------
+
+ def connect(self, retries: int = 1, retry_delay: float = 0.1) -> None:
+ self.connected = True
+
+ def sync(self) -> None:
+ pass # no server to hand-shake with
+
+ def close(self) -> None:
+ self.connected = False
+ if self.trace is not None:
+ self.trace.flush(self._recorder)
+
+ def __enter__(self) -> "SimClient":
+ return self
+
+ def __exit__(self, *exc) -> None:
+ self.close()
+
+ # -- registers ---------------------------------------------------------
+
+ def read_registers(self) -> dict[str, int]:
+ x = self.machine.x
+ out = {name: x[i] for i, name in enumerate(ABI_NAMES)}
+ out["fp"] = out["s0"] # QEMU's target.xml names x8 "fp"
+ out["pc"] = self.machine.pc
+ return out
+
+ def _index(self, name_or_number: str | int) -> int:
+ if isinstance(name_or_number, int):
+ if 0 <= name_or_number < 32:
+ return name_or_number
+ if name_or_number == 32:
+ return 32
+ raise RspProtocolError(f"unknown register number {name_or_number}")
+ key = _X_TO_ABI.get(name_or_number, name_or_number)
+ if key == "pc":
+ return 32
+ index = _ABI_TO_INDEX.get(key)
+ if index is None:
+ raise RspProtocolError(f"unknown register {name_or_number!r}")
+ return index
+
+ def read_reg(self, name_or_number: str | int) -> int:
+ index = self._index(name_or_number)
+ if index == 32:
+ return self.machine.pc
+ return self.machine.reg(index)
+
+ def write_reg(self, name_or_number: str | int, value: int) -> None:
+ index = self._index(name_or_number)
+ if index == 32:
+ self.machine.pc = value & ((1 << 64) - 1)
+ else:
+ self.machine.set_reg(index, value)
+
+ @property
+ def pc(self) -> int:
+ return self.machine.pc
+
+ # -- memory ------------------------------------------------------------
+
+ def read_mem(self, addr: int, size: int, chunk: int = 0) -> bytes:
+ try:
+ return self.machine.read_mem(addr, size)
+ except Exception as exc:
+ raise RspProtocolError(str(exc)) from exc
+
+ def write_mem(self, addr: int, data: bytes, chunk: int = 0) -> None:
+ try:
+ self.machine.write_mem(addr, data)
+ except Exception as exc:
+ raise RspProtocolError(str(exc)) from exc
+
+ def read_cstring(self, addr: int, max_len: int = 256) -> str:
+ raw = self.read_mem(addr, max_len)
+ return raw.split(b"\x00", 1)[0].decode("utf-8", "replace")
+
+ # -- breakpoints / watchpoints ----------------------------------------
+
+ def set_sw_break(self, addr: int, kind: int = 4) -> None:
+ self._bps.add(addr)
+
+ def clear_sw_break(self, addr: int, kind: int = 4) -> None:
+ self._bps.discard(addr)
+
+ def set_hw_break(self, addr: int, kind: int = 4) -> None:
+ self._hw_bps.add(addr)
+
+ def clear_hw_break(self, addr: int, kind: int = 4) -> None:
+ self._hw_bps.discard(addr)
+
+ _WATCH_KINDS = {"w": 2, "r": 3, "a": 4}
+ _WATCH_REVERSE = {"w": "watch", "r": "rwatch", "a": "awatch"}
+
+ def set_watch(self, addr: int, size: int = 8, kind: str = "w") -> None:
+ if kind not in self._WATCH_KINDS:
+ raise RspProtocolError(f"unknown watchpoint kind {kind!r}")
+ self._watches[(addr, size, kind)] = None
+
+ def clear_watch(self, addr: int, size: int = 8, kind: str = "w") -> None:
+ self._watches.pop((addr, size, kind), None)
+
+ def _watcher_list(self):
+ out = []
+ for (addr, size, kind) in self._watches:
+ mask = (2 if kind in ("r", "a") else 0) | (1 if kind in ("w", "a") else 0)
+ out.append((addr, addr + size, kind, mask))
+ return tuple(out)
+
+ # -- run control -------------------------------------------------------
+
+ def _budget(self, timeout: float | None) -> int | None:
+ if timeout is None:
+ return None
+ return max(1, int(timeout * self.sim_ips))
+
+ def _to_stop(self, result) -> StopReply:
+ reason, pc, a, b = result
+ if reason == R_WATCH:
+ kind = self._WATCH_REVERSE.get(b, "watch")
+ return StopReply(
+ raw=f"T05watch:{a:x};",
+ signal=5,
+ watch_addr=a,
+ watch_kind=kind,
+ )
+ if reason == R_FAULT:
+ signal = 4 if a == 2 else 11
+ return StopReply(raw=f"T0{signal:x}", signal=signal)
+ # bp, ebreak, ecall and step all report SIGTRAP, like the gdbstub.
+ return StopReply(raw="T05", signal=5)
+
+ def continue_(self, timeout: float | None = None) -> StopReply:
+ budget = self._budget(timeout)
+ if budget is None:
+ budget = MAX_UNBOUNDED_BUDGET
+ result = self.machine.run(
+ budget,
+ breakpoints=frozenset(self._bps | self._hw_bps),
+ watchers=self._watcher_list(),
+ trace=self.trace,
+ )
+ if result[0] == R_BUDGET:
+ raise RspTimeout(f"native budget exhausted after {budget} instructions")
+ return self._to_stop(result)
+
+ def step(self, timeout: float | None = None) -> StopReply:
+ result = self.machine.step(watchers=self._watcher_list(), trace=self.trace)
+ return self._to_stop(result)
+
+ def interrupt(self, timeout: float | None = None) -> StopReply:
+ return StopReply(raw="T02", signal=2)
+
+ # -- monitor -----------------------------------------------------------
+
+ def monitor(self, cmd: str) -> str:
+ return ""
+
+
+class SimTarget:
+ """QemuTarget-shaped handle for a native Machine (no process, no socket)."""
+
+ def __init__(
+ self,
+ machine: Machine,
+ *,
+ memory_mb: int = 256,
+ ram_base: int = 0x80000000,
+ sim_ips: int = SIM_IPS,
+ keep_alive: bool = False,
+ trace=None,
+ ):
+ self.machine = machine
+ self.memory_mb = memory_mb
+ self.ram_base = ram_base
+ self.keep_alive = keep_alive
+ self.client = SimClient(machine, sim_ips=sim_ips, trace=trace)
+ self.kernel: str | None = None
+ self.workdir: str | None = None
+ self._alive = True
+
+ # QemuTarget lifecycle surface ----------------------------------------
+
+ def start(self, paused: bool = True, timeout: float = 0.0) -> "SimTarget":
+ return self
+
+ def wait_for_port(self, timeout: float = 0.0) -> None:
+ return None
+
+ def alive(self) -> bool:
+ return self._alive
+
+ def kill(self, timeout: float = 0.0) -> None:
+ self._alive = False
+ self.client.close()
+
+ def read_uart(self) -> str:
+ return self.machine.uart.text()
+
+ @property
+ def mmio_log(self) -> list[dict]:
+ """Lenient-mode log (empty unless ``unknown_mmio="lenient"``)."""
+ return self.machine.mmio_log
+
+ @property
+ def mmio_unknown(self) -> int:
+ """Count of unmodelled MMIO accesses served leniently."""
+ return self.machine.mmio_unknown
+
+ def wait_for_uart(self, marker: str, timeout: float = 5.0, interval: float = 0.05) -> bool:
+ # Execution is synchronous: by the time the caller looks, the marker
+ # either exists or the budget was too small. No polling needed.
+ return marker in self.read_uart()
+
+ def read_qemu_log(self) -> str:
+ return ""
+
+ @property
+ def command_line(self) -> list[str]:
+ return [f"native-sim:{self.kernel or '<none>'}"]
+
+ def shell_command(self) -> str:
+ return " ".join(self.command_line)
+
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
+ return f"SimTarget(pc={self.machine.pc:#x}, alive={self._alive})"
+
+
+def launch_native(
+ *,
+ kernel: str | None = None,
+ memory_mb: int = 256,
+ record=None,
+ symbols: SymbolDB | None = None,
+ name: str = "session",
+ load_symbols: bool = True,
+ keep_alive: bool = False,
+ sim_ips: int = SIM_IPS,
+ trace=None,
+ device_map=None,
+ unknown_mmio: str = "fault",
+ learn_mmio: bool = False,
+ bias: int = 0,
+) -> Session:
+ """Boot the native simulator and return a ready Session.
+
+ ``trace`` enables the R4 execution/access trace: ``True`` for defaults, a
+ dict of ``{"exec": bool, "start": int, "end": int, "max_events": int}``,
+ or a pre-built :class:`sim.trace.Trace`. The trace is written to the
+ session as ``blobs/trace.jsonl`` when the Session closes; tracing implies
+ recording, so a session directory is created when none was given.
+
+ ``device_map`` is a :class:`greeneye.device_map.DeviceMap` (or a path to a
+ saved map) describing modelled MMIO; ``unknown_mmio="lenient"`` serves
+ unmodelled accesses from it (or zero) instead of faulting, logging each in
+ ``machine.mmio_log`` and counting ``machine.mmio_unknown``.
+ ``learn_mmio=True`` writes the observed values back into the map.
+ ``bias`` relocates PT_LOAD segments and the entry for images linked for
+ GPU physical addresses (e.g. ``0x80000000`` for the real GSP fwimage).
+ """
+ try:
+ tracer = make_trace(trace)
+ except ValueError as exc:
+ raise GreenEyeError(f"invalid trace spec: {exc}") from exc
+ if tracer is not None and not record:
+ record = True # the trace artifact lives in the session
+ if isinstance(device_map, (str, os.PathLike)):
+ from .device_map import DeviceMap
+
+ device_map = DeviceMap.load(os.fspath(device_map))
+ machine = Machine(
+ memory_mb=memory_mb,
+ device_map=device_map,
+ unknown_mmio=unknown_mmio,
+ learn_mmio=learn_mmio,
+ bias=bias,
+ )
+ target = SimTarget(
+ machine,
+ memory_mb=memory_mb,
+ sim_ips=sim_ips,
+ keep_alive=keep_alive,
+ trace=tracer,
+ )
+ target.kernel = kernel
+ symdb = symbols or SymbolDB()
+ meta = {"backend": "native", "sim_ips": sim_ips, "firmware": kernel}
+ if unknown_mmio != "fault" or device_map is not None:
+ meta["unknown_mmio"] = unknown_mmio
+ session = Session(
+ client=target.client,
+ target=target,
+ symbols=symdb,
+ record=record,
+ name=name,
+ meta=meta,
+ )
+ if tracer is not None:
+ target.client._recorder = session.recorder
+ session.recorder.meta["trace"] = {**tracer.config()}
+ if kernel:
+ image = load_elf(kernel)
+ if not image.is_riscv:
+ raise GreenEyeError(f"{kernel}: not a RISC-V ELF image")
+ machine.load_segments(image.flat_load_plan(bias=bias), image.relocated_entry(bias=bias))
+ session.load_image(image, bias=bias, load_symbols=load_symbols)
+ return session
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/symbols.py b/debug/risc-v-gsp-rm-vm/greeneye/symbols.py
index 7b2e9dd..cb11f7f 100644
--- a/debug/risc-v-gsp-rm-vm/greeneye/symbols.py
+++ b/debug/risc-v-gsp-rm-vm/greeneye/symbols.py
@@ -25,7 +25,7 @@ import json
import os
from dataclasses import dataclass, field as _field
-from .errors import SymbolNotFound
+from .errors import GreenEyeError, SymbolNotFound
@dataclass
@@ -193,9 +193,15 @@ class SymbolDB:
return count
def load_json(self, path: str) -> int:
- with open(path, "r", encoding="utf-8") as fh:
- payload = json.load(fh)
- symbols = payload.get("symbols", payload if isinstance(payload, list) else [])
+ """Load a green-eye JSON symbol DB; raise GreenEyeError on bad input."""
+ try:
+ with open(path, "r", encoding="utf-8") as fh:
+ payload = json.load(fh)
+ except (OSError, json.JSONDecodeError) as exc:
+ raise GreenEyeError(f"cannot load symbol DB {path!r}: {exc}") from exc
+ if not isinstance(payload, (dict, list)):
+ raise GreenEyeError(f"symbol DB {path!r} must be a JSON object or list")
+ symbols = payload.get("symbols", []) if isinstance(payload, dict) else payload
count = 0
for item in symbols:
try:
@@ -276,12 +282,15 @@ class SymbolDB:
return f"{addr:#x} {sym.label(addr)}"
def nearest(self, addr: int, max_delta: int = 0x10000) -> Symbol | None:
+ """Alias of :meth:`resolve` kept for call sites that read better as
+ "nearest symbol" (same containing/preceding/max-delta rules)."""
return self.resolve(addr, max_delta=max_delta)
def names(self) -> list[str]:
return sorted(self._by_name)
def by_subsystem(self, subsystem: str) -> list[Symbol]:
+ """Return all symbols tagged with ``subsystem`` (loaders set it when known)."""
return [s for s in self._symbols if s.subsystem == subsystem]
diff --git a/debug/risc-v-gsp-rm-vm/harness/__init__.py b/debug/risc-v-gsp-rm-vm/harness/__init__.py
new file mode 100644
index 0000000..d99d547
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/__init__.py
@@ -0,0 +1,11 @@
+"""green-eye automation harness.
+
+Two stdlib-only orchestration tools:
+
+* :mod:`harness.portability_matrix` - multi-version/multi-target probe + demo
+ orchestration with a cross-target delta report (roadmap R6, H4.1);
+* :mod:`harness.mutation_harness` - bounded, seeded mutation of the demo
+ firmware's mailbox input surface (roadmap R5, H4.3).
+
+Both are development tools; see docs/18-HARNESS-AUTOMATION.md.
+"""
diff --git a/debug/risc-v-gsp-rm-vm/harness/ci_release.py b/debug/risc-v-gsp-rm-vm/harness/ci_release.py
new file mode 100644
index 0000000..dbc1f6f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/ci_release.py
@@ -0,0 +1,444 @@
+#!/usr/bin/env python3
+"""Release triage orchestration for green-eye (planned docs/21).
+
+Given an old and a new firmware target it runs, in order:
+
+1. ``harness/version_diff.py`` -- image/symbol/corpus/probe/behavior diff;
+2. ``harness/portability_matrix.py`` -- probe + scenario + cross-target deltas;
+3. ``harness/mutation_harness.py`` -- a small deterministic mutation smoke.
+
+Every step writes under ``--out`` and the whole directory is hashed into
+``bundle-manifest.json`` (the evidence bundle). A failing step is recorded and
+the remaining steps still run; the process exits non-zero only when a step
+reports an error.
+
+``--dry-run`` prints the plan and exits 0 without opening targets, and the
+RM 610 gate (docs/23) is enforced before any target file is read. Nothing is
+ever downloaded.
+
+Run from the tool root:
+
+ python3 harness/ci_release.py --old old.elf --new new.elf --out dumps/release
+ python3 harness/ci_release.py --old old.elf --new new.elf --dry-run
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+try:
+ from .common import DEMO_ELF, ensure_dir, qemu_available, sha256_file, write_json, write_text
+ from .mutation_harness import HarnessOptions, run_harness
+ from .portability_matrix import MatrixOptions, TargetSpec, render_markdown, run_matrix
+ from .version_diff import (
+ RM610_TOKEN,
+ Rm610GateError,
+ VersionDiffOptions,
+ check_rm610_gate,
+ run_version_diff,
+ )
+except ImportError: # direct script execution (python3 harness/ci_release.py)
+ from common import ( # type: ignore[no-redef]
+ DEMO_ELF,
+ ensure_dir,
+ qemu_available,
+ sha256_file,
+ write_json,
+ write_text,
+ )
+ from mutation_harness import HarnessOptions, run_harness # type: ignore[no-redef]
+ from portability_matrix import MatrixOptions, TargetSpec, render_markdown, run_matrix # type: ignore[no-redef]
+ from version_diff import ( # type: ignore[no-redef]
+ RM610_TOKEN,
+ Rm610GateError,
+ VersionDiffOptions,
+ check_rm610_gate,
+ run_version_diff,
+ )
+
+FORMAT = "greeneye-ci-release/1"
+PLAN_FORMAT = "greeneye-ci-release-plan/1"
+BUNDLE_FORMAT = "greeneye-release-bundle/1"
+
+
+@dataclass
+class ReleaseOptions:
+ old: str
+ new: str
+ out: str | None = None
+ old_label: str | None = None
+ new_label: str | None = None
+ release: str | None = None
+ approval_token: str | None = None
+ old_symbols: str | None = None
+ new_symbols: str | None = None
+ matrix_symbols: str | None = None
+ old_ghidra_names: str | None = None
+ new_ghidra_names: str | None = None
+ old_ghidra_index: str | None = None
+ new_ghidra_index: str | None = None
+ old_ghidra_exports: str | None = None
+ new_ghidra_exports: str | None = None
+ old_ghidra_bias: int = 0
+ new_ghidra_bias: int = 0
+ corpus_diff: tuple[str, str] | None = None
+ corpus_report: str | None = None
+ stimulus: str | None = None
+ probe_instructions: int = 2000
+ behavior_duration: float = 0.4
+ max_rows: int = 40
+ mutation_kernel: str = DEMO_ELF
+ mutation_iterations: int = 8
+ mutation_seed: int = 7
+ mutation_timeout: float = 0.25
+ mutation_settle: float = 0.1
+ allow_foreign_mutation: bool = False
+ dry_run: bool = False
+
+ def labels(self) -> list[str]:
+ return [
+ value
+ for value in (
+ self.release,
+ self.old_label,
+ self.new_label,
+ os.path.basename(self.old),
+ os.path.basename(self.new),
+ os.path.basename(self.mutation_kernel) if self.mutation_kernel else None,
+ )
+ if value
+ ]
+
+
+def _version_diff_options(options: ReleaseOptions, out_dir: str) -> VersionDiffOptions:
+ return VersionDiffOptions(
+ old=options.old,
+ new=options.new,
+ out=out_dir,
+ old_label=options.old_label,
+ new_label=options.new_label,
+ release=options.release,
+ old_symbols=options.old_symbols,
+ new_symbols=options.new_symbols,
+ old_ghidra_names=options.old_ghidra_names,
+ new_ghidra_names=options.new_ghidra_names,
+ old_ghidra_index=options.old_ghidra_index,
+ new_ghidra_index=options.new_ghidra_index,
+ old_ghidra_exports=options.old_ghidra_exports,
+ new_ghidra_exports=options.new_ghidra_exports,
+ old_ghidra_bias=options.old_ghidra_bias,
+ new_ghidra_bias=options.new_ghidra_bias,
+ corpus_diff=options.corpus_diff,
+ corpus_report=options.corpus_report,
+ stimulus=options.stimulus,
+ probe_instructions=options.probe_instructions,
+ behavior_duration=options.behavior_duration,
+ max_rows=options.max_rows,
+ approval_token=options.approval_token,
+ )
+
+
+# ---------------------------------------------------------------------------
+# plan (dry run)
+# ---------------------------------------------------------------------------
+
+
+def build_plan(options: ReleaseOptions) -> dict:
+ out = os.path.abspath(options.out) if options.out else "<out>"
+ vd_out = os.path.join(out, "version-diff")
+ matrix_out = os.path.join(out, "portability-matrix")
+ mutation_out = os.path.join(out, "mutation-smoke")
+ vd_argv = [sys.executable, "harness/version_diff.py", "--old", options.old, "--new", options.new,
+ "--out", vd_out, "--probe-instructions", str(options.probe_instructions),
+ "--behavior-duration", str(options.behavior_duration), "--max-rows", str(options.max_rows)]
+ if options.old_label:
+ vd_argv += ["--old-label", options.old_label]
+ if options.new_label:
+ vd_argv += ["--new-label", options.new_label]
+ if options.release:
+ vd_argv += ["--release", options.release]
+ if options.old_symbols:
+ vd_argv += ["--old-symbols", options.old_symbols]
+ if options.new_symbols:
+ vd_argv += ["--new-symbols", options.new_symbols]
+ if options.stimulus:
+ vd_argv += ["--stimulus", options.stimulus]
+ matrix_argv = [sys.executable, "harness/portability_matrix.py", "--target", options.old,
+ "--target", options.new, "--out", matrix_out,
+ "--probe-instructions", str(options.probe_instructions),
+ "--scenario-duration", str(options.behavior_duration),
+ "--max-rows", str(options.max_rows)]
+ mutation_argv = [sys.executable, "harness/mutation_harness.py", "--kernel", options.mutation_kernel,
+ "--out", mutation_out, "--iterations", str(options.mutation_iterations),
+ "--seed", str(options.mutation_seed), "--timeout", str(options.mutation_timeout),
+ "--settle", str(options.mutation_settle)]
+ if options.allow_foreign_mutation:
+ mutation_argv.append("--allow-foreign-elf")
+ return {
+ "format": PLAN_FORMAT,
+ "tool": "harness/ci_release.py",
+ "dry_run": True,
+ "status": "planned",
+ "rm610_gate": {"status": "passed", "labels": sorted(set(options.labels()))},
+ "old": {"label": options.old_label or Path(options.old).stem, "path": options.old},
+ "new": {"label": options.new_label or Path(options.new).stem, "path": options.new},
+ "out": out,
+ "steps": [
+ {"name": "version_diff", "out": vd_out, "argv": vd_argv},
+ {"name": "portability_matrix", "out": matrix_out, "argv": matrix_argv},
+ {"name": "mutation_smoke", "out": mutation_out, "argv": mutation_argv},
+ ],
+ "manifest": os.path.join(out, "bundle-manifest.json"),
+ "release_report": os.path.join(out, "release-report.json"),
+ }
+
+
+# ---------------------------------------------------------------------------
+# steps
+# ---------------------------------------------------------------------------
+
+
+def step_version_diff(options: ReleaseOptions, out_dir: str) -> dict:
+ report = run_version_diff(_version_diff_options(options, out_dir))
+ sections = {name: section["status"] for name, section in report["sections"].items()}
+ status = "error" if "error" in sections.values() else "ok"
+ return {
+ "status": status,
+ "out": os.path.basename(out_dir),
+ "report": "version-diff.json",
+ "sections": sections,
+ "summary": report["summary"],
+ }
+
+
+def step_portability_matrix(options: ReleaseOptions, out_dir: str) -> dict:
+ matrix = run_matrix(
+ [TargetSpec(path=os.path.abspath(options.old), name=Path(options.old).stem),
+ TargetSpec(path=os.path.abspath(options.new), name=Path(options.new).stem)],
+ MatrixOptions(
+ out=out_dir,
+ probe_instructions=options.probe_instructions,
+ scenario_duration=options.behavior_duration,
+ max_rows=options.max_rows,
+ symbols_json=options.matrix_symbols,
+ ),
+ )
+ ensure_dir(out_dir)
+ write_json(os.path.join(out_dir, "portability-report.json"), matrix)
+ write_text(
+ os.path.join(out_dir, "portability-report.md"),
+ render_markdown(matrix, MatrixOptions(out=out_dir, max_rows=options.max_rows)),
+ )
+ statuses = [target["status"] for target in matrix["targets"]]
+ status = "error" if "error" in statuses else "ok"
+ return {
+ "status": status,
+ "report": "portability-report.json",
+ "targets": {target["name"]: target["status"] for target in matrix["targets"]},
+ "comparisons": [
+ {"a": comp["a"], "b": comp["b"], "status": comp["status"], "all_zero": comp["all_zero"]}
+ for comp in matrix["comparisons"]
+ ],
+ }
+
+
+def step_mutation_smoke(options: ReleaseOptions, out_dir: str) -> dict:
+ if not qemu_available():
+ return {"status": "skipped", "reason": "qemu-system-riscv64 not found"}
+ if not os.path.isfile(options.mutation_kernel):
+ return {"status": "skipped", "reason": f"mutation kernel not found: {options.mutation_kernel}"}
+ harness = HarnessOptions(
+ kernel=os.path.abspath(options.mutation_kernel),
+ out=out_dir,
+ iterations=options.mutation_iterations,
+ seed=options.mutation_seed,
+ timeout=options.mutation_timeout,
+ settle=options.mutation_settle,
+ allow_foreign_elf=options.allow_foreign_mutation,
+ )
+ findings = run_harness(harness)
+ outcomes: dict[str, int] = {}
+ for finding in findings:
+ outcomes[finding["outcome"]] = outcomes.get(finding["outcome"], 0) + 1
+ return {
+ "status": "ok",
+ "report": "findings.jsonl",
+ "iterations": len(findings),
+ "outcomes": dict(sorted(outcomes.items())),
+ }
+
+
+def _run_step(name: str, fn, *args) -> dict:
+ try:
+ detail = fn(*args)
+ except Exception as exc: # a failed step must not hide the others
+ return {"name": name, "status": "error", "reason": f"{type(exc).__name__}: {exc}"}
+ entry = {"name": name}
+ entry.update(detail)
+ return entry
+
+
+# ---------------------------------------------------------------------------
+# bundle
+# ---------------------------------------------------------------------------
+
+
+def build_manifest(out_dir: str) -> dict:
+ files: list[dict] = []
+ for root, dirs, names in os.walk(out_dir):
+ dirs.sort()
+ for name in sorted(names):
+ path = os.path.join(root, name)
+ rel = os.path.relpath(path, out_dir)
+ if rel == "bundle-manifest.json":
+ continue
+ files.append({"path": rel, "bytes": os.path.getsize(path), "sha256": sha256_file(path)})
+ files.sort(key=lambda row: row["path"])
+ return {"format": BUNDLE_FORMAT, "tool": "harness/ci_release.py", "files": files}
+
+
+def run_release(options: ReleaseOptions) -> dict:
+ check_rm610_gate(options.labels(), approval_token=options.approval_token)
+ if not options.out:
+ raise ValueError("--out is required unless --dry-run is used")
+ out = os.path.abspath(options.out)
+ ensure_dir(out)
+ steps = [
+ _run_step("version_diff", step_version_diff, options, os.path.join(out, "version-diff")),
+ _run_step("portability_matrix", step_portability_matrix, options,
+ os.path.join(out, "portability-matrix")),
+ _run_step("mutation_smoke", step_mutation_smoke, options, os.path.join(out, "mutation-smoke")),
+ ]
+ report = {
+ "format": FORMAT,
+ "tool": "harness/ci_release.py",
+ "dry_run": False,
+ "rm610_gate": {"status": "passed", "labels": sorted(set(options.labels()))},
+ "old": {"label": options.old_label or Path(options.old).stem, "path": os.path.basename(options.old)},
+ "new": {"label": options.new_label or Path(options.new).stem, "path": os.path.basename(options.new)},
+ "steps": steps,
+ "errors": [step["name"] for step in steps if step["status"] == "error"],
+ "manifest": "bundle-manifest.json",
+ }
+ write_json(os.path.join(out, "release-report.json"), report)
+ manifest = build_manifest(out)
+ write_json(os.path.join(out, "bundle-manifest.json"), manifest)
+ return report
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="ci_release", description=__doc__.splitlines()[0])
+ parser.add_argument("--old", required=True, help="old target (RISC-V ELF or raw .fwimage)")
+ parser.add_argument("--new", required=True, help="new target (RISC-V ELF or raw .fwimage)")
+ parser.add_argument("--out", help="evidence bundle directory (required unless --dry-run)")
+ parser.add_argument("--dry-run", action="store_true", help="print the plan and exit without touching targets")
+ parser.add_argument("--old-label", help="report label for the old target")
+ parser.add_argument("--new-label", help="report label for the new target")
+ parser.add_argument("--release", help="release label; an rm610 release is gated by docs/23")
+ parser.add_argument("--approval-token", help=f"explicit RM610 token ({RM610_TOKEN})")
+ parser.add_argument("--old-symbols", help="green-eye symbol DB JSON for the old target")
+ parser.add_argument("--new-symbols", help="green-eye symbol DB JSON for the new target")
+ parser.add_argument("--matrix-symbols", help="one symbol DB JSON applied to both matrix targets")
+ parser.add_argument("--old-ghidra-names", help="Ghidra names.tsv for the old target")
+ parser.add_argument("--new-ghidra-names", help="Ghidra names.tsv for the new target")
+ parser.add_argument("--old-ghidra-index", help="Ghidra decompiled_index.tsv for the old target")
+ parser.add_argument("--new-ghidra-index", help="Ghidra decompiled_index.tsv for the new target")
+ parser.add_argument("--old-ghidra-exports", help="Ghidra exports.tsv / 3-column export for the old target")
+ parser.add_argument("--new-ghidra-exports", help="Ghidra exports.tsv / 3-column export for the new target")
+ parser.add_argument("--old-ghidra-bias", type=lambda value: int(value, 0), default=0)
+ parser.add_argument("--new-ghidra-bias", type=lambda value: int(value, 0), default=0)
+ parser.add_argument("--corpus-diff", nargs=2, metavar=("OLD_TSV", "NEW_TSV"),
+ help="diff two decompiled_index.tsv corpus exports")
+ parser.add_argument("--corpus-report", help="attach a previously produced corpus diff report")
+ parser.add_argument("--stimulus", help="green-eye script replacing the builtin demo scenario")
+ parser.add_argument("--probe-instructions", type=int, default=2000)
+ parser.add_argument("--behavior-duration", type=float, default=0.4)
+ parser.add_argument("--max-rows", type=int, default=40)
+ parser.add_argument("--mutation-kernel", default=DEMO_ELF, help="kernel for the mutation smoke (default: demo)")
+ parser.add_argument("--mutation-iterations", type=int, default=8)
+ parser.add_argument("--mutation-seed", type=int, default=7)
+ parser.add_argument("--mutation-timeout", type=float, default=0.25)
+ parser.add_argument("--mutation-settle", type=float, default=0.1)
+ parser.add_argument("--allow-foreign-mutation", action="store_true",
+ help="permit a non-demo mutation kernel (QEMU only)")
+ return parser
+
+
+def options_from_args(args: argparse.Namespace) -> ReleaseOptions:
+ return ReleaseOptions(
+ old=args.old,
+ new=args.new,
+ out=args.out,
+ old_label=args.old_label,
+ new_label=args.new_label,
+ release=args.release,
+ approval_token=args.approval_token,
+ old_symbols=args.old_symbols,
+ new_symbols=args.new_symbols,
+ matrix_symbols=args.matrix_symbols,
+ old_ghidra_names=args.old_ghidra_names,
+ new_ghidra_names=args.new_ghidra_names,
+ old_ghidra_index=args.old_ghidra_index,
+ new_ghidra_index=args.new_ghidra_index,
+ old_ghidra_exports=args.old_ghidra_exports,
+ new_ghidra_exports=args.new_ghidra_exports,
+ old_ghidra_bias=args.old_ghidra_bias,
+ new_ghidra_bias=args.new_ghidra_bias,
+ corpus_diff=tuple(args.corpus_diff) if args.corpus_diff else None, # type: ignore[arg-type]
+ corpus_report=args.corpus_report,
+ stimulus=args.stimulus,
+ probe_instructions=args.probe_instructions,
+ behavior_duration=args.behavior_duration,
+ max_rows=args.max_rows,
+ mutation_kernel=args.mutation_kernel,
+ mutation_iterations=args.mutation_iterations,
+ mutation_seed=args.mutation_seed,
+ mutation_timeout=args.mutation_timeout,
+ mutation_settle=args.mutation_settle,
+ allow_foreign_mutation=args.allow_foreign_mutation,
+ dry_run=args.dry_run,
+ )
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ options = options_from_args(args)
+ try:
+ check_rm610_gate(options.labels(), approval_token=options.approval_token)
+ except Rm610GateError as exc:
+ print(f"[release] RM610 gate refused: {exc}", file=sys.stderr)
+ return 3
+ if options.dry_run:
+ plan = build_plan(options)
+ print(json.dumps(plan, indent=2, sort_keys=True))
+ print(f"[release] dry-run: {len(plan['steps'])} steps planned, no targets touched")
+ return 0
+ if not options.out:
+ parser.error("--out is required unless --dry-run is used")
+ try:
+ report = run_release(options)
+ except ValueError as exc:
+ parser.error(str(exc))
+ except Exception as exc:
+ print(f"[release] error: {type(exc).__name__}: {exc}", file=sys.stderr)
+ return 2
+ for step in report["steps"]:
+ reason = f" reason={step['reason']}" if step.get("reason") else ""
+ print(f"[release] {step['name']}: status={step['status']}{reason}")
+ print(f"[release] wrote {os.path.join(os.path.abspath(options.out), 'release-report.json')}")
+ print(f"[release] wrote {os.path.join(os.path.abspath(options.out), 'bundle-manifest.json')}")
+ return 1 if report["errors"] else 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/common.py b/debug/risc-v-gsp-rm-vm/harness/common.py
new file mode 100644
index 0000000..74dabe0
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/common.py
@@ -0,0 +1,218 @@
+#!/usr/bin/env python3
+"""Shared helpers for the green-eye automation harness.
+
+The harness scripts run from the tool root without installation, so this
+module bootstraps the tool root onto ``sys.path`` before importing greeneye.
+The QEMU backend is the only execution backend the harness claims: the matrix
+must stay comparable to the CLI ``probe``/``demo`` paths and must not depend
+on the in-development native simulator.
+"""
+from __future__ import annotations
+
+import contextlib
+import hashlib
+import json
+import os
+import shutil
+import sys
+import tempfile
+from dataclasses import dataclass, field
+
+TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if TOOL_ROOT not in sys.path:
+ sys.path.insert(0, TOOL_ROOT)
+
+from greeneye.analyze import parse_qemu_faults # noqa: E402
+from greeneye.elf import FirmwareImage, load_elf, write_flat_kernel # noqa: E402
+from greeneye.errors import GreenEyeError, TargetError # noqa: E402
+from greeneye.rsp import RspClient # noqa: E402
+from greeneye.session import Session # noqa: E402
+from greeneye.symbols import SymbolDB # noqa: E402
+from greeneye.target import QemuTarget, find_qemu # noqa: E402
+
+DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+DEMO_SCHEMA = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "gsp_ctrl.schema.json")
+
+#: Raw images are relocated to the top of QEMU's virt RAM, matching `greeneye probe`.
+LOAD_ADDR = 0x80000000
+#: Refuse images whose flat form would be unreasonably large.
+MAX_FLAT_BYTES = 256 * 1024 * 1024
+#: Cap the QEMU memory size the harness will request.
+MAX_MEMORY_MB = 2048
+#: Default bound when reading a QEMU log window (trap loops flood `-d int`).
+MAX_LOG_BYTES = 1 << 20
+
+
+def sha256_file(path: str) -> str:
+ digest = hashlib.sha256()
+ with open(path, "rb") as fh:
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def qemu_available() -> bool:
+ try:
+ find_qemu()
+ return True
+ except TargetError:
+ return False
+
+
+def qemu_path() -> str:
+ return find_qemu()
+
+
+def load_target(path: str) -> FirmwareImage:
+ if not os.path.isfile(path):
+ raise GreenEyeError(f"target not found: {path}")
+ image = load_elf(path)
+ if not image.is_riscv:
+ raise GreenEyeError(f"{path}: not a RISC-V ELF image")
+ span = image.max_vaddr - image.min_vaddr
+ if span > MAX_FLAT_BYTES:
+ raise GreenEyeError(f"{path}: image span {span:#x} exceeds the {MAX_FLAT_BYTES:#x} harness cap")
+ return image
+
+
+def memory_mb_for(image: FirmwareImage) -> int:
+ span = image.max_vaddr - image.min_vaddr
+ return min(MAX_MEMORY_MB, max(256, span // (1024 * 1024) + 64))
+
+
+def ensure_dir(path: str) -> str:
+ os.makedirs(path, exist_ok=True)
+ return path
+
+
+def write_json(path: str, payload: dict) -> str:
+ ensure_dir(os.path.dirname(path))
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(payload, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ return path
+
+
+def write_text(path: str, text: str) -> str:
+ ensure_dir(os.path.dirname(path))
+ with open(path, "w", encoding="utf-8") as fh:
+ fh.write(text)
+ return path
+
+
+def relpath(path: str, root: str) -> str:
+ return os.path.relpath(path, root)
+
+
+def counter_delta(a: dict, b: dict) -> list[dict]:
+ """Stable union delta of two count maps (B minus A), sorted by key."""
+ rows = []
+ for key in sorted(set(a) | set(b)):
+ a_count = int(a.get(key, 0))
+ b_count = int(b.get(key, 0))
+ rows.append({"key": key, "a": a_count, "b": b_count, "delta": b_count - a_count})
+ return rows
+
+
+def read_log_window(path: str | None, offset: int = 0, max_bytes: int = MAX_LOG_BYTES) -> tuple[str, bool]:
+ """Read a bounded QEMU log window; returns ``(text, capped)``.
+
+ Trap loops emit a ``-d int`` line per faulting instruction, so an
+ unbounded read can be hundreds of megabytes. Faults of interest (the
+ first exception) are at the start of the window.
+ """
+ if not path:
+ return "", False
+ try:
+ with open(path, "rb") as fh:
+ fh.seek(offset)
+ data = fh.read(max_bytes)
+ except (FileNotFoundError, OSError):
+ return "", False
+ capped = len(data) == max_bytes
+ text = data.decode("utf-8", "replace")
+ if capped:
+ text = text.rsplit("\n", 1)[0]
+ return text, capped
+
+
+def read_faults_window(path: str | None, offset: int = 0, max_bytes: int = MAX_LOG_BYTES) -> tuple[list[dict], bool]:
+ text, capped = read_log_window(path, offset=offset, max_bytes=max_bytes)
+ return parse_qemu_faults(text), capped
+
+
+@dataclass
+class FlatSession:
+ """A live QEMU session around a relocated flat kernel."""
+
+ image: FirmwareImage
+ bias: int
+ session: Session
+ target: QemuTarget
+ workdir: str
+ flat_kernel: str
+ owns_workdir: bool = field(default=False)
+
+
+@contextlib.contextmanager
+def open_flat_session(
+ image: FirmwareImage,
+ target_path: str,
+ *,
+ record: str | bool | None,
+ name: str,
+ symbols: SymbolDB | None = None,
+ log_faults: bool = True,
+ workdir: str | None = None,
+ meta: dict | None = None,
+):
+ """Start QEMU on the target's flat relocated image and yield a session.
+
+ Relocation (``LOAD_ADDR - image.min_vaddr``) mirrors ``greeneye probe``:
+ firmware linked for GPU physical addresses has to be placed into virt RAM
+ before QEMU can execute it. Symbols are loaded at the relocated addresses.
+ """
+ owns_workdir = workdir is None
+ if workdir is None:
+ workdir = tempfile.mkdtemp(prefix="greeneye-harness-")
+ flat_kernel = os.path.join(workdir, "flat.bin")
+ write_flat_kernel(target_path, flat_kernel, load_addr=LOAD_ADDR)
+ bias = LOAD_ADDR - image.min_vaddr
+ target = QemuTarget(
+ kernel=flat_kernel,
+ memory_mb=memory_mb_for(image),
+ dlog="guest_errors,unimp,int" if log_faults else None,
+ workdir=workdir,
+ )
+ target.start(paused=True)
+ client = RspClient(target.host, target.port)
+ client.connect()
+ client.sync()
+ db = symbols if symbols is not None else SymbolDB()
+ session_meta = {"backend": "qemu", "bias": bias, "firmware": target_path, "harness": name}
+ session_meta.update(meta or {})
+ session = Session(
+ client=client,
+ target=target,
+ symbols=db,
+ record=record,
+ name=name,
+ meta=session_meta,
+ )
+ session.load_image(image, bias=bias, load_symbols=symbols is None)
+ try:
+ yield FlatSession(
+ image=image,
+ bias=bias,
+ session=session,
+ target=target,
+ workdir=workdir,
+ flat_kernel=flat_kernel,
+ owns_workdir=owns_workdir,
+ )
+ finally:
+ try:
+ session.close()
+ finally:
+ if owns_workdir:
+ shutil.rmtree(workdir, ignore_errors=True)
diff --git a/debug/risc-v-gsp-rm-vm/harness/control_discovery.py b/debug/risc-v-gsp-rm-vm/harness/control_discovery.py
new file mode 100644
index 0000000..eecf082
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/control_discovery.py
@@ -0,0 +1,1055 @@
+#!/usr/bin/env python3
+"""Unknown-call discovery over firmware control surfaces (H4, "Sandsifter for RM").
+
+The problem this tool attacks: NVIDIA ships new GSP images on a hidden
+schedule and new RM messages/controls/services appear without documentation.
+Instead of waiting for a new control to be noticed, sweep the input space and
+ask the firmware itself which calls exist, which code path each one takes, and
+what it changes.
+
+Each probe uses a fresh QEMU session, settles the firmware through its
+self-test, hooks the command dispatcher (and every handler the control block
+declares), applies exactly one command with a deterministic argument pattern,
+runs a bounded window and classifies the outcome:
+
+ known-handler the documented/id-table handler for this id ran
+ injected-slot the id routed through the injected-service handler table
+ default-path the dispatcher's catch-all ran (undiscovered id)
+ trap the id reached an ``ebreak`` (debugger trap)
+ fault the id produced a load/store/fetch/illegal exception
+ no-progress the mailbox was never consumed within the budget
+
+Findings are written to ``findings.jsonl`` and a markdown report groups ids by
+class, listing unknown/undiscovered ids explicitly.
+
+Surfaces (``--surface``):
+
+* ``demo`` -- the bundled uproc mailbox command space (works today on QEMU);
+* ``rm`` -- an RM control/method entry point for the future simulator or a
+ live session. It is implemented behind the same :class:`Surface`
+ abstraction (a probe function + an effect observer), so a real RM surface is
+ a new class, not a rewrite. When no entry point is exposed the tool prints
+ a documented message and exits cleanly.
+
+Safety: the bundled demo (or an explicitly allowed foreign ELF) under QEMU,
+fresh session per probe, bounded budgets, host writes limited to ``--out``.
+Never hardware, a guest, an existing gdbstub or the native simulator by
+default. See docs/19-CONTROL-DISCOVERY.md.
+
+Run from the tool root:
+
+ python3 harness/control_discovery.py --surface demo --out dumps/discovery
+"""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import random
+import shutil
+import struct
+import sys
+import time
+from collections import Counter
+from dataclasses import dataclass, field
+
+try:
+ from .common import (
+ DEMO_ELF,
+ MAX_LOG_BYTES,
+ ensure_dir,
+ load_target,
+ qemu_available,
+ read_faults_window,
+ sha256_file,
+ )
+except ImportError: # direct script execution (python3 harness/control_discovery.py)
+ from common import ( # type: ignore[no-redef]
+ DEMO_ELF,
+ MAX_LOG_BYTES,
+ ensure_dir,
+ load_target,
+ qemu_available,
+ read_faults_window,
+ sha256_file,
+ )
+
+from greeneye import launch # noqa: E402
+from greeneye.errors import GreenEyeError, RspTimeout # noqa: E402
+
+FORMAT = "greeneye-control-discovery/1"
+MASK64 = (1 << 64) - 1
+
+#: gsp_ctrl field offsets (firmware/uproc_demo/gsp_ctrl.schema.json).
+CTRL_MAGIC = 0
+CTRL_STATE = 8
+CTRL_RPC_COUNT = 16
+CTRL_LAST_RPC = 24
+CTRL_MAILBOX = 32
+CTRL_ARG0 = 40
+CTRL_ARG1 = 48
+CTRL_ARG2 = 56
+CTRL_RESULT = 64
+CTRL_BUF = 72
+CTRL_BUF_SIZE = 256
+CTRL_HANDLER_COUNT = 328
+CTRL_HANDLERS = 336
+CTRL_SIZE = 400
+
+#: The demo dispatcher's documented switch cases.
+DEMO_CASE_HANDLERS = {
+ 1: "rpc_ping",
+ 2: "rpc_echo",
+ 3: "rpc_fill",
+ 4: "rpc_sum",
+ 5: "rpc_state",
+ 6: "rpc_fault",
+}
+#: Mailbox commands 16..23 select handler-table slots 0..7 (docs/12 section 4).
+INJECT_CMD_BASE = 16
+INJECT_CMD_END = 24
+
+SETTLE_MARKER = "SELFTEST DONE"
+SETTLE_SLICE = 0.02
+RUN_SLICE = 0.02
+TRAP_GRACE = 0.05
+#: gdb signals that carry a hazard (illegal, trap, bus, segv); SIGINT from the
+#: budget interrupt is wall-clock dependent and must never enter a finding.
+HAZARD_SIGNALS = (4, 5, 7, 11)
+
+PATTERNS = ("zero", "sentinel", "boundary")
+OUTCOMES = (
+ "known-handler",
+ "injected-slot",
+ "method-return",
+ "default-path",
+ "trap",
+ "fault",
+ "no-progress",
+ "settle-failed",
+ "error",
+)
+DEFAULT_RECORD_CLASSES = ("injected-slot", "method-return", "trap", "fault", "default-path", "settle-failed", "error")
+
+
+# ---------------------------------------------------------------------------
+# deterministic probe generation
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class Probe:
+ index: int
+ cmd: int
+ pattern: str
+ args: tuple[int, int, int]
+
+ @property
+ def name(self) -> str:
+ return f"{self.index:04d}-id{self.cmd:03d}-{self.pattern}"
+
+
+def pattern_args(pattern: str, seed: int, cmd: int) -> tuple[int, int, int]:
+ """Deterministic argument vector per (pattern, seed, cmd).
+
+ The per-command RNG stream keeps sentinels distinct across ids while a
+ fixed seed makes the whole sweep replayable.
+ """
+ if pattern == "zero":
+ return (0, 0, 0)
+ rng = random.Random(seed * 1_000_003 + cmd * 97 + 0x5EED)
+ if pattern == "sentinel":
+ return tuple(rng.getrandbits(64) for _ in range(3)) # type: ignore[return-value]
+ if pattern == "boundary":
+ return (MASK64, 1, 0x7FFF_FFFF_FFFF_FFFF)
+ raise GreenEyeError(f"unknown pattern {pattern!r} (expected one of {', '.join(PATTERNS)})")
+
+
+def generate_probes(max_id: int, patterns: list[str], seed: int, iterations: int = 0) -> list[Probe]:
+ probes: list[Probe] = []
+ for cmd in range(1, max_id + 1):
+ for pattern in patterns:
+ probes.append(Probe(index=len(probes), cmd=cmd, pattern=pattern, args=pattern_args(pattern, seed, cmd)))
+ if iterations > 0:
+ probes = probes[:iterations]
+ return probes
+
+
+# ---------------------------------------------------------------------------
+# effect observer
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class WatchedField:
+ name: str
+ offset: int
+ size: int
+ fmt: str # u8 | u32 | u64 | bytes
+
+
+@dataclass
+class EffectObserver:
+ """Captures the effect half of a surface: field snapshots + store PCs."""
+
+ watched: tuple[WatchedField, ...] = ()
+ hits: list[dict] = field(default_factory=list)
+
+ def reset(self) -> None:
+ self.hits.clear()
+
+ def arm(self, session, base: int) -> None:
+ """Install write watchpoints for every watched field."""
+ for watched in self.watched:
+ if watched.fmt == "bytes":
+ continue # byte-range watchpoints are not portable; hash at snapshot time
+ session.watch(base + watched.offset, size=watched.size, kind="w", callback=self._make_callback(watched))
+
+ def _make_callback(self, watched: WatchedField):
+ def callback(hit) -> None:
+ self.hits.append(
+ {
+ "field": watched.name,
+ "pc": hit.pc,
+ "symbol": hit.symbol.name if hit.symbol else None,
+ "addr": hit.addr,
+ }
+ )
+
+ return callback
+
+ def snapshot(self, session, base: int) -> dict:
+ values: dict = {}
+ for watched in self.watched:
+ raw = session.memory.read(base + watched.offset, watched.size)
+ if watched.fmt == "bytes":
+ values[watched.name] = {
+ "sha256": hashlib.sha256(raw).hexdigest()[:16],
+ "len": len(raw),
+ "preview": raw[:16].hex(),
+ }
+ elif watched.fmt == "u8":
+ values[watched.name] = raw[0]
+ elif watched.fmt == "u32":
+ values[watched.name] = struct.unpack_from("<I", raw, 0)[0]
+ else:
+ values[watched.name] = struct.unpack_from("<Q", raw, 0)[0]
+ return values
+
+ def delta(self, before: dict, after: dict) -> dict:
+ changed = [
+ {"field": name, "before": before.get(name), "after": after.get(name)}
+ for name in before
+ if before.get(name) != after.get(name)
+ ]
+ return {"changed": changed, "watch": list(self.hits)}
+
+
+# ---------------------------------------------------------------------------
+# surfaces
+# ---------------------------------------------------------------------------
+
+
+class Surface:
+ """A sweepable input surface: command space + probe action + effects.
+
+ A future surface is a subclass; the sweep, classification and reporting
+ machinery is shared. ``probe`` applies one command, ``observer`` returns
+ the effect model, and ``handler_map``/``command_for_addr`` model the
+ entry points so ids can be attributed to code.
+ """
+
+ name = "abstract"
+
+ def describe(self) -> str:
+ return self.name
+
+ def available(self) -> tuple[bool, str | None]:
+ return True, None
+
+ def documented_handlers(self, session) -> dict[int, str]:
+ return {}
+
+ def handler_map(self, session) -> dict[int, tuple[int, str | None]]:
+ """Map command id -> (entry address, symbol name) for declared handlers."""
+ return {}
+
+ def command_hook(self, session) -> str | None:
+ return None
+
+ def is_injected(self, cmd: int) -> bool:
+ return False
+
+ def probe(self, session, cmd: int, args: tuple[int, int, int], *, timeout: float) -> int | None:
+ """Apply one command. Returns a direct return value when the surface is
+ a call entry point, else None (effects carry the reply)."""
+ raise NotImplementedError
+
+ def observer(self) -> EffectObserver:
+ return EffectObserver()
+
+ def command_for_addr(self, session, addr: int) -> list[dict]:
+ """Ids whose declared handler resolves to ``addr`` (trigger discovery)."""
+ out: list[dict] = []
+ for cmd, (entry, name) in sorted(self.handler_map(session).items()):
+ if entry == addr:
+ out.append({"id": cmd, "handler": name})
+ return out
+
+
+class DemoSurface(Surface):
+ """The uproc demo mailbox surface (gsp_ctrl.handlers + 1..6 switch)."""
+
+ name = "demo"
+
+ def documented_handlers(self, session) -> dict[int, str]:
+ return {
+ cmd: name for cmd, name in DEMO_CASE_HANDLERS.items() if session.symbols.get(name) is not None
+ }
+
+ def handler_map(self, session) -> dict[int, tuple[int, str | None]]:
+ mapping: dict[int, tuple[int, str | None]] = {}
+ for cmd, name in self.documented_handlers(session).items():
+ mapping[cmd] = (session.symbols.lookup(name), name)
+ ctrl = session.symbols.get("gsp_ctrl")
+ if ctrl is not None:
+ for slot in range(8):
+ entry = session.memory.read_u64(ctrl.addr + CTRL_HANDLERS + slot * 8)
+ if entry:
+ symbol = session.symbols.resolve(entry)
+ mapping[INJECT_CMD_BASE + slot] = (entry, symbol.name if symbol else None)
+ return mapping
+
+ def command_hook(self, session) -> str | None:
+ return "rpc_dispatch" if session.symbols.get("rpc_dispatch") is not None else None
+
+ def is_injected(self, cmd: int) -> bool:
+ return INJECT_CMD_BASE <= cmd < INJECT_CMD_END
+
+ def probe(self, session, cmd: int, args: tuple[int, int, int], *, timeout: float) -> int | None:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ # Args first: the firmware polls the mailbox last and consumes all four.
+ session.memory.write_u64(ctrl + CTRL_ARG0, args[0])
+ session.memory.write_u64(ctrl + CTRL_ARG1, args[1])
+ session.memory.write_u64(ctrl + CTRL_ARG2, args[2])
+ session.memory.write_u64(ctrl + CTRL_MAILBOX, cmd)
+ return None
+
+ def observer(self) -> EffectObserver:
+ return EffectObserver(
+ watched=(
+ WatchedField("state", CTRL_STATE, 4, "u32"),
+ WatchedField("rpc_count", CTRL_RPC_COUNT, 8, "u64"),
+ WatchedField("last_rpc", CTRL_LAST_RPC, 8, "u64"),
+ WatchedField("result", CTRL_RESULT, 8, "u64"),
+ WatchedField("buf", CTRL_BUF, CTRL_BUF_SIZE, "bytes"),
+ )
+ )
+
+ def snapshot_extra(self, session, base: int) -> dict:
+ raw = session.memory.read(base, CTRL_SIZE)
+ return {
+ "magic": struct.unpack_from("<I", raw, CTRL_MAGIC)[0],
+ "handler_count": struct.unpack_from("<Q", raw, CTRL_HANDLER_COUNT)[0],
+ }
+
+
+class RMSurface(Surface):
+ """Future RM control/method entry point (roadmap H1.3/H4).
+
+ On a simulator or live session that exposes an RM method dispatcher, the
+ surface is: call the method entry with ``(control, a0, a1, a2)`` and read
+ the reply buffer at ``reply_addr``. Today no such entry is exposed, so
+ ``available()`` reports why and the CLI exits cleanly.
+ """
+
+ name = "rm"
+
+ def __init__(self, entry: str | None = None, reply_addr: int | None = None, reply_size: int = 0x100):
+ self.entry = entry
+ self.reply_addr = reply_addr
+ self.reply_size = reply_size
+
+ def describe(self) -> str:
+ bits = [f"entry={self.entry or '<none>'}"]
+ bits.append(f"reply={self.reply_addr:#x}/{self.reply_size:#x}" if self.reply_addr else "reply=<none>")
+ return "rm(" + ", ".join(bits) + ")"
+
+ def available(self) -> tuple[bool, str | None]:
+ if not self.entry:
+ return False, (
+ "surface 'rm' unavailable: no RM control/method entry point is exposed by the "
+ "simulator or a live session yet (roadmap H1.3). Pass --rm-entry SYMBOL once the "
+ "RM method dispatcher exists; the demo surface works today with --surface demo."
+ )
+ return True, None
+
+ def is_injected(self, cmd: int) -> bool:
+ return False
+
+ def probe(self, session, cmd: int, args: tuple[int, int, int], *, timeout: float) -> int | None:
+ return session.call(self.entry, (cmd, args[0], args[1], args[2]), timeout=timeout)
+
+ def observer(self) -> EffectObserver:
+ if not self.reply_addr:
+ return EffectObserver()
+ return EffectObserver(
+ watched=(WatchedField("reply", self.reply_addr, self.reply_size, "bytes"),)
+ )
+
+ def snapshot_extra(self, session, base: int) -> dict:
+ return {}
+
+
+def make_surface(args) -> Surface:
+ if args.surface == "demo":
+ return DemoSurface()
+ if args.surface == "rm":
+ return RMSurface(entry=args.rm_entry, reply_addr=args.rm_reply, reply_size=args.rm_reply_size)
+ raise GreenEyeError(f"unknown surface {args.surface!r}")
+
+
+# ---------------------------------------------------------------------------
+# one probe
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class Finding:
+ probe: int
+ cmd: int
+ pattern: str
+ args: list[int]
+ outcome: str = "error"
+ handler: str | None = None
+ handler_entry: int | None = None
+ documented_handler: str | None = None
+ dispatch_seen: bool = False
+ dispatch_pc: int | None = None
+ dispatch_args: list[int] | None = None
+ call_retval: int | None = None
+ rpc_count_delta: int = 0
+ effects: dict = field(default_factory=dict)
+ fault: dict | None = None
+ signals: list[dict] = field(default_factory=list)
+ settle_ok: bool = True
+ session: str | None = None
+ reason: str | None = None
+
+ def to_json(self) -> dict:
+ return {
+ "probe": self.probe,
+ "cmd": self.cmd,
+ "pattern": self.pattern,
+ "args": self.args,
+ "outcome": self.outcome,
+ "handler": self.handler,
+ "handler_entry": self.handler_entry,
+ "documented_handler": self.documented_handler,
+ "dispatch_seen": self.dispatch_seen,
+ "dispatch_pc": self.dispatch_pc,
+ "dispatch_args": self.dispatch_args,
+ "call_retval": self.call_retval,
+ "rpc_count_delta": self.rpc_count_delta,
+ "effects": self.effects,
+ "fault": self.fault,
+ "signals": self.signals,
+ "settle_ok": self.settle_ok,
+ "session": self.session,
+ "reason": self.reason,
+ }
+
+
+def _compact_fault(fault: dict) -> dict:
+ return {
+ "cause": fault.get("cause"),
+ "desc": fault.get("desc"),
+ "epc": fault.get("epc"),
+ "tval": fault.get("tval"),
+ "access": fault.get("access"),
+ }
+
+
+def classify_outcome(
+ *,
+ cmd: int,
+ is_injected: bool,
+ handler: str | None,
+ dispatch_seen: bool,
+ call_retval: int | None,
+ faults: list[dict],
+ signals: list[dict],
+ settle_ok: bool,
+) -> str:
+ """Priority-ordered classification of one probe result (pure function)."""
+ if not settle_ok:
+ return "settle-failed"
+ causes = [int(fault.get("cause", 0)) for fault in faults]
+ if any(cause == 3 for cause in causes) or any(sig.get("signal") == 5 for sig in signals):
+ return "trap"
+ if causes or any(sig.get("signal") in (4, 7, 11) for sig in signals):
+ return "fault"
+ if call_retval is not None:
+ return "method-return"
+ if not dispatch_seen:
+ return "no-progress"
+ if is_injected and handler:
+ return "injected-slot"
+ if handler:
+ return "known-handler"
+ return "default-path"
+
+
+class ProbeRunner:
+ """Runs one probe in a fresh session and builds the finding."""
+
+ def __init__(self, surface: Surface, options: "DiscoveryOptions", image, kernel_sha: str):
+ self.surface = surface
+ self.options = options
+ self.image = image
+ self.kernel_sha = kernel_sha
+
+ def run(self, probe: Probe) -> Finding:
+ finding = Finding(probe=probe.index, cmd=probe.cmd, pattern=probe.pattern, args=list(probe.args))
+ staging = os.path.join(self.options.out, ".staging", probe.name)
+ ensure_dir(staging)
+ qemu_log = os.path.join(staging, "qemu-faults.log")
+ # Record into staging unconditionally: the classification decides whether
+ # the session is evidence worth keeping, and re-running a probe to
+ # record it later would double the sweep cost.
+ record_path = staging
+ session = None
+ try:
+ extra_args = None
+ if self.options.backend == "qemu":
+ extra_args = ["-d", "guest_errors,unimp,int", "-D", qemu_log]
+ session = launch(
+ kernel=self.options.kernel,
+ record=record_path,
+ name=f"discovery-{probe.name}",
+ backend=self.options.backend,
+ extra_args=extra_args,
+ )
+ finding.settle_ok = self._settle(session, self.options.settle)
+ if not finding.settle_ok:
+ finding.reason = f"{SETTLE_MARKER!r} not observed within the settle budget"
+ return self._finish(session, probe, finding)
+
+ observer = self.surface.observer()
+ before = observer.snapshot(session, self._base(session))
+ before.update(self._extra(self.surface, session))
+ observer.reset()
+ observer.arm(session, self._base(session))
+
+ dispatches: list[dict] = []
+ handlers: list[dict] = []
+ signals: list[dict] = []
+ self._install_hooks(session, dispatches, handlers, signals)
+ observer.reset()
+
+ log_offset = os.path.getsize(qemu_log) if os.path.exists(qemu_log) else 0
+ call_retval: int | None = None
+ try:
+ call_retval = self.surface.probe(session, probe.cmd, probe.args, timeout=self.options.timeout)
+ except RspTimeout as exc:
+ finding.reason = f"probe call timed out: {exc}"
+ self._run_window(session, dispatches, handlers, signals, observer)
+
+ faults: list[dict] = []
+ if self.options.backend == "qemu":
+ faults, _capped = read_faults_window(qemu_log, log_offset, self.options.max_log_bytes)
+
+ after = observer.snapshot(session, self._base(session))
+ after.update(self._extra(self.surface, session))
+ effects = observer.delta(before, after)
+ effects["before"] = before
+ effects["after"] = after
+
+ dispatch = dispatches[0] if dispatches else None
+ handler = handlers[0] if handlers else None
+ mapped = self.surface.handler_map(session)
+ finding.documented_handler = self.surface.documented_handlers(session).get(probe.cmd)
+ finding.dispatch_seen = dispatch is not None
+ finding.dispatch_pc = dispatch.get("addr") if dispatch else None
+ finding.dispatch_args = dispatch.get("args") if dispatch else None
+ finding.handler = handler.get("symbol") if handler else None
+ finding.handler_entry = handler.get("addr") if handler else None
+ if handler is None and probe.cmd in mapped:
+ entry, name = mapped[probe.cmd]
+ finding.handler = name
+ finding.handler_entry = entry
+ finding.call_retval = call_retval
+ finding.rpc_count_delta = int(effects.get("after", {}).get("rpc_count", 0)) - int(
+ effects.get("before", {}).get("rpc_count", 0)
+ )
+ finding.effects = effects
+ finding.fault = _compact_fault(faults[0]) if faults else None
+ finding.signals = signals
+ finding.outcome = classify_outcome(
+ cmd=probe.cmd,
+ is_injected=self.surface.is_injected(probe.cmd),
+ handler=finding.handler,
+ dispatch_seen=dispatch is not None,
+ call_retval=call_retval,
+ faults=faults,
+ signals=signals,
+ settle_ok=True,
+ )
+ return self._finish(session, probe, finding)
+ except Exception as exc: # one bad probe must not end the sweep
+ finding.outcome = "error"
+ finding.reason = f"{type(exc).__name__}: {exc}"
+ return self._finish(session, probe, finding)
+
+ # -- helpers -----------------------------------------------------------
+
+ def _base(self, session) -> int:
+ return session.symbols.lookup("gsp_ctrl") if session.symbols.get("gsp_ctrl") else 0
+
+ def _extra(self, surface: Surface, session) -> dict:
+ extra = getattr(surface, "snapshot_extra", None)
+ if extra is None:
+ return {}
+ return extra(session, self._base(session))
+
+ def _install_hooks(self, session, dispatches: list[dict], handlers: list[dict], signals: list[dict]) -> None:
+ def on_signal(hit) -> None:
+ stop = hit.stop
+ if stop is not None and stop.signal in HAZARD_SIGNALS:
+ signals.append({"signal": stop.signal, "pc": hit.pc})
+
+ session.on_event("stop", on_signal)
+ hook_name = self.surface.command_hook(session)
+ if hook_name:
+ session.hook(
+ hook_name,
+ on_enter=lambda hit: dispatches.append(
+ {
+ "addr": hit.addr,
+ "symbol": hook_name,
+ "args": [hit.arg(0), hit.arg(1), hit.arg(2), hit.arg(3)],
+ "caller": hit.regs.get("ra", 0),
+ }
+ ),
+ listener=f"discovery:{hook_name}",
+ )
+ for cmd, (entry, name) in sorted(self.surface.handler_map(session).items()):
+ session.hook(
+ entry,
+ on_enter=lambda hit, name=name: handlers.append(
+ {
+ "addr": hit.addr,
+ "symbol": hit.symbol.name if hit.symbol else name,
+ "args": [hit.arg(0), hit.arg(1), hit.arg(2)],
+ "caller": hit.regs.get("ra", 0),
+ }
+ ),
+ listener=f"discovery:handler-{entry:#x}",
+ )
+
+ def _settle(self, session, budget: float) -> bool:
+ deadline = time.monotonic() + budget
+ while time.monotonic() < deadline:
+ if SETTLE_MARKER in session.target.read_uart():
+ return True
+ session.run(duration=SETTLE_SLICE, max_stops=1)
+ return SETTLE_MARKER in session.target.read_uart()
+
+ def _run_window(self, session, dispatches, handlers, signals, observer) -> None:
+ """Two-phase bounded window: wait for dispatch, then for completion.
+
+ Completion is a ``result`` store (the dispatcher writes it after every
+ handler) or a trap/grace for ids that never return to the dispatcher.
+ """
+ deadline = time.monotonic() + self.options.timeout
+ handler_seen_at: float | None = None
+ while time.monotonic() < deadline:
+ if not session.target.alive():
+ return
+ remaining = max(0.005, min(RUN_SLICE, deadline - time.monotonic()))
+ session.run(duration=remaining, max_stops=64)
+ if handlers and handler_seen_at is None:
+ handler_seen_at = time.monotonic()
+ result_written = any(hit.get("field") == "result" for hit in observer.hits)
+ trapped = any(sig.get("signal") == 5 for sig in signals) or session.pc < 0x100
+ if dispatches and (result_written or trapped):
+ return
+ if handler_seen_at is not None and time.monotonic() - handler_seen_at >= TRAP_GRACE:
+ # Slow/hung handler: one grace window after the entry, then stop.
+ return
+
+ def _keep(self, outcome: str) -> bool:
+ return self.options.record_any or outcome in self.options.record_classes
+
+ def _finish(self, session, probe: Probe, finding: Finding) -> Finding:
+ if session is not None:
+ try:
+ session.close()
+ except Exception:
+ pass
+ staging = os.path.join(self.options.out, ".staging", probe.name)
+ if self._keep(finding.outcome) and os.path.isdir(staging):
+ final = os.path.join(self.options.out, "sessions", probe.name)
+ ensure_dir(os.path.dirname(final))
+ if os.path.isdir(final):
+ shutil.rmtree(final, ignore_errors=True)
+ os.replace(staging, final)
+ finding.session = os.path.join("sessions", probe.name)
+ else:
+ shutil.rmtree(staging, ignore_errors=True)
+ return finding
+
+
+# ---------------------------------------------------------------------------
+# report
+# ---------------------------------------------------------------------------
+
+
+def aggregate(findings: list[Finding]) -> dict:
+ per_id: dict[int, dict] = {}
+ for finding in findings:
+ row = per_id.setdefault(
+ finding.cmd,
+ {
+ "id": finding.cmd,
+ "patterns": [],
+ "outcomes": [],
+ "handlers": [],
+ "documented_handler": finding.documented_handler,
+ "results": [],
+ "rpc_count_delta": 0,
+ "sessions": [],
+ "faults": [],
+ },
+ )
+ row["patterns"].append(finding.pattern)
+ row["outcomes"].append(finding.outcome)
+ if finding.handler:
+ row["handlers"].append(finding.handler)
+ result = (finding.effects or {}).get("after", {}).get("result")
+ if result is not None and finding.dispatch_seen:
+ row["results"].append(result)
+ row["rpc_count_delta"] += finding.rpc_count_delta
+ if finding.session:
+ row["sessions"].append(finding.session)
+ if finding.fault:
+ row["faults"].append(finding.fault)
+ for row in per_id.values():
+ row["outcomes"] = sorted(set(row["outcomes"]))
+ row["handlers"] = sorted(set(row["handlers"]))
+ row["results"] = sorted(set(row["results"]))
+ return dict(sorted(per_id.items()))
+
+
+def primary_class(row: dict) -> str:
+ """One label per id for grouping: handle, injected, trap, fault, unknown."""
+ outcomes = set(row["outcomes"])
+ if "trap" in outcomes:
+ return "trap/fault"
+ if "fault" in outcomes:
+ return "trap/fault"
+ if "known-handler" in outcomes and row["documented_handler"]:
+ return "known-handler"
+ if "injected-slot" in outcomes:
+ return "injected-slot"
+ if "method-return" in outcomes:
+ return "method-return"
+ if outcomes & {"default-path", "no-progress"}:
+ return "unknown/undiscovered"
+ return "unknown/undiscovered"
+
+
+def render_markdown(report: dict, options: "DiscoveryOptions") -> str:
+ per_id = report["ids"]
+ outcome_counts = Counter(finding["outcome"] for finding in report["findings"])
+ grouped: dict[str, list[int]] = {}
+ for row in per_id.values():
+ grouped.setdefault(primary_class(row), []).append(row["id"])
+ lines = [
+ "# green-eye control discovery report",
+ "",
+ "> TOOL IN DEVELOPMENT - NOT READY YET! Generated by `harness/control_discovery.py`.",
+ "",
+ f"* format: `{FORMAT}`",
+ f"* surface: `{report['surface']}`",
+ f"* kernel: `{report['kernel']}`",
+ f"* kernel sha256: `{report['kernel_sha256']}`",
+ f"* backend: `{report['backend']}`",
+ f"* ids swept: 1..{report['max_id']} ({report['probes_run']} probes), "
+ f"seed={report['seed']}, patterns={','.join(report['patterns'])}",
+ f"* per-probe timeout: {report['timeout']}s; settle budget: {report['settle']}s",
+ "",
+ "## Outcome counts",
+ "",
+ ]
+ lines += _table(
+ ["outcome", "count"],
+ [[outcome, outcome_counts[outcome]] for outcome in sorted(outcome_counts)],
+ )
+ lines += ["", "## Ids by class", ""]
+ for klass in ("known-handler", "injected-slot", "method-return", "trap/fault", "unknown/undiscovered"):
+ ids = grouped.get(klass, [])
+ lines += [f"* **{klass}**: " + (", ".join(str(i) for i in ids) if ids else "_none_")]
+ lines += ["", "## Known handler ids", ""]
+ known = [row for row in per_id.values() if row["documented_handler"]]
+ lines += _render_rows(known)
+ lines += ["", "## Injected-slot dispatch ids", ""]
+ injected = [row for row in per_id.values() if "injected-slot" in row["outcomes"]]
+ lines += _render_rows(injected)
+ lines += ["", "## Unknown / undiscovered ids", ""]
+ unknown = [
+ row
+ for row in per_id.values()
+ if not row["documented_handler"] and not (set(row["outcomes"]) & {"trap", "fault"})
+ ]
+ if unknown:
+ lines += _render_rows(unknown)
+ lines += [
+ "",
+ "These ids reached the dispatcher's catch-all (result ``~id``) and no handler "
+ "table entry; they are the candidate space for new/unmapped controls.",
+ ]
+ else:
+ lines += ["_none_"]
+ lines += ["", "## Traps and faults", ""]
+ hazards = [row for row in per_id.values() if set(row["outcomes"]) & {"trap", "fault"}]
+ lines += _render_rows(hazards)
+ lines += ["", "## All findings by id", ""]
+ lines += _render_rows(list(per_id.values()))
+ lines += ["", "## Reproduce", "", "```bash", report["reproduce"], "```", ""]
+ return "\n".join(lines) + "\n"
+
+
+def _render_rows(rows: list[dict]) -> list[str]:
+ if not rows:
+ return ["_none_"]
+ return _table(
+ ["id", "documented handler", "observed handler", "outcomes", "results", "rpc delta", "sessions"],
+ [
+ [
+ row["id"],
+ row["documented_handler"] or "-",
+ ", ".join(row["handlers"]) or "-",
+ ", ".join(row["outcomes"]),
+ ", ".join(f"{value:#x}" for value in row["results"][:4]) or "-",
+ row["rpc_count_delta"],
+ ", ".join(f"`{path}`" for path in row["sessions"]) or "-",
+ ]
+ for row in rows
+ ],
+ )
+
+
+def _table(headers: list[str], rows: list[list]) -> list[str]:
+ lines = ["| " + " | ".join(headers) + " |", "|" + "|".join("---" for _ in headers) + "|"]
+ for row in rows:
+ lines.append("| " + " | ".join(str(cell) for cell in row) + " |")
+ return lines
+
+
+# ---------------------------------------------------------------------------
+# orchestration
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class DiscoveryOptions:
+ surface: str
+ out: str
+ kernel: str = DEMO_ELF
+ max_id: int = 63
+ patterns: list[str] = field(default_factory=lambda: list(PATTERNS))
+ seed: int = 1
+ iterations: int = 0
+ timeout: float = 0.25
+ settle: float = 2.0
+ record_all: bool = False
+ record_classes: tuple[str, ...] = DEFAULT_RECORD_CLASSES
+ backend: str = "qemu"
+ allow_foreign_elf: bool = False
+ max_log_bytes: int = MAX_LOG_BYTES
+ rm_entry: str | None = None
+ rm_reply: int | None = None
+ rm_reply_size: int = 0x100
+
+ @property
+ def record_any(self) -> bool:
+ return self.record_all
+
+
+def run_discovery(options: DiscoveryOptions) -> dict:
+ if options.backend == "qemu" and not qemu_available():
+ raise GreenEyeError("qemu-system-riscv64 not found; install qemu-system-misc or set GREENEYE_QEMU")
+ if not os.path.isfile(options.kernel):
+ raise GreenEyeError(f"kernel not found: {options.kernel}")
+ if not options.allow_foreign_elf and os.path.abspath(options.kernel) != os.path.abspath(DEMO_ELF):
+ raise GreenEyeError(
+ "control discovery is restricted to the bundled demo firmware; "
+ "pass --allow-foreign-elf to sweep another ELF under QEMU"
+ )
+ image = load_target(options.kernel)
+ kernel_sha = sha256_file(options.kernel)
+ surface = make_surface(options)
+ ensure_dir(options.out)
+ probes = generate_probes(options.max_id, options.patterns, options.seed, options.iterations)
+ runner = ProbeRunner(surface, options, image, kernel_sha)
+ findings: list[Finding] = []
+ findings_path = os.path.join(options.out, "findings.jsonl")
+ with open(findings_path, "w", encoding="utf-8") as fh:
+ for probe in probes:
+ finding = runner.run(probe)
+ findings.append(finding)
+ fh.write(json.dumps(finding.to_json(), sort_keys=True) + "\n")
+ fh.flush()
+ print(
+ f"[discovery] {finding.probe + 1}/{len(probes)} id={finding.cmd} {finding.pattern} "
+ f"-> {finding.outcome}" + (f" ({finding.handler})" if finding.handler else ""),
+ file=sys.stderr,
+ )
+ shutil.rmtree(os.path.join(options.out, ".staging"), ignore_errors=True)
+ report = {
+ "format": FORMAT,
+ "tool": "harness/control_discovery.py",
+ "surface": surface.describe(),
+ "kernel": options.kernel,
+ "kernel_sha256": kernel_sha,
+ "backend": options.backend,
+ "max_id": options.max_id,
+ "patterns": options.patterns,
+ "seed": options.seed,
+ "timeout": options.timeout,
+ "settle": options.settle,
+ "probes_run": len(findings),
+ "ids": aggregate(findings),
+ "findings": [finding.to_json() for finding in findings],
+ "reproduce": reproduce_command(options),
+ }
+ write_json(os.path.join(options.out, "discovery-report.json"), report)
+ with open(os.path.join(options.out, "control-discovery.md"), "w", encoding="utf-8") as fh:
+ fh.write(render_markdown(report, options))
+ return report
+
+
+def reproduce_command(options: DiscoveryOptions) -> str:
+ foreign = " --allow-foreign-elf" if options.allow_foreign_elf else ""
+ record = " --record-all" if options.record_all else ""
+ rm = ""
+ if options.surface == "rm":
+ rm = f" --rm-entry {options.rm_entry}" if options.rm_entry else ""
+ if options.rm_reply:
+ rm += f" --rm-reply {options.rm_reply:#x}"
+ return (
+ f"python3 harness/control_discovery.py --surface {options.surface} --kernel {options.kernel} "
+ f"--out {options.out} --max-id {options.max_id} --patterns {','.join(options.patterns)} "
+ f"--seed {options.seed} --timeout {options.timeout} --settle {options.settle}"
+ f"{record}{foreign}{rm}"
+ )
+
+
+def write_json(path: str, payload: dict) -> str:
+ ensure_dir(os.path.dirname(path))
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(payload, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ return path
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def parse_patterns(text: str) -> list[str]:
+ patterns = [part.strip() for part in text.split(",") if part.strip()]
+ unknown = [pattern for pattern in patterns if pattern not in PATTERNS]
+ if unknown:
+ raise argparse.ArgumentTypeError(f"unknown pattern(s) {unknown}; expected {', '.join(PATTERNS)}")
+ return patterns
+
+
+def parse_record_classes(text: str) -> tuple[str, ...]:
+ classes = tuple(part.strip() for part in text.split(",") if part.strip())
+ unknown = [klass for klass in classes if klass not in OUTCOMES]
+ if unknown:
+ raise argparse.ArgumentTypeError(f"unknown outcome class(es) {unknown}; expected {', '.join(OUTCOMES)}")
+ return classes
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="control_discovery", description=__doc__.splitlines()[0])
+ parser.add_argument("--surface", choices=("demo", "rm"), default="demo", help="input surface to sweep")
+ parser.add_argument("--kernel", default=DEMO_ELF, help="demo/re-hosted ELF (default: bundled demo)")
+ parser.add_argument("--out", required=True, help="output directory for findings and reports")
+ parser.add_argument("--max-id", type=int, default=63, help="highest command id to sweep (0..4096)")
+ parser.add_argument("--patterns", type=parse_patterns, default=list(PATTERNS), help="comma list: zero,sentinel,boundary")
+ parser.add_argument("--seed", type=int, default=1, help="deterministic pattern seed")
+ parser.add_argument("--iterations", type=int, default=0, help="cap total probes (0 = all)")
+ parser.add_argument("--timeout", type=float, default=0.25, help="bounded observation window per probe (seconds)")
+ parser.add_argument("--settle", type=float, default=2.0, help="budget to observe the self-test before probing")
+ parser.add_argument("--record-all", action="store_true", help="record every probe (default: interesting classes only)")
+ parser.add_argument(
+ "--record-classes",
+ type=parse_record_classes,
+ default=DEFAULT_RECORD_CLASSES,
+ help="outcome classes whose sessions are kept",
+ )
+ parser.add_argument("--backend", choices=("qemu", "native"), default="qemu", help="execution backend (qemu is the supported default)")
+ parser.add_argument("--allow-foreign-elf", action="store_true", help="permit a non-demo ELF under QEMU")
+ parser.add_argument("--max-log-bytes", type=int, default=MAX_LOG_BYTES, help="QEMU fault-log window per probe")
+ parser.add_argument("--rm-entry", help="RM surface: method/control entry symbol (future, H1.3)")
+ parser.add_argument("--rm-reply", type=lambda value: int(value, 0), help="RM surface: reply buffer address")
+ parser.add_argument("--rm-reply-size", type=lambda value: int(value, 0), default=0x100, help="RM surface: reply buffer size")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ if not 1 <= args.max_id <= 4096:
+ parser.error("--max-id must be in 1..4096")
+ if args.iterations < 0:
+ parser.error("--iterations must be >= 0")
+ if not 0.01 <= args.timeout <= 30.0:
+ parser.error("--timeout must be in 0.01..30.0")
+ options = DiscoveryOptions(
+ surface=args.surface,
+ out=os.path.abspath(args.out),
+ kernel=os.path.abspath(args.kernel),
+ max_id=args.max_id,
+ patterns=args.patterns,
+ seed=args.seed,
+ iterations=args.iterations,
+ timeout=args.timeout,
+ settle=args.settle,
+ record_all=args.record_all,
+ record_classes=args.record_classes,
+ backend=args.backend,
+ allow_foreign_elf=args.allow_foreign_elf,
+ max_log_bytes=args.max_log_bytes,
+ rm_entry=args.rm_entry,
+ rm_reply=args.rm_reply,
+ rm_reply_size=args.rm_reply_size,
+ )
+ surface = make_surface(args)
+ available, reason = surface.available()
+ if not available:
+ print(f"[discovery] skipped: {reason}")
+ return 0
+ try:
+ report = run_discovery(options)
+ except GreenEyeError as exc:
+ print(f"control discovery error: {exc}", file=sys.stderr)
+ return 2
+ counts = Counter(finding["outcome"] for finding in report["findings"])
+ print(f"[discovery] {report['probes_run']} probe(s) on surface={report['surface']}, out={options.out}")
+ for outcome in sorted(counts):
+ print(f"[discovery] {outcome}: {counts[outcome]}")
+ print(f"[discovery] wrote {os.path.join(options.out, 'findings.jsonl')}")
+ print(f"[discovery] wrote {os.path.join(options.out, 'control-discovery.md')}")
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/mutation_harness.py b/debug/risc-v-gsp-rm-vm/harness/mutation_harness.py
new file mode 100644
index 0000000..7b558bd
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/mutation_harness.py
@@ -0,0 +1,586 @@
+#!/usr/bin/env python3
+"""Bounded deterministic mutation of the demo firmware input surface (R5, H4.3).
+
+Each iteration starts a fresh QEMU session, settles the demo firmware to the
+end of its self-test, mutates the mailbox command/args (and optionally a RAM
+region), runs it for a bounded budget, and classifies the outcome: normal,
+trap (ebreak / control transfer to an invalid PC), fault (first new QEMU
+exception, parsed from the guest_errors/int log), no-progress, or
+unexpected-result against a small demo oracle. Every attempt is recorded as a
+green-eye session and a compact finding.
+
+Strategies: invalid command IDs, boundary values, single bit flips of valid
+inputs, and buffer length boundaries for the ECHO/FILL/SUM commands.
+
+Safety: the harness launches its own QEMU on the bundled (or explicitly
+allowed) demo/re-hosted ELF only. It never attaches to hardware, a guest or a
+kernel module, and it never uses the native simulator or an existing gdbstub.
+
+Run from the tool root:
+
+ python3 harness/mutation_harness.py --out dumps/mutation --iterations 64 --seed 7
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import random
+import shutil
+import struct
+import sys
+import tempfile
+from collections import Counter
+from dataclasses import dataclass
+
+try:
+ from .common import (
+ DEMO_ELF,
+ LOAD_ADDR,
+ MAX_LOG_BYTES,
+ ensure_dir,
+ load_target,
+ memory_mb_for,
+ open_flat_session,
+ qemu_available,
+ read_faults_window,
+ sha256_file,
+ )
+except ImportError: # direct script execution (python3 harness/mutation_harness.py)
+ from common import ( # type: ignore[no-redef]
+ DEMO_ELF,
+ LOAD_ADDR,
+ MAX_LOG_BYTES,
+ ensure_dir,
+ load_target,
+ memory_mb_for,
+ open_flat_session,
+ qemu_available,
+ read_faults_window,
+ sha256_file,
+ )
+
+from greeneye.errors import GreenEyeError # noqa: E402
+
+FORMAT = "greeneye-mutation-findings/1"
+MASK64 = (1 << 64) - 1
+VALID_CMDS = (1, 2, 3, 4, 5, 6)
+INVALID_CMDS = (0, 7, 8, 15, 24, 25, 31, 32, 63, 0x100, 0xFFFF, 0xFFFFFFFF, MASK64)
+BOUNDARY_VALUES = (
+ 0,
+ 1,
+ 2,
+ 3,
+ 4,
+ 7,
+ 8,
+ 16,
+ 32,
+ 63,
+ 64,
+ 65,
+ 128,
+ 0xFF,
+ 0x100,
+ 0x101,
+ 1024,
+ 0x7FFFFFFF,
+ 0x80000000,
+ 0x40000000,
+ 0xFFFFFFFF,
+ 0x8000000000000000,
+ MASK64,
+)
+BUFFER_LENGTHS = (0, 1, 2, 31, 32, 33, 255, 256, 257, 512, 4096, 0x10000, 0x10000000, MASK64)
+STRATEGIES = ("cmd-invalid", "arg-boundary", "bit-flip", "buffer-length")
+
+CTRL_MAGIC = 0
+CTRL_VERSION = 4
+CTRL_STATE = 8
+CTRL_RPC_COUNT = 16
+CTRL_LAST_RPC = 24
+CTRL_MAILBOX = 32
+CTRL_ARG0 = 40
+CTRL_ARG1 = 48
+CTRL_ARG2 = 56
+CTRL_RESULT = 64
+CTRL_HANDLER_COUNT = 328
+CTRL_BUF_OFFSET = 72 # char buf[256]
+CTRL_SIZE = 400
+SETTLE_MARKER = "SELFTEST DONE"
+SETTLE_RETRIES = 3
+
+
+@dataclass
+class Mutation:
+ iteration: int
+ strategy: str
+ cmd: int = 0
+ arg0: int | str = 0
+ arg1: int = 0
+ arg2: int = 0
+ region: dict | None = None
+
+ def to_json(self) -> dict:
+ return {
+ "cmd": self.cmd,
+ "arg0": self.arg0,
+ "arg1": self.arg1,
+ "arg2": self.arg2,
+ "region": self.region,
+ }
+
+
+@dataclass
+class HarnessOptions:
+ kernel: str
+ out: str
+ iterations: int = 50
+ seed: int = 1
+ timeout: float = 0.25
+ settle: float = 0.2
+ phase_a: float = 0.05
+ memory_region: tuple[int, int] | None = None
+ allow_foreign_elf: bool = False
+ max_log_bytes: int = MAX_LOG_BYTES
+ kernel_sha256: str = ""
+ is_demo: bool = False
+
+
+# ---------------------------------------------------------------------------
+# mutation generation
+# ---------------------------------------------------------------------------
+
+
+def generate_mutations(iterations: int, seed: int, region: tuple[int, int] | None = None) -> list[Mutation]:
+ """Deterministic strategy rotation; per-iteration RNG seeded by (seed, index)."""
+ strategies = STRATEGIES + (("mem-region",) if region else ())
+ mutations: list[Mutation] = []
+ for index in range(iterations):
+ # Integer seed: deterministic across Python versions/processes.
+ rng = random.Random(seed * 1_000_003 + index)
+ strategy = strategies[index % len(strategies)]
+ mutation = Mutation(iteration=index, strategy=strategy)
+ if strategy == "cmd-invalid":
+ mutation.cmd = rng.choice(INVALID_CMDS)
+ elif strategy == "arg-boundary":
+ mutation.cmd = rng.choice(VALID_CMDS)
+ args = [0, 0, 0]
+ args[rng.randrange(3)] = rng.choice(BOUNDARY_VALUES)
+ mutation.arg0, mutation.arg1, mutation.arg2 = args
+ elif strategy == "bit-flip":
+ values = {
+ "cmd": rng.choice(VALID_CMDS),
+ "arg0": rng.choice((0, 1, 0x41, 0xFF)),
+ "arg1": rng.choice((0, 1, 32)),
+ "arg2": 0,
+ }
+ target = rng.choice(("cmd", "arg0", "arg1", "arg2"))
+ values[target] = (values[target] ^ (1 << rng.randrange(64))) & MASK64
+ mutation.cmd = values["cmd"]
+ mutation.arg0, mutation.arg1, mutation.arg2 = values["arg0"], values["arg1"], values["arg2"]
+ elif strategy == "buffer-length":
+ mutation.cmd = rng.choice((2, 3, 4))
+ mutation.arg0 = "ctrl_buf" if mutation.cmd in (2, 4) else rng.choice((0, 0x41))
+ mutation.arg1 = rng.choice(BUFFER_LENGTHS)
+ elif strategy == "mem-region":
+ addr, size = region # type: ignore[misc]
+ mutation.region = {
+ "addr": addr,
+ "size": size,
+ "offset": rng.randrange(size),
+ "xor": 1 << rng.randrange(8),
+ "old": None,
+ "new": None,
+ }
+ mutations.append(mutation)
+ return mutations
+
+
+def resolve_arg0(mutation: Mutation, ctrl: int) -> int:
+ if mutation.arg0 == "ctrl_buf":
+ return ctrl + CTRL_BUF_OFFSET
+ return int(mutation.arg0)
+
+
+# ---------------------------------------------------------------------------
+# demo oracle (documented subset only; everything else is not judged)
+# ---------------------------------------------------------------------------
+
+
+def expected_for(mutation: Mutation, before: dict, ctrl: int) -> dict:
+ cmd = mutation.cmd
+ if cmd == 0:
+ return {"mode": "same"}
+ if cmd == 1:
+ return {"mode": "value", "value": (resolve_arg0(mutation, ctrl) ^ 0xBEEF) & MASK64}
+ if cmd == 3:
+ return {"mode": "value", "value": min(mutation.arg1, 256)}
+ if cmd == 5:
+ return {"mode": "one_of", "values": [0x10, 0x20, 0x30, 0x40]}
+ if 16 <= cmd <= 21:
+ return {"mode": None, "reason": "injected handler slot result is service-dependent"}
+ if cmd in (2, 4, 6):
+ return {"mode": None, "reason": "memory/trap-dependent handler"}
+ return {"mode": "value", "value": (~cmd) & MASK64}
+
+
+def expected_ok(expectation: dict, mutation: Mutation, before: dict, after: dict) -> bool | None:
+ mode = expectation.get("mode")
+ if mode is None:
+ return None
+ if mode == "same":
+ return after["result"] == before["result"] and after["last_rpc"] == before["last_rpc"]
+ if mode == "value":
+ return after["result"] == expectation["value"] and after["last_rpc"] == mutation.cmd
+ if mode == "one_of":
+ return after["result"] in expectation["values"] and after["last_rpc"] == mutation.cmd
+ return None
+
+
+# ---------------------------------------------------------------------------
+# iteration execution
+# ---------------------------------------------------------------------------
+
+
+def read_ctrl_state(session, ctrl: int) -> dict:
+ raw = session.memory.read(ctrl, CTRL_SIZE)
+ u32 = lambda offset: struct.unpack_from("<I", raw, offset)[0] # noqa: E731
+ u64 = lambda offset: struct.unpack_from("<Q", raw, offset)[0] # noqa: E731
+ return {
+ "magic": u32(CTRL_MAGIC),
+ "version": u32(CTRL_VERSION),
+ "state": u32(CTRL_STATE),
+ "rpc_count": u64(CTRL_RPC_COUNT),
+ "last_rpc": u64(CTRL_LAST_RPC),
+ "result": u64(CTRL_RESULT),
+ "handler_count": u64(CTRL_HANDLER_COUNT),
+ }
+
+
+def apply_mutation(session, ctrl: int, mutation: Mutation) -> None:
+ if mutation.region:
+ region = mutation.region
+ data = bytearray(session.memory.read(region["addr"], region["size"]))
+ region["old"] = data[region["offset"]]
+ data[region["offset"]] ^= region["xor"]
+ region["new"] = data[region["offset"]]
+ session.memory.write(region["addr"], bytes(data))
+ session.memory.write_u64(ctrl + CTRL_MAILBOX, mutation.cmd)
+ session.memory.write_u64(ctrl + CTRL_ARG0, resolve_arg0(mutation, ctrl))
+ session.memory.write_u64(ctrl + CTRL_ARG1, mutation.arg1)
+ session.memory.write_u64(ctrl + CTRL_ARG2, mutation.arg2)
+
+
+def _compact_fault(fault: dict) -> dict:
+ return {
+ "cause": fault.get("cause"),
+ "desc": fault.get("desc"),
+ "epc": fault.get("epc"),
+ "tval": fault.get("tval"),
+ "access": fault.get("access"),
+ }
+
+
+def classify(
+ mutation: Mutation,
+ before: dict,
+ after: dict,
+ faults: list[dict],
+ trap_loop: bool,
+ exit_seen: bool,
+ settle_ok: bool,
+ ctrl: int,
+) -> tuple[str, dict]:
+ delta = after["rpc_count"] - before["rpc_count"]
+ detail = {
+ "observed": {
+ "result": after["result"],
+ "last_rpc": after["last_rpc"],
+ "rpc_count_delta": delta,
+ },
+ "expected": None,
+ "expected_ok": None,
+ }
+ if not settle_ok:
+ detail["reason"] = f"{SETTLE_MARKER!r} not observed within the settle budget"
+ return "settle-failed", detail
+ if exit_seen:
+ return "exit", detail
+ if faults:
+ fault = faults[0]
+ detail["fault"] = _compact_fault(fault)
+ return ("trap" if fault.get("cause") == 3 else "fault"), detail
+ if trap_loop:
+ return "trap", detail
+ if mutation.cmd != 0 and delta <= 0:
+ return "no-progress", detail
+ expectation = expected_for(mutation, before, ctrl)
+ detail["expected"] = expectation
+ verdict = expected_ok(expectation, mutation, before, after)
+ detail["expected_ok"] = verdict
+ if verdict is False:
+ return "unexpected-result", detail
+ return "ok", detail
+
+
+def _settle(session, fs, options: HarnessOptions) -> bool:
+ for _ in range(SETTLE_RETRIES):
+ session.run(duration=options.settle, max_stops=1)
+ if SETTLE_MARKER in fs.target.read_uart():
+ return True
+ return False
+
+
+def run_iteration(mutation: Mutation, options: HarnessOptions, image) -> dict:
+ session_rel = os.path.join("sessions", f"iter-{mutation.iteration:04d}")
+ finding = {
+ "iteration": mutation.iteration,
+ "strategy": mutation.strategy,
+ "mutation": mutation.to_json(),
+ "outcome": "error",
+ "detail": {},
+ "session": session_rel,
+ }
+ workdir = tempfile.mkdtemp(prefix="greeneye-mut-")
+ try:
+ with open_flat_session(
+ image,
+ options.kernel,
+ record=os.path.join(options.out, session_rel),
+ name=f"mut-{mutation.iteration:04d}",
+ symbols=None,
+ log_faults=True,
+ workdir=workdir,
+ ) as fs:
+ session = fs.session
+ ctrl_symbol = session.symbols.get("gsp_ctrl")
+ if ctrl_symbol is None:
+ raise GreenEyeError("gsp_ctrl symbol not found: the mutation harness targets the demo control block")
+ ctrl = ctrl_symbol.addr
+ settle_ok = _settle(session, fs, options)
+ before = read_ctrl_state(session, ctrl)
+ faults: list[dict] = []
+ trap_loop = False
+ exit_seen = False
+ after = before
+ if settle_ok:
+ log_offset = os.path.getsize(fs.target.qemu_log) if os.path.exists(fs.target.qemu_log) else 0
+ apply_mutation(session, ctrl, mutation)
+ phase_a = min(options.phase_a, options.timeout)
+ session.run(duration=phase_a, max_stops=1)
+ faults, _capped = read_faults_window(fs.target.qemu_log, log_offset, options.max_log_bytes)
+ trap_loop = session.pc < 0x100
+ after = read_ctrl_state(session, ctrl)
+ # Slow/hung handlers get one confirmation window before we call
+ # it no-progress; faults are still picked up if they appear.
+ if not faults and not trap_loop and mutation.cmd != 0 and after["rpc_count"] == before["rpc_count"]:
+ session.run(duration=max(0.01, options.timeout - phase_a), max_stops=1)
+ more, _capped = read_faults_window(fs.target.qemu_log, log_offset, options.max_log_bytes)
+ faults = more or faults
+ trap_loop = session.pc < 0x100
+ after = read_ctrl_state(session, ctrl)
+ exit_seen = not fs.target.alive()
+ outcome, detail = classify(mutation, before, after, faults, trap_loop, exit_seen, settle_ok, ctrl)
+ finding["outcome"] = outcome
+ finding["detail"] = detail
+ except Exception as exc: # one bad iteration must not end the run
+ finding["outcome"] = "error"
+ finding["detail"] = {"reason": f"{type(exc).__name__}: {exc}"}
+ finally:
+ shutil.rmtree(workdir, ignore_errors=True)
+ return finding
+
+
+# ---------------------------------------------------------------------------
+# run + report
+# ---------------------------------------------------------------------------
+
+
+def run_harness(options: HarnessOptions) -> list[dict]:
+ if not qemu_available():
+ raise GreenEyeError("qemu-system-riscv64 not found; install qemu-system-misc or set GREENEYE_QEMU")
+ if not os.path.isfile(options.kernel):
+ raise GreenEyeError(f"kernel not found: {options.kernel}")
+ image = load_target(options.kernel)
+ options.kernel_sha256 = sha256_file(options.kernel)
+ demo_sha = sha256_file(DEMO_ELF) if os.path.isfile(DEMO_ELF) else None
+ options.is_demo = options.kernel_sha256 == demo_sha or os.path.abspath(options.kernel) == os.path.abspath(DEMO_ELF)
+ if not options.is_demo and not options.allow_foreign_elf:
+ raise GreenEyeError(
+ "mutation is restricted to the bundled demo firmware; "
+ "pass --allow-foreign-elf to mutate another re-hosted ELF under QEMU"
+ )
+ if options.memory_region:
+ addr, size = options.memory_region
+ limit = LOAD_ADDR + memory_mb_for(image) * 1024 * 1024
+ if size <= 0 or addr < LOAD_ADDR or addr + size > limit:
+ raise GreenEyeError(
+ f"--memory-region {addr:#x}:{size:#x} is outside QEMU RAM "
+ f"[{LOAD_ADDR:#x}, {limit:#x})"
+ )
+ ensure_dir(options.out)
+ findings: list[dict] = []
+ mutations = generate_mutations(options.iterations, options.seed, options.memory_region)
+ findings_path = os.path.join(options.out, "findings.jsonl")
+ with open(findings_path, "w", encoding="utf-8") as fh:
+ for mutation in mutations:
+ finding = run_iteration(mutation, options, image)
+ findings.append(finding)
+ fh.write(json.dumps(finding, sort_keys=True) + "\n")
+ fh.flush()
+ write_summary(options, findings)
+ return findings
+
+
+def write_summary(options: HarnessOptions, findings: list[dict]) -> str:
+ classes = Counter(finding["outcome"] for finding in findings)
+ strategies = Counter(finding["strategy"] for finding in findings)
+ lines = [
+ "# green-eye mutation findings",
+ "",
+ "> TOOL IN DEVELOPMENT - NOT READY YET! Generated by `harness/mutation_harness.py`.",
+ "",
+ f"* format: `{FORMAT}`",
+ f"* kernel: `{options.kernel}`",
+ f"* kernel sha256: `{options.kernel_sha256}`",
+ f"* demo restriction: {'demo' if options.is_demo else 'foreign ELF (explicitly allowed)'}",
+ f"* seed: {options.seed}, iterations: {options.iterations}",
+ f"* timeout: {options.timeout}s, settle: {options.settle}s",
+ f"* memory region: {_fmt_region(options.memory_region)}",
+ "",
+ "## Outcome classes",
+ "",
+ ]
+ lines += _table(
+ ["outcome", "count", "first iteration"],
+ [
+ [outcome, classes[outcome], next(f["iteration"] for f in findings if f["outcome"] == outcome)]
+ for outcome in sorted(classes)
+ ],
+ )
+ lines += ["", "## Strategy coverage", ""]
+ lines += _table(
+ ["strategy", "iterations", "outcomes"],
+ [
+ [
+ strategy,
+ strategies[strategy],
+ ", ".join(
+ f"{outcome}={count}"
+ for outcome, count in sorted(Counter(f["outcome"] for f in findings if f["strategy"] == strategy).items())
+ ),
+ ]
+ for strategy in sorted(strategies)
+ ],
+ )
+ notable = [finding for finding in findings if finding["outcome"] != "ok"][:20]
+ lines += ["", "## Notable findings (first 20 non-ok)", ""]
+ if not notable:
+ lines += ["_all iterations completed normally_", ""]
+ for finding in notable:
+ lines += [
+ f"* iteration {finding['iteration']} `{finding['strategy']}` "
+ f"cmd={finding['mutation']['cmd']:#x} -> **{finding['outcome']}** "
+ f"{_short_json(finding['detail'])}",
+ ]
+ lines += ["", "## Reproduce", "", "```bash", _reproduce_command(options), "```", ""]
+ path = os.path.join(options.out, "summary.md")
+ with open(path, "w", encoding="utf-8") as fh:
+ fh.write("\n".join(lines))
+ return path
+
+
+def _reproduce_command(options: HarnessOptions) -> str:
+ region = f" --memory-region {_fmt_region(options.memory_region)}" if options.memory_region else ""
+ foreign = " --allow-foreign-elf" if not options.is_demo else ""
+ return (
+ f"python3 harness/mutation_harness.py --kernel {options.kernel} --out {options.out} "
+ f"--iterations {options.iterations} --seed {options.seed} --timeout {options.timeout} "
+ f"--settle {options.settle}{region}{foreign}"
+ )
+
+
+def _fmt_region(region: tuple[int, int] | None) -> str:
+ return "-" if not region else f"{region[0]:#x}:{region[1]:#x}"
+
+
+def _short_json(value, limit: int = 120) -> str:
+ text = json.dumps(value, sort_keys=True)
+ return f"`{text}`" if len(text) <= limit else f"`{text[:limit - 1]}...`"
+
+
+def _table(headers: list[str], rows: list[list]) -> list[str]:
+ lines = ["| " + " | ".join(headers) + " |", "|" + "|".join("---" for _ in headers) + "|"]
+ for row in rows:
+ lines.append("| " + " | ".join(str(cell) for cell in row) + " |")
+ return lines
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def parse_region(text: str) -> tuple[int, int]:
+ try:
+ addr_text, size_text = text.split(":", 1)
+ addr = int(addr_text, 0)
+ size = int(size_text, 0)
+ except ValueError as exc:
+ raise argparse.ArgumentTypeError(f"expected ADDR:SIZE, got {text!r}") from exc
+ return addr, size
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="mutation_harness", description=__doc__.splitlines()[0])
+ parser.add_argument("--kernel", default=DEMO_ELF, help="demo/re-hosted ELF to mutate (default: bundled demo)")
+ parser.add_argument("--out", required=True, help="output directory for findings and recorded sessions")
+ parser.add_argument("--iterations", type=int, default=50, help="mutation count (1..10000)")
+ parser.add_argument("--seed", type=int, default=1, help="deterministic RNG seed")
+ parser.add_argument("--timeout", type=float, default=0.25, help="total observation budget per mutation (seconds)")
+ parser.add_argument("--settle", type=float, default=0.2, help="settle budget per attempt before mutating (seconds)")
+ parser.add_argument("--phase-a", type=float, default=0.05, help="first observation window before confirmation")
+ parser.add_argument("--memory-region", type=parse_region, help="optional RAM region to mutate, ADDR:SIZE")
+ parser.add_argument("--allow-foreign-elf", action="store_true", help="permit a non-demo ELF (QEMU only)")
+ parser.add_argument("--max-log-bytes", type=int, default=MAX_LOG_BYTES, help="QEMU log window per fault check")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ if not 1 <= args.iterations <= 10000:
+ parser.error("--iterations must be in 1..10000")
+ if not 0.01 <= args.timeout <= 10.0:
+ parser.error("--timeout must be in 0.01..10.0")
+ if not 0.0 <= args.settle <= 10.0:
+ parser.error("--settle must be in 0..10.0")
+ options = HarnessOptions(
+ kernel=os.path.abspath(args.kernel),
+ out=os.path.abspath(args.out),
+ iterations=args.iterations,
+ seed=args.seed,
+ timeout=args.timeout,
+ settle=args.settle,
+ phase_a=args.phase_a,
+ memory_region=args.memory_region,
+ allow_foreign_elf=args.allow_foreign_elf,
+ max_log_bytes=args.max_log_bytes,
+ )
+ try:
+ findings = run_harness(options)
+ except GreenEyeError as exc:
+ print(f"mutation harness error: {exc}", file=sys.stderr)
+ return 2
+ classes = Counter(finding["outcome"] for finding in findings)
+ print(f"[mutation] {len(findings)} iteration(s), out={options.out}")
+ for outcome in sorted(classes):
+ print(f"[mutation] {outcome}: {classes[outcome]}")
+ print(f"[mutation] wrote {os.path.join(options.out, 'findings.jsonl')}")
+ print(f"[mutation] wrote {os.path.join(options.out, 'summary.md')}")
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/portability_matrix.py b/debug/risc-v-gsp-rm-vm/harness/portability_matrix.py
new file mode 100644
index 0000000..82af458
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/portability_matrix.py
@@ -0,0 +1,779 @@
+#!/usr/bin/env python3
+"""Multi-version portability orchestration (roadmap R6, H4.1).
+
+Given one or more firmware ELF targets (a demo or re-hosted build, a raw
+``.fwimage``, a future version), run the same deterministic scenario against
+each on the QEMU backend:
+
+1. bounded probe: relocate into virt RAM and run/step a fixed budget,
+ capturing the first exception via ``parse_qemu_faults``;
+2. symbol DB build: ELF symbols plus optional Ghidra exports / an existing
+ green-eye JSON DB, saved per target;
+3. scripted demo run with handler hooks and a control-block watchpoint,
+ recorded as a normal green-eye session;
+4. cross-target comparison with ``greeneye.analyze.compare_sessions`` plus
+ explicit hit/watch/write profile deltas and the docs/06 M1-M6 skeleton
+ (metrics without a reference capture are marked "not measured").
+
+Outputs ``portability-report.json`` and ``portability-report.md`` under
+``--out DIR`` with stable ordering. Unsupported targets are skipped with a
+reason instead of aborting the matrix.
+
+Run from the tool root:
+
+ python3 harness/portability_matrix.py --target firmware/uproc_demo/build/uproc_demo.elf --out dumps/matrix
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import os
+import struct
+from collections import Counter
+from dataclasses import dataclass
+from pathlib import Path
+
+try:
+ from .common import (
+ LOAD_ADDR,
+ MAX_LOG_BYTES,
+ SymbolDB,
+ counter_delta,
+ ensure_dir,
+ load_target,
+ open_flat_session,
+ qemu_available,
+ read_log_window,
+ sha256_file,
+ write_json,
+ write_text,
+ )
+except ImportError: # direct script execution (python3 harness/portability_matrix.py)
+ from common import ( # type: ignore[no-redef]
+ LOAD_ADDR,
+ MAX_LOG_BYTES,
+ SymbolDB,
+ counter_delta,
+ ensure_dir,
+ load_target,
+ open_flat_session,
+ qemu_available,
+ read_log_window,
+ sha256_file,
+ write_json,
+ write_text,
+ )
+
+from greeneye.analyze import compare_sessions, parse_qemu_faults # noqa: E402
+from greeneye.record import load_events # noqa: E402
+
+FORMAT = "greeneye-portability-matrix/1"
+RPC_HANDLERS = ("rpc_ping", "rpc_echo", "rpc_fill", "rpc_sum", "rpc_state", "rpc_fault")
+CTRL_SIZE = 400
+CTRL_RPC_COUNT = 16
+CTRL_STATE = 8
+CTRL_HANDLER_COUNT = 328
+
+METRIC_DEFS = (
+ ("M1", "Inventory coverage"),
+ ("M2", "Reply agreement"),
+ ("M3", "Ordering agreement"),
+ ("M4", "State agreement"),
+ ("M5", "Error-path agreement"),
+ ("M6", "Memory side-effects"),
+)
+NO_REFERENCE = "agreement is not measured: needs a reference capture (docs/06 5)"
+
+
+@dataclass
+class TargetSpec:
+ path: str
+ name: str
+
+
+@dataclass
+class MatrixOptions:
+ out: str
+ probe_instructions: int = 2000
+ probe_duration: float = 0.5
+ scenario_duration: float = 0.4
+ max_stops: int = 5000
+ max_rows: int = 40
+ symbols_json: str | None = None
+ ghidra_names: str | None = None
+ ghidra_index: str | None = None
+ ghidra_exports: str | None = None
+ ghidra_callgraph: str | None = None
+ ghidra_bias: int = 0
+ scenario_script: str | None = None
+ log_bytes: int = MAX_LOG_BYTES
+
+
+def _skipped_probe(reason: str | None) -> dict:
+ return {
+ "status": "skipped",
+ "reason": reason,
+ "session": None,
+ "instructions": None,
+ "stopped_pc": None,
+ "stopped_symbol": None,
+ "fault_count": 0,
+ "faults_capped": False,
+ "first_fault": None,
+ }
+
+
+def _skipped_scenario(reason: str | None) -> dict:
+ return {
+ "status": "skipped",
+ "reason": reason,
+ "session": None,
+ "hooks": [],
+ "uart_banner": None,
+ "ctrl": None,
+ "snapshot": None,
+ "event_counts": {},
+ "bp": {},
+ "watch": {},
+ "writes": {},
+ "fault_count": 0,
+ "faults_capped": False,
+ "first_fault": None,
+ }
+
+
+# ---------------------------------------------------------------------------
+# per-target steps
+# ---------------------------------------------------------------------------
+
+
+def build_symbols(spec: TargetSpec, bias: int, out_dir: str, options: MatrixOptions):
+ """ELF symbols + optional Ghidra exports / JSON DB, saved per target."""
+ db = SymbolDB()
+ db.load_elf(spec.path, bias=bias, source="elf")
+ sources = ["elf"]
+ if options.symbols_json:
+ if not os.path.isfile(options.symbols_json):
+ raise FileNotFoundError(f"symbol DB not found: {options.symbols_json}")
+ db.load_json(options.symbols_json)
+ sources.append(os.path.basename(options.symbols_json))
+ ghidra_inputs = any(
+ (
+ options.ghidra_names,
+ options.ghidra_index,
+ options.ghidra_exports,
+ options.ghidra_callgraph,
+ )
+ )
+ if ghidra_inputs:
+ from greeneye.ghidra import build_symbol_db
+
+ gdb = build_symbol_db(
+ names_tsv=options.ghidra_names,
+ index_tsv=options.ghidra_index,
+ exports_tsv=options.ghidra_exports,
+ callgraph_tsv=options.ghidra_callgraph,
+ bias=options.ghidra_bias,
+ )
+ for symbol in gdb:
+ db.add(symbol)
+ sources.append("ghidra")
+ rel = os.path.join("symbols", f"{spec.name}.json")
+ out_path = os.path.join(out_dir, rel)
+ ensure_dir(os.path.dirname(out_path))
+ db.save_json(
+ out_path,
+ meta={"bias": bias, "sources": sources, "firmware": os.path.basename(spec.path)},
+ )
+ return db, sources, rel
+
+
+def run_probe(spec: TargetSpec, image, db: SymbolDB, out_dir: str, options: MatrixOptions, qemu_ok: bool) -> dict:
+ if not qemu_ok:
+ return _skipped_probe("qemu-system-riscv64 not found")
+ result = _skipped_probe(None)
+ result["instructions"] = options.probe_instructions
+ session_rel = os.path.join("sessions", f"{spec.name}-probe")
+ try:
+ with open_flat_session(
+ image,
+ spec.path,
+ record=os.path.join(out_dir, session_rel),
+ name=f"{spec.name}-probe",
+ symbols=db,
+ log_faults=True,
+ ) as fs:
+ session = fs.session
+ if options.probe_instructions > 0:
+ session.step(options.probe_instructions)
+ else:
+ session.run(duration=options.probe_duration)
+ result["stopped_pc"] = session.pc
+ symbol = session.symbols.resolve(session.pc)
+ result["stopped_symbol"] = symbol.name if symbol else None
+ text, capped = read_log_window(fs.target.qemu_log, 0, options.log_bytes)
+ faults = parse_qemu_faults(text)
+ result["fault_count"] = len(faults)
+ result["faults_capped"] = capped
+ result["first_fault"] = faults[0] if faults else None
+ result["session"] = session_rel
+ if text:
+ fs.session.recorder.meta["qemu_log_blob"] = fs.session.recorder.blob("qemu.log", text.encode("utf-8"))
+ fs.session.recorder.meta["faults_capped"] = capped
+ result["status"] = "ok"
+ except Exception as exc: # a failed step must not abort the matrix
+ result["status"] = "error"
+ result["reason"] = f"{type(exc).__name__}: {exc}"
+ return result
+
+
+def install_scenario(session) -> list[str]:
+ hooks: list[str] = []
+ for name in RPC_HANDLERS:
+ symbol = session.symbols.get(name)
+ if symbol is None:
+ continue
+ session.hook(symbol.addr, on_enter=lambda hit: None, listener=f"matrix:{name}")
+ hooks.append(name)
+ return hooks
+
+
+def read_ctrl(session, symbol) -> dict | None:
+ if symbol is None:
+ return None
+ raw = session.memory.read(symbol.addr, CTRL_SIZE)
+ u32 = lambda offset: struct.unpack_from("<I", raw, offset)[0] # noqa: E731
+ u64 = lambda offset: struct.unpack_from("<Q", raw, offset)[0] # noqa: E731
+ return {
+ "addr": f"{symbol.addr:#x}",
+ "magic": u32(0),
+ "version": u32(4),
+ "state": u32(CTRL_STATE),
+ "rpc_count": u64(CTRL_RPC_COUNT),
+ "last_rpc": u64(24),
+ "result": u64(64),
+ "handler_count": u64(CTRL_HANDLER_COUNT),
+ }
+
+
+def profile_from_events(events: list[dict]) -> dict:
+ counts: Counter = Counter()
+ bp: Counter = Counter()
+ watch: Counter = Counter()
+ writes: Counter = Counter()
+ for event in events:
+ kind = event.get("type", "?")
+ counts[kind] += 1
+ if kind == "bp_hit":
+ bp[event.get("symbol") or f"{event.get('pc', 0):#x}"] += 1
+ elif kind == "watch_hit":
+ watch[f"{event.get('addr', 0):#x}:{event.get('kind') or 'w'}"] += 1
+ elif kind == "mem_write":
+ writes[f"{(event.get('addr', 0) >> 12) << 12:#x}"] += 1
+ return {
+ "event_counts": dict(sorted(counts.items())),
+ "bp": {key: bp[key] for key in sorted(bp)},
+ "watch": {key: watch[key] for key in sorted(watch)},
+ "writes": {key: writes[key] for key in sorted(writes)},
+ }
+
+
+def run_scenario(spec: TargetSpec, image, db: SymbolDB, out_dir: str, options: MatrixOptions, qemu_ok: bool) -> dict:
+ if not qemu_ok:
+ return _skipped_scenario("qemu-system-riscv64 not found")
+ result = _skipped_scenario(None)
+ session_rel = os.path.join("sessions", f"{spec.name}-scenario")
+ try:
+ with open_flat_session(
+ image,
+ spec.path,
+ record=os.path.join(out_dir, session_rel),
+ name=f"{spec.name}-scenario",
+ symbols=db,
+ log_faults=True,
+ ) as fs:
+ session = fs.session
+ result["hooks"] = install_scenario(session)
+ ctrl_symbol = session.symbols.get("gsp_ctrl")
+ ctrl_addr = ctrl_symbol.addr if ctrl_symbol else None
+ if ctrl_addr is not None:
+ session.watch(ctrl_addr + CTRL_RPC_COUNT, size=8, kind="w", listener="matrix:rpc_count")
+ if options.scenario_script:
+ import greeneye as ge
+
+ script_path = Path(options.scenario_script)
+ namespace = {
+ "ge": ge,
+ "greeneye": ge,
+ "session": session,
+ "s": session,
+ "memory": session.memory,
+ "symbols": session.symbols,
+ "recorder": session.recorder,
+ }
+ exec(compile(script_path.read_text(encoding="utf-8"), str(script_path), "exec"), namespace)
+ else:
+ session.run(duration=options.scenario_duration, max_stops=options.max_stops)
+ if ctrl_addr is not None:
+ session.memory.write_u64(ctrl_addr + 32, 1) # PING
+ session.memory.write_u64(ctrl_addr + 40, 0x1234)
+ session.run(duration=options.scenario_duration, max_stops=options.max_stops)
+ result["ctrl"] = read_ctrl(session, ctrl_symbol)
+ if ctrl_addr is not None:
+ data = session.snapshot(ctrl_addr, CTRL_SIZE, name="ctrl")
+ result["snapshot"] = {"label": "ctrl", "size": len(data), "sha256": hashlib.sha256(data).hexdigest()}
+ uart = fs.target.read_uart()
+ result["uart_banner"] = uart.strip().splitlines()[0] if uart.strip() else None
+ text, capped = read_log_window(fs.target.qemu_log, 0, options.log_bytes)
+ faults = parse_qemu_faults(text)
+ result["fault_count"] = len(faults)
+ result["faults_capped"] = capped
+ result["first_fault"] = faults[0] if faults else None
+ result.update(profile_from_events(load_events(os.path.join(out_dir, session_rel))))
+ result["session"] = session_rel
+ result["status"] = "ok"
+ except Exception as exc: # a failed step must not abort the matrix
+ result["status"] = "error"
+ result["reason"] = f"{type(exc).__name__}: {exc}"
+ return result
+
+
+# ---------------------------------------------------------------------------
+# metrics + comparisons
+# ---------------------------------------------------------------------------
+
+
+def metric_rows(probe: dict, scenario: dict) -> list[dict]:
+ """docs/06 M1-M6 skeleton; observed data is local, agreement stays unmeasured."""
+ bp = scenario.get("bp") or {}
+ ctrl = scenario.get("ctrl") or {}
+ snapshot = scenario.get("snapshot") or {}
+ probe_fault = (probe.get("first_fault") or {}).get("desc")
+ scenario_fault = (scenario.get("first_fault") or {}).get("desc")
+ rows = [
+ {
+ "id": "M1",
+ "name": "Inventory coverage",
+ "status": "not measured",
+ "reason": NO_REFERENCE,
+ "observed": {
+ "rpc_handlers_hit": sorted(k for k in bp if k in RPC_HANDLERS),
+ "other_hit_sites": sorted(k for k in bp if k not in RPC_HANDLERS),
+ "declared_handlers": ctrl.get("handler_count"),
+ },
+ },
+ {
+ "id": "M2",
+ "name": "Reply agreement",
+ "status": "not measured",
+ "reason": NO_REFERENCE,
+ "observed": {"note": "reply fields are not decoded by the builtin scenario", "result": ctrl.get("result")},
+ },
+ {
+ "id": "M3",
+ "name": "Ordering agreement",
+ "status": "not measured",
+ "reason": NO_REFERENCE,
+ "observed": {"events": sum((scenario.get("event_counts") or {}).values())},
+ },
+ {
+ "id": "M4",
+ "name": "State agreement",
+ "status": "not measured",
+ "reason": NO_REFERENCE,
+ "observed": {"final_state": ctrl.get("state"), "rpc_state_hits": bp.get("rpc_state", 0)},
+ },
+ {
+ "id": "M5",
+ "name": "Error-path agreement",
+ "status": "not measured",
+ "reason": NO_REFERENCE,
+ "observed": {
+ "probe_first_fault": probe_fault,
+ "scenario_first_fault": scenario_fault,
+ "scenario_fault_count": scenario.get("fault_count", 0),
+ },
+ },
+ {
+ "id": "M6",
+ "name": "Memory side-effects",
+ "status": "not measured",
+ "reason": NO_REFERENCE,
+ "observed": {
+ "written_pages": len(scenario.get("writes") or {}),
+ "ctrl_snapshot_sha256": snapshot.get("sha256"),
+ },
+ },
+ ]
+ return rows
+
+
+def compare_targets(a: dict, b: dict, out_dir: str, options: MatrixOptions) -> dict:
+ entry = {
+ "a": a["name"],
+ "b": b["name"],
+ "status": "ok",
+ "reason": None,
+ "compare_report": None,
+ "deltas": {"bp": [], "watch": [], "writes": []},
+ "all_zero": True,
+ "metrics": [],
+ }
+ a_scenario = a.get("scenario") or {}
+ b_scenario = b.get("scenario") or {}
+ if a_scenario.get("status") != "ok" or b_scenario.get("status") != "ok":
+ entry["status"] = "skipped"
+ entry["reason"] = "a scenario session is not available on both targets"
+ return entry
+ a_dir = os.path.join(out_dir, a_scenario.get("session") or "")
+ b_dir = os.path.join(out_dir, b_scenario.get("session") or "")
+ try:
+ report = compare_sessions(a_dir, b_dir, max_rows=options.max_rows)
+ rel = os.path.join("compare", f"{a['name']}--{b['name']}.md")
+ write_text(os.path.join(out_dir, rel), report)
+ entry["compare_report"] = rel
+ except Exception as exc:
+ entry["reason"] = f"{type(exc).__name__}: {exc}"
+ deltas = {"bp": [], "watch": [], "writes": []}
+ for name in ("bp", "watch", "writes"):
+ deltas[name] = counter_delta(a_scenario.get(name) or {}, b_scenario.get(name) or {})
+ entry["deltas"] = deltas
+ entry["all_zero"] = all(row["delta"] == 0 for rows in deltas.values() for row in rows)
+ a_metrics = {row["id"]: row for row in a.get("metrics") or []}
+ b_metrics = {row["id"]: row for row in b.get("metrics") or []}
+ entry["metrics"] = [
+ {
+ "id": metric_id,
+ "name": name,
+ "status": "not measured",
+ "reason": NO_REFERENCE,
+ "a_observed": (a_metrics.get(metric_id) or {}).get("observed"),
+ "b_observed": (b_metrics.get(metric_id) or {}).get("observed"),
+ }
+ for metric_id, name in METRIC_DEFS
+ ]
+ return entry
+
+
+# ---------------------------------------------------------------------------
+# report assembly + rendering
+# ---------------------------------------------------------------------------
+
+
+def analyze_target(spec: TargetSpec, options: MatrixOptions, qemu_ok: bool) -> dict:
+ target = {
+ "name": spec.name,
+ "path": spec.path,
+ "sha256": "",
+ "kind": "elf",
+ "entry": None,
+ "status": "skipped",
+ "skip_reason": None,
+ "probe": _skipped_probe(None),
+ "symbols": {"count": 0, "sources": [], "path": None},
+ "scenario": _skipped_scenario(None),
+ "metrics": metric_rows({}, {}),
+ }
+ if not os.path.isfile(spec.path):
+ target["skip_reason"] = "file not found"
+ return target
+ target["sha256"] = sha256_file(spec.path)
+ try:
+ image = load_target(spec.path)
+ except Exception as exc:
+ target["kind"] = "unknown"
+ target["skip_reason"] = f"{type(exc).__name__}: {exc}"
+ return target
+ target["entry"] = f"{image.entry:#x}"
+ bias = LOAD_ADDR - image.min_vaddr
+ db = SymbolDB()
+ try:
+ db, sources, sym_rel = build_symbols(spec, bias, options.out, options)
+ target["symbols"] = {"count": len(db), "sources": sources, "path": sym_rel}
+ except Exception as exc:
+ target["symbols"] = {"count": 0, "sources": [], "path": None, "reason": f"{type(exc).__name__}: {exc}"}
+ db.load_elf(spec.path, bias=bias, source="elf")
+ target["symbols"]["count"] = len(db)
+ target["symbols"]["sources"] = ["elf"]
+ target["probe"] = run_probe(spec, image, db, options.out, options, qemu_ok)
+ target["scenario"] = run_scenario(spec, image, db, options.out, options, qemu_ok)
+ target["metrics"] = metric_rows(target["probe"], target["scenario"])
+ statuses = {target["probe"]["status"], target["scenario"]["status"]}
+ if statuses == {"ok"}:
+ target["status"] = "ok"
+ elif "ok" in statuses:
+ target["status"] = "partial"
+ elif statuses == {"skipped"}:
+ target["status"] = "skipped"
+ target["skip_reason"] = target["probe"].get("reason") or target["scenario"].get("reason")
+ else:
+ target["status"] = "error"
+ target["skip_reason"] = target["probe"].get("reason") or target["scenario"].get("reason")
+ return target
+
+
+def run_matrix(specs: list[TargetSpec], options: MatrixOptions) -> dict:
+ ensure_dir(options.out)
+ qemu_ok = qemu_available()
+ targets = [analyze_target(spec, options, qemu_ok) for spec in specs]
+ comparisons: list[dict] = []
+ for i in range(len(targets)):
+ for j in range(i + 1, len(targets)):
+ comparisons.append(compare_targets(targets[i], targets[j], options.out, options))
+ report = {
+ "format": FORMAT,
+ "tool": "harness/portability_matrix.py",
+ "backend": "qemu",
+ "qemu_available": qemu_ok,
+ "options": {
+ "probe_instructions": options.probe_instructions,
+ "probe_duration": options.probe_duration,
+ "scenario_duration": options.scenario_duration,
+ "scenario_script": options.scenario_script,
+ "max_rows": options.max_rows,
+ "symbols_json": options.symbols_json,
+ "ghidra_bias": options.ghidra_bias,
+ },
+ "targets": targets,
+ "comparisons": comparisons,
+ "skipped": [
+ {"name": target["name"], "reason": target["skip_reason"]}
+ for target in targets
+ if target["status"] == "skipped"
+ ],
+ }
+ return report
+
+
+def _table(headers: list[str], rows: list[list]) -> list[str]:
+ lines = ["| " + " | ".join(headers) + " |", "|" + "|".join("---" for _ in headers) + "|"]
+ for row in rows:
+ lines.append("| " + " | ".join(str(cell) for cell in row) + " |")
+ return lines
+
+
+def _profile_tables(title: str, profile: dict, max_rows: int) -> list[str]:
+ lines = [f"**{title}**", ""]
+ if not profile:
+ return lines + ["_none observed_", ""]
+ rows = [[f"`{key}`", count] for key, count in sorted(profile.items(), key=lambda kv: (-int(kv[1]), str(kv[0])))[:max_rows]]
+ lines += _table(["key", "hits"], rows) + [""]
+ return lines
+
+
+def render_markdown(report: dict, options: MatrixOptions) -> str:
+ lines = [
+ "# green-eye portability delta report",
+ "",
+ "> TOOL IN DEVELOPMENT - NOT READY YET! Generated by `harness/portability_matrix.py`.",
+ "",
+ f"* format: `{report['format']}` (green-eye {_greeneye_version()})",
+ f"* backend: `{report['backend']}` (qemu available: {report['qemu_available']})",
+ f"* targets: {len(report['targets'])}",
+ "",
+ "## Targets",
+ "",
+ ]
+ lines += _table(
+ ["target", "status", "sha256", "probe", "symbols", "scenario events"],
+ [
+ [
+ f"`{target['name']}`",
+ target["status"],
+ f"`{(target['sha256'] or '')[:16]}`",
+ f"{target['probe']['status']} ({target['probe']['fault_count']} faults)",
+ target["symbols"]["count"],
+ sum((target["scenario"].get("event_counts") or {}).values()),
+ ]
+ for target in report["targets"]
+ ],
+ )
+ lines += [""]
+
+ for target in report["targets"]:
+ lines += [f"### {target['name']}", ""]
+ lines += [f"* path: `{target['path']}`", f"* entry: `{target['entry']}`", f"* sha256: `{target['sha256']}`"]
+ if target["skip_reason"]:
+ lines += [f"* note: {target['skip_reason']}"]
+ lines += [""]
+
+ probe = target["probe"]
+ lines += ["#### Probe", ""]
+ fault = probe.get("first_fault")
+ stopped_pc = probe.get("stopped_pc")
+ probe_rows = [
+ ["status", probe["status"]],
+ ["instructions", probe.get("instructions")],
+ ["stopped pc", f"`{stopped_pc:#x}`" if stopped_pc is not None else None],
+ ["stopped symbol", probe.get("stopped_symbol")],
+ ["fault count", probe.get("fault_count")],
+ ["faults capped", probe.get("faults_capped")],
+ ]
+ if fault:
+ probe_rows.append(
+ ["first fault", f"cause {fault.get('cause')} {fault.get('desc')} @ epc `{fault.get('epc', 0):#x}`"]
+ )
+ if probe.get("reason"):
+ probe_rows.append(["reason", probe["reason"]])
+ lines += _table(["field", "value"], probe_rows) + [""]
+
+ lines += ["#### Symbols", ""]
+ lines += _table(
+ ["count", "sources", "path"],
+ [[target["symbols"]["count"], ", ".join(target["symbols"]["sources"]), target["symbols"]["path"]]],
+ ) + [""]
+
+ scenario = target["scenario"]
+ lines += ["#### Scenario", ""]
+ lines += _table(
+ ["status", "session", "hooks", "uart banner", "faults"],
+ [[
+ scenario["status"],
+ f"`{scenario.get('session')}`",
+ ", ".join(scenario.get("hooks") or []),
+ scenario.get("uart_banner"),
+ scenario.get("fault_count"),
+ ]],
+ ) + [""]
+ lines += _profile_tables("Breakpoint hits", scenario.get("bp") or {}, options.max_rows)
+ lines += _profile_tables("Watchpoint hits", scenario.get("watch") or {}, options.max_rows)
+ lines += _profile_tables("Written pages", scenario.get("writes") or {}, options.max_rows)
+
+ lines += ["#### M1-M6", ""]
+ lines += [f"> {NO_REFERENCE}", ""]
+ lines += _table(
+ ["#", "metric", "status", "observed"],
+ [
+ [row["id"], row["name"], row["status"], _short_json(row["observed"])]
+ for row in target["metrics"]
+ ],
+ ) + [""]
+
+ lines += ["## Cross-target comparisons", ""]
+ if not report["comparisons"]:
+ lines += ["_single target: no comparison_", ""]
+ for comp in report["comparisons"]:
+ lines += [f"### {comp['a']} vs {comp['b']}", ""]
+ lines += [f"* status: {comp['status']}", f"* zero deltas: {'yes' if comp['all_zero'] else 'no'}"]
+ if comp.get("compare_report"):
+ lines += [f"* compare report: `{comp['compare_report']}`"]
+ if comp.get("reason"):
+ lines += [f"* note: {comp['reason']}"]
+ lines += [""]
+ for name in ("bp", "watch", "writes"):
+ rows = [row for row in comp["deltas"][name] if row["delta"] != 0]
+ rows.sort(key=lambda row: (-abs(row["delta"]), row["key"]))
+ lines += [f"**{name} delta (nonzero, first {options.max_rows})**", ""]
+ if rows:
+ lines += _table(
+ ["key", comp["a"], comp["b"], "delta"],
+ [[f"`{row['key']}`", row["a"], row["b"], row["delta"]] for row in rows[: options.max_rows]],
+ ) + [""]
+ else:
+ lines += ["_no deltas_", ""]
+ lines += ["**M1-M6 (not measured: reference capture required)**", ""]
+ lines += _table(
+ ["#", "metric", "A observed", "B observed"],
+ [
+ [row["id"], row["name"], _short_json(row["a_observed"]), _short_json(row["b_observed"])]
+ for row in comp["metrics"]
+ ],
+ ) + [""]
+
+ if report["skipped"]:
+ lines += ["## Skipped targets", ""]
+ lines += _table(["target", "reason"], [[f"`{item['name']}`", item["reason"]] for item in report["skipped"]])
+ lines += [""]
+ return "\n".join(lines)
+
+
+def _short_json(value, limit: int = 72) -> str:
+ import json
+
+ if value is None:
+ return "-"
+ text = json.dumps(value, sort_keys=True)
+ return f"`{text}`" if len(text) <= limit else f"`{text[:limit - 1]}...`"
+
+
+def _greeneye_version() -> str:
+ try:
+ import greeneye
+
+ return greeneye.__version__
+ except Exception: # pragma: no cover
+ return "unknown"
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="portability_matrix", description=__doc__.splitlines()[0])
+ parser.add_argument("targets", nargs="*", help="firmware ELF paths")
+ parser.add_argument("--target", action="append", default=[], help="firmware ELF path (repeatable)")
+ parser.add_argument("--out", required=True, help="output directory for the report, sessions and symbols")
+ parser.add_argument("--symbols", dest="symbols_json", help="existing greeneye symbol DB JSON")
+ parser.add_argument("--ghidra-names", help="Ghidra names.tsv export")
+ parser.add_argument("--ghidra-index", help="Ghidra decompiled_index.tsv export")
+ parser.add_argument("--ghidra-exports", help="Ghidra exports.tsv")
+ parser.add_argument("--ghidra-callgraph", help="Ghidra callgraph.tsv")
+ parser.add_argument("--ghidra-bias", type=lambda value: int(value, 0), default=0)
+ parser.add_argument("--probe-instructions", type=int, default=2000, help="probe step budget (0 = wall-clock run)")
+ parser.add_argument("--probe-duration", type=float, default=0.5, help="probe wall budget when --probe-instructions=0")
+ parser.add_argument("--scenario-duration", type=float, default=0.4, help="budget for each scenario phase")
+ parser.add_argument("--max-stops", type=int, default=5000, help="safety bound on serviced stops per scenario phase")
+ parser.add_argument("--max-rows", type=int, default=40, help="table row cap in reports")
+ parser.add_argument("--scenario-script", help="replace the builtin scenario with a green-eye script")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ targets = list(args.targets) + list(args.target)
+ if not targets:
+ parser.error("at least one target is required (positional or --target)")
+ options = MatrixOptions(
+ out=os.path.abspath(args.out),
+ probe_instructions=args.probe_instructions,
+ probe_duration=args.probe_duration,
+ scenario_duration=args.scenario_duration,
+ max_stops=args.max_stops,
+ max_rows=args.max_rows,
+ symbols_json=args.symbols_json,
+ ghidra_names=args.ghidra_names,
+ ghidra_index=args.ghidra_index,
+ ghidra_exports=args.ghidra_exports,
+ ghidra_callgraph=args.ghidra_callgraph,
+ ghidra_bias=args.ghidra_bias,
+ scenario_script=args.scenario_script,
+ )
+ specs = _name_specs(targets)
+ report = run_matrix(specs, options)
+ json_path = write_json(os.path.join(options.out, "portability-report.json"), report)
+ md_path = write_text(os.path.join(options.out, "portability-report.md"), render_markdown(report, options))
+ print(f"[matrix] {len(specs)} target(s), out={options.out}")
+ for target in report["targets"]:
+ print(f"[matrix] {target['name']}: status={target['status']} probe={target['probe']['status']} "
+ f"scenario={target['scenario']['status']}")
+ print(f"[matrix] wrote {json_path}")
+ print(f"[matrix] wrote {md_path}")
+ return 0
+
+
+def _name_specs(paths: list[str]) -> list[TargetSpec]:
+ seen: dict[str, int] = {}
+ specs: list[TargetSpec] = []
+ for path in paths:
+ base = Path(path).stem
+ seen[base] = seen.get(base, 0) + 1
+ name = base if seen[base] == 1 else f"{base}-{seen[base]}"
+ specs.append(TargetSpec(path=os.path.abspath(path), name=name))
+ return specs
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/reach.py b/debug/risc-v-gsp-rm-vm/harness/reach.py
new file mode 100644
index 0000000..9df0c4c
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/reach.py
@@ -0,0 +1,946 @@
+#!/usr/bin/env python3
+"""Reachability answers for a decompiled firmware target (H4.1/H4.5).
+
+Given a symbol name or address taken from Ghidra or the symbol DB, answer the
+practical questions a reimplementation needs: *is this code reachable, how do
+I trigger it, what arguments does it need, and what happens when it runs?*
+
+Methods attempted (``--method auto`` tries them in this order and stops at the
+first hit unless ``--all-methods`` is given):
+
+* ``stimulus`` -- run a supplied green-eye script (``--stimulus script.py``)
+ or probe spec (``--probe probe.json``) under instrumentation;
+* ``trigger`` -- discover the dispatch entry that routes to the target (the
+ demo's handler table), apply that command, and run bounded;
+* ``run`` -- ``Session.run_until(target)`` from launch, which catches the
+ natural boot/self-test path;
+* ``call`` -- ``Session.call(target, args)`` with ``--args`` and
+ ``--arg-count`` auto-fill (zeros or sentinels).
+
+On a hit the report records the entry register state, the prior hook events
+that led there, the caller, and the effects after resume (field deltas and
+watchpoint stores, optional recorded-session trace summary). A repeatable
+probe spec is written to ``--emit-probe`` (default ``<out>/probe.json``) so a
+later run (or docs/21 CI) can replay the exact route.
+
+Demonstrated on the demo firmware: ``rpc_ping`` via ``call``, ``rpc_sum`` via
+``run`` (self-test), ``rpc_fault`` via ``trigger`` (mailbox 6). See
+docs/19-CONTROL-DISCOVERY.md.
+
+Run from the tool root:
+
+ python3 harness/reach.py --target rpc_fault --method trigger --out dumps/reach-fault
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import shutil
+import sys
+import time
+from collections import Counter
+from dataclasses import dataclass, field
+from pathlib import Path
+
+try:
+ from .common import DEMO_ELF, ensure_dir, qemu_available, read_faults_window, sha256_file
+ from .control_discovery import DemoSurface, SETTLE_MARKER, SETTLE_SLICE
+except ImportError: # direct script execution (python3 harness/reach.py)
+ from common import DEMO_ELF, ensure_dir, qemu_available, read_faults_window, sha256_file # type: ignore[no-redef]
+ from control_discovery import DemoSurface, SETTLE_MARKER, SETTLE_SLICE # type: ignore[no-redef]
+
+from greeneye import launch # noqa: E402
+from greeneye.errors import GreenEyeError, RspTimeout # noqa: E402
+from greeneye.record import load_events # noqa: E402
+from greeneye.symbols import SymbolDB # noqa: E402
+
+FORMAT = "greeneye-reach-report/1"
+PROBE_FORMAT = "greeneye-reach-probe/1"
+SENTINEL_BASE = 0xA5A5_0000_0000_0000
+RUN_SLICE = 0.02
+GRACE = 0.10
+AUTO_ORDER = ("stimulus", "trigger", "run", "call")
+TRAP_SIGNALS = (5,)
+FAULT_SIGNALS = (4, 7, 11)
+
+
+@dataclass
+class ReachOptions:
+ kernel: str = DEMO_ELF
+ out: str = ""
+ target: str = ""
+ method: str = "auto"
+ args: list[int] = field(default_factory=list)
+ arg_count: int = 0
+ arg_fill: str = "zero"
+ timeout: float = 0.5
+ settle: float = 2.0
+ stimulus: str | None = None
+ probe: str | None = None
+ emit_probe: str | None = None
+ record: str | None = None
+ backend: str = "qemu"
+ max_prior: int = 12
+ all_methods: bool = False
+ allow_foreign_elf: bool = False
+ watch: list[dict] = field(default_factory=list)
+
+
+@dataclass
+class Attempt:
+ method: str
+ ok: bool = False
+ reason: str | None = None
+ entry: dict | None = None
+ retval: int | None = None
+ dispatch: list[dict] = field(default_factory=list)
+ prior: list[dict] = field(default_factory=list)
+ effects: dict = field(default_factory=dict)
+ trap: bool = False
+ fault: bool = False
+ trigger: dict | None = None
+ session: str | None = None
+ hit_count: int = 0
+
+ def summary(self) -> dict:
+ return {
+ "method": self.method,
+ "ok": self.ok,
+ "reason": self.reason,
+ "entry": self.entry,
+ "retval": self.retval,
+ "dispatch": self.dispatch[:4],
+ "prior_events": self.prior,
+ "effects": self.effects,
+ "trap": self.trap,
+ "fault": self.fault,
+ "trigger": self.trigger,
+ "session": self.session,
+ "hit_count": self.hit_count,
+ }
+
+
+def build_args(options: ReachOptions) -> list[int]:
+ """Explicit --args plus deterministic --arg-count auto-fill."""
+ args = list(options.args)
+ count = max(options.arg_count, len(args))
+ if count > 8:
+ raise GreenEyeError(f"--arg-count {count}: the RISC-V ABI passes at most 8 integer args")
+ while len(args) < count:
+ if options.arg_fill == "sentinel":
+ args.append(SENTINEL_BASE | len(args))
+ else:
+ args.append(0)
+ return args
+
+
+class Collector:
+ """Ordered hook/watch evidence for one reachability attempt."""
+
+ def __init__(
+ self,
+ session,
+ surface: DemoSurface | None,
+ target_addr: int,
+ target_name: str,
+ options: ReachOptions,
+ fault_log: str | None = None,
+ ):
+ self.session = session
+ self.surface = surface
+ self.target_addr = target_addr
+ self.target_name = target_name
+ self.options = options
+ self.fault_log = fault_log
+ self.fault_offset = 0
+ self.faults: list[dict] = []
+ self.hits: list[dict] = []
+ self.entry: dict | None = None
+ self.prior: list[dict] = []
+ self.dispatch: list[dict] = []
+ self.signals: list[dict] = []
+ self.watch: list[dict] = []
+ self.observer = surface.observer() if surface is not None else None
+ self.before: dict = {}
+ self.base = 0
+
+ # -- installation ------------------------------------------------------
+
+ def install(self) -> None:
+ self.session.on_event("stop", self._on_stop)
+ if self.fault_log and os.path.exists(self.fault_log):
+ self.fault_offset = os.path.getsize(self.fault_log)
+ if self.surface is not None and self.session.symbols.get("gsp_ctrl"):
+ self.base = self.session.symbols.lookup("gsp_ctrl")
+ if self.observer is not None:
+ self.observer.reset()
+ self.observer.arm(self.session, self.base)
+ for spec in self.options.watch:
+ self._install_watch(spec)
+ hooked: set[int] = set()
+ self._hook_addr(self.target_addr, self.target_name)
+ hooked.add(self.target_addr)
+ if self.surface is not None:
+ command_hook = self.surface.command_hook(self.session)
+ if command_hook:
+ addr = self.session.symbols.lookup(command_hook)
+ self._hook_addr(addr, command_hook, stage="dispatch")
+ hooked.add(addr)
+ for cmd, (entry, name) in sorted(self.surface.handler_map(self.session).items()):
+ if entry in hooked:
+ continue
+ self._hook_addr(entry, name or f"handler-{entry:#x}", stage="handler", cmd=cmd)
+ hooked.add(entry)
+
+ def _install_watch(self, spec: dict) -> None:
+ target = spec.get("symbol", spec.get("addr"))
+ if target is None:
+ return
+ if isinstance(target, str) and not target.lower().startswith("0x"):
+ base = self.session.symbols.lookup(target)
+ else:
+ base = int(target, 0) if isinstance(target, str) else int(target)
+ offset = int(spec.get("offset", 0))
+ size = int(spec.get("size", 8))
+ kind = spec.get("kind", "w")
+ label = spec.get("name") or f"{target}+{offset:#x}"
+
+ def callback(hit, label=label):
+ self.watch.append({"name": label, "pc": hit.pc, "symbol": hit.symbol.name if hit.symbol else None})
+
+ self.session.watch(base + offset, size=size, kind=kind, callback=callback)
+
+ def _hook_addr(self, addr: int, name: str, stage: str = "handler", cmd: int | None = None) -> None:
+ def callback(hit, stage=stage, cmd=cmd):
+ item = {
+ "stage": stage,
+ "symbol": hit.symbol.name if hit.symbol else name,
+ "addr": hit.addr,
+ "args": [hit.arg(i) for i in range(4)],
+ }
+ if stage == "dispatch":
+ self.dispatch.append(item)
+ if hit.addr == self.target_addr:
+ self.entry = {
+ "symbol": hit.symbol.name if hit.symbol else name,
+ "pc": hit.addr,
+ "args": [hit.arg(i) for i in range(4)],
+ "ra": hit.regs.get("ra", 0),
+ "sp": hit.regs.get("sp", 0),
+ "cmd": cmd,
+ # The demo compiler's jump table clobbers a0 at handler entry;
+ # the dispatcher's own args are the authoritative call model.
+ "dispatch_args": self.dispatch[-1]["args"] if self.dispatch else None,
+ }
+ self.prior = list(self.hits[-self.options.max_prior :])
+ self.hits.append(item)
+
+ self.session.hook(addr, on_enter=callback, listener=f"reach:{name}")
+
+ def _on_stop(self, hit) -> None:
+ stop = hit.stop
+ if stop is not None and stop.signal is not None:
+ self.signals.append({"signal": stop.signal, "pc": hit.pc})
+
+ # -- accounting --------------------------------------------------------
+
+ def snapshot_before(self) -> None:
+ if self.observer is not None:
+ self.before = self.observer.snapshot(self.session, self.base)
+
+ def effects(self, retval: int | None) -> dict:
+ before = self.before
+ after = self.observer.snapshot(self.session, self.base) if self.observer is not None else {}
+ changed = [
+ {"field": name, "before": before.get(name), "after": after.get(name)}
+ for name in before
+ if before.get(name) != after.get(name)
+ ]
+ faults: list[dict] = []
+ if self.fault_log:
+ faults, _capped = read_faults_window(self.fault_log, self.fault_offset)
+ causes = [int(fault.get("cause", 0)) for fault in faults]
+ trap = any(cause == 3 for cause in causes) or any(
+ sig.get("signal") in TRAP_SIGNALS for sig in self.signals
+ ) or self.session.pc < 0x100
+ # A trap often drags a fetch-fault loop behind it (mtvec=0); do not
+ # double-label the consequence as an independent fault.
+ fault = (not trap) and (
+ any(cause != 3 for cause in causes)
+ or any(sig.get("signal") in FAULT_SIGNALS for sig in self.signals)
+ )
+ effects = {
+ "changed": changed,
+ "watch": list(self.watch) + (list(self.observer.hits) if self.observer is not None else []),
+ "retval": retval,
+ "trap": bool(trap),
+ "fault": bool(fault),
+ "pc_after": self.session.pc,
+ }
+ if faults:
+ effects["faults"] = [
+ {"cause": fault.get("cause"), "desc": fault.get("desc"), "epc": fault.get("epc"),
+ "access": fault.get("access")}
+ for fault in faults[:4]
+ ]
+ return effects
+
+
+# ---------------------------------------------------------------------------
+# session plumbing
+# ---------------------------------------------------------------------------
+
+
+class SessionHandle:
+ def __init__(self, session, record_path: str | None, fault_log: str | None = None):
+ self.session = session
+ self.path = record_path
+ self.fault_log = fault_log
+
+
+def open_session(options: ReachOptions, method: str, record: bool) -> SessionHandle:
+ record_path = os.path.join(options.record, method) if record and options.record else None
+ if record_path:
+ ensure_dir(record_path)
+ fault_log = None
+ extra_args = None
+ if options.backend == "qemu":
+ fault_log = os.path.join(options.out, ".faults", f"{method}.log")
+ ensure_dir(os.path.dirname(fault_log))
+ extra_args = ["-d", "guest_errors,unimp,int", "-D", fault_log]
+ session = launch(
+ kernel=options.kernel,
+ backend=options.backend,
+ name=f"reach-{method}",
+ record=record_path,
+ extra_args=extra_args,
+ )
+ return SessionHandle(session, record_path, fault_log)
+
+
+def settle(session, budget: float) -> bool:
+ deadline = time.monotonic() + budget
+ while time.monotonic() < deadline:
+ if SETTLE_MARKER in session.target.read_uart():
+ return True
+ session.run(duration=SETTLE_SLICE, max_stops=1)
+ return SETTLE_MARKER in session.target.read_uart()
+
+
+def run_window(session, collector: Collector, timeout: float) -> None:
+ """Run bounded until the target entry is hit, then a short grace window."""
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if collector.entry is not None:
+ break
+ if not session.target.alive():
+ return
+ remaining = max(0.005, min(RUN_SLICE, deadline - time.monotonic()))
+ session.run(duration=remaining, max_stops=64)
+ if collector.entry is not None:
+ session.run(duration=min(GRACE, max(0.01, timeout)), max_stops=64)
+
+
+# ---------------------------------------------------------------------------
+# methods
+# ---------------------------------------------------------------------------
+
+
+def attempt_call(options: ReachOptions, surface: DemoSurface | None, target_addr: int, target_name: str,
+ record: bool) -> Attempt:
+ attempt = Attempt(method="call")
+ args = build_args(options)
+ handle = open_session(options, "call", record)
+ session = handle.session
+ attempt.session = handle.path
+ try:
+ collector = Collector(session, surface, target_addr, target_name, options, fault_log=handle.fault_log)
+ collector.install()
+ collector.snapshot_before()
+ retval: int | None = None
+ try:
+ retval = session.call(target_addr, args, timeout=options.timeout)
+ except RspTimeout as exc:
+ attempt.reason = f"call did not return: {exc}"
+ except GreenEyeError as exc:
+ attempt.reason = f"call failed: {exc}"
+ attempt.entry = collector.entry
+ attempt.prior = collector.prior
+ attempt.dispatch = collector.dispatch
+ attempt.hit_count = len(collector.hits)
+ attempt.retval = retval
+ attempt.effects = collector.effects(retval)
+ attempt.trap = attempt.effects["trap"]
+ attempt.fault = attempt.effects["fault"]
+ attempt.ok = collector.entry is not None
+ if not attempt.ok and attempt.reason is None:
+ attempt.reason = "target entry hook never fired"
+ finally:
+ session.close()
+ return attempt
+
+
+def attempt_run(options: ReachOptions, surface: DemoSurface | None, target_addr: int, target_name: str,
+ record: bool) -> Attempt:
+ attempt = Attempt(method="run")
+ handle = open_session(options, "run", record)
+ session = handle.session
+ attempt.session = handle.path
+ try:
+ collector = Collector(session, surface, target_addr, target_name, options, fault_log=handle.fault_log)
+ collector.install()
+ collector.snapshot_before()
+ try:
+ session.run_until(target_addr, timeout=options.timeout)
+ except RspTimeout as exc:
+ attempt.reason = f"run_until not reached: {exc}"
+ except GreenEyeError as exc:
+ attempt.reason = f"run_until failed: {exc}"
+ if collector.entry is not None:
+ session.run(duration=min(GRACE, options.timeout), max_stops=64)
+ attempt.entry = collector.entry
+ attempt.prior = collector.prior
+ attempt.dispatch = collector.dispatch
+ attempt.hit_count = len(collector.hits)
+ attempt.effects = collector.effects(None)
+ attempt.trap = attempt.effects["trap"]
+ attempt.fault = attempt.effects["fault"]
+ attempt.ok = collector.entry is not None
+ if not attempt.ok and attempt.reason is None:
+ attempt.reason = "target never executed on the boot path"
+ finally:
+ session.close()
+ return attempt
+
+
+def attempt_trigger(options: ReachOptions, surface: DemoSurface | None, target_addr: int, target_name: str,
+ record: bool) -> Attempt:
+ attempt = Attempt(method="trigger")
+ if surface is None:
+ attempt.reason = "no command surface available (gsp_ctrl/rpc_dispatch missing)"
+ return attempt
+ args = build_args(options)[:3]
+ while len(args) < 3:
+ args.append(0)
+ handle = open_session(options, "trigger", record)
+ session = handle.session
+ attempt.session = handle.path
+ try:
+ if not settle(session, options.settle):
+ attempt.reason = f"{SETTLE_MARKER!r} not observed; trigger state unknown"
+ return attempt
+ candidates = surface.command_for_addr(session, target_addr)
+ if not candidates:
+ attempt.reason = "target is not in the declared handler/dispatch table"
+ return attempt
+ # Prefer the documented switch case over the injected-slot alias.
+ candidates.sort(key=lambda item: (item["id"] >= 16, item["id"]))
+ chosen = candidates[0]
+ collector = Collector(session, surface, target_addr, target_name, options, fault_log=handle.fault_log)
+ collector.install()
+ collector.snapshot_before()
+ surface.probe(session, int(chosen["id"]), (args[0], args[1], args[2]), timeout=options.timeout)
+ run_window(session, collector, options.timeout)
+ attempt.entry = collector.entry
+ attempt.prior = collector.prior
+ attempt.dispatch = collector.dispatch
+ attempt.hit_count = len(collector.hits)
+ attempt.effects = collector.effects(None)
+ attempt.trap = attempt.effects["trap"]
+ attempt.fault = attempt.effects["fault"]
+ attempt.ok = collector.entry is not None
+ attempt.trigger = {"surface": surface.name, "id": chosen["id"], "handler": chosen["handler"], "args": args,
+ "candidates": candidates}
+ if not attempt.ok and attempt.reason is None:
+ attempt.reason = f"mailbox command {chosen['id']} did not reach the target within the budget"
+ finally:
+ session.close()
+ return attempt
+
+
+def attempt_stimulus(options: ReachOptions, surface: DemoSurface | None, target_addr: int, target_name: str,
+ record: bool) -> Attempt:
+ attempt = Attempt(method="stimulus")
+ if not options.stimulus:
+ attempt.reason = "no --stimulus script supplied"
+ return attempt
+ handle = open_session(options, "stimulus", record)
+ session = handle.session
+ attempt.session = handle.path
+ try:
+ collector = Collector(session, surface, target_addr, target_name, options, fault_log=handle.fault_log)
+ collector.install()
+ collector.snapshot_before()
+ import greeneye as ge
+
+ script_path = Path(options.stimulus)
+ namespace = {
+ "ge": ge,
+ "greeneye": ge,
+ "session": session,
+ "s": session,
+ "memory": session.memory,
+ "symbols": session.symbols,
+ "recorder": session.recorder,
+ }
+ try:
+ exec(compile(script_path.read_text(encoding="utf-8"), str(script_path), "exec"), namespace)
+ except Exception as exc: # a stimulus failure is a finding, not a crash
+ attempt.reason = f"stimulus raised {type(exc).__name__}: {exc}"
+ if collector.entry is None:
+ run_window(session, collector, options.timeout)
+ attempt.entry = collector.entry
+ attempt.prior = collector.prior
+ attempt.dispatch = collector.dispatch
+ attempt.hit_count = len(collector.hits)
+ attempt.effects = collector.effects(None)
+ attempt.trap = attempt.effects["trap"]
+ attempt.fault = attempt.effects["fault"]
+ attempt.ok = collector.entry is not None
+ if not attempt.ok and attempt.reason is None:
+ attempt.reason = "stimulus completed without reaching the target"
+ finally:
+ session.close()
+ return attempt
+
+
+METHOD_FUNCS = {
+ "call": attempt_call,
+ "run": attempt_run,
+ "trigger": attempt_trigger,
+ "stimulus": attempt_stimulus,
+}
+
+
+# ---------------------------------------------------------------------------
+# orchestration, report, probe spec
+# ---------------------------------------------------------------------------
+
+
+def attempt_order(options: ReachOptions, target_addr: int, surface: DemoSurface | None) -> list[str]:
+ if options.method != "auto":
+ return [options.method]
+ order = list(AUTO_ORDER)
+ if not options.stimulus:
+ order.remove("stimulus")
+ return order
+
+
+def run_reach(options: ReachOptions) -> dict:
+ if options.backend == "qemu" and not qemu_available():
+ raise GreenEyeError("qemu-system-riscv64 not found; install qemu-system-misc or set GREENEYE_QEMU")
+ if not os.path.isfile(options.kernel):
+ raise GreenEyeError(f"kernel not found: {options.kernel}")
+ if not options.allow_foreign_elf and os.path.abspath(options.kernel) != os.path.abspath(DEMO_ELF):
+ raise GreenEyeError(
+ "reachability is restricted to the bundled demo firmware; "
+ "pass --allow-foreign-elf to run against another ELF under QEMU"
+ )
+ if not options.target:
+ raise GreenEyeError("--target is required (symbol name or 0x address)")
+ ensure_dir(options.out)
+ # Resolve the target once on a short-lived probe session so the report can
+ # name the address even if every method misses.
+ target_addr, target_name, symbols = resolve_target(options)
+ surface = DemoSurface() if symbols.get("gsp_ctrl") is not None else None
+ attempts: list[Attempt] = []
+ for method in attempt_order(options, target_addr, surface):
+ attempt = METHOD_FUNCS[method](options, surface, target_addr, target_name, bool(options.record))
+ attempts.append(attempt)
+ if attempt.ok and not options.all_methods:
+ break
+ success = next((attempt for attempt in attempts if attempt.ok), None)
+ report = build_report(options, target_addr, target_name, attempts, success)
+ shutil.rmtree(os.path.join(options.out, ".faults"), ignore_errors=True)
+ if report["reachable"]:
+ probe_path = options.emit_probe or os.path.join(options.out, "probe.json")
+ ensure_dir(os.path.dirname(os.path.abspath(probe_path)))
+ write_json(probe_path, suggested_probe(options, target_name, success))
+ report["probe_spec"] = probe_path
+ write_json(os.path.join(options.out, "reach-report.json"), report)
+ with open(os.path.join(options.out, "reach-report.md"), "w", encoding="utf-8") as fh:
+ fh.write(render_markdown(report, options))
+ return report
+
+
+def resolve_target(options: ReachOptions) -> tuple[int, str, SymbolDB]:
+ """Resolve the target address with a paused session, then close it."""
+ session = launch(kernel=options.kernel, backend=options.backend, name="reach-resolve")
+ try:
+ symbols = session.symbols
+ addr = session.resolve(options.target)
+ symbol = symbols.resolve(addr)
+ name = symbol.name if symbol else options.target
+ return addr, name, symbols
+ finally:
+ session.close()
+
+
+def suggested_probe(options: ReachOptions, target_name: str, success: Attempt | None) -> dict:
+ method = success.method if success is not None else options.method
+ spec = {
+ "format": PROBE_FORMAT,
+ "target": target_name,
+ "kernel": options.kernel,
+ "method": method,
+ "args": list(options.args),
+ "arg_count": options.arg_count,
+ "arg_fill": options.arg_fill,
+ "timeout": options.timeout,
+ "settle": options.settle,
+ "stimulus": options.stimulus,
+ "trigger": success.trigger if success is not None and success.method == "trigger" else None,
+ "watch": options.watch,
+ }
+ return spec
+
+
+def build_report(options: ReachOptions, target_addr: int, target_name: str, attempts: list[Attempt],
+ success: Attempt | None) -> dict:
+ reachable = success is not None
+ entry = success.entry if success is not None else None
+ prior = success.prior if success is not None else []
+ return {
+ "format": FORMAT,
+ "tool": "harness/reach.py",
+ "target": target_name,
+ "target_addr": target_addr,
+ "kernel": options.kernel,
+ "kernel_sha256": sha256_file(options.kernel) if os.path.isfile(options.kernel) else None,
+ "backend": options.backend,
+ "reachable": reachable,
+ "method": success.method if success is not None else None,
+ "methods_tried": [attempt.summary() for attempt in attempts],
+ "entry": entry,
+ "prior_events": prior,
+ "dispatch": success.dispatch if success is not None else [],
+ "required_args": required_args(entry, success),
+ "effects": success.effects if success is not None else {},
+ "trigger": success.trigger if success is not None else None,
+ "trace_window": trace_window(success),
+ "session": success.session if success is not None else next(
+ (attempt.session for attempt in attempts if attempt.session), None
+ ),
+ "probe_spec": None,
+ "reproduce": reproduce_command(options),
+ "reason": None if reachable else (attempts[-1].reason if attempts else "no method attempted"),
+ }
+
+
+def required_args(entry: dict | None, success: Attempt | None) -> dict | None:
+ if entry is None:
+ return None
+ caller = entry.get("ra", 0)
+ dispatch_args = entry.get("dispatch_args")
+ if dispatch_args:
+ return {
+ "source": "firmware-dispatch",
+ "cmd": dispatch_args[0],
+ "a0": dispatch_args[1] if len(dispatch_args) > 1 else None,
+ "a1": dispatch_args[2] if len(dispatch_args) > 2 else None,
+ "a2": dispatch_args[3] if len(dispatch_args) > 3 else None,
+ "handler_entry_args": entry.get("args"),
+ "caller": f"{caller:#x}" if caller else None,
+ }
+ args = entry.get("args") or []
+ return {
+ "source": "call-args" if success is not None and success.method == "call" else "firmware-dispatch",
+ "a0": args[0] if len(args) > 0 else None,
+ "a1": args[1] if len(args) > 1 else None,
+ "a2": args[2] if len(args) > 2 else None,
+ "a3": args[3] if len(args) > 3 else None,
+ "caller": f"{caller:#x}" if caller else None,
+ }
+
+
+def trace_window(success: Attempt | None) -> dict | None:
+ if success is None:
+ return None
+ counts = Counter(item.get("stage", "hook") for item in success.prior)
+ window = {"hook_counts": dict(sorted(counts.items())), "prior": success.prior}
+ if success.session and os.path.isdir(success.session):
+ try:
+ events = load_events(success.session)
+ except Exception:
+ return window
+ window["session_event_counts"] = dict(sorted(Counter(event.get("type", "?") for event in events).items()))
+ return window
+
+
+def render_markdown(report: dict, options: ReachOptions) -> str:
+ lines = [
+ "# green-eye reachability report",
+ "",
+ "> TOOL IN DEVELOPMENT - NOT READY YET! Generated by `harness/reach.py`.",
+ "",
+ f"* format: `{FORMAT}`",
+ f"* target: `{report['target']}` @ `{report['target_addr']:#x}`",
+ f"* kernel: `{report['kernel']}` (sha256 `{(report['kernel_sha256'] or '')[:16]}`)",
+ f"* backend: `{report['backend']}`",
+ f"* reachable: **{'yes' if report['reachable'] else 'no'}**"
+ + (f" via `{report['method']}`" if report["reachable"] else ""),
+ "",
+ "## Methods tried",
+ "",
+ ]
+ lines += _table(
+ ["method", "result", "hits", "reason"],
+ [
+ [
+ attempt["method"],
+ "reachable" if attempt["ok"] else "miss",
+ attempt["hit_count"],
+ attempt["reason"] or "-",
+ ]
+ for attempt in report["methods_tried"]
+ ],
+ )
+ if report["reachable"]:
+ entry = report["entry"] or {}
+ args = entry.get("args") or []
+ req = report["required_args"] or {}
+ lines += ["", "## Entry state", ""]
+ lines += _table(
+ ["field", "value"],
+ [
+ ["symbol", entry.get("symbol")],
+ ["pc", f"`{entry.get('pc', 0):#x}`"],
+ ["args a0..a3", ", ".join(f"`{value:#x}`" for value in args[:4]) or "-"],
+ ["ra", f"`{entry.get('ra', 0):#x}`"],
+ ["sp", f"`{entry.get('sp', 0):#x}`"],
+ ],
+ )
+ lines += ["", "## Required args discovered", ""]
+ lines += _table(
+ ["source", "cmd", "a0", "a1", "a2", "a3", "caller"],
+ [[
+ req.get("source"),
+ f"`{req['cmd']:#x}`" if req.get("cmd") is not None else "-",
+ *[f"`{req[key]:#x}`" if req.get(key) is not None else "-" for key in ("a0", "a1", "a2", "a3")],
+ f"`{req['caller']}`" if req.get("caller") else "-",
+ ]],
+ )
+ if report.get("trigger"):
+ trigger = report["trigger"]
+ lines += ["", "## Trigger", ""]
+ lines += _table(
+ ["surface", "command id", "handler", "args"],
+ [[trigger["surface"], trigger["id"], trigger["handler"],
+ ", ".join(f"`{value:#x}`" for value in trigger["args"])]],
+ )
+ lines += ["", "## Prior events (hooks that led here)", ""]
+ if report["prior_events"]:
+ lines += _table(
+ ["#", "stage", "symbol", "addr", "args"],
+ [
+ [index, item.get("stage"), item.get("symbol"), f"`{item.get('addr', 0):#x}`",
+ ", ".join(f"`{value:#x}`" for value in item.get("args", []))]
+ for index, item in enumerate(report["prior_events"])
+ ],
+ )
+ else:
+ lines += ["_none: the target was the first hooked event_"]
+ lines += ["", "## Effects after resume", ""]
+ effects = report["effects"]
+ changed = effects.get("changed") or []
+ lines += _table(
+ ["field", "before", "after"],
+ [[row["field"], _short(row["before"]), _short(row["after"])] for row in changed] or [["-", "-", "-"]],
+ )
+ lines += ["", f"* return value: {_hex(effects.get('retval'))}",
+ f"* trap (ebreak) observed: {effects.get('trap')}",
+ f"* fault observed: {effects.get('fault')}",
+ f"* watchpoint stores: {len(effects.get('watch') or [])}"]
+ window = report.get("trace_window") or {}
+ if window.get("session_event_counts"):
+ lines += ["", "## Trace window (recorded session)", "",
+ f"`{json.dumps(window['session_event_counts'], sort_keys=True)}`"]
+ else:
+ lines += ["", "## Not reached", "",
+ f"* reason: {report.get('reason') or 'unknown'}",
+ "* absence of a hit is evidence about this stimulus at this fidelity, not proof of dead code."]
+ lines += ["", "## Reproduce", "", "```bash", report["reproduce"], "```"]
+ if report.get("probe_spec"):
+ lines += ["", f"* repeatable probe spec: `{report['probe_spec']}`"]
+ lines += [""]
+ return "\n".join(lines)
+
+
+def _short(value) -> str:
+ text = json.dumps(value, sort_keys=True)
+ return f"`{text[:48]}`" if len(text) <= 48 else f"`{text[:47]}...`"
+
+
+def _hex(value) -> str:
+ return f"`{value:#x}`" if isinstance(value, int) else "-"
+
+
+def _table(headers: list[str], rows: list[list]) -> list[str]:
+ lines = ["| " + " | ".join(headers) + " |", "|" + "|".join("---" for _ in headers) + "|"]
+ for row in rows:
+ lines.append("| " + " | ".join(str(cell) for cell in row) + " |")
+ return lines
+
+
+def reproduce_command(options: ReachOptions) -> str:
+ args = f" --args {','.join(hex(value) for value in options.args)}" if options.args else ""
+ arg_count = f" --arg-count {options.arg_count}" if options.arg_count else ""
+ stimulus = f" --stimulus {options.stimulus}" if options.stimulus else ""
+ methods = " --all-methods" if options.all_methods else ""
+ foreign = " --allow-foreign-elf" if options.allow_foreign_elf else ""
+ record = f" --record {options.record}" if options.record else ""
+ return (
+ f"python3 harness/reach.py --target {options.target} --method {options.method} "
+ f"--kernel {options.kernel} --out {options.out} --timeout {options.timeout} "
+ f"--settle {options.settle}{args}{arg_count}{stimulus}{methods}{record}{foreign}"
+ )
+
+
+def write_json(path: str, payload: dict) -> str:
+ ensure_dir(os.path.dirname(path))
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(payload, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ return path
+
+
+# ---------------------------------------------------------------------------
+# probe spec loading
+# ---------------------------------------------------------------------------
+
+
+def load_probe_spec(path: str) -> dict:
+ with open(path, "r", encoding="utf-8") as fh:
+ spec = json.load(fh)
+ if not isinstance(spec, dict) or "target" not in spec:
+ raise GreenEyeError(f"{path}: reachability probe spec must be an object with a 'target'")
+ return spec
+
+
+def apply_probe_spec(options: ReachOptions, spec: dict) -> None:
+ if not options.target:
+ options.target = str(spec.get("target", ""))
+ if spec.get("kernel") and os.path.abspath(options.kernel) == os.path.abspath(DEMO_ELF):
+ options.kernel = os.path.abspath(str(spec["kernel"]))
+ for attr, key in (
+ ("method", "method"),
+ ("stimulus", "stimulus"),
+ ):
+ if getattr(options, attr) in (None, "", "auto") and spec.get(key):
+ setattr(options, attr, spec[key])
+ if not options.args and spec.get("args"):
+ options.args = [int(value) for value in spec["args"]]
+ if options.arg_count == 0 and spec.get("arg_count"):
+ options.arg_count = int(spec["arg_count"])
+ if options.arg_fill == "zero" and spec.get("arg_fill"):
+ options.arg_fill = str(spec["arg_fill"])
+ if options.timeout == 0.5 and spec.get("timeout"):
+ options.timeout = float(spec["timeout"])
+ if options.settle == 2.0 and spec.get("settle"):
+ options.settle = float(spec["settle"])
+ if not options.watch and spec.get("watch"):
+ options.watch = list(spec["watch"])
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def parse_args_list(text: str) -> list[int]:
+ values: list[int] = []
+ for part in text.replace(" ", ",").split(","):
+ if part:
+ values.append(int(part, 0))
+ return values
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="reach", description=__doc__.splitlines()[0])
+ parser.add_argument("--target", help="symbol name or 0x address to reach")
+ parser.add_argument("--kernel", default=DEMO_ELF, help="demo/re-hosted ELF (default: bundled demo)")
+ parser.add_argument("--out", default=None, help="output directory (default: dumps/reach-<target>)")
+ parser.add_argument(
+ "--method",
+ choices=("auto", "stimulus", "trigger", "run", "call"),
+ default="auto",
+ help="methods to attempt; auto tries stimulus/trigger/run/call and stops at the first hit",
+ )
+ parser.add_argument("--all-methods", action="store_true", help="attempt every method, not just until the first hit")
+ parser.add_argument("--args", type=parse_args_list, default=[], help="comma-separated integer args (a0..a7)")
+ parser.add_argument("--arg-count", type=int, default=0, help="auto-fill a0..a(n-1) when --args is short")
+ parser.add_argument("--arg-fill", choices=("zero", "sentinel"), default="zero", help="auto-fill value policy")
+ parser.add_argument("--timeout", type=float, default=0.5, help="per-attempt budget (seconds)")
+ parser.add_argument("--settle", type=float, default=2.0, help="self-test settle budget before trigger")
+ parser.add_argument("--stimulus", help="green-eye script to run under instrumentation")
+ parser.add_argument("--probe", help="JSON probe spec to replay (see --emit-probe)")
+ parser.add_argument("--emit-probe", help="where to write the repeatable probe spec (default <out>/probe.json)")
+ parser.add_argument("--record", nargs="?", const=True, default=None, help="record attempts under DIR/<method>")
+ parser.add_argument("--backend", choices=("qemu", "native"), default="qemu")
+ parser.add_argument("--max-prior", type=int, default=12, help="prior hook events kept in the report")
+ parser.add_argument("--allow-foreign-elf", action="store_true", help="permit a non-demo ELF under QEMU")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ if args.probe:
+ try:
+ spec = load_probe_spec(args.probe)
+ except (OSError, ValueError, GreenEyeError) as exc:
+ print(f"reach error: {exc}", file=sys.stderr)
+ return 2
+ else:
+ spec = {}
+ target_hint = args.target or str(spec.get("target", "target"))
+ out = args.out or os.path.join("dumps", "reach-" + _safe_name(target_hint))
+ if isinstance(args.record, str):
+ record_base: str | None = os.path.abspath(args.record)
+ elif args.record:
+ record_base = os.path.join(os.path.abspath(out), "sessions")
+ else:
+ record_base = None
+ options = ReachOptions(
+ kernel=os.path.abspath(args.kernel),
+ out=os.path.abspath(out),
+ target=args.target or "",
+ method=args.method,
+ args=args.args,
+ arg_count=args.arg_count,
+ arg_fill=args.arg_fill,
+ timeout=args.timeout,
+ settle=args.settle,
+ stimulus=args.stimulus,
+ probe=args.probe,
+ emit_probe=os.path.abspath(args.emit_probe) if args.emit_probe else None,
+ record=record_base,
+ backend=args.backend,
+ max_prior=args.max_prior,
+ all_methods=args.all_methods,
+ allow_foreign_elf=args.allow_foreign_elf,
+ )
+ if spec:
+ apply_probe_spec(options, spec)
+ if not options.target:
+ parser.error("--target is required (or provide a probe spec with 'target')")
+ try:
+ report = run_reach(options)
+ except GreenEyeError as exc:
+ print(f"reach error: {exc}", file=sys.stderr)
+ return 2
+ if report["reachable"]:
+ print(f"[reach] {report['target']} reachable via {report['method']} ({os.path.join(options.out, 'reach-report.md')})")
+ else:
+ print(f"[reach] {report['target']} not reached: {report['reason']}")
+ return 0 if report["reachable"] else 1
+
+
+def _safe_name(text: str) -> str:
+ return "".join(ch if ch.isalnum() or ch in "-_." else "-" for ch in text)
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/README.md b/debug/risc-v-gsp-rm-vm/harness/value_demos/README.md
new file mode 100644
index 0000000..e1a450c
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/README.md
@@ -0,0 +1,83 @@
+# green-eye value-validation demos
+
+Runnable demonstrations that tie green-eye tool capabilities to **real**
+Open-IOV scenarios (cited from `Handoff-vGPU-IO.md` and the project docs) and
+measure the time-to-value against the static/Ghidra-only baseline.
+
+Each demo:
+
+* names the real scenario (handoff PART/addendum + date) and what it cost;
+* prints the question, the green-eye commands used, the answer, the evidence
+ artifact and the elapsed wall-clock;
+* writes a machine-readable `result.json` under `artifacts/<demo>/`;
+* labels what is **measured on the analog** vs **projected for the real
+ case**; device-model-blocked scenarios say so explicitly.
+
+> green-eye is development-only tooling (docs/00); these demos never touch
+> hardware, a guest or a kernel module. The real `.fwimage` cannot boot
+> without the H2 device model (docs/06 §3), so every demo is honest about
+> being a *shape* reproduction on the bundled `uproc_demo` firmware + native
+> RV64 simulator / QEMU.
+
+## Run
+
+From the tool root (`open-iov/debug/risc-v-gsp-rm-vm`):
+
+```bash
+make demo # once: build firmware/uproc_demo/build/uproc_demo.elf
+make sdk # optional (s06): builds sdk/build/injected_demo.elf
+
+python3 harness/value_demos/run_all.py # all 10, results JSON
+python3 harness/value_demos/run_all.py --only s01,s04,s05 # subset
+python3 harness/value_demos/s01_reply_consumer.py # one demo, verbose
+
+VALUE_DEMO_BACKEND=qemu python3 harness/value_demos/run_all.py # force QEMU
+```
+
+Defaults: native backend (deterministic, in-process) for s01–s08; QEMU for
+s09/s10 (with a native fallback if QEMU is absent). `VALUE_DEMO_BACKEND`
+overrides all of them. No hardware is required.
+
+The aggregate lands in `artifacts/results.json`; each demo writes under
+`artifacts/<name>/` (results, sessions, TSVs, reports). The runner enforces a
+per-demo timeout and the full suite runs in ~18 s on the reference host
+(budget: each demo < ~30 s, suite < ~3 min).
+
+## Demos
+
+| # | Script | Real scenario (source, date) | Static/Ghidra-only baseline | Analog answer |
+|---|---|---|---|---|
+| s01 | `s01_reply_consumer.py` | 0x59 RM-init chase; fn72/fn73 hidden error (PART 91 + ADDENDA 2–7, 2026-10-01) | cycles 2–13, ~5 h, 10+ supervised boots; reply was a generic `0x59` | hook dispatcher + watch `result`: every command consumed and its exact reply decoded (1.14 s) |
+| s02 | `s02_counter_publication.py` | 20 GR per-channel counters; only 4/20 moved (PARTs 46–64, 2026-09-24…29) | ~5 days; slot layout re-derived per boot; ~2–3 s pin vs ~2 s deadline | 20/20 slots published, each store PC captured (1.34 s) |
+| s03 | `s03_unknown_command.py` | unknown/new control discovery; injected slots 16–23 (docs/19, 2026-10-02) | read the indirect dispatch table per stripped image | 24 IDs swept + classified: 6 known, 8 injected-slot, 10 default (5.96 s) |
+| s04 | `s04_run_until_function.py` | RM-client ladder reachability per rung (PART 91 ADDENDUM 9 cycles 18–25, 2026-10-01) | ~1 h 41 m / 8 supervised cycles | natural run / timeout / trigger / direct call in one session (0.24 s) |
+| s05 | `s05_snapshot_diff.py` | fn72 identity fix + 1656-B static-config reply (PART 91 ADDENDUM 6/7, 2026-10-01) | malformed field suspected statically; a live boot per fix iteration | every changed byte attributed to a named schema field (0.50 s) |
+| s06 | `s06_inject_service.py` | P6 service wiring / guest CE copies (PART 92 ADDENDA 5–11, 2026-10-02) | kernel rebuild + supervised cycle (~5–8 min) per rung | service blob loaded, registered, invoked both routes (0.63 s) |
+| s07 | `s07_version_diff.py` | 570.144 vs 570.211 pairing disproof; semantic diff (PART 91 cycles 2–3, 2026-10-01) | one full boot per version + manual log compare | same stimulus on two builds; exactly one reply field moved (1.14 s) |
+| s08 | `s08_mutation_triage.py` | error-path corpus / fail-closed rules (R5; docs/19 §3.4, docs/18 §2) | hand-derived asserts and NOCAT records across cycles | 7 seeded mutations classified ok/fault/no-progress (1.82 s) |
+| s09 | `s09_grabbing_a_ghidra_address.py` | static/dynamic join; GPFIFO GET fault `0x40000` vs `0x101000` (PART 91 ADDENDUM 9 cycle 24) | hypothesis unproven until the decoded status-queue fault (8 cycles) | Ghidra addr + bias = runtime; hook hit + `greeneye_hits.tsv` (0.72 s) |
+| s10 | `s10_full_session_evidence.py` | evidence pack for a milestone (docs/14 §6; cycles 11–13, 2026-10-01) | hashes/sessions/logs/compare gathered by hand per cycle | one run → analyze + compare + hits + manifest + M1–M6 (4.92 s) |
+
+Times are the measured green-eye path on the reference host (native for
+s01–s08, QEMU for s09/s10); see `artifacts/results.json` for a fresh run.
+
+## Projected vs measured
+
+* **Measured:** every analog result, elapsed time, session size, trace
+ slowdown and slot/reply count in `result.json`.
+* **Projected (labelled `projected` in each result):** the real-case time
+ saving. No demo claims a real-GSP result the simulator cannot reach.
+* **Blocked (`blocked_on_device_model: true`):** all real-firmware claims
+ wait on the H2.2 device model; the workflow is demonstrated on the analog.
+
+## Tests
+
+```bash
+python3 -m unittest tests.test_value_demos -v # 5 tests, ~5 s
+```
+
+The suite runs the fastest demos end-to-end as subprocesses, asserts their
+answers and checks `run_all.py` emits `artifacts/results.json`. It skips
+cleanly when QEMU or the demo firmware is absent. See
+[`docs/25-VALUE-VALIDATION.md`](../../docs/25-VALUE-VALIDATION.md) for the
+methodology, overhead analysis and scenario representativeness argument.
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/__init__.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/__init__.py
new file mode 100644
index 0000000..87cabe7
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/__init__.py
@@ -0,0 +1 @@
+"""green-eye value-validation demos (see docs/25-VALUE-VALIDATION.md)."""
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/_common.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/_common.py
new file mode 100644
index 0000000..f70ef1f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/_common.py
@@ -0,0 +1,196 @@
+#!/usr/bin/env python3
+"""Shared plumbing for the green-eye value-validation demos.
+
+Every demo maps one *real* Open-IOV scenario (cited from the handoffs) onto a
+runnable demonstration on the bundled demo firmware / native RV64 simulator,
+prints the question, the green-eye commands, the answer, the evidence artifact
+and the measured wall-clock, and emits one machine-readable ``RESULT_JSON``
+line so ``run_all.py`` can aggregate.
+
+Stdlib only; no hardware; runs from the tool root
+(``open-iov/debug/risc-v-gsp-rm-vm``).
+"""
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+import shutil
+import sys
+import time
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.dirname(os.path.dirname(HERE))
+if TOOL_ROOT not in sys.path:
+ sys.path.insert(0, TOOL_ROOT)
+
+ARTIFACT_ROOT = os.path.join(HERE, "artifacts")
+DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+SCHEMA_JSON = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "gsp_ctrl.schema.json")
+SDK_ELF = os.path.join(TOOL_ROOT, "sdk", "build", "injected_demo.elf")
+
+CTRL_MAGIC = 0x47535031
+CTRL_RPC_COUNT = 0x10
+CTRL_LAST_RPC = 0x18
+CTRL_MAILBOX = 0x20
+CTRL_ARG0 = 0x28
+CTRL_ARG1 = 0x30
+CTRL_ARG2 = 0x38
+CTRL_RESULT = 0x40
+CTRL_BUF = 0x48
+CTRL_SIZE = 0x190
+PING_XOR = 0xBEEF
+INJECT_CMD_BASE = 16
+SERVICE_SLOT = 6
+
+#: Per-demo wall-clock ceiling the runner enforces (subprocess timeout).
+DEMO_TIMEOUT_S = 45.0
+#: Runner ceiling so the whole suite stays well under ~3 min.
+SUITE_BUDGET_S = 180.0
+
+
+class DemoError(RuntimeError):
+ """A demo precondition failed in a way the caller should report, not mask."""
+
+
+def qemu_available() -> bool:
+ return bool(shutil.which("qemu-system-riscv64"))
+
+
+def demo_available() -> bool:
+ return os.path.isfile(DEMO_ELF)
+
+
+def choose_backend(preferred: str = "native") -> str:
+ """Environment override wins; QEMU is used only when preferred and present."""
+ env = os.environ.get("VALUE_DEMO_BACKEND")
+ if env:
+ return env
+ if preferred == "qemu":
+ return "qemu" if qemu_available() else "native"
+ return "native"
+
+
+def sha256_file(path: str) -> str:
+ digest = hashlib.sha256()
+ with open(path, "rb") as fh:
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+class Stopwatch:
+ def __init__(self) -> None:
+ self.t0 = time.monotonic()
+ self.elapsed = 0.0
+
+ def stop(self) -> float:
+ self.elapsed = time.monotonic() - self.t0
+ return self.elapsed
+
+
+def artifact_dir(name: str) -> str:
+ path = os.path.join(ARTIFACT_ROOT, name)
+ os.makedirs(path, exist_ok=True)
+ return path
+
+
+def write_json(path: str, payload) -> str:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(payload, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ return path
+
+
+def write_text(path: str, text: str) -> str:
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "w", encoding="utf-8") as fh:
+ fh.write(text)
+ return path
+
+
+def load_schema(ge):
+ with open(SCHEMA_JSON, "r", encoding="utf-8") as fh:
+ return ge.schema_from_json(json.load(fh))
+
+
+def decode_ctrl(ge, data: bytes, schema=None) -> dict:
+ schema = schema or load_schema(ge)
+ out = schema.decode(data, base=0)
+ if isinstance(out.get("buf"), (bytes, bytearray)):
+ out["buf"] = bytes(out["buf"]).split(b"\x00", 1)[0].decode("utf-8", "replace")
+ return out
+
+
+def short_hex(value) -> str:
+ if value is None:
+ return "None"
+ if isinstance(value, str):
+ return value
+ return f"{value:#x}" if isinstance(value, int) else str(value)
+
+
+def render_banner(demo: str, question: str, scenario: str, baseline: str, commands) -> None:
+ print(f"=== {demo} ===")
+ print(f"real scenario : {scenario}")
+ print(f"baseline : {baseline}")
+ print(f"question : {question}")
+ print("commands :")
+ for command in commands:
+ print(f" - {command}")
+
+
+def finish(result: dict, artifact: str, elapsed: float) -> dict:
+ """Persist the per-demo result and print the human + machine summary."""
+ result["elapsed_s"] = round(elapsed, 4)
+ result["artifact"] = os.path.relpath(artifact, TOOL_ROOT) if artifact else None
+ result.setdefault("success", True)
+ if artifact:
+ write_json(os.path.join(artifact, "result.json"), result)
+ print(f"answer : {result.get('answer')}")
+ print(f"evidence : {result.get('artifact')}")
+ print(f"analog time : {elapsed:.3f} s ({result.get('backend')} backend)")
+ if result.get("real_baseline"):
+ print(f"real baseline : {result['real_baseline']}")
+ if result.get("projected"):
+ print(f"projected : {result['projected']}")
+ if result.get("blocked_on_device_model"):
+ print("blocked : real case blocked on H2 device model; workflow shown on analog")
+ if not result.get("success", True):
+ print(f"status : FAILED - {result.get('failure', 'unknown')}")
+ print("RESULT_JSON: " + json.dumps(result, sort_keys=True))
+ sys.stdout.flush()
+ return result
+
+
+def timed_demo(fn, artifact: str) -> dict:
+ watch = Stopwatch()
+ try:
+ result = fn()
+ except Exception as exc: # report, never hide
+ import traceback
+
+ result = {
+ "success": False,
+ "failure": f"{type(exc).__name__}: {exc}",
+ "traceback": traceback.format_exc()[-2000:],
+ }
+ return finish(result, artifact, watch.stop())
+
+
+def scenario(
+ *,
+ ids,
+ baseline,
+ projection,
+ blocked=False,
+ device=None,
+) -> dict:
+ return {
+ "citation_ids": ids,
+ "static_baseline": baseline,
+ "projection": projection,
+ "blocked_on_device_model": blocked,
+ "device_model_needed": device,
+ }
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/run_all.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/run_all.py
new file mode 100644
index 0000000..808c227
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/run_all.py
@@ -0,0 +1,123 @@
+#!/usr/bin/env python3
+"""Run every value-validation demo and emit one machine-readable results JSON.
+
+Usage (from the tool root, ``open-iov/debug/risc-v-gsp-rm-vm``):
+
+ python3 harness/value_demos/run_all.py
+ python3 harness/value_demos/run_all.py --only s01,s03,s05,s09
+ VALUE_DEMO_BACKEND=qemu python3 harness/value_demos/run_all.py
+
+Each demo runs in its own subprocess with a hard timeout, so one wedged demo
+cannot take the suite down. The aggregate lands in
+``harness/value_demos/artifacts/results.json``.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import subprocess
+import sys
+import time
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.dirname(os.path.dirname(HERE))
+sys.path.insert(0, HERE)
+
+from _common import ARTIFACT_ROOT, SUITE_BUDGET_S, write_json # noqa: E402
+
+DEMOS = [
+ ("s01", "s01_reply_consumer.py"),
+ ("s02", "s02_counter_publication.py"),
+ ("s03", "s03_unknown_command.py"),
+ ("s04", "s04_run_until_function.py"),
+ ("s05", "s05_snapshot_diff.py"),
+ ("s06", "s06_inject_service.py"),
+ ("s07", "s07_version_diff.py"),
+ ("s08", "s08_mutation_triage.py"),
+ ("s09", "s09_grabbing_a_ghidra_address.py"),
+ ("s10", "s10_full_session_evidence.py"),
+]
+
+
+def _run_one(key: str, script: str, timeout: float) -> dict:
+ path = os.path.join(HERE, script)
+ start = time.monotonic()
+ try:
+ proc = subprocess.run(
+ [sys.executable, path],
+ cwd=TOOL_ROOT,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ stdout, stderr, returncode = proc.stdout, proc.stderr, proc.returncode
+ except subprocess.TimeoutExpired as exc:
+ stdout = (exc.stdout or b"").decode("utf-8", "replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
+ stderr = (exc.stderr or b"").decode("utf-8", "replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
+ returncode = -9
+ elapsed = time.monotonic() - start
+ result = None
+ for line in reversed(stdout.splitlines()):
+ if line.startswith("RESULT_JSON: "):
+ try:
+ result = json.loads(line[len("RESULT_JSON: ") :])
+ except json.JSONDecodeError:
+ result = None
+ break
+ if result is None:
+ result = {
+ "success": False,
+ "failure": f"no RESULT_JSON (exit {returncode})",
+ "stderr_tail": stderr[-800:],
+ }
+ result["demo_key"] = key
+ result["runner_elapsed_s"] = round(elapsed, 4)
+ result["exit_code"] = returncode
+ return result
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--only", default="", help="comma-separated demo keys (e.g. s01,s03)")
+ parser.add_argument("--timeout", type=float, default=45.0, help="per-demo timeout seconds")
+ parser.add_argument("--out", default=os.path.join(ARTIFACT_ROOT, "results.json"))
+ args = parser.parse_args()
+
+ selected = [item for item in DEMOS if not args.only or item[0] in args.only.split(",")]
+ if args.only and not selected:
+ print(f"no demos match --only {args.only!r}", file=sys.stderr)
+ return 2
+
+ started = time.monotonic()
+ results = []
+ for key, script in selected:
+ print(f"--- running {script} ---", flush=True)
+ result = _run_one(key, script, args.timeout)
+ results.append(result)
+ status = "PASS" if result.get("success") else "FAIL"
+ print(f"--- {key}: {status} ({result['runner_elapsed_s']:.2f}s) ---", flush=True)
+ if time.monotonic() - started > SUITE_BUDGET_S:
+ print(f"[run_all] suite budget {SUITE_BUDGET_S:.0f}s exceeded; continuing", flush=True)
+
+ total = time.monotonic() - started
+ passed = [r for r in results if r.get("success")]
+ payload = {
+ "format": "greeneye-value-demos-results/1",
+ "tool": "harness/value_demos/run_all.py",
+ "backend_env": os.environ.get("VALUE_DEMO_BACKEND", "native (default)"),
+ "demos_selected": [k for k, _ in selected],
+ "demos_passed": [r["demo_key"] for r in passed],
+ "demos_failed": [r["demo_key"] for r in results if not r.get("success")],
+ "total_elapsed_s": round(total, 3),
+ "measured_elapsed_s": round(sum(r["runner_elapsed_s"] for r in results), 3),
+ "results": results,
+ }
+ write_json(args.out, payload)
+ print(f"\n{len(passed)}/{len(results)} demos passed in {total:.1f}s; results: {args.out}")
+ print(f"RESULT_JSON: {json.dumps({k: payload[k] for k in ('format', 'demos_passed', 'demos_failed', 'total_elapsed_s')}, sort_keys=True)}")
+ return 0 if len(passed) == len(results) else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s01_reply_consumer.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s01_reply_consumer.py
new file mode 100644
index 0000000..b86f272
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s01_reply_consumer.py
@@ -0,0 +1,151 @@
+#!/usr/bin/env python3
+"""s01 - reply consumer: did the server consume the command, and what came back?
+
+Real scenario (Handoff-vGPU-IO.md, PART 91 + ADDENDA 2-7, 2026-10-01):
+the physical backend booted the real GSP-RM and sent the pre-boot
+``GSP_SET_SYSTEM_INFO`` (fn72) / ``SET_REGISTRY`` (fn73) RPCs; the only reply
+the host could see was a generic ``GSP_INIT_DONE rpc_result=0x59``
+(``NV_ERR_OPERATING_SYSTEM``) wrapped by ``RmStartup``. Cycles 2-13
+(~11:40-16:52, ten supervised hardware cycles) were spent chasing which
+collector step consumed which field because the RM log ring was never mapped
+(LOGRM id bug) and the failure was un-journaled. Static/Ghidra-only work
+(REPORT3-10) produced hypotheses (display, devid, chipset, ACPI) that needed a
+live ``0x59``/success reply to accept or reject.
+
+Analog: hook the demo dispatcher, watch the control block's ``result`` field,
+post known/unknown/fault commands and decode the exact reply per command.
+"""
+from __future__ import annotations
+
+import os
+
+try: # package import (tests) or direct script execution (run_all)
+ from . import _common as C
+except ImportError:
+ import sys as _sys
+
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+ import _common as C
+
+
+def run() -> dict:
+ import greeneye as ge
+
+ backend = C.choose_backend("native")
+ out = C.artifact_dir("s01_reply_consumer")
+ commands = [
+ 'ge.launch(kernel=DEMO_ELF, backend="%s")' % backend,
+ 's.hook("rpc_dispatch", on_enter=record cmd/args)',
+ "s.watch(gsp_ctrl+0x40, size=8, kind='w') # result",
+ "s.memory.write_u64(ctrl+0x20, cmd); s.run(duration=...) # mailbox post",
+ "s.memory.read_u64(ctrl+0x40) # decode reply",
+ ]
+ scenario = C.scenario(
+ ids=[
+ "Handoff-vGPU-IO.md PART 91 (2026-10-01) cycles 2-3",
+ "PART 91 ADDENDUM 2-7 (2026-10-01): NOCAT, fn72/fn73, LOGRM id, identity",
+ ],
+ baseline=(
+ "cycles 2-13, ~5 h wall-clock and 10+ supervised hardware boots; "
+ "each candidate fix cost one full boot cycle (~5-6 min) and the "
+ "only visible reply was a generic 0x59"
+ ),
+ projection=(
+ "real GSP-RM reply consumption is blocked on the H2 device model; "
+ "on the analog the same observe->decode step is one hook + one "
+ "watchpoint per command"
+ ),
+ blocked=True,
+ device="H2.2 QEMU device model (RM message loop / real .fwimage)",
+ )
+ C.render_banner(
+ "s01_reply_consumer",
+ "When the host posts a command, did the dispatcher consume it and what exact reply came back?",
+ "0x59 RM-init chase: generic reply hid which collector step consumed which field",
+ scenario["static_baseline"],
+ commands,
+ )
+
+ cases = [
+ {"cmd": 1, "arg0": 0x1234, "label": "PING"},
+ {"cmd": 7, "arg0": 0, "label": "unknown-id"},
+ {"cmd": 6, "arg0": 0, "label": "FAULT"},
+ ]
+ replies = []
+ session = ge.launch(kernel=C.DEMO_ELF, backend=backend, name="vd-s01")
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ dispatches = []
+ stores = []
+ session.hook(
+ "rpc_dispatch",
+ on_enter=lambda hit: dispatches.append(
+ {"cmd": hit.arg(0), "arg0": hit.arg(1), "arg1": hit.arg(2), "arg2": hit.arg(3)}
+ ),
+ )
+ session.watch(ctrl + C.CTRL_RESULT, size=8, kind="w", callback=lambda hit: stores.append(hit.pc))
+ session.run(duration=0.3)
+ for case in cases:
+ before_count = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT)
+ session.memory.write_u64(ctrl + C.CTRL_ARG0, case["arg0"])
+ session.memory.write_u64(ctrl + C.CTRL_MAILBOX, case["cmd"])
+ session.run(duration=0.25)
+ after_count = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT)
+ result = session.memory.read_u64(ctrl + C.CTRL_RESULT)
+ expected = {
+ 1: (0x1234 ^ C.PING_XOR),
+ 7: (~7) & 0xFFFFFFFFFFFFFFFF,
+ 6: None,
+ }[case["cmd"]]
+ replies.append(
+ {
+ "label": case["label"],
+ "cmd": case["cmd"],
+ "arg0": case["arg0"],
+ "consumed": after_count > before_count,
+ "result": result,
+ "expected": expected,
+ "result_ok": (expected is None) or (result == expected),
+ "rpc_count_delta": after_count - before_count,
+ }
+ )
+ if case["cmd"] == 6:
+ # ebreak trap: the dispatcher's post-handler stores never run.
+ session.step(1)
+ known = replies[0]
+ unknown = replies[1]
+ fault = replies[2]
+ payload = {
+ "success": known["result_ok"] and unknown["result_ok"] and not fault["consumed"],
+ "backend": backend,
+ "firmware": os.path.relpath(C.DEMO_ELF, C.TOOL_ROOT),
+ "firmware_sha256": C.sha256_file(C.DEMO_ELF),
+ "dispatches": dispatches,
+ "reply_store_pcs": stores,
+ "replies": replies,
+ "question": "consumed and decoded every posted command",
+ "answer": (
+ f"PING consumed, reply {known['result']:#x} == arg0 ^ 0xbeef "
+ f"(expected {known['expected']:#x}); unknown id returns ~cmd "
+ f"({unknown['result']:#x}); FAULT consumed={fault['consumed']} "
+ f"(trap before the reply store) - the exact first-error visibility "
+ "gap the 0x59 chase lacked"
+ ),
+ "success_criteria": {
+ "pass": "3/3 dispatches observed; PING/unknown replies match the ABI; FAULT traps before result",
+ "fail": "a posted command is not consumed or a reply disagrees with the ABI",
+ },
+ "real_case": scenario,
+ }
+ C.write_json(os.path.join(out, "reply.json"), payload)
+ finally:
+ session.close()
+ return payload
+
+
+def main() -> int:
+ return 0 if C.timed_demo(run, C.artifact_dir("s01_reply_consumer"))["success"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s02_counter_publication.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s02_counter_publication.py
new file mode 100644
index 0000000..690f60f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s02_counter_publication.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+"""s02 - counter publication: which of N slots advanced, and when?
+
+Real scenario (Handoff-vGPU-IO.md PART 46-64, 2026-09-24..2026-09-29):
+``cuCtxCreate`` waits on 20 GR per-channel completion counters. The host saw
+the client's releases but could not translate the counter page; for many boots
+only 4 of 20 client-visible slots ever moved (PART 49). The exact slot layout
+(page VA ``0x20400f000``, slots at ``+0xec0``, 16-byte stride) was only proven
+by a raw live page dump (PART 64, 2026-09-29 ~16:55), and the host raced a
+~2-3 s pin path against the client's ~2 s GR-phase deadline. Static reading
+could not tell which slots were published: the page's GPA changed every boot
+and stale translations pointed at the previous client's page (PART 51).
+
+Analog: post 20 sequential commands and watch the demo's published result
+field; prove every slot was observed, with the publish PC recorded per slot.
+"""
+from __future__ import annotations
+
+import os
+
+try: # package import (tests) or direct script execution (run_all)
+ from . import _common as C
+except ImportError:
+ import sys as _sys
+
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+ import _common as C
+
+SLOTS = 20
+
+
+def run() -> dict:
+ import greeneye as ge
+
+ backend = C.choose_backend("native")
+ out = C.artifact_dir("s02_counter_publication")
+ commands = [
+ 'ge.launch(kernel=DEMO_ELF, backend="%s")' % backend,
+ "s.watch(gsp_ctrl+0x40, size=8, kind='w', callback=record pc) # publication",
+ "for slot in 1..20: write mailbox cmd=5 (STATE); run(duration=0.05)",
+ "read gsp_ctrl.result after every slot; compare against the expected stage value",
+ ]
+ scenario = C.scenario(
+ ids=[
+ "Handoff-vGPU-IO.md PART 49/50 (2026-09-29): only 4/20 counters moved; all 20 reached 1 after the queue fix",
+ "PART 64 (2026-09-29 ~16:55): raw page dump proves 20 slots at 16-byte stride",
+ "PART 46/48 (2026-09-29): rescue/sweep latency and tracker waits",
+ ],
+ baseline=(
+ "PARTs 46->64: ~5 days of sessions; PART 49 'only 4 of 20 counters "
+ "ever moved'; each boot re-derived the slot layout because the "
+ "counter page GPA changed (PART 51/53)"
+ ),
+ projection=(
+ "on real silicon the 20 slots are per-channel memory pages; the "
+ "analog publishes 20 logical slots through the same watchpoint "
+ "shape, one store each"
+ ),
+ blocked=True,
+ device="H2.2 device model + real GSP-RM counter pages",
+ )
+ C.render_banner(
+ "s02_counter_publication",
+ "Of the N client-visible slots, which ones advanced, and where was each published?",
+ "20 GR per-channel completion counters: 4/20 published for days until a live page dump",
+ scenario["static_baseline"],
+ commands,
+ )
+
+ session = ge.launch(kernel=C.DEMO_ELF, backend=backend, name="vd-s02")
+ slots = []
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ publishes = []
+ session.watch(ctrl + C.CTRL_RESULT, size=8, kind="w", callback=lambda hit: publishes.append(hit.pc))
+ session.run(duration=0.25)
+ before = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT)
+ for slot in range(SLOTS):
+ session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 5) # STATE
+ session.run(duration=0.05)
+ value = session.memory.read_u64(ctrl + C.CTRL_RESULT)
+ slots.append(
+ {
+ "slot": slot + 1,
+ "result": value,
+ "result_hex": f"{value:#x}",
+ "published": value in (0x10, 0x20, 0x30, 0x40),
+ }
+ )
+ after = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT)
+ published = [s for s in slots if s["published"]]
+ payload = {
+ "success": len(published) == SLOTS and (after - before) == SLOTS,
+ "backend": backend,
+ "slots_requested": SLOTS,
+ "slots_published": len(published),
+ "rpc_count_delta": after - before,
+ "watch_stores": len(publishes),
+ "distinct_values": sorted({s["result_hex"] for s in published}),
+ "per_slot": slots,
+ "publish_pcs": sorted(set(publishes)),
+ "question": f"did all {SLOTS} slots publish, and can each publication be attributed?",
+ "answer": (
+ f"{len(published)}/{SLOTS} slots published ({after - before} dispatcher "
+ f"completions, {len(publishes)} stores to gsp_ctrl.result, values "
+ f"{sorted({s['result_hex'] for s in published})}); every store PC is captured, "
+ "exactly the evidence class that took a live 512-byte page dump on the real path"
+ ),
+ "success_criteria": {
+ "pass": f"{SLOTS}/{SLOTS} slots observed with rpc_count advancing once per slot",
+ "fail": "any slot does not publish or the count delta disagrees",
+ },
+ "real_case": scenario,
+ }
+ C.write_json(os.path.join(out, "counters.json"), payload)
+ finally:
+ session.close()
+ return payload
+
+
+def main() -> int:
+ return 0 if C.timed_demo(run, C.artifact_dir("s02_counter_publication"))["success"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s03_unknown_command.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s03_unknown_command.py
new file mode 100644
index 0000000..162bc47
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s03_unknown_command.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+"""s03 - unknown command discovery: which IDs are handled, injected, or new?
+
+Real scenario (docs/19-CONTROL-DISCOVERY.md, measured 2026-10-02): a swept
+input surface is how new/undocumented controls are found between firmware
+versions. On the demo, IDs 16..23 were *discovered* to fall through the
+switch into the injected-service handler table (16..21 alias the built-in
+handlers, 22..23 are NULL slots returning ``~id``) - wiring a new image can
+add silently. Statically this requires reading an indirect dispatch table
+per version; behaviorally it is a bounded sweep.
+
+Analog: 24 command IDs, zero arguments, deterministic, fresh session per ID
+(the same safety strategy as the real discovery tool), classified by hooks on
+the dispatcher and every handler-table entry.
+"""
+from __future__ import annotations
+
+import json
+import os
+
+try: # package import (tests) or direct script execution (run_all)
+ from . import _common as C
+except ImportError:
+ import sys as _sys
+
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+ import _common as C
+
+HANDLERS = ("rpc_ping", "rpc_echo", "rpc_fill", "rpc_sum", "rpc_state", "rpc_fault")
+
+
+def _classify(cmd: int, handler_hits, dispatch_seen, result, trap) -> str:
+ if 16 <= cmd <= 23:
+ return "injected-slot"
+ if 1 <= cmd <= 6:
+ return "known-handler"
+ if trap:
+ return "trap"
+ if result is not None and result == (~cmd) & 0xFFFFFFFFFFFFFFFF:
+ return "default-path"
+ return "no-progress"
+
+
+def run() -> dict:
+ import greeneye as ge
+
+ backend = C.choose_backend("native")
+ out = C.artifact_dir("s03_unknown_command")
+ commands = [
+ 'for cmd in 1..24: ge.launch(backend="%s"); hook(rpc_dispatch + 6 handlers)' % backend,
+ "write mailbox cmd with zero args; run bounded; classify",
+ "record: outcome, handler alias, result, trap signal, rpc_count delta",
+ ]
+ scenario = C.scenario(
+ ids=[
+ "docs/19-CONTROL-DISCOVERY.md (2026-10-02): 189-probe demo sweep in ~36 s",
+ "Handoff-vGPU-IO.md PART 91 (2026-10-01): new controls appear between images (0x59 era)",
+ "docs/14-ADVERSARIAL-INTEROP-GUIDE.md 4.1: static diff says code changed, not which input activates it",
+ ],
+ baseline=(
+ "static-only: read the indirect dispatcher table in Ghidra for every "
+ "image; on the real corpus the demo sweep took ~36 s for 189 probes "
+ "and still found an undocumented injected-slot class"
+ ),
+ projection=(
+ "the RM surface (control IDs by class, method opcodes) is gated on "
+ "H1.3/H2.2; the sweep/classification shape demonstrated here is "
+ "exactly what docs/19 section 5 designs for it"
+ ),
+ blocked=True,
+ device="H1.3/H2.2: RM dispatch table exposed to the simulator",
+ )
+ C.render_banner(
+ "s03_unknown_command",
+ "Which command IDs are handled, which route to injected slots, and which are new/default?",
+ "unknown-control discovery between stripped firmware versions",
+ scenario["static_baseline"],
+ commands,
+ )
+
+ findings = []
+ for cmd in range(1, 25):
+ session = ge.launch(kernel=C.DEMO_ELF, backend=backend, name=f"vd-s03-{cmd:02d}")
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ dispatch = []
+ handler_hits = []
+ signals = []
+ session.hook(
+ "rpc_dispatch",
+ on_enter=lambda hit, d=dispatch: d.append(
+ {"cmd": hit.arg(0), "arg0": hit.arg(1), "arg1": hit.arg(2), "arg2": hit.arg(3)}
+ ),
+ )
+ for name in HANDLERS:
+ try:
+ session.hook(name, on_enter=lambda hit, n=name: handler_hits.append(n))
+ except Exception:
+ pass
+ session.on_event("stop", lambda hit, sig=signals: sig.append(hit.stop.signal) if hit.stop else None)
+ session.run(duration=0.15)
+ session.memory.write_u64(ctrl + C.CTRL_MAILBOX, cmd)
+ session.run(duration=0.15)
+ result = session.memory.read_u64(ctrl + C.CTRL_RESULT)
+ trap = 5 in [s for s in signals if s]
+ outcome = _classify(cmd, handler_hits, bool(dispatch), result, trap)
+ findings.append(
+ {
+ "cmd": cmd,
+ "outcome": outcome,
+ "trap": trap,
+ "signals": [s for s in signals if s],
+ "handler_alias": handler_hits[0] if handler_hits else None,
+ "dispatch_seen": bool(dispatch),
+ "result": result,
+ "result_hex": f"{result:#x}",
+ "expected_default": f"{(~cmd) & 0xFFFFFFFFFFFFFFFF:#x}",
+ }
+ )
+ finally:
+ session.close()
+
+ by_class: dict[str, list[int]] = {}
+ for finding in findings:
+ by_class.setdefault(finding["outcome"], []).append(finding["cmd"])
+ injected = by_class.get("injected-slot", [])
+ aliases = {f["cmd"]: f["handler_alias"] for f in findings if f["cmd"] in injected}
+ payload = {
+ "success": len(injected) == 8 and len(by_class.get("known-handler", [])) == 6,
+ "backend": backend,
+ "ids_swept": len(findings),
+ "classes": {key: {"count": len(v), "ids": v} for key, v in sorted(by_class.items())},
+ "injected_aliases": aliases,
+ "findings": findings,
+ "question": "enumerate handled / injected / default command space in one bounded sweep",
+ "answer": (
+ f"{len(by_class.get('known-handler', []))} known handlers, "
+ f"{len(injected)} injected-slot IDs ({injected}), aliases {aliases}, "
+ f"{len(by_class.get('default-path', []))} default-path IDs; "
+ "the same shape (sweep + dispatcher/handler hooks) is what found the "
+ "silent 16..23 wiring the static map did not highlight"
+ ),
+ "success_criteria": {
+ "pass": "IDs 1..6 classified known, 16..23 classified injected-slot, remaining default; cmd 6 traps",
+ "fail": "an ID is misclassified or a fresh-session probe cannot be replayed",
+ },
+ "real_case": scenario,
+ }
+ os.makedirs(out, exist_ok=True)
+ with open(os.path.join(out, "findings.jsonl"), "w", encoding="utf-8") as fh:
+ for finding in findings:
+ fh.write(json.dumps(finding, sort_keys=True) + "\n")
+ lines = ["# control discovery (analog)", "", f"* backend: {backend}", f"* ids swept: {len(findings)}", ""]
+ for key, value in sorted(by_class.items()):
+ lines.append(f"* {key}: {value}")
+ lines.append("")
+ C.write_text(os.path.join(out, "control-discovery.md"), "\n".join(lines) + "\n")
+ C.write_json(os.path.join(out, "discovery.json"), payload)
+ return payload
+
+
+def main() -> int:
+ return 0 if C.timed_demo(run, C.artifact_dir("s03_unknown_command"))["success"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s04_run_until_function.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s04_run_until_function.py
new file mode 100644
index 0000000..a74c83f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s04_run_until_function.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+"""s04 - run-until a function: is this code on the live path, and with what args?
+
+Real scenario (docs/19-CONTROL-DISCOVERY.md 4.4 and
+Handoff-vGPU-IO.md PART 91 ADDENDUM 9, 2026-10-01 cycles 18-25): the
+``gsp-phys-rm-call`` RM-client ladder asked, per cycle, whether a symbolized
+function had executed under the current stimulus (VAS commit, token, BIND,
+fetch, semaphore). Cycles 18->25 (~1 h 41 m, eight supervised boots) walked
+the ladder one reachability question at a time; ``reach.py`` answers that
+question in ~0.5 s per target, and a ``run_until`` timeout means "not executed
+under this stimulus at this fidelity", never "unreachable".
+
+Analog: run to the self-test's ``rpc_sum``, show ``rpc_fault`` is *not* on the
+natural path (timeout), then trigger it through the mailbox and reach it with
+the dispatch arguments captured; finally call ``rpc_ping`` directly
+(Frida NativeFunction) to show the unit-test route.
+"""
+from __future__ import annotations
+
+import os
+
+try: # package import (tests) or direct script execution (run_all)
+ from . import _common as C
+except ImportError:
+ import sys as _sys
+
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+ import _common as C
+
+
+def run() -> dict:
+ import greeneye as ge
+ from greeneye.errors import RspTimeout
+
+ backend = C.choose_backend("native")
+ out = C.artifact_dir("s04_run_until_function")
+ commands = [
+ 's.run_until("rpc_sum", timeout=5.0) # natural path',
+ 's.run_until("rpc_fault", timeout=0.2) # not on natural path',
+ 'write mailbox cmd=6; s.run_until("rpc_fault") # trigger route',
+ 's.call("rpc_ping", (0x41,)) # Frida-like direct call',
+ ]
+ scenario = C.scenario(
+ ids=[
+ "docs/19-CONTROL-DISCOVERY.md 4.4 (2026-10-02): reach.py measured ~0.5 s per target",
+ "Handoff-vGPU-IO.md PART 91 ADDENDUM 9 (2026-10-01): RM-client ladder cycles 18-25",
+ "docs/14-ADVERSARIAL-INTEROP-GUIDE.md 5: run_until timeouts are not unreachability",
+ ],
+ baseline=(
+ "cycles 18-25 took ~1 h 41 m of supervised hardware cycles (full "
+ "boot + RPC ladder per hypothesis) to answer the same class of "
+ "'did this function execute under this stimulus' question"
+ ),
+ projection=(
+ "real RM ladder reachability needs the live GSP-RM (H1.3/H2.2); "
+ "run_until/hook/call already work against the re-hosted firmware "
+ "shape and every probe spec emitted here replays"
+ ),
+ blocked=True,
+ device="H1.3 live RM method entry / H2.2 device model",
+ )
+ C.render_banner(
+ "s04_run_until_function",
+ "Is a symbolized function on the live path under this stimulus, what args does it take, and can it be called directly?",
+ "RM-client ladder: reachability of each rung, one supervised boot per question",
+ scenario["static_baseline"],
+ commands,
+ )
+
+ session = ge.launch(kernel=C.DEMO_ELF, backend=backend, name="vd-s04")
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ dispatch_args = []
+ session.hook(
+ "rpc_dispatch",
+ on_enter=lambda hit: dispatch_args.append(
+ {"cmd": hit.arg(0), "a0": hit.arg(1), "a1": hit.arg(2), "a2": hit.arg(3)}
+ ),
+ )
+ natural_hit = session.run_until("rpc_sum", timeout=5.0)
+ natural = {
+ "target": "rpc_sum",
+ "method": "run",
+ "reachable": True,
+ "pc": f"{natural_hit.addr:#x}",
+ "dispatch_args": list(dispatch_args),
+ }
+ timeout_hit = None
+ try:
+ timeout_hit = session.run_until("rpc_fault", timeout=0.2)
+ timeout_seen = False
+ except RspTimeout:
+ timeout_seen = True
+ fault_before = {
+ "target": "rpc_fault",
+ "method": "run_until",
+ "reachable": timeout_hit is not None,
+ "timeout": timeout_seen,
+ "reading": "not executed under this stimulus at this fidelity (docs/14 section 5)",
+ }
+ session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 6)
+ trigger_hit = session.run_until("rpc_fault", timeout=5.0)
+ triggered = {
+ "target": "rpc_fault",
+ "method": "trigger (mailbox cmd=6)",
+ "reachable": True,
+ "pc": f"{trigger_hit.addr:#x}",
+ "dispatch_args": list(dispatch_args[-1:]),
+ }
+ direct = session.call("rpc_ping", (0x41,))
+ payload = {
+ "success": natural["reachable"] and fault_before["timeout"] and triggered["reachable"]
+ and direct == (0x41 ^ C.PING_XOR),
+ "backend": backend,
+ "natural_path": natural,
+ "before_trigger": fault_before,
+ "after_trigger": triggered,
+ "direct_call": {"target": "rpc_ping", "args": [0x41], "result": direct, "expected": 0x41 ^ C.PING_XOR},
+ "question": "reachability and required arguments per symbolized function",
+ "answer": (
+ f"rpc_sum reached on the self-test path (pc {natural['pc']}); rpc_fault "
+ f"times out (reachable=False) until mailbox cmd=6 triggers it "
+ f"(pc {triggered['pc']}, args {triggered['dispatch_args']}); direct call "
+ f"rpc_ping(0x41)={direct:#x} - three reachability answers, one session"
+ ),
+ "success_criteria": {
+ "pass": "natural target reached, un-triggered target times out, trigger route reaches it, direct call matches",
+ "fail": "a route reports reachable without a hit, or the direct call disagrees",
+ },
+ "real_case": scenario,
+ }
+ C.write_json(os.path.join(out, "reach.json"), payload)
+ finally:
+ session.close()
+ return payload
+
+
+def main() -> int:
+ return 0 if C.timed_demo(run, C.artifact_dir("s04_run_until_function"))["success"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s05_snapshot_diff.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s05_snapshot_diff.py
new file mode 100644
index 0000000..fd89131
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s05_snapshot_diff.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+"""s05 - snapshot diff: which control-block fields did one command change?
+
+Real scenario (Handoff-vGPU-IO.md PART 91 ADDENDUM 6, 2026-10-01 cycle 12/13):
+the fn72 ``GSP_SET_SYSTEM_INFO`` payload carried *unpacked* identity fields and
+the reference sends raw config-space dwords; the server read the device id
+back as zero and only a field-level comparison of the live payload/reply
+could confirm the fix ("all zeroed fn72 fields cannot matter to this failure"
+was settled by decoding, not by reading). Static review (REVIEW-FN72.md,
+cycle 13's 1656-byte static-config reply) still needed a live before/after of
+the control block to call the fix verified.
+
+Analog: snapshot the decoded control block before and after one command,
+attribute every changed byte run to its named schema field, and show ECHO's
+buffer side-effect separately.
+"""
+from __future__ import annotations
+
+import os
+
+try: # package import (tests) or direct script execution (run_all)
+ from . import _common as C
+except ImportError:
+ import sys as _sys
+
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+ import _common as C
+
+
+def _fields_hit(schema, diffs) -> list[str]:
+ names = []
+ for diff in diffs:
+ for field in schema.fields:
+ start = field.offset
+ end = field.offset + field.nbytes
+ if start <= diff.addr < end:
+ if field.name not in names:
+ names.append(field.name)
+ return names
+
+
+def run() -> dict:
+ import greeneye as ge
+ from greeneye.memory import Memory
+
+ backend = C.choose_backend("native")
+ out = C.artifact_dir("s05_snapshot_diff")
+ commands = [
+ 's.snapshot(gsp_ctrl, 0x188, "before")',
+ "write mailbox cmd=2 (ECHO) with a known string; run",
+ 's.snapshot(gsp_ctrl, 0x188, "after")',
+ "Memory.diff(before, after); decode both via gsp_ctrl.schema.json",
+ ]
+ scenario = C.scenario(
+ ids=[
+ "Handoff-vGPU-IO.md PART 91 ADDENDUM 6 cycle 12 (2026-10-01): unpacked fn72 identity fields",
+ "PART 91 ADDENDUM 7 (2026-10-01): 1656-byte static-config reply decoded field-by-field",
+ "docs/06-ACCURACY.md M6: memory side-effect agreement / snapshot hashes",
+ ],
+ baseline=(
+ "the malformed identity value was suspected statically (REVIEW-FN72) "
+ "but accepting the fix required a live boot per iteration; ten NOCAT "
+ "cycles ran before the field-level root cause was visible"
+ ),
+ projection=(
+ "the same before/after diff runs against any firmware region once "
+ "the H2 device model exposes the real RM control pages"
+ ),
+ blocked=True,
+ device="H2.2 RM control/reply pages for the real GSP-RM",
+ )
+ C.render_banner(
+ "s05_snapshot_diff",
+ "Which named control-block fields did this one command change?",
+ "fn72/static-config field-level validation (identity fix, cycle 12/13)",
+ scenario["static_baseline"],
+ commands,
+ )
+
+ schema = C.load_schema(ge)
+ session = ge.launch(kernel=C.DEMO_ELF, backend=backend, name="vd-s05")
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ session.run(duration=0.25)
+ before = session.snapshot(ctrl, C.CTRL_SIZE, "before-echo")
+ message = b"hello-green-eye"
+ # Locate the string literal in the loaded image so the stimulus is
+ # self-describing (no hardcoded address).
+ hits = session.memory.search(0x80000000, 0x2000, message)
+ literal = hits[0] if hits else 0
+ session.memory.write_u64(ctrl + C.CTRL_ARG0, literal)
+ session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 2)
+ session.run(duration=0.25)
+ after = session.snapshot(ctrl, C.CTRL_SIZE, "after-echo")
+
+ before_dec = C.decode_ctrl(ge, before, schema)
+ after_dec = C.decode_ctrl(ge, after, schema)
+ diffs = Memory.diff(before, after)
+ changed_fields = _fields_hit(schema, diffs)
+ changed_names = [name for name in changed_fields if before_dec.get(name) != after_dec.get(name)]
+ rendered = Memory.render_diff(diffs, symbols=session.symbols)
+ payload = {
+ "success": bool(changed_names)
+ and set(changed_names) >= {"rpc_count", "last_rpc", "result", "buf"}
+ and after_dec.get("result") == len(message),
+ "backend": backend,
+ "changed_field_names": changed_names,
+ "changed_byte_runs": len(diffs),
+ "before": {k: v for k, v in before_dec.items() if k in changed_names},
+ "after": {k: v for k, v in after_dec.items() if k in changed_names},
+ "rendered_diff": rendered,
+ "question": "attribute every changed byte in one region to a named field",
+ "answer": (
+ f"ECHO changed {len(changed_names)} named fields {changed_names}; "
+ f"buf={after_dec.get('buf')!r} result={after_dec.get('result')} "
+ f"(len={len(message)}); {len(diffs)} byte runs exactly covered by the schema - "
+ "the field-level 'what did the reply actually consume' check"
+ ),
+ "success_criteria": {
+ "pass": "rpc_count/last_rpc/result/buf change, result equals the ECHO length, no unattributed run",
+ "fail": "a region changes with no named field, or the ECHO reply length disagrees",
+ },
+ "real_case": scenario,
+ }
+ C.write_json(os.path.join(out, "snapshot-diff.json"), payload)
+ C.write_text(
+ os.path.join(out, "snapshot-diff.md"),
+ "# snapshot diff (analog)\n\n"
+ f"* changed fields: {changed_names}\n"
+ f"* changed byte runs: {len(diffs)}\n\n"
+ "```\n" + rendered + "\n```\n",
+ )
+ finally:
+ session.close()
+ return payload
+
+
+def main() -> int:
+ return 0 if C.timed_demo(run, C.artifact_dir("s05_snapshot_diff"))["success"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s06_inject_service.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s06_inject_service.py
new file mode 100644
index 0000000..04dd001
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s06_inject_service.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""s06 - inject a service: add an RM engine without rebuilding the firmware.
+
+Real scenario (Handoff-vGPU-IO.md PART 92 ADDENDUM 5-11, 2026-10-02): the P6
+work wired guest-originated CE copies through the real engine; every change to
+the service/wiring path cost a kernel rebuild plus a supervised physical boot
+cycle (P6a/b/c, ~5-8 min each, one device-write experiment per cycle) and was
+only accepted after guest smoke ``ALL OK 64/64``. docs/12-INJECTION-SDK.md
+defines the in-simulator equivalent: load a position-independent service blob
+and register it with the firmware's dispatch table (commands 16..23).
+
+Analog: load ``sdk/build/injected_demo.elf`` into slot 6, invoke it directly
+(Frida NativeFunction) and through the firmware mailbox, and record the
+``service_injected`` evidence event. No kernel rebuild, no hardware.
+"""
+from __future__ import annotations
+
+import os
+
+try: # package import (tests) or direct script execution (run_all)
+ from . import _common as C
+except ImportError:
+ import sys as _sys
+
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+ import _common as C
+
+
+def run() -> dict:
+ import greeneye as ge
+ from greeneye.inject import invoke_remote, invoke_via_firmware, load_service
+
+ backend = C.choose_backend("native")
+ out = C.artifact_dir("s06_inject_service")
+ commands = [
+ 'load_service(s, "sdk/build/injected_demo.elf", slot=6)',
+ "invoke_remote(s, svc, cmd=1, a0=0x41) # debugger-side call",
+ "invoke_via_firmware(s, svc, cmd=1, a0=0x41) # mailbox cmd=16",
+ "read gsp_ctrl.result / handlers[6]",
+ ]
+ scenario = C.scenario(
+ ids=[
+ "Handoff-vGPU-IO.md PART 92 ADDENDUM 5-11 (2026-10-02): P6 service/wiring rungs",
+ "PART 92 ADDENDUM 6 (2026-10-02): p6run7 first real engine copy after service wiring",
+ "docs/12-INJECTION-SDK.md: blob/ABI contract for injecting RM services",
+ ],
+ baseline=(
+ "each P6 rung was a kernel rebuild plus a supervised physical cycle "
+ "(~5-8 min boots; P6a->P6c spanned ~02:20-08:30) with fail-safe "
+ "release discipline after every run"
+ ),
+ projection=(
+ "on real GSP-RM the registry is the RM service table (M5), gated on "
+ "H2.2; the load/register/invoke/observe shape is identical to the "
+ "demo handler-table slot"
+ ),
+ blocked=True,
+ device="H2.2 device model + M5 RM service registry",
+ )
+ C.render_banner(
+ "s06_inject_service",
+ "Can a new service be loaded, registered and invoked without rebuilding the firmware?",
+ "guest-originated CE service wiring (P6a-P6c): kernel rebuild + supervised boot per rung",
+ scenario["static_baseline"],
+ commands,
+ )
+
+ if not os.path.isfile(C.SDK_ELF):
+ payload = {
+ "success": False,
+ "skipped": True,
+ "failure": "sdk/build/injected_demo.elf not built (run: make sdk)",
+ "backend": backend,
+ "real_case": scenario,
+ }
+ C.write_json(os.path.join(out, "inject.json"), payload)
+ return payload
+
+ session = ge.launch(kernel=C.DEMO_ELF, backend=backend, record=os.path.join(out, "session"), name="vd-s06")
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ session.run(duration=0.25)
+ service = load_service(session, C.SDK_ELF, slot=C.SERVICE_SLOT)
+ remote_ping = invoke_remote(session, service, 1, 0x41)
+ remote_sum = invoke_remote(session, service, 2, 0x10, 0x20)
+ via_fw_ping = invoke_via_firmware(session, service, 1, 0x41)
+ via_fw_sum = invoke_via_firmware(session, service, 2, 0x10, 0x20)
+ handlers7 = session.memory.read_u64(ctrl + 0x150 + 8 * C.SERVICE_SLOT)
+ payload = {
+ "success": remote_ping == (0x41 ^ C.PING_XOR) and remote_sum == 0x30
+ and via_fw_ping == remote_ping and via_fw_sum == remote_sum,
+ "backend": backend,
+ "service": service.summary(),
+ "service_sha256": C.sha256_file(C.SDK_ELF),
+ "handlers_slot6": f"{handlers7:#x}",
+ "remote": {"ping": remote_ping, "sum": remote_sum},
+ "via_firmware": {"ping": via_fw_ping, "sum": via_fw_sum},
+ "mailbox_command": C.INJECT_CMD_BASE + C.SERVICE_SLOT,
+ "session": os.path.relpath(os.path.join(out, "session"), C.TOOL_ROOT),
+ "question": "load/register/invoke a service blob end-to-end, both routes",
+ "answer": (
+ f"service loaded at {service.load_addr:#x}, slot {service.slot} "
+ f"(handlers[{service.slot}]={handlers7:#x}); remote ping={remote_ping:#x} "
+ f"sum={remote_sum:#x}; via mailbox cmd={C.INJECT_CMD_BASE + C.SERVICE_SLOT} "
+ f"ping={via_fw_ping:#x} sum={via_fw_sum:#x} - both paths agree, one session"
+ ),
+ "success_criteria": {
+ "pass": "remote and firmware-mailbox invocations return the SDK ABI values and agree",
+ "fail": "a route disagrees, the slot is not registered, or the blob cannot load",
+ },
+ "real_case": scenario,
+ }
+ C.write_json(os.path.join(out, "inject.json"), payload)
+ finally:
+ session.close()
+ return payload
+
+
+def main() -> int:
+ return 0 if C.timed_demo(run, C.artifact_dir("s06_inject_service"))["success"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s07_version_diff.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s07_version_diff.py
new file mode 100644
index 0000000..e4748d7
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s07_version_diff.py
@@ -0,0 +1,179 @@
+#!/usr/bin/env python3
+"""s07 - version diff: same stimulus against two firmware versions.
+
+Real scenario (Handoff-vGPU-IO.md PART 91, 2026-10-01 cycles 2-3): the staged
+570.211 image and a verified 570.144 image both failed ``GSP_INIT_DONE`` with
+``rpc_result=0x59``; disproving the image-pairing hypothesis cost one full
+supervised boot cycle per version, and docs/14 section 4.1 exists because
+"the code changed" and "the behaviour moved" must be separated before the
+emulation is touched. Sorting a new image into the decision table
+(static changed x hit/write profile changed) is the H4.1 triage.
+
+Analog: byte-patch the demo's PING reply constant (0xbeef -> 0xbef0) to make a
+labelled "new version", run the *same* recorded stimulus on both, and diff the
+sessions and the decoded control block. The answer is one live-path field.
+"""
+from __future__ import annotations
+
+import os
+import shutil
+
+try: # package import (tests) or direct script execution (run_all)
+ from . import _common as C
+except ImportError:
+ import sys as _sys
+
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+ import _common as C
+
+#: addiw a1, a1, -0x111 (the 0xbeef PING key) -> -0x110 (0xbef0)
+ADDW_OLD = bytes.fromhex("9b85f5ee")
+ADDW_NEW = bytes.fromhex("9b8505ef")
+STRING_OLD = b"hello-green-eye"
+STRING_NEW = b"hello-green-eyf"
+
+
+def _make_variant(src: str, dst: str) -> dict:
+ with open(src, "rb") as fh:
+ data = bytearray(fh.read())
+ count = data.count(ADDW_OLD)
+ if count == 1:
+ offset = data.find(ADDW_OLD)
+ old_bytes = bytes(data[offset : offset + 4])
+ data[offset : offset + 4] = ADDW_NEW
+ kind = "ping-key-0xbeef->0xbef0"
+ else:
+ count = data.count(STRING_OLD)
+ if count != 1:
+ raise C.DemoError(f"no unique patch site (addiw x{data.count(ADDW_OLD)}, string x{count})")
+ offset = data.find(STRING_OLD)
+ old_bytes = bytes(data[offset : offset + len(STRING_OLD)])
+ data[offset : offset + len(STRING_OLD)] = STRING_NEW
+ kind = "echo-string"
+ with open(dst, "wb") as fh:
+ fh.write(data)
+ return {
+ "kind": kind,
+ "offset": offset,
+ "old_bytes": old_bytes.hex(),
+ "source_sha256": C.sha256_file(src),
+ "variant_sha256": C.sha256_file(dst),
+ }
+
+
+def run() -> dict:
+ import greeneye as ge
+ from greeneye.analyze import compare_sessions
+
+ backend = C.choose_backend("native")
+ out = C.artifact_dir("s07_version_diff")
+ va = os.path.join(out, "uproc_demo_vA.elf")
+ vb = os.path.join(out, "uproc_demo_vB.elf")
+ shutil.copyfile(C.DEMO_ELF, va)
+ patch = _make_variant(C.DEMO_ELF, vb)
+
+ commands = [
+ "byte-patch PING key 0xbeef -> 0xbef0 to create version B",
+ 'for v in (A,B): s = ge.launch(v, backend="%s", record=...)' % backend,
+ "identical stimulus: mailbox cmd=1 arg0=0x1234; snapshot+decode gsp_ctrl",
+ "compare_sessions(A, B, symbols) + decoded field diff",
+ ]
+ scenario = C.scenario(
+ ids=[
+ "Handoff-vGPU-IO.md PART 91 (2026-10-01) cycles 2-3: 570.211 vs 570.144 pairing disproven",
+ "PART 91 ADDENDUM 6 (2026-10-01): LOGRM id + fn72 identity changed behaviour between cycles",
+ "docs/14-ADVERSARIAL-INTEROP-GUIDE.md 4.1: version-diff decision table",
+ ],
+ baseline=(
+ "one full supervised boot (~5-6 min) per version plus manual log "
+ "comparison; the pairing hypothesis took cycles 2 and 3 back-to-back"
+ ),
+ projection=(
+ "green-eye cannot boot the real image without the H2 device model; "
+ "the same same-stimulus/compare-sessions loop runs on re-hosted "
+ "firmware and the produced symbol/session deltas are the H4.1 artifact"
+ ),
+ blocked=True,
+ device="H2.2 device model for real .fwimage versions",
+ )
+ C.render_banner(
+ "s07_version_diff",
+ "On the same stimulus, which live-path behaviour moved between two firmware versions?",
+ "570.211 vs 570.144 image pairing; semantic diff before touching the emulation",
+ scenario["static_baseline"],
+ commands,
+ )
+
+ schema = C.load_schema(ge)
+ results = {}
+ for label, path in (("A", va), ("B", vb)):
+ session = ge.launch(kernel=path, backend=backend, record=os.path.join(out, f"session-{label}"), name=f"vd-s07-{label}")
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ dispatches = []
+ session.hook(
+ "rpc_dispatch",
+ on_enter=lambda hit, d=dispatches: d.append({"cmd": hit.arg(0), "arg0": hit.arg(1)}),
+ )
+ session.watch(ctrl + C.CTRL_RESULT, size=8, kind="w")
+ session.run(duration=0.25)
+ session.memory.write_u64(ctrl + C.CTRL_ARG0, 0x1234)
+ session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 1)
+ session.run(duration=0.25)
+ decoded = C.decode_ctrl(ge, session.snapshot(ctrl, C.CTRL_SIZE, f"ctrl-{label}"), schema)
+ session.note("version", label=label, patch=patch["kind"], reply=decoded.get("result"))
+ results[label] = {
+ "path": os.path.relpath(path, C.TOOL_ROOT),
+ "sha256": C.sha256_file(path),
+ "dispatches": dispatches,
+ "reply": decoded.get("result"),
+ "rpc_count": decoded.get("rpc_count"),
+ "last_rpc": decoded.get("last_rpc"),
+ }
+ finally:
+ session.close()
+
+ compare_md = compare_sessions(
+ os.path.join(out, "session-A"), os.path.join(out, "session-B"), symbols=session.symbols, max_rows=20
+ )
+ C.write_text(os.path.join(out, "compare-sessions.md"), compare_md)
+ moved = sorted(
+ key
+ for key in ("reply", "rpc_count", "last_rpc")
+ if results["A"][key] != results["B"][key]
+ )
+ payload = {
+ "success": results["A"]["reply"] == 0xACDB and results["B"]["reply"] == 0xACC4 and moved == ["reply"],
+ "backend": backend,
+ "patch": patch,
+ "version_A": results["A"],
+ "version_B": results["B"],
+ "moved_fields": moved,
+ "decision_table_row": {
+ "static_code_changed": True,
+ "live_profile_changed": bool(moved),
+ "reading": "new logic on a live path - prioritise (docs/14 4.1)",
+ },
+ "compare_report": os.path.relpath(os.path.join(out, "compare-sessions.md"), C.TOOL_ROOT),
+ "question": "separate 'the code changed' from 'the behaviour moved' on a fixed stimulus",
+ "answer": (
+ f"version A replies {results['A']['reply']:#x}, version B replies {results['B']['reply']:#x}; "
+ f"moved fields {moved}; the patch site is {patch['kind']} at file offset {patch['offset']:#x} - "
+ "the decision-table row is 'code changed + profile changed -> prioritise'"
+ ),
+ "success_criteria": {
+ "pass": "exactly the patched reply field moves on the live path; both sessions replay from the same stimulus",
+ "fail": "unrelated fields move or a version cannot be replayed",
+ },
+ "real_case": scenario,
+ }
+ C.write_json(os.path.join(out, "version-diff.json"), payload)
+ return payload
+
+
+def main() -> int:
+ return 0 if C.timed_demo(run, C.artifact_dir("s07_version_diff"))["success"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s08_mutation_triage.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s08_mutation_triage.py
new file mode 100644
index 0000000..8fcaed1
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s08_mutation_triage.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+"""s08 - mutation triage: mutate one field, classify the outcome.
+
+Real scenario (docs/18-HARNESS-AUTOMATION.md R5 and
+Handoff-vGPU-IO.md PART 77 / docs/19 3.4, 2026-09-30..2026-10-02): the
+error-path corpus (sentinel/boundary pointers on ECHO/SUM, invalid command
+IDs) is how the emulation's fail-closed rules are derived. Statically the
+demo's 4-of-24 outcomes were invisible; the discovery sweep found
+``rpc_echo``/``rpc_sum`` fault on sentinel/boundary pointers while
+``rpc_ping``/``rpc_state``/``rpc_fill`` stay on the handler path.
+
+Analog: a seeded mutation table over command/arguments, one fresh session per
+attempt (the documented safety strategy), classified as ok / trap / fault /
+no-progress from stop signals, ``rpc_count`` and the result field.
+"""
+from __future__ import annotations
+
+import json
+import os
+
+try: # package import (tests) or direct script execution (run_all)
+ from . import _common as C
+except ImportError:
+ import sys as _sys
+
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+ import _common as C
+
+FAULT_SIGNALS = {4, 7, 11} # SIGILL, SIGBUS, SIGSEGV
+TRAP_SIGNAL = 5 # SIGTRAP (ebreak)
+
+
+def _probe(ge, mutation: dict, backend: str) -> dict:
+ session = ge.launch(kernel=C.DEMO_ELF, backend=backend, name="vd-s08")
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ signals = []
+ session.on_event("stop", lambda hit, sig=signals: sig.append(hit.stop.signal) if hit.stop else None)
+ session.run(duration=0.15)
+ literal = 0
+ if mutation.get("arg0") == "hello":
+ hits = session.memory.search(0x80000000, 0x2000, b"hello-green-eye")
+ literal = hits[0] if hits else ctrl + C.CTRL_BUF
+ before = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT)
+ session.memory.write_u64(ctrl + C.CTRL_ARG0, literal if mutation.get("arg0") == "hello" else mutation.get("arg0", 0))
+ session.memory.write_u64(ctrl + C.CTRL_ARG1, mutation.get("arg1", 0))
+ session.memory.write_u64(ctrl + C.CTRL_MAILBOX, mutation["cmd"])
+ # max_stops=1: a faulted target keeps re-faulting at the same PC, so
+ # stop at the first signal instead of spinning the fault loop.
+ session.run(duration=0.2, max_stops=1)
+ after = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT)
+ if after == before and not any(signals):
+ session.run(duration=0.3, max_stops=1)
+ after = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT)
+ result = session.memory.read_u64(ctrl + C.CTRL_RESULT)
+ if any(s == TRAP_SIGNAL for s in signals):
+ outcome = "trap"
+ elif any(s in FAULT_SIGNALS for s in signals):
+ outcome = "fault"
+ elif after > before:
+ outcome = "ok"
+ else:
+ outcome = "no-progress"
+ return {
+ "outcome": outcome,
+ "rpc_count_delta": after - before,
+ "result": result,
+ "result_hex": f"{result:#x}",
+ "signals": [s for s in signals if s],
+ "consumed": after > before,
+ }
+ finally:
+ session.close()
+
+
+def run() -> dict:
+ import greeneye as ge
+
+ out = C.artifact_dir("s08_mutation_triage")
+ backend = C.choose_backend("native")
+ mutations = [
+ {"strategy": "arg-boundary", "cmd": 2, "arg0": 0, "arg1": 0, "expect": "fault"},
+ {"strategy": "valid", "cmd": 2, "arg0": "hello", "arg1": 0, "expect": "ok"},
+ {"strategy": "arg-boundary", "cmd": 2, "arg0": 0xFFFFFFFFFFFFFFFF, "arg1": 0, "expect": "fault"},
+ {"strategy": "valid", "cmd": 4, "arg0": "hello", "arg1": 5, "expect": "ok"},
+ {"strategy": "buffer-length", "cmd": 4, "arg0": "hello", "arg1": 0x8000000000000000, "expect": "no-progress"},
+ {"strategy": "buffer-length", "cmd": 3, "arg0": 0x41, "arg1": 4096, "expect": "ok"},
+ {"strategy": "cmd-invalid", "cmd": 7, "arg0": 0, "arg1": 0, "expect": "ok"},
+ ]
+ commands = [
+ "for each seeded mutation: fresh native session, settle, one mailbox write",
+ "run bounded; classify from stop signals + rpc_count + result",
+ "write findings.jsonl (deterministic order) for corpus diffing",
+ ]
+ scenario = C.scenario(
+ ids=[
+ "docs/18-HARNESS-AUTOMATION.md 2 (R5): bounded seeded mutation, outcome classes",
+ "docs/19-CONTROL-DISCOVERY.md 3.4 (2026-10-02): sentinel/boundary pointer faults found by the sweep",
+ "Handoff-vGPU-IO.md PARTs 3-9 (2026-09-21..23): hand-derived assert/error paths",
+ ],
+ baseline=(
+ "statically the fault surface is invisible; the project derived error "
+ "paths by hand from asserts and NOCAT records across many physical "
+ "cycles; a 64-iteration QEMU mutation run is the tracked baseline"
+ ),
+ projection=(
+ "real RM controls land in the same mutation harness once the RM "
+ "surface exists (docs/19 section 5); one device-write experiment "
+ "per physical cycle is the current cap"
+ ),
+ blocked=True,
+ device="H1.3 RM control surface for mutation of real controls",
+ )
+ C.render_banner(
+ "s08_mutation_triage",
+ "Mutate one input field deterministically and classify the outcome (ok/trap/fault/no-progress).",
+ "error-path corpus for fail-closed emulation rules (R5/H4.3)",
+ scenario["static_baseline"],
+ commands,
+ )
+
+ findings = []
+ for index, mutation in enumerate(mutations):
+ observed = _probe(ge, mutation, backend)
+ finding = {"iteration": index, **mutation, **observed, "matched_expectation": observed["outcome"] == mutation["expect"]}
+ findings.append(finding)
+ classes: dict[str, int] = {}
+ for finding in findings:
+ classes[finding["outcome"]] = classes.get(finding["outcome"], 0) + 1
+ payload = {
+ "success": all(f["matched_expectation"] for f in findings),
+ "backend": backend,
+ "mutations": len(findings),
+ "outcome_classes": classes,
+ "findings": findings,
+ "question": "enumerate and classify one-field mutations without hardware",
+ "answer": (
+ f"{len(findings)} mutations -> {classes}; all expectations matched; "
+ "faults localised to the dereferencing handlers, the oversized SUM "
+ "stays no-progress, FILL clamps at the buffer size - a corpus entry "
+ "per mutation with no device writes"
+ ),
+ "success_criteria": {
+ "pass": "every seeded mutation produces its documented outcome class",
+ "fail": "an outcome contradicts the case table or a session leaks",
+ },
+ "real_case": scenario,
+ }
+ with open(os.path.join(out, "findings.jsonl"), "w", encoding="utf-8") as fh:
+ for finding in findings:
+ fh.write(json.dumps(finding, sort_keys=True) + "\n")
+ C.write_json(os.path.join(out, "mutation.json"), payload)
+ return payload
+
+
+def main() -> int:
+ return 0 if C.timed_demo(run, C.artifact_dir("s08_mutation_triage"))["success"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s09_grabbing_a_ghidra_address.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s09_grabbing_a_ghidra_address.py
new file mode 100644
index 0000000..1577772
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s09_grabbing_a_ghidra_address.py
@@ -0,0 +1,146 @@
+#!/usr/bin/env python3
+"""s09 - grab a Ghidra address: did the firmware actually execute it?
+
+Real scenario (docs/05-GHIDRA-BRIDGE.md, docs/13, docs/14 5; and
+Handoff-vGPU-IO.md PART 91 ADDENDUM 9 cycle 24, 2026-10-01): a static
+hypothesis (the GPFIFO GET encodes entry VA ``0x101000``) was only accepted
+after the decoded status-queue fault showed the engine actually fetched
+``0x40000`` because the GET field dropped the high VA bits (fix ``6f9cbfa``).
+The static/dynamic join is exactly this: Ghidra owns addresses and
+decompilation, green-eye answers "did it execute here".
+
+Analog: export a Ghidra-style ``names.tsv`` from the demo ELF *with a base
+offset* (the classic program-address vs runtime-address trap), load it with
+the right bias, hook the raw Ghidra address, trigger the path, and write the
+resulting ``greeneye_hits.tsv`` annotation back for Ghidra.
+"""
+from __future__ import annotations
+
+import os
+
+try: # package import (tests) or direct script execution (run_all)
+ from . import _common as C
+except ImportError:
+ import sys as _sys
+
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+ import _common as C
+
+#: Pretend the Ghidra project was based at runtime - 0x1000 (the bias pitfall).
+BIAS = 0x1000
+
+
+def _ghidra_tsv(ge, out_path: str) -> dict:
+ image = ge.load_elf(C.DEMO_ELF)
+ rows = []
+ for symbol in image.symbols:
+ if not getattr(symbol, "is_function", False) or not symbol.name:
+ continue
+ program = symbol.value - BIAS
+ rows.append((program, symbol.name, symbol.size))
+ rows.sort()
+ with open(out_path, "w", encoding="utf-8") as fh:
+ for program, name, size in rows:
+ fh.write(f"{program:#x}\t{name}\tsize={size}\n")
+ return {"functions": len(rows), "bias": BIAS}
+
+
+def run() -> dict:
+ import greeneye as ge
+ from greeneye.ghidra import export_hits
+
+ backend = C.choose_backend("qemu")
+ out = C.artifact_dir("s09_grabbing_a_ghidra_address")
+ names_tsv = os.path.join(out, "ghidra_names.tsv")
+ facts = _ghidra_tsv(ge, names_tsv)
+ runtime_rpc_fault = ge.load_elf(C.DEMO_ELF)
+ addr_rpc_fault = None
+ for symbol in runtime_rpc_fault.symbols:
+ if symbol.name == "rpc_fault":
+ addr_rpc_fault = symbol.value
+ if addr_rpc_fault is None:
+ raise C.DemoError("rpc_fault not found in demo ELF")
+ ghidra_addr = addr_rpc_fault - BIAS
+
+ commands = [
+ f'write Ghidra names.tsv from ELF (program = runtime - {BIAS:#x})',
+ f'SymbolDB.load_names_tsv(names, bias={BIAS:#x})',
+ f'hook(ghidra_addr {ghidra_addr:#x} + bias) # raw static address',
+ "trigger mailbox cmd=6; hits recorded; export_hits -> greeneye_hits.tsv",
+ ]
+ scenario = C.scenario(
+ ids=[
+ "docs/05-GHIDRA-BRIDGE.md / docs/13: static/dynamic address join",
+ "Handoff-vGPU-IO.md PART 91 ADDENDUM 9 cycle 24 (2026-10-01): GET fault at 0x40000 vs hypothesis 0x101000",
+ "docs/14-ADVERSARIAL-INTEROP-GUIDE.md 5: program vs runtime addresses and bias",
+ ],
+ baseline=(
+ "cycle 24's decoded status-queue fault ended a run of eight "
+ "supervised cycles (18-25, ~1 h 41 m); the static GET-encoding "
+ "hypothesis was unproven until a live PC/fault address existed"
+ ),
+ projection=(
+ "for the real image the same names.tsv + bias + hook loop runs once "
+ "H2.2 exposes execution of the .fwimage; the bias arithmetic demoed "
+ "here is exactly what docs/05 warns about"
+ ),
+ blocked=True,
+ device="H2.2 device model to execute the real .fwimage",
+ )
+ C.render_banner(
+ "s09_grabbing_a_ghidra_address",
+ "Ghidra gave an address: does the firmware execute exactly there, and can the hit be annotated back?",
+ "static/dynamic join for the GPFIFO GET fault (0x40000 vs 0x101000)",
+ scenario["static_baseline"],
+ commands,
+ )
+
+ session = ge.launch(kernel=C.DEMO_ELF, backend=backend, record=os.path.join(out, "session"), name="vd-s09")
+ try:
+ session.symbols.load_names_tsv(names_tsv, bias=BIAS)
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ resolved = session.resolve(ghidra_addr + BIAS)
+ hits = []
+ session.hook(ghidra_addr + BIAS, on_enter=lambda hit: hits.append(hit.addr))
+ session.run(duration=0.25)
+ session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 6)
+ session.run(duration=0.25)
+ hits_tsv = export_hits(os.path.join(out, "session"), os.path.join(out, "greeneye_hits.tsv"), symbols=session.symbols)
+ payload = {
+ "success": len(hits) >= 1 and resolved == addr_rpc_fault,
+ "backend": backend,
+ "ghidra_functions": facts["functions"],
+ "ghidra_names_tsv": os.path.relpath(names_tsv, C.TOOL_ROOT),
+ "bias": BIAS,
+ "ghidra_addr": f"{ghidra_addr:#x}",
+ "runtime_addr": f"{resolved:#x}",
+ "program_addr_used_without_bias": f"{ghidra_addr:#x}",
+ "hits": hits,
+ "hits_tsv": os.path.relpath(hits_tsv, C.TOOL_ROOT),
+ "question": "join a static Ghidra address to a runtime hit, with the bias handled explicitly",
+ "answer": (
+ f"Ghidra addr {ghidra_addr:#x} + bias {BIAS:#x} = runtime {resolved:#x}; "
+ f"the hook fired {len(hits)}x at that exact address; export_hits wrote "
+ "the annotated greeneye_hits.tsv - the static hypothesis is now a "
+ "recorded dynamic fact (and using the program address raw would miss by 0x1000)"
+ ),
+ "success_criteria": {
+ "pass": "the biased address resolves to the real function and the hook records the hit; TSV annotates it",
+ "fail": "the hit lands at a different address or the TSV is empty",
+ },
+ "real_case": scenario,
+ }
+ with open(hits_tsv, "r", encoding="utf-8") as fh:
+ payload["greeneye_hits_tsv"] = fh.read()
+ C.write_json(os.path.join(out, "ghidra-join.json"), payload)
+ finally:
+ session.close()
+ return payload
+
+
+def main() -> int:
+ return 0 if C.timed_demo(run, C.artifact_dir("s09_grabbing_a_ghidra_address"))["success"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s10_full_session_evidence.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s10_full_session_evidence.py
new file mode 100644
index 0000000..f6cd48f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s10_full_session_evidence.py
@@ -0,0 +1,213 @@
+#!/usr/bin/env python3
+"""s10 - full session evidence: one run produces the whole evidence pack.
+
+Real scenario (docs/14-ADVERSARIAL-INTEROP-GUIDE.md 6; Handoff-vGPU-IO.md
+PART 91 ADDENDA 6-7 and PART 92 ADDENDA 6-11, 2026-10-01..10-02): a finding
+is only a finding with firmware hashes, baseline/candidate sessions, an
+analyze report, a compare report, the M1-M6 table, ``greeneye_hits.tsv`` and
+an unexplained-delta list. The physical milestone cycles each gathered this
+by hand across separate boots and log windows (cycle evidence dirs
+``backups/evidence-20261001-cycle{11,12,13}``, P6 evidence dirs).
+
+Analog: one recorded session with hooks/watchpoints/notes/snapshots, a second
+identical run for determinism, an analyze report, a session compare, an
+exported hits TSV and a manifest with hashes - plus measured tool overhead
+(startup, session size, trace slowdown).
+"""
+from __future__ import annotations
+
+import os
+
+try: # package import (tests) or direct script execution (run_all)
+ from . import _common as C
+except ImportError:
+ import sys as _sys
+
+ _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+ import _common as C
+
+
+def _stimulus(ge, record, backend: str) -> dict:
+ session = ge.launch(kernel=C.DEMO_ELF, backend=backend, record=record, name=os.path.basename(record))
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ stats = {"dispatches": [], "sum_hits": 0, "result_stores": 0}
+ session.hook(
+ "rpc_dispatch",
+ on_enter=lambda hit: stats["dispatches"].append(hit.arg(0)),
+ )
+ session.hook("rpc_sum", on_enter=lambda hit: stats.__setitem__("sum_hits", stats["sum_hits"] + 1))
+ session.watch(ctrl + C.CTRL_RESULT, size=8, kind="w", callback=lambda hit: stats.__setitem__("result_stores", stats["result_stores"] + 1))
+ session.run(duration=0.4)
+ session.snapshot(ctrl, C.CTRL_SIZE, "selftest-done")
+ session.memory.write_u64(ctrl + C.CTRL_ARG0, 0x1234)
+ session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 1)
+ session.run(duration=0.2)
+ session.note("stimulus", cmd=1, arg0=0x1234, expected=0x1234 ^ C.PING_XOR)
+ session.snapshot(ctrl, C.CTRL_SIZE, "after-ping")
+ stats["ping_reply"] = session.memory.read_u64(ctrl + C.CTRL_RESULT)
+ session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 5)
+ session.run(duration=0.2)
+ stats["state_reply"] = session.memory.read_u64(ctrl + C.CTRL_RESULT)
+ stats["rpc_count"] = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT)
+ return stats
+ finally:
+ session.close()
+
+
+def _dir_size(path: str) -> int:
+ total = 0
+ for root, _dirs, files in os.walk(path):
+ for name in files:
+ total += os.path.getsize(os.path.join(root, name))
+ return total
+
+
+def _trace_overhead(ge, out: str) -> dict:
+ import time
+
+ def timed(trace):
+ record = os.path.join(out, "trace-off" if not trace else "trace-on")
+ start = time.monotonic()
+ session = ge.launch(kernel=C.DEMO_ELF, backend="native", record=record, trace=trace, name="vd-s10-trace")
+ run_start = time.monotonic()
+ try:
+ session.run(duration=0.25)
+ finally:
+ run_s = time.monotonic() - run_start
+ session.close()
+ return run_s, time.monotonic() - start
+
+ off_run, off_total = timed(None)
+ on_run, on_total = timed(True)
+ return {
+ "untraced_run_s": round(off_run, 4),
+ "traced_run_s": round(on_run, 4),
+ "run_slowdown_x": round(on_run / off_run, 2) if off_run else None,
+ "untraced_total_s": round(off_total, 4),
+ "traced_total_s": round(on_total, 4),
+ "total_slowdown_x": round(on_total / off_total, 2) if off_total else None,
+ "note": "total includes writing blobs/trace.jsonl (~200k exec lines) at close",
+ }
+
+
+def run() -> dict:
+ import time
+
+ import greeneye as ge
+ from greeneye.analyze import compare_sessions, render_summary
+ from greeneye.ghidra import export_hits
+
+ backend = C.choose_backend("qemu")
+ out = C.artifact_dir("s10_full_session_evidence")
+ commands = [
+ 'ge.launch(backend="%s", record="session-A") + hooks/watch/notes/snapshots' % backend,
+ "repeat the identical stimulus into session-B",
+ "render_summary(A); compare_sessions(A, B, --symbols); export_hits(A)",
+ "hash firmware + artifacts into manifest.json with the M1-M6 skeleton",
+ ]
+ scenario = C.scenario(
+ ids=[
+ "docs/14-ADVERSARIAL-INTEROP-GUIDE.md 6: evidence-pack checklist and report template",
+ "Handoff-vGPU-IO.md PART 91 ADDENDUM 6-7 (2026-10-01): cycle 11-13 evidence dirs",
+ "PART 92 ADDENDUM 6-11 (2026-10-02): P6 evidence dirs and per-cycle release transcripts",
+ "docs/06-ACCURACY.md 5: M1-M6 measurement contract",
+ ],
+ baseline=(
+ "every physical milestone gathered hash + session + logs + compare "
+ "by hand across separate boots and log windows; cycles 11-13 and "
+ "the P6 runs each have their own evidence directory"
+ ),
+ projection=(
+ "on real firmware the same one-session pack is produced once H2.2 "
+ "executes the image; the manifest/M1-M6 format is backend-agnostic"
+ ),
+ blocked=True,
+ device="H2.2 device model for real .fwimage session evidence",
+ )
+ C.render_banner(
+ "s10_full_session_evidence",
+ "Can one run produce the complete evidence pack (sessions, analyze, compare, hits, hashes, M1-M6)?",
+ "milestone evidence packs gathered manually across boots",
+ scenario["static_baseline"],
+ commands,
+ )
+
+ start = time.monotonic()
+ session_a = _stimulus(ge, os.path.join(out, "session-A"), backend)
+ session_b = _stimulus(ge, os.path.join(out, "session-B"), backend)
+
+ symbols = ge.SymbolDB()
+ symbols.load_elf(ge.load_elf(C.DEMO_ELF))
+ report = render_summary(os.path.join(out, "session-A"), symbols=symbols, max_rows=30)
+ C.write_text(os.path.join(out, "analyze.md"), report)
+ compare = compare_sessions(os.path.join(out, "session-A"), os.path.join(out, "session-B"), symbols=symbols, max_rows=30)
+ C.write_text(os.path.join(out, "compare.md"), compare)
+ hits = export_hits(os.path.join(out, "session-A"), os.path.join(out, "greeneye_hits.tsv"), symbols=symbols)
+ trace = _trace_overhead(ge, out)
+ size_a = _dir_size(os.path.join(out, "session-A"))
+ size_b = _dir_size(os.path.join(out, "session-B"))
+ deltas_match = session_a["dispatches"] == session_b["dispatches"] and session_a["ping_reply"] == session_b["ping_reply"]
+ manifest = {
+ "format": "greeneye-value-evidence/1",
+ "firmware": {
+ "path": os.path.relpath(C.DEMO_ELF, C.TOOL_ROOT),
+ "sha256": C.sha256_file(C.DEMO_ELF),
+ },
+ "sessions": {
+ "a": {"path": os.path.relpath(os.path.join(out, "session-A"), C.TOOL_ROOT), "bytes": size_a, "sha256_manifest": C.sha256_file(os.path.join(out, "session-A", "events.jsonl"))},
+ "b": {"path": os.path.relpath(os.path.join(out, "session-B"), C.TOOL_ROOT), "bytes": size_b, "sha256_manifest": C.sha256_file(os.path.join(out, "session-B", "events.jsonl"))},
+ },
+ "stimulus": {"commands": [1, 5], "arg0": 0x1234, "note": "PING then STATE"},
+ "observed": {"a": session_a, "b": session_b, "delta_match": deltas_match},
+ "metrics": [
+ {"id": "M1", "name": "inventory coverage", "status": "observed", "value": "dispatch inventory [1,2,3,4,5,5,5] self-test + [1,5] stimulus"},
+ {"id": "M2", "name": "reply agreement", "status": "not measured", "reason": "no physical reference capture (needs H2/production trace)"},
+ {"id": "M3", "name": "ordering agreement", "status": "observed", "value": "identical dispatch sequence across A/B"},
+ {"id": "M4", "name": "state agreement", "status": "observed", "value": "rpc_state stage cycling on the control block"},
+ {"id": "M5", "name": "error-path agreement", "status": "not measured", "reason": "error path demoed in s08; no reference to agree against"},
+ {"id": "M6", "name": "memory side-effects", "status": "observed", "value": "2 named region snapshots (selftest-done, after-ping) per session"},
+ ],
+ "artifacts": {
+ "analyze": "analyze.md",
+ "compare": "compare.md",
+ "hits": os.path.relpath(hits, out),
+ "manifest": "manifest.json",
+ },
+ "overhead": {
+ "session_a_bytes": size_a,
+ "session_b_bytes": size_b,
+ "trace": trace,
+ "doc16_trace_slowdown": "~2.4x exec trace on the demo workload (docs/16 measured 0.54 MIPS traced vs 1.5 MIPS untraced)",
+ },
+ "unexplained_deltas": [] if deltas_match else ["session A/B observed values differ"],
+ }
+ payload = {
+ "success": deltas_match and session_a["ping_reply"] == (0x1234 ^ C.PING_XOR) and size_a > 0,
+ "backend": backend,
+ "elapsed_within_demo_s": round(time.monotonic() - start, 3),
+ "manifest": manifest,
+ "question": "one run -> hash + sessions + analyze + compare + hits + M1-M6, plus tool overhead",
+ "answer": (
+ f"one {backend} stimulus pair produced {session_a['dispatches']} dispatches, "
+ f"PING reply {session_a['ping_reply']:#x}, STATE reply {session_a['state_reply']:#x}, "
+ f"{session_a['result_stores']} publication stores; session {size_a} B; "
+ f"A/B deterministic={deltas_match}; analyze+compare+hits+manifest written"
+ ),
+ "success_criteria": {
+ "pass": "reply matches the ABI, both sessions agree, all artifacts exist, deltas list is empty",
+ "fail": "nondeterminism, missing artifact, or an unexplained delta",
+ },
+ "real_case": scenario,
+ }
+ C.write_json(os.path.join(out, "manifest.json"), manifest)
+ C.write_json(os.path.join(out, "evidence.json"), payload)
+ return payload
+
+
+def main() -> int:
+ return 0 if C.timed_demo(run, C.artifact_dir("s10_full_session_evidence"))["success"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/version_diff.py b/debug/risc-v-gsp-rm-vm/harness/version_diff.py
new file mode 100644
index 0000000..a95f244
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/version_diff.py
@@ -0,0 +1,1509 @@
+#!/usr/bin/env python3
+"""Version diff: old vs new firmware targets (roadmap H4.1, planned docs/21).
+
+Compares two firmware images (a built ELF or a raw ``.fwimage``) section by
+section; every section degrades to ``status: skipped`` with a reason instead
+of aborting the report:
+
+1. image/shape -- entry, PT_LOAD layout, ELF section sizes, hashes, span;
+2. symbol inventory -- ELF ``.symtab`` plus Ghidra names/index/exports TSVs
+ (including the 3-column ``ExportForGreenEye`` form) and
+ green-eye JSON DBs; added/removed/moved/resized with
+ the anonymous-shift matching discipline of
+ ``userspace/cuda/tools/diff_corpus.py``;
+3. decompiled hints -- a corpus diff over two ``decompiled_index.tsv`` files,
+ an attached corpus report, or an explicit "not
+ supplied";
+4. probe -- bounded QEMU first-touch probe of both targets with
+ ``parse_qemu_faults`` and firmware-vaddr translation of
+ the epc and access addresses;
+5. behavior -- the shared demo scenario (or a ``--stimulus`` script)
+ under QEMU, ``analyze.compare_sessions`` plus
+ hit/watch/write deltas and the docs/06 M1-M6 skeleton.
+
+Everything is local and stdlib-only: nothing is downloaded. The RM 610 gate
+(docs/23) is enforced before any target file is opened.
+
+Run from the tool root:
+
+ python3 harness/version_diff.py --old old.elf --new new.elf --out dumps/vdiff
+"""
+from __future__ import annotations
+
+import argparse
+import contextlib
+import os
+import re
+import shutil
+import statistics
+import struct
+import sys
+import tempfile
+from collections import defaultdict
+from dataclasses import dataclass
+from pathlib import Path
+
+try:
+ from .common import (
+ LOAD_ADDR,
+ MAX_FLAT_BYTES,
+ MAX_LOG_BYTES,
+ SymbolDB,
+ counter_delta,
+ ensure_dir,
+ memory_mb_for,
+ qemu_available,
+ read_log_window,
+ sha256_file,
+ write_json,
+ write_text,
+ )
+ from .portability_matrix import (
+ CTRL_RPC_COUNT,
+ METRIC_DEFS,
+ NO_REFERENCE,
+ install_scenario,
+ metric_rows,
+ profile_from_events,
+ read_ctrl,
+ )
+except ImportError: # direct script execution (python3 harness/version_diff.py)
+ from common import ( # type: ignore[no-redef]
+ LOAD_ADDR,
+ MAX_FLAT_BYTES,
+ MAX_LOG_BYTES,
+ SymbolDB,
+ counter_delta,
+ ensure_dir,
+ memory_mb_for,
+ qemu_available,
+ read_log_window,
+ sha256_file,
+ write_json,
+ write_text,
+ )
+ from portability_matrix import ( # type: ignore[no-redef]
+ CTRL_RPC_COUNT,
+ METRIC_DEFS,
+ NO_REFERENCE,
+ install_scenario,
+ metric_rows,
+ profile_from_events,
+ read_ctrl,
+ )
+
+from greeneye.analyze import compare_sessions, parse_qemu_faults # noqa: E402
+from greeneye.elf import EM_RISCV, FirmwareImage, Segment, load_elf, write_flat_kernel # noqa: E402
+from greeneye.errors import GreenEyeError # noqa: E402
+from greeneye.record import load_events # noqa: E402
+from greeneye.rsp import RspClient # noqa: E402
+from greeneye.session import Session # noqa: E402
+from greeneye.symbols import Symbol # noqa: E402
+from greeneye.target import QemuTarget # noqa: E402
+
+FORMAT = "greeneye-version-diff/1"
+RM610_DOC = "docs/23-RM610-READINESS.md"
+RM610_TOKEN = "RM610-APPROVED"
+RM610_ENV = "GREENEYE_RM610_APPROVED"
+RM610_RE = re.compile(r"rm[\s._-]*610", re.IGNORECASE)
+PT_LOAD = 1
+
+
+class Rm610GateError(GreenEyeError):
+ """A target/release carries an RM 610 label without explicit approval."""
+
+
+def is_rm610_label(value: str | None) -> bool:
+ return bool(value) and RM610_RE.search(str(value)) is not None
+
+
+def rm610_approved(token: str | None = None, environ: dict | None = None) -> bool:
+ """Explicit approval is ``--approval-token RM610-APPROVED`` or the env flag."""
+ env = os.environ if environ is None else environ
+ token_ok = (token or "").strip() == RM610_TOKEN
+ env_ok = str(env.get(RM610_ENV, "")).strip().lower() in ("1", "true", "yes", "approved")
+ return token_ok or env_ok
+
+
+def check_rm610_gate(
+ labels: list[str] | tuple[str, ...],
+ *,
+ approval_token: str | None = None,
+ environ: dict | None = None,
+) -> None:
+ """Refuse RM 610 labels before anything is read, opened or downloaded."""
+ blocked = sorted({str(label) for label in labels if is_rm610_label(label)})
+ if not blocked:
+ return
+ if rm610_approved(approval_token, environ):
+ return
+ raise Rm610GateError(
+ "target/release label(s) " + ", ".join(repr(label) for label in blocked) + " indicate RM 610. "
+ f"Refused without explicit approval: pass --approval-token {RM610_TOKEN} or set "
+ f"{RM610_ENV}=1. See {RM610_DOC}. Nothing was downloaded and no target file was opened."
+ )
+
+
+# ---------------------------------------------------------------------------
+# target loading (ELF or raw)
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class LoadedTarget:
+ path: str
+ name: str
+ kind: str # elf | raw | unsupported | missing
+ image: FirmwareImage | None = None
+ sha256: str = ""
+ size: int = 0
+ entry: int = 0
+ bias: int = 0
+ reason: str | None = None
+
+ @property
+ def ok(self) -> bool:
+ return self.kind in ("elf", "raw")
+
+
+def _read_bytes(path: str) -> bytes:
+ with open(path, "rb") as fh:
+ return fh.read()
+
+
+def load_any_target(path: str) -> LoadedTarget:
+ """Read a target as RISC-V ELF or as a raw relocated image (no download)."""
+ name = Path(path).stem
+ if not os.path.isfile(path):
+ return LoadedTarget(path=path, name=name, kind="missing", reason="file not found")
+ size = os.path.getsize(path)
+ sha = sha256_file(path)
+ with open(path, "rb") as fh:
+ magic = fh.read(4)
+ if magic == b"\x7fELF":
+ try:
+ image = load_elf(path)
+ except Exception as exc: # malformed ELF -> skipped, never fatal
+ return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=size,
+ reason=f"{type(exc).__name__}: {exc}")
+ if not image.is_riscv:
+ return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=size,
+ reason=f"not a RISC-V ELF (e_machine={image.e_machine})")
+ span = image.max_vaddr - image.min_vaddr
+ if span > MAX_FLAT_BYTES:
+ return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=size,
+ reason=f"image span {span:#x} exceeds the {MAX_FLAT_BYTES:#x} harness cap")
+ return LoadedTarget(path=path, name=name, kind="elf", image=image, sha256=sha, size=size,
+ entry=image.entry, bias=LOAD_ADDR - image.min_vaddr)
+ if size == 0:
+ return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=0,
+ reason="empty raw image")
+ if size > MAX_FLAT_BYTES:
+ return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=size,
+ reason=f"raw image {size:#x} exceeds the {MAX_FLAT_BYTES:#x} harness cap")
+ data = _read_bytes(path)
+ segment = Segment(p_type=PT_LOAD, p_flags=0x7, p_offset=0, p_vaddr=0, p_paddr=0,
+ p_filesz=len(data), p_memsz=len(data), p_align=0x1000)
+ image = FirmwareImage(path=path, is64=True, e_machine=EM_RISCV, entry=0, segments=[segment],
+ symbols=[], _blob=data)
+ return LoadedTarget(path=path, name=name, kind="raw", image=image, sha256=sha, size=size,
+ entry=0, bias=LOAD_ADDR)
+
+
+# ---------------------------------------------------------------------------
+# 1. image / shape
+# ---------------------------------------------------------------------------
+
+
+def _cstr(blob: bytes, offset: int) -> str:
+ if offset >= len(blob):
+ return ""
+ end = blob.find(b"\x00", offset)
+ if end == -1:
+ end = len(blob)
+ return blob[offset:end].decode("utf-8", "replace")
+
+
+def elf_sections(data: bytes) -> list[dict]:
+ """Section name/type/addr/size table (minimal reader; [] when unusable)."""
+ if len(data) < 64 or data[:4] != b"\x7fELF" or data[4] not in (1, 2):
+ return []
+ is64 = data[4] == 2
+ try:
+ if is64:
+ sh_off = struct.unpack_from("<Q", data, 40)[0]
+ shentsize, shnum, shstrndx = struct.unpack_from("<HHH", data, 58)
+ sh_fmt = "<IIQQQQIIQQ"
+ else:
+ sh_off = struct.unpack_from("<I", data, 32)[0]
+ shentsize, shnum, shstrndx = struct.unpack_from("<HHH", data, 46)
+ sh_fmt = "<IIIIIIIIII"
+ except struct.error:
+ return []
+ sh_size = struct.calcsize(sh_fmt)
+ if not sh_off or sh_off + sh_size > len(data):
+ return []
+ shstr = b""
+ if 0 <= shstrndx < shnum:
+ base = sh_off + shstrndx * shentsize
+ if base + sh_size <= len(data):
+ sh = struct.unpack_from(sh_fmt, data, base)
+ shstr = data[sh[4] : sh[4] + sh[5]]
+ sections: list[dict] = []
+ for index in range(shnum):
+ base = sh_off + index * shentsize
+ if base + sh_size > len(data):
+ break
+ sh = struct.unpack_from(sh_fmt, data, base)
+ sections.append(
+ {
+ "name": _cstr(shstr, sh[0]) or f"<section-{index}>",
+ "type": sh[1],
+ "addr": sh[3],
+ "offset": sh[4],
+ "size": sh[5],
+ }
+ )
+ return sections
+
+
+def shape_payload(loaded: LoadedTarget) -> dict:
+ payload = {
+ "kind": loaded.kind,
+ "sha256": loaded.sha256,
+ "size": loaded.size,
+ "reason": loaded.reason,
+ }
+ if not loaded.ok or loaded.image is None:
+ return payload
+ image = loaded.image
+ payload.update(
+ {
+ "entry": loaded.entry,
+ "bias": loaded.bias,
+ "min_vaddr": image.min_vaddr,
+ "max_vaddr": image.max_vaddr,
+ "span": image.max_vaddr - image.min_vaddr,
+ "loads": [
+ {
+ "index": index,
+ "vaddr": seg.p_vaddr,
+ "paddr": seg.p_paddr,
+ "filesz": seg.p_filesz,
+ "memsz": seg.p_memsz,
+ "flags": seg.p_flags,
+ "align": seg.p_align,
+ }
+ for index, seg in enumerate(image.load_segments)
+ ],
+ }
+ )
+ if loaded.kind == "elf":
+ payload["sections"] = elf_sections(image.read())
+ else:
+ payload["sections"] = []
+ payload["note"] = "raw image: one implicit load at vaddr 0; no ELF section table"
+ return payload
+
+
+def _scalar_delta(a, b, name: str) -> dict:
+ return {"a": a, "b": b, "delta": b - a if isinstance(a, int) and isinstance(b, int) else None,
+ "changed": a != b}
+
+
+def _cap(rows: list, max_rows: int) -> list:
+ return rows[:max_rows]
+
+
+def image_diff(old: LoadedTarget, new: LoadedTarget, max_rows: int) -> dict:
+ a, b = shape_payload(old), shape_payload(new)
+ result = {"status": "skipped", "reason": None, "a": a, "b": b, "same": None, "deltas": None}
+ if not old.ok or not new.ok:
+ result["reason"] = "; ".join(
+ f"{side}: {loaded.reason or loaded.kind}" for side, loaded in (("old", old), ("new", new)) if not loaded.ok
+ )
+ return result
+
+ loads_a, loads_b = a["loads"], b["loads"]
+ load_added = [payload for index, payload in enumerate(loads_b) if index >= len(loads_a)]
+ load_removed = [payload for index, payload in enumerate(loads_a) if index >= len(loads_b)]
+ load_changed = []
+ for index, (la, lb) in enumerate(zip(loads_a, loads_b)):
+ fields = {key: _scalar_delta(la[key], lb[key], key) for key in ("vaddr", "filesz", "memsz", "flags")}
+ if any(row["changed"] for row in fields.values()):
+ load_changed.append({"index": index, "fields": fields})
+
+ sec_a = {row["name"]: row for row in a.get("sections", [])}
+ sec_b = {row["name"]: row for row in b.get("sections", [])}
+ sec_added = [{key: sec_b[name][key] for key in ("name", "type", "addr", "size")} for name in sorted(set(sec_b) - set(sec_a))]
+ sec_removed = [{key: sec_a[name][key] for key in ("name", "type", "addr", "size")} for name in sorted(set(sec_a) - set(sec_b))]
+ sec_moved, sec_resized = [], []
+ for name in sorted(set(sec_a) & set(sec_b)):
+ ra, rb = sec_a[name], sec_b[name]
+ if ra["addr"] != rb["addr"]:
+ sec_moved.append({"name": name, "old_addr": ra["addr"], "new_addr": rb["addr"]})
+ if ra["size"] != rb["size"]:
+ sec_resized.append({"name": name, "old_size": ra["size"], "new_size": rb["size"]})
+
+ deltas = {
+ "entry": _scalar_delta(a["entry"], b["entry"], "entry"),
+ "span": _scalar_delta(a["span"], b["span"], "span"),
+ "file_size": _scalar_delta(a["size"], b["size"], "size"),
+ "loads": {
+ "added": _cap(load_added, max_rows),
+ "removed": _cap(load_removed, max_rows),
+ "changed": _cap(load_changed, max_rows),
+ },
+ "sections": {
+ "added": _cap(sec_added, max_rows),
+ "removed": _cap(sec_removed, max_rows),
+ "moved": _cap(sec_moved, max_rows),
+ "resized": _cap(sec_resized, max_rows),
+ },
+ }
+ same = (
+ not deltas["entry"]["changed"]
+ and not deltas["span"]["changed"]
+ and not deltas["file_size"]["changed"]
+ and not any(deltas["loads"][key] for key in ("added", "removed", "changed"))
+ and not any(deltas["sections"][key] for key in ("added", "removed", "moved", "resized"))
+ )
+ result.update({"status": "ok", "same": same, "deltas": deltas})
+ return result
+
+
+# ---------------------------------------------------------------------------
+# 2. symbol inventory (diff_corpus.py matching discipline)
+# ---------------------------------------------------------------------------
+
+
+def looks_like_three_column_export(path: str) -> bool:
+ """``addr<TAB>name<TAB>size`` (ExportForGreenEye) vs names/exports TSVs."""
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
+ for line in fh:
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ cols = line.split("\t")
+ if len(cols) >= 3 and [c.strip() for c in cols[:3]] == ["addr", "name", "size"]:
+ return True
+ if len(cols) == 3:
+ try:
+ int(cols[0], 16)
+ int(cols[2], 0)
+ return True
+ except ValueError:
+ return False
+ return False
+ except OSError:
+ return False
+ return False
+
+
+def load_three_column_export(db: SymbolDB, path: str, bias: int = 0) -> int:
+ count = 0
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
+ for line in fh:
+ line = line.rstrip("\n")
+ if not line or line.startswith("#"):
+ continue
+ cols = line.split("\t")
+ if len(cols) != 3 or cols[0].strip() == "addr":
+ continue
+ try:
+ addr = int(cols[0], 16)
+ size = int(cols[2], 0)
+ except ValueError:
+ continue
+ if not cols[1]:
+ continue
+ db.add(Symbol(addr=addr + bias, name=cols[1], size=size, source="ghidra-export"))
+ count += 1
+ return count
+
+
+def load_symbol_sources(
+ loaded: LoadedTarget,
+ *,
+ symbols_json: str | None = None,
+ ghidra_names: str | None = None,
+ ghidra_index: str | None = None,
+ ghidra_exports: str | None = None,
+ ghidra_bias: int = 0,
+) -> tuple[SymbolDB, list[str], list[str]]:
+ """Merge every supplied symbol source for one side into one SymbolDB."""
+ db = SymbolDB()
+ sources: list[str] = []
+ notes: list[str] = []
+ if loaded.ok and loaded.image is not None and loaded.image.symbols:
+ count = db.load_elf(loaded.image, bias=loaded.bias, source="elf")
+ if count:
+ sources.append(f"elf:{count}")
+ if symbols_json:
+ if os.path.isfile(symbols_json):
+ count = db.load_json(symbols_json)
+ sources.append(f"{os.path.basename(symbols_json)}:{count}")
+ else:
+ notes.append(f"symbol DB not found: {symbols_json}")
+ if ghidra_names:
+ if os.path.isfile(ghidra_names):
+ count = db.load_names_tsv(ghidra_names, bias=ghidra_bias)
+ sources.append(f"{os.path.basename(ghidra_names)}:{count}")
+ else:
+ notes.append(f"Ghidra names TSV not found: {ghidra_names}")
+ if ghidra_index:
+ if os.path.isfile(ghidra_index):
+ count = db.load_index_tsv(ghidra_index, bias=ghidra_bias)
+ sources.append(f"{os.path.basename(ghidra_index)}:{count}")
+ else:
+ notes.append(f"Ghidra index TSV not found: {ghidra_index}")
+ if ghidra_exports:
+ if os.path.isfile(ghidra_exports):
+ if looks_like_three_column_export(ghidra_exports):
+ count = load_three_column_export(db, ghidra_exports, bias=ghidra_bias)
+ sources.append(f"{os.path.basename(ghidra_exports)}:{count} (3-column export)")
+ else:
+ count = db.load_index_tsv(ghidra_exports, bias=ghidra_bias)
+ sources.append(f"{os.path.basename(ghidra_exports)}:{count}")
+ else:
+ notes.append(f"Ghidra exports TSV not found: {ghidra_exports}")
+ return db, sources, notes
+
+
+def inventory_records(db: SymbolDB) -> list[dict]:
+ records = [
+ {
+ "name": symbol.name,
+ "addr": symbol.addr,
+ "size": symbol.size,
+ "source": symbol.source,
+ }
+ for symbol in db
+ ]
+ records.sort(key=lambda row: (row["addr"], row["name"], row["size"]))
+ return records
+
+
+def _is_named(name: str) -> bool:
+ return bool(name) and not name.startswith("FUN_")
+
+
+def _record_brief(record: dict | None) -> dict | None:
+ if record is None:
+ return None
+ return {"addr": record["addr"], "name": record["name"], "size": record["size"]}
+
+
+def inventory_diff(a_records: list[dict], b_records: list[dict], max_rows: int = 40) -> dict:
+ """Named-by-name + anonymous-at-shifted-address matching (diff_corpus.py)."""
+ a_by_addr = {row["addr"]: row for row in a_records}
+ b_by_addr = {row["addr"]: row for row in b_records}
+ named_a: dict[str, list[int]] = defaultdict(list)
+ named_b: dict[str, list[int]] = defaultdict(list)
+ for row in a_records:
+ if _is_named(row["name"]):
+ named_a[row["name"]].append(row["addr"])
+ for row in b_records:
+ if _is_named(row["name"]):
+ named_b[row["name"]].append(row["addr"])
+
+ matched_named: list[tuple[str, int, int]] = []
+ for name in sorted(set(named_a) & set(named_b)):
+ for addr_a, addr_b in zip(sorted(named_a[name]), sorted(named_b[name])):
+ matched_named.append((name, addr_a, addr_b))
+
+ deltas = [addr_b - addr_a for _, addr_a, addr_b in matched_named]
+ shift = int(statistics.median(deltas)) if deltas else 0
+ dominant = (sum(1 for value in deltas if abs(value - shift) <= 0x40) / len(deltas)) if deltas else 0.0
+
+ used_a = {addr_a for _, addr_a, _ in matched_named}
+ used_b = {addr_b for _, _, addr_b in matched_named}
+ moved = [(name, addr_a, addr_b) for name, addr_a, addr_b in matched_named if addr_a != addr_b]
+ resized_named = [
+ (name, addr_a, addr_b)
+ for name, addr_a, addr_b in matched_named
+ if a_by_addr[addr_a]["size"] != b_by_addr[addr_b]["size"]
+ ]
+
+ shifted: list[tuple[int, int]] = []
+ for row in sorted(a_records, key=lambda item: item["addr"]):
+ addr_a = row["addr"]
+ if addr_a in used_a or _is_named(row["name"]):
+ continue
+ addr_b = addr_a + shift
+ if addr_b in b_by_addr and addr_b not in used_b:
+ shifted.append((addr_a, addr_b))
+ used_a.add(addr_a)
+ used_b.add(addr_b)
+ shifted_resized = [(fa, fb) for fa, fb in shifted if a_by_addr[fa]["size"] != b_by_addr[fb]["size"]]
+ shifted_ok = [(fa, fb) for fa, fb in shifted if a_by_addr[fa]["size"] == b_by_addr[fb]["size"]]
+
+ removed = sorted(addr for addr in a_by_addr if addr not in used_a)
+ added = sorted(addr for addr in b_by_addr if addr not in used_b)
+ matched_total = len(matched_named) + len(shifted)
+ named_count_a = sum(len(v) for v in named_a.values())
+ named_count_b = sum(len(v) for v in named_b.values())
+
+ return {
+ "shift": shift,
+ "shift_dominant": round(dominant, 4),
+ "matched_named": len(matched_named),
+ "matched_shifted": len(shifted_ok),
+ "matched_shifted_resized": len(shifted_resized),
+ "moved": _cap(
+ [{"name": name, "old_addr": fa, "new_addr": fb, "delta": fb - fa} for name, fa, fb in moved],
+ max_rows,
+ ),
+ "resized": _cap(
+ [
+ {
+ "name": name,
+ "old_addr": fa,
+ "new_addr": fb,
+ "old_size": a_by_addr[fa]["size"],
+ "new_size": b_by_addr[fb]["size"],
+ }
+ for name, fa, fb in resized_named
+ ],
+ max_rows,
+ ),
+ "resized_anonymous": _cap(
+ [
+ {
+ "old_addr": fa,
+ "new_addr": fb,
+ "old_size": a_by_addr[fa]["size"],
+ "new_size": b_by_addr[fb]["size"],
+ }
+ for fa, fb in shifted_resized
+ ],
+ max_rows,
+ ),
+ "added": _cap([_record_brief(b_by_addr[addr]) for addr in added], max_rows),
+ "removed": _cap([_record_brief(a_by_addr[addr]) for addr in removed], max_rows),
+ "totals": {
+ "a": len(a_records),
+ "b": len(b_records),
+ "named_a": named_count_a,
+ "named_b": named_count_b,
+ "anonymous_a": sum(1 for row in a_records if not _is_named(row["name"])),
+ "anonymous_b": sum(1 for row in b_records if not _is_named(row["name"])),
+ "added": len(added),
+ "removed": len(removed),
+ "moved": len(moved),
+ "resized_named": len(resized_named),
+ },
+ "rates": {
+ "match_a": round(matched_total / len(a_records), 4) if a_records else 0.0,
+ "match_b": round(matched_total / len(b_records), 4) if b_records else 0.0,
+ "named_transfer": round(len(matched_named) / named_count_a, 4) if named_count_a else 0.0,
+ },
+ }
+
+
+def symbol_section(
+ old: LoadedTarget,
+ new: LoadedTarget,
+ old_db: SymbolDB,
+ old_sources: list[str],
+ old_notes: list[str],
+ new_db: SymbolDB,
+ new_sources: list[str],
+ new_notes: list[str],
+ options: "VersionDiffOptions",
+ out_dir: str,
+) -> dict:
+ result = {
+ "status": "skipped",
+ "reason": None,
+ "a": {"count": 0, "sources": old_sources, "notes": old_notes, "db": None},
+ "b": {"count": 0, "sources": new_sources, "notes": new_notes, "db": None},
+ }
+ if not old.ok or not new.ok:
+ result["reason"] = "; ".join(
+ f"{side}: {loaded.reason or loaded.kind}" for side, loaded in (("old", old), ("new", new)) if not loaded.ok
+ )
+ return result
+ a_records = inventory_records(old_db)
+ b_records = inventory_records(new_db)
+ result["a"]["count"] = len(a_records)
+ result["b"]["count"] = len(b_records)
+ if not a_records and not b_records:
+ result["reason"] = (
+ "no symbols on either side: no ELF .symtab and no Ghidra/JSON symbol sources supplied"
+ )
+ return result
+ result.update(inventory_diff(a_records, b_records, options.max_rows))
+ result["status"] = "ok"
+ for key, db, loaded, sources, notes in (
+ ("a", old_db, old, old_sources, old_notes),
+ ("b", new_db, new, new_sources, new_notes),
+ ):
+ if not len(db):
+ continue
+ rel = os.path.join("symbols", f"{loaded.name}.json")
+ path = os.path.join(out_dir, rel)
+ ensure_dir(os.path.dirname(path))
+ db.save_json(path, meta={"firmware": os.path.basename(loaded.path), "bias": loaded.bias,
+ "sources": sources, "notes": notes})
+ result[key]["db"] = rel
+ return result
+
+
+# ---------------------------------------------------------------------------
+# 3. decompiled-change hints
+# ---------------------------------------------------------------------------
+
+
+def corpus_records(path: str) -> list[dict]:
+ db = SymbolDB()
+ if looks_like_three_column_export(path):
+ load_three_column_export(db, path, bias=0)
+ else:
+ db.load_index_tsv(path, bias=0)
+ return inventory_records(db)
+
+
+def corpus_diff(path_a: str, path_b: str, max_rows: int = 40) -> dict:
+ diff = inventory_diff(corpus_records(path_a), corpus_records(path_b), max_rows)
+ diff["a"] = os.path.basename(path_a)
+ diff["b"] = os.path.basename(path_b)
+ return diff
+
+
+def corpus_section(options: "VersionDiffOptions", out_dir: str) -> dict:
+ if options.corpus_report:
+ if not os.path.isfile(options.corpus_report):
+ return {"status": "skipped", "reason": f"corpus report not found: {options.corpus_report}",
+ "mode": "corpus-report"}
+ data = _read_bytes(options.corpus_report)
+ preview = data.decode("utf-8", "replace")
+ return {
+ "status": "ok",
+ "reason": None,
+ "mode": "corpus-report",
+ "report": {
+ "path": os.path.basename(options.corpus_report),
+ "sha256": sha256_file(options.corpus_report),
+ "bytes": len(data),
+ "preview": preview[:4000],
+ "truncated": len(preview) > 4000,
+ },
+ }
+ if options.corpus_diff:
+ path_a, path_b = options.corpus_diff
+ missing = [path for path in (path_a, path_b) if not os.path.isfile(path)]
+ if missing:
+ return {"status": "skipped", "mode": "corpus-diff",
+ "reason": "corpus TSV not found: " + ", ".join(missing)}
+ diff = corpus_diff(path_a, path_b, options.max_rows)
+ rel = os.path.join("corpus", "corpus-diff.json")
+ write_json(os.path.join(out_dir, rel), diff)
+ return {"status": "ok", "reason": None, "mode": "corpus-diff", "diff": diff, "report": rel}
+ return {
+ "status": "skipped",
+ "reason": "not supplied: pass --corpus-diff OLD_TSV NEW_TSV or --corpus-report FILE",
+ "mode": None,
+ }
+
+
+# ---------------------------------------------------------------------------
+# QEMU sessions for ELF and raw targets
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class OpenTarget:
+ loaded: LoadedTarget
+ session: Session
+ target: QemuTarget
+ workdir: str
+ owns_workdir: bool = False
+
+
+@contextlib.contextmanager
+def open_target_session(
+ loaded: LoadedTarget,
+ *,
+ record: str | bool | None,
+ name: str,
+ symbols: SymbolDB | None = None,
+ log_faults: bool = True,
+ workdir: str | None = None,
+):
+ """Relocate the target to LOAD_ADDR, start QEMU and yield a live session."""
+ owns_workdir = workdir is None
+ if workdir is None:
+ workdir = tempfile.mkdtemp(prefix="greeneye-vdiff-")
+ flat_kernel = os.path.join(workdir, "flat.bin")
+ assert loaded.image is not None
+ if loaded.kind == "elf":
+ write_flat_kernel(loaded.path, flat_kernel, load_addr=LOAD_ADDR)
+ else:
+ with open(flat_kernel, "wb") as fh:
+ fh.write(loaded.image.read())
+ target = QemuTarget(
+ kernel=flat_kernel,
+ memory_mb=memory_mb_for(loaded.image),
+ dlog="guest_errors,unimp,int" if log_faults else None,
+ workdir=workdir,
+ )
+ target.start(paused=True)
+ client = RspClient(target.host, target.port)
+ client.connect()
+ client.sync()
+ db = symbols if symbols is not None else SymbolDB()
+ session = Session(
+ client=client,
+ target=target,
+ symbols=db,
+ record=record,
+ name=name,
+ meta={
+ "backend": "qemu",
+ "bias": loaded.bias,
+ "firmware": loaded.path,
+ "kind": loaded.kind,
+ "harness": "version_diff",
+ },
+ )
+ session.load_image(loaded.image, bias=loaded.bias, load_symbols=symbols is None)
+ try:
+ yield OpenTarget(loaded=loaded, session=session, target=target, workdir=workdir,
+ owns_workdir=owns_workdir)
+ finally:
+ try:
+ session.close()
+ finally:
+ if owns_workdir:
+ shutil.rmtree(workdir, ignore_errors=True)
+
+
+def enrich_fault(fault: dict, bias: int, span: int) -> dict:
+ """Add firmware-vaddr translations for epc and the access address."""
+ out = {
+ "cause": fault.get("cause"),
+ "desc": fault.get("desc"),
+ "epc": fault.get("epc"),
+ "tval": fault.get("tval"),
+ "access": fault.get("access"),
+ }
+ epc = fault.get("epc")
+ out["epc_firmware"] = epc - bias if isinstance(epc, int) else None
+ access = fault.get("access") or {}
+ access_addr = access.get("access_addr")
+ if isinstance(access_addr, int):
+ if LOAD_ADDR <= access_addr < LOAD_ADDR + span:
+ out["access_firmware"] = access_addr - bias
+ else:
+ out["access_firmware"] = None
+ else:
+ out["access_firmware"] = None
+ return out
+
+
+# ---------------------------------------------------------------------------
+# 4. probe first-touch diff
+# ---------------------------------------------------------------------------
+
+
+def _skipped_probe(reason: str | None) -> dict:
+ return {
+ "status": "skipped",
+ "reason": reason,
+ "session": None,
+ "instructions": None,
+ "stopped_pc": None,
+ "stopped_symbol": None,
+ "fault_count": 0,
+ "faults_capped": False,
+ "first_fault": None,
+ }
+
+
+def run_probe(
+ loaded: LoadedTarget,
+ db: SymbolDB,
+ options: "VersionDiffOptions",
+ out_dir: str,
+ qemu_ok: bool,
+) -> dict:
+ if not qemu_ok:
+ return _skipped_probe("qemu-system-riscv64 not found")
+ if not loaded.ok:
+ return _skipped_probe(loaded.reason)
+ result = _skipped_probe(None)
+ result["instructions"] = options.probe_instructions
+ session_rel = os.path.join("sessions", f"{loaded.name}-probe")
+ try:
+ with open_target_session(
+ loaded,
+ record=os.path.join(out_dir, session_rel),
+ name=f"{loaded.name}-probe",
+ symbols=db,
+ log_faults=True,
+ ) as ot:
+ session = ot.session
+ if options.probe_instructions > 0:
+ session.step(options.probe_instructions)
+ else:
+ session.run(duration=options.probe_duration)
+ result["stopped_pc"] = session.pc
+ symbol = session.symbols.resolve(session.pc)
+ result["stopped_symbol"] = symbol.name if symbol else None
+ text, capped = read_log_window(ot.target.qemu_log, 0, options.log_bytes)
+ faults = parse_qemu_faults(text)
+ result["fault_count"] = len(faults)
+ result["faults_capped"] = capped
+ span = loaded.image.max_vaddr - loaded.image.min_vaddr if loaded.image else 0
+ result["first_fault"] = enrich_fault(faults[0], loaded.bias, span) if faults else None
+ result["session"] = session_rel
+ if text:
+ session.recorder.meta["qemu_log_blob"] = session.recorder.blob("qemu.log", text.encode("utf-8"))
+ session.recorder.meta["faults_capped"] = capped
+ result["status"] = "ok"
+ except Exception as exc: # a failed probe must not abort the diff
+ result["status"] = "error"
+ result["reason"] = f"{type(exc).__name__}: {exc}"
+ return result
+
+
+def _probe_signature(probe: dict) -> tuple:
+ first = probe.get("first_fault") or {}
+ return (
+ probe.get("stopped_pc"),
+ first.get("desc"),
+ first.get("cause"),
+ first.get("epc_firmware"),
+ first.get("access_firmware"),
+ (first.get("access") or {}).get("access_addr"),
+ )
+
+
+def probe_diff(
+ old: LoadedTarget,
+ new: LoadedTarget,
+ old_db: SymbolDB,
+ new_db: SymbolDB,
+ options: "VersionDiffOptions",
+ out_dir: str,
+ qemu_ok: bool,
+) -> dict:
+ a = run_probe(old, old_db, options, out_dir, qemu_ok)
+ b = run_probe(new, new_db, options, out_dir, qemu_ok)
+ statuses = {a["status"], b["status"]}
+ if statuses == {"ok"}:
+ status, reason = "ok", None
+ elif "error" in statuses:
+ status = "error"
+ reason = "; ".join(row["reason"] for row in (a, b) if row.get("reason")) or "probe failed"
+ else:
+ status = "skipped"
+ reason = "; ".join(row["reason"] for row in (a, b) if row.get("reason")) or "probe not run"
+ changed = _probe_signature(a) != _probe_signature(b)
+ if status == "ok":
+ summary = "first fault and stopped PC unchanged" if not changed else "first fault or stopped PC changed"
+ else:
+ summary = None
+ return {"status": status, "reason": reason, "a": a, "b": b, "changed": changed, "summary": summary}
+
+
+# ---------------------------------------------------------------------------
+# 5. behavior diff
+# ---------------------------------------------------------------------------
+
+
+def _skipped_behavior(reason: str | None) -> dict:
+ return {
+ "status": "skipped",
+ "reason": reason,
+ "session": None,
+ "hooks": [],
+ "uart_banner": None,
+ "ctrl": None,
+ "event_counts": {},
+ "bp": {},
+ "watch": {},
+ "writes": {},
+ "fault_count": 0,
+ "faults_capped": False,
+ "first_fault": None,
+ }
+
+
+def run_behavior(
+ loaded: LoadedTarget,
+ db: SymbolDB,
+ options: "VersionDiffOptions",
+ out_dir: str,
+ qemu_ok: bool,
+) -> dict:
+ if not qemu_ok:
+ return _skipped_behavior("qemu-system-riscv64 not found")
+ if not loaded.ok:
+ return _skipped_behavior(loaded.reason)
+ result = _skipped_behavior(None)
+ session_rel = os.path.join("sessions", f"{loaded.name}-behavior")
+ try:
+ with open_target_session(
+ loaded,
+ record=os.path.join(out_dir, session_rel),
+ name=f"{loaded.name}-behavior",
+ symbols=db,
+ log_faults=True,
+ ) as ot:
+ session = ot.session
+ result["hooks"] = install_scenario(session)
+ ctrl_symbol = session.symbols.get("gsp_ctrl")
+ ctrl_addr = ctrl_symbol.addr if ctrl_symbol else None
+ if ctrl_addr is not None:
+ session.watch(ctrl_addr + CTRL_RPC_COUNT, size=8, kind="w",
+ listener="version-diff:rpc_count")
+ if options.stimulus:
+ import greeneye as ge
+
+ script_path = Path(options.stimulus)
+ namespace = {
+ "ge": ge,
+ "greeneye": ge,
+ "session": session,
+ "s": session,
+ "memory": session.memory,
+ "symbols": session.symbols,
+ "recorder": session.recorder,
+ }
+ exec(compile(script_path.read_text(encoding="utf-8"), str(script_path), "exec"), namespace)
+ else:
+ session.run(duration=options.behavior_duration, max_stops=options.max_stops)
+ if ctrl_addr is not None:
+ session.memory.write_u64(ctrl_addr + 32, 1) # PING
+ session.memory.write_u64(ctrl_addr + 40, 0x1234)
+ session.run(duration=options.behavior_duration, max_stops=options.max_stops)
+ result["ctrl"] = read_ctrl(session, ctrl_symbol)
+ uart = ot.target.read_uart()
+ result["uart_banner"] = uart.strip().splitlines()[0] if uart.strip() else None
+ text, capped = read_log_window(ot.target.qemu_log, 0, options.log_bytes)
+ faults = parse_qemu_faults(text)
+ result["fault_count"] = len(faults)
+ result["faults_capped"] = capped
+ span = loaded.image.max_vaddr - loaded.image.min_vaddr if loaded.image else 0
+ result["first_fault"] = enrich_fault(faults[0], loaded.bias, span) if faults else None
+ result.update(profile_from_events(load_events(os.path.join(out_dir, session_rel))))
+ result["session"] = session_rel
+ result["status"] = "ok"
+ except Exception as exc: # a failed scenario must not abort the diff
+ result["status"] = "error"
+ result["reason"] = f"{type(exc).__name__}: {exc}"
+ return result
+
+
+def behavior_diff(
+ old: LoadedTarget,
+ new: LoadedTarget,
+ old_db: SymbolDB,
+ new_db: SymbolDB,
+ probe_a: dict,
+ probe_b: dict,
+ options: "VersionDiffOptions",
+ out_dir: str,
+ qemu_ok: bool,
+) -> dict:
+ a = run_behavior(old, old_db, options, out_dir, qemu_ok)
+ b = run_behavior(new, new_db, options, out_dir, qemu_ok)
+ entry = {
+ "status": "skipped",
+ "reason": None,
+ "a": a,
+ "b": b,
+ "compare_report": None,
+ "deltas": {"bp": [], "watch": [], "writes": []},
+ "all_zero": None,
+ "metrics": [],
+ }
+ a["metrics"] = metric_rows(probe_a, a)
+ b["metrics"] = metric_rows(probe_b, b)
+ if a["status"] != "ok" or b["status"] != "ok":
+ reasons = [row.get("reason") for row in (a, b) if row.get("reason")]
+ entry["reason"] = "; ".join(reasons) or "a behavior session is not available on both targets"
+ entry["status"] = "error" if "error" in {a["status"], b["status"]} else "skipped"
+ return entry
+ a_dir = os.path.join(out_dir, a["session"])
+ b_dir = os.path.join(out_dir, b["session"])
+ try:
+ report = compare_sessions(a_dir, b_dir, max_rows=options.max_rows)
+ rel = os.path.join("compare", f"{old.name}--{new.name}.md")
+ write_text(os.path.join(out_dir, rel), report)
+ entry["compare_report"] = rel
+ except Exception as exc:
+ entry["reason"] = f"{type(exc).__name__}: {exc}"
+ deltas = {
+ name: counter_delta(a.get(name) or {}, b.get(name) or {})
+ for name in ("bp", "watch", "writes")
+ }
+ entry["deltas"] = deltas
+ entry["all_zero"] = all(row["delta"] == 0 for rows in deltas.values() for row in rows)
+ a_metrics = {row["id"]: row for row in a["metrics"]}
+ b_metrics = {row["id"]: row for row in b["metrics"]}
+ entry["metrics"] = [
+ {
+ "id": metric_id,
+ "name": name,
+ "status": "not measured",
+ "reason": NO_REFERENCE,
+ "a_observed": (a_metrics.get(metric_id) or {}).get("observed"),
+ "b_observed": (b_metrics.get(metric_id) or {}).get("observed"),
+ }
+ for metric_id, name in METRIC_DEFS
+ ]
+ entry["status"] = "ok"
+ return entry
+
+
+# ---------------------------------------------------------------------------
+# report assembly + rendering
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class VersionDiffOptions:
+ old: str
+ new: str
+ out: str
+ old_label: str | None = None
+ new_label: str | None = None
+ release: str | None = None
+ old_symbols: str | None = None
+ new_symbols: str | None = None
+ old_ghidra_names: str | None = None
+ new_ghidra_names: str | None = None
+ old_ghidra_index: str | None = None
+ new_ghidra_index: str | None = None
+ old_ghidra_exports: str | None = None
+ new_ghidra_exports: str | None = None
+ old_ghidra_bias: int = 0
+ new_ghidra_bias: int = 0
+ corpus_diff: tuple[str, str] | None = None
+ corpus_report: str | None = None
+ stimulus: str | None = None
+ probe_instructions: int = 2000
+ probe_duration: float = 0.5
+ behavior_duration: float = 0.4
+ max_stops: int = 5000
+ max_rows: int = 40
+ log_bytes: int = MAX_LOG_BYTES
+ approval_token: str | None = None
+
+ def labels(self) -> list[str]:
+ return [
+ value
+ for value in (
+ self.release,
+ self.old_label,
+ self.new_label,
+ os.path.basename(self.old),
+ os.path.basename(self.new),
+ )
+ if value
+ ]
+
+ def label_for(self, side: str) -> str:
+ explicit = self.old_label if side == "old" else self.new_label
+ path = self.old if side == "old" else self.new
+ return explicit or Path(path).stem
+
+
+def _target_brief(label: str, loaded: LoadedTarget, qemu_ok: bool, options: VersionDiffOptions) -> dict:
+ return {
+ "label": label,
+ "path": os.path.basename(loaded.path),
+ "kind": loaded.kind,
+ "status": "ok" if loaded.ok else "skipped",
+ "reason": loaded.reason,
+ "sha256": loaded.sha256,
+ "size": loaded.size,
+ "entry": loaded.entry if loaded.ok else None,
+ "bias": loaded.bias if loaded.ok else None,
+ "probe_instructions": options.probe_instructions,
+ "qemu_available": qemu_ok,
+ }
+
+
+def run_version_diff(options: VersionDiffOptions) -> dict:
+ """Run every section, write JSON+markdown under ``--out``, return the report."""
+ check_rm610_gate(options.labels(), approval_token=options.approval_token)
+ ensure_dir(options.out)
+ old_label, new_label = options.label_for("old"), options.label_for("new")
+ qemu_ok = qemu_available()
+ old = load_any_target(options.old)
+ new = load_any_target(options.new)
+
+ if old.ok:
+ old_db, old_sources, old_notes = load_symbol_sources(
+ old, symbols_json=options.old_symbols, ghidra_names=options.old_ghidra_names,
+ ghidra_index=options.old_ghidra_index, ghidra_exports=options.old_ghidra_exports,
+ ghidra_bias=options.old_ghidra_bias,
+ )
+ else:
+ old_db, old_sources, old_notes = SymbolDB(), [], []
+ if new.ok:
+ new_db, new_sources, new_notes = load_symbol_sources(
+ new, symbols_json=options.new_symbols, ghidra_names=options.new_ghidra_names,
+ ghidra_index=options.new_ghidra_index, ghidra_exports=options.new_ghidra_exports,
+ ghidra_bias=options.new_ghidra_bias,
+ )
+ else:
+ new_db, new_sources, new_notes = SymbolDB(), [], []
+
+ image = image_diff(old, new, options.max_rows)
+ symbols = symbol_section(old, new, old_db, old_sources, old_notes, new_db, new_sources, new_notes,
+ options, options.out)
+ corpus = corpus_section(options, options.out)
+ probe = probe_diff(old, new, old_db, new_db, options, options.out, qemu_ok)
+ behavior = behavior_diff(old, new, old_db, new_db, probe["a"], probe["b"], options, options.out, qemu_ok)
+
+ sections = {"image": image, "symbols": symbols, "corpus": corpus, "probe": probe, "behavior": behavior}
+ report = {
+ "format": FORMAT,
+ "tool": "harness/version_diff.py",
+ "backend": "qemu",
+ "qemu_available": qemu_ok,
+ "old": _target_brief(old_label, old, qemu_ok, options),
+ "new": _target_brief(new_label, new, qemu_ok, options),
+ "options": {
+ "probe_instructions": options.probe_instructions,
+ "probe_duration": options.probe_duration,
+ "behavior_duration": options.behavior_duration,
+ "max_rows": options.max_rows,
+ "stimulus": os.path.basename(options.stimulus) if options.stimulus else None,
+ "corpus_report": os.path.basename(options.corpus_report) if options.corpus_report else None,
+ "corpus_diff": [os.path.basename(p) for p in options.corpus_diff] if options.corpus_diff else None,
+ },
+ "sections": sections,
+ "summary": {
+ "sections_ok": sorted(name for name, section in sections.items() if section["status"] == "ok"),
+ "sections_skipped": {
+ name: section.get("reason")
+ for name, section in sections.items()
+ if section["status"] == "skipped"
+ },
+ "sections_error": {
+ name: section.get("reason")
+ for name, section in sections.items()
+ if section["status"] == "error"
+ },
+ },
+ }
+ write_json(os.path.join(options.out, "version-diff.json"), report)
+ write_text(os.path.join(options.out, "version-diff.md"), render_markdown(report, options))
+ return report
+
+
+# ---------------------------------------------------------------------------
+# markdown rendering
+# ---------------------------------------------------------------------------
+
+
+def _table(headers: list[str], rows: list[list]) -> list[str]:
+ lines = ["| " + " | ".join(headers) + " |", "|" + "|".join("---" for _ in headers) + "|"]
+ for row in rows:
+ lines.append("| " + " | ".join(str(cell) for cell in row) + " |")
+ return lines
+
+
+def _hex(value) -> str:
+ return f"`{value:#x}`" if isinstance(value, int) else "-"
+
+
+def render_markdown(report: dict, options: VersionDiffOptions) -> str:
+ old, new = report["old"], report["new"]
+ lines = [
+ "# green-eye version diff",
+ "",
+ "> **# TOOL IN DEVELOPMENT - NOT READY YET!** Generated by `harness/version_diff.py`.",
+ "",
+ f"* format: `{report['format']}`",
+ f"* targets: `{old['label']}` -> `{new['label']}`",
+ f"* backend: `{report['backend']}` (qemu available: {report['qemu_available']})",
+ "",
+ "## Targets",
+ "",
+ ]
+ lines += _table(
+ ["side", "label", "kind", "size", "sha256", "entry"],
+ [
+ [side, f"`{item['label']}`", item["kind"], item["size"], f"`{(item['sha256'] or '')[:16]}`",
+ _hex(item["entry"])]
+ for side, item in (("old", old), ("new", new))
+ ],
+ )
+ lines += [""]
+
+ image = report["sections"]["image"]
+ lines += ["## 1. Image / shape", "", f"* status: {image['status']}"]
+ if image.get("reason"):
+ lines += [f"* reason: {image['reason']}"]
+ if image["status"] == "ok":
+ deltas = image["deltas"]
+ lines += _table(
+ ["field", "old", "new", "delta"],
+ [
+ ["entry", _hex(deltas["entry"]["a"]), _hex(deltas["entry"]["b"]), _hex(deltas["entry"]["delta"])],
+ ["span", _hex(deltas["span"]["a"]), _hex(deltas["span"]["b"]), _hex(deltas["span"]["delta"])],
+ ["file size", deltas["file_size"]["a"], deltas["file_size"]["b"], deltas["file_size"]["delta"]],
+ ],
+ )
+ lines += ["", f"* identical shape: {'yes' if image['same'] else 'no'}", ""]
+ lines += _table(
+ ["load delta", "count"],
+ [[key, len(image["deltas"]["loads"][key])] for key in ("added", "removed", "changed")]
+ + [["section " + key, len(image["deltas"]["sections"][key])]
+ for key in ("added", "removed", "moved", "resized")],
+ )
+ lines += [""]
+
+ symbols = report["sections"]["symbols"]
+ lines += ["## 2. Symbol inventory", "", f"* status: {symbols['status']}"]
+ if symbols.get("reason"):
+ lines += [f"* reason: {symbols['reason']}"]
+ if symbols["status"] == "ok":
+ lines += _table(
+ ["side", "symbols", "named", "anonymous", "sources"],
+ [
+ [side, symbols[side]["count"], symbols["totals"][f"named_{side}"],
+ symbols["totals"][f"anonymous_{side}"], ", ".join(symbols[side]["sources"]) or "-"]
+ for side in ("a", "b")
+ ],
+ )
+ lines += [""]
+ lines += _table(
+ ["address shift", "dominant", "matched named", "matched anon", "moved", "resized", "added", "removed"],
+ [[
+ _hex(symbols["shift"]), f"{symbols['shift_dominant'] * 100:.1f}%", symbols["matched_named"],
+ symbols["matched_shifted"], symbols["totals"]["moved"], symbols["totals"]["resized_named"],
+ symbols["totals"]["added"], symbols["totals"]["removed"],
+ ]],
+ )
+ lines += [""]
+ for key, title in (("moved", "Moved named"), ("resized", "Resized named"), ("added", "Added"),
+ ("removed", "Removed")):
+ rows = symbols.get(key) or []
+ lines += [f"**{title} (first {options.max_rows})**", ""]
+ if not rows:
+ lines += ["_none_", ""]
+ continue
+ if key in ("added", "removed"):
+ lines += _table(["addr", "name", "size"], [[_hex(r["addr"]), f"`{r['name']}`", r["size"]] for r in rows])
+ elif key == "moved":
+ lines += _table(["name", "old addr", "new addr", "delta"],
+ [[f"`{r['name']}`", _hex(r["old_addr"]), _hex(r["new_addr"]), _hex(r["delta"])] for r in rows])
+ else:
+ lines += _table(["name", "old addr", "new addr", "old size", "new size"],
+ [[f"`{r['name']}`", _hex(r["old_addr"]), _hex(r["new_addr"]), r["old_size"], r["new_size"]]
+ for r in rows])
+ lines += [""]
+ else:
+ lines += [""]
+
+ corpus = report["sections"]["corpus"]
+ lines += ["## 3. Decompiled-change hints", "", f"* status: {corpus['status']}",
+ f"* mode: {corpus.get('mode') or '-'}"]
+ if corpus.get("reason"):
+ lines += [f"* reason: {corpus['reason']}"]
+ if corpus["status"] == "ok" and corpus.get("mode") == "corpus-diff":
+ diff = corpus["diff"]
+ lines += _table(
+ ["functions A", "functions B", "shift", "added", "removed", "moved", "resized"],
+ [[diff["totals"]["a"], diff["totals"]["b"], _hex(diff["shift"]), diff["totals"]["added"],
+ diff["totals"]["removed"], diff["totals"]["moved"], diff["totals"]["resized_named"]]],
+ )
+ elif corpus["status"] == "ok" and corpus.get("mode") == "corpus-report":
+ info = corpus["report"]
+ lines += [f"* report: `{info['path']}` ({info['bytes']} bytes, sha256 `{info['sha256'][:16]}`)"]
+ lines += [""]
+
+ probe = report["sections"]["probe"]
+ lines += ["## 4. Probe first-touch", "", f"* status: {probe['status']}"]
+ if probe.get("reason"):
+ lines += [f"* reason: {probe['reason']}"]
+ if probe["status"] == "ok":
+ lines += [f"* {probe['summary']}", ""]
+ lines += _table(
+ ["side", "stopped pc", "stopped symbol", "fault", "epc (fw vaddr)", "access", "access (fw vaddr)"],
+ [
+ [
+ side,
+ _hex(probe[side].get("stopped_pc")),
+ probe[side].get("stopped_symbol") or "-",
+ (probe[side].get("first_fault") or {}).get("desc") or "none",
+ _hex((probe[side].get("first_fault") or {}).get("epc_firmware")),
+ _hex(((probe[side].get("first_fault") or {}).get("access") or {}).get("access_addr")),
+ _hex((probe[side].get("first_fault") or {}).get("access_firmware")),
+ ]
+ for side in ("a", "b")
+ ],
+ )
+ lines += [""]
+
+ behavior = report["sections"]["behavior"]
+ lines += ["## 5. Behavior", "", f"* status: {behavior['status']}"]
+ if behavior.get("reason"):
+ lines += [f"* reason: {behavior['reason']}"]
+ if behavior["status"] == "ok":
+ lines += _table(
+ ["side", "session", "hooks", "uart banner", "faults", "events"],
+ [
+ [
+ side,
+ f"`{behavior[side].get('session')}`",
+ ", ".join(behavior[side].get("hooks") or []),
+ behavior[side].get("uart_banner") or "-",
+ behavior[side].get("fault_count"),
+ sum((behavior[side].get("event_counts") or {}).values()),
+ ]
+ for side in ("a", "b")
+ ],
+ )
+ lines += [f"* zero deltas: {'yes' if behavior['all_zero'] else 'no'}"]
+ if behavior.get("compare_report"):
+ lines += [f"* compare report: `{behavior['compare_report']}`"]
+ lines += [""]
+ for name in ("bp", "watch", "writes"):
+ rows = [row for row in behavior["deltas"][name] if row["delta"] != 0]
+ rows.sort(key=lambda row: (-abs(row["delta"]), str(row["key"])))
+ lines += [f"**{name} delta (nonzero, first {options.max_rows})**", ""]
+ if rows:
+ lines += _table(["key", "old", "new", "delta"],
+ [[f"`{row['key']}`", row["a"], row["b"], row["delta"]] for row in rows[: options.max_rows]])
+ else:
+ lines += ["_no deltas_"]
+ lines += [""]
+ lines += [f"> {NO_REFERENCE}", ""]
+ lines += _table(
+ ["#", "metric", "old observed", "new observed"],
+ [[row["id"], row["name"], _short_json(row["a_observed"]), _short_json(row["b_observed"])]
+ for row in behavior["metrics"]],
+ )
+ lines += [""]
+
+ summary = report["summary"]
+ lines += ["## Summary", ""]
+ lines += [f"* ok: {', '.join(summary['sections_ok']) or '-'}"]
+ if summary["sections_skipped"]:
+ for name, reason in sorted(summary["sections_skipped"].items()):
+ lines += [f"* skipped `{name}`: {reason}"]
+ if summary["sections_error"]:
+ for name, reason in sorted(summary["sections_error"].items()):
+ lines += [f"* error `{name}`: {reason}"]
+ return "\n".join(lines) + "\n"
+
+
+def _short_json(value, limit: int = 72) -> str:
+ import json
+
+ if value is None:
+ return "-"
+ text = json.dumps(value, sort_keys=True)
+ return f"`{text}`" if len(text) <= limit else f"`{text[: limit - 1]}...`"
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def _add_side_symbols(parser: argparse.ArgumentParser, side: str) -> None:
+ parser.add_argument(f"--{side}-symbols", help="green-eye symbol DB JSON for the %s target" % side)
+ parser.add_argument(f"--{side}-ghidra-names", help="Ghidra names.tsv for the %s target" % side)
+ parser.add_argument(f"--{side}-ghidra-index", help="Ghidra decompiled_index.tsv for the %s target" % side)
+ parser.add_argument(
+ f"--{side}-ghidra-exports",
+ help="Ghidra exports.tsv or 3-column ExportForGreenEye TSV for the %s target" % side,
+ )
+ parser.add_argument(f"--{side}-ghidra-bias", type=lambda value: int(value, 0), default=0,
+ help="bias added to %s Ghidra symbol addresses" % side)
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="version_diff", description=__doc__.splitlines()[0])
+ parser.add_argument("--old", required=True, help="old target (RISC-V ELF or raw .fwimage)")
+ parser.add_argument("--new", required=True, help="new target (RISC-V ELF or raw .fwimage)")
+ parser.add_argument("--out", required=True, help="output directory for the report, sessions and DBs")
+ parser.add_argument("--old-label", help="report label for the old target (default: file stem)")
+ parser.add_argument("--new-label", help="report label for the new target (default: file stem)")
+ parser.add_argument("--release", help="release label; an rm610 release is gated by docs/23")
+ parser.add_argument("--approval-token", help=f"explicit RM610 token ({RM610_TOKEN})")
+ _add_side_symbols(parser, "old")
+ _add_side_symbols(parser, "new")
+ parser.add_argument("--corpus-diff", nargs=2, metavar=("OLD_TSV", "NEW_TSV"),
+ help="diff two decompiled_index.tsv corpus exports")
+ parser.add_argument("--corpus-report", help="attach a previously produced corpus diff report")
+ parser.add_argument("--stimulus", help="green-eye script replacing the builtin demo scenario")
+ parser.add_argument("--probe-instructions", type=int, default=2000,
+ help="probe step budget (0 = wall-clock --probe-duration)")
+ parser.add_argument("--probe-duration", type=float, default=0.5,
+ help="probe wall budget when --probe-instructions=0")
+ parser.add_argument("--behavior-duration", type=float, default=0.4,
+ help="budget for each behavior phase")
+ parser.add_argument("--max-stops", type=int, default=5000, help="safety bound on serviced stops per phase")
+ parser.add_argument("--max-rows", type=int, default=40, help="table row cap in reports")
+ parser.add_argument("--log-bytes", type=int, default=MAX_LOG_BYTES, help="QEMU log window per fault check")
+ return parser
+
+
+def options_from_args(args: argparse.Namespace) -> VersionDiffOptions:
+ corpus = tuple(args.corpus_diff) if args.corpus_diff else None
+ return VersionDiffOptions(
+ old=args.old,
+ new=args.new,
+ out=os.path.abspath(args.out),
+ old_label=args.old_label,
+ new_label=args.new_label,
+ release=args.release,
+ old_symbols=args.old_symbols,
+ new_symbols=args.new_symbols,
+ old_ghidra_names=args.old_ghidra_names,
+ new_ghidra_names=args.new_ghidra_names,
+ old_ghidra_index=args.old_ghidra_index,
+ new_ghidra_index=args.new_ghidra_index,
+ old_ghidra_exports=args.old_ghidra_exports,
+ new_ghidra_exports=args.new_ghidra_exports,
+ old_ghidra_bias=args.old_ghidra_bias,
+ new_ghidra_bias=args.new_ghidra_bias,
+ corpus_diff=corpus, # type: ignore[arg-type]
+ corpus_report=args.corpus_report,
+ stimulus=args.stimulus,
+ probe_instructions=args.probe_instructions,
+ probe_duration=args.probe_duration,
+ behavior_duration=args.behavior_duration,
+ max_stops=args.max_stops,
+ max_rows=args.max_rows,
+ log_bytes=args.log_bytes,
+ approval_token=args.approval_token,
+ )
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ options = options_from_args(args)
+ try:
+ report = run_version_diff(options)
+ except Rm610GateError as exc:
+ print(f"[version-diff] RM610 gate refused: {exc}", file=sys.stderr)
+ return 3
+ except GreenEyeError as exc:
+ print(f"[version-diff] error: {exc}", file=sys.stderr)
+ return 2
+ print(f"[version-diff] {report['old']['label']} -> {report['new']['label']}")
+ for name in ("image", "symbols", "corpus", "probe", "behavior"):
+ section = report["sections"][name]
+ print(f"[version-diff] {name}: status={section['status']}"
+ + (f" reason={section['reason']}" if section.get("reason") else ""))
+ print(f"[version-diff] wrote {os.path.join(options.out, 'version-diff.json')}")
+ print(f"[version-diff] wrote {os.path.join(options.out, 'version-diff.md')}")
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/plugins/README.md b/debug/risc-v-gsp-rm-vm/plugins/README.md
index aa9bc72..7fe69da 100644
--- a/debug/risc-v-gsp-rm-vm/plugins/README.md
+++ b/debug/risc-v-gsp-rm-vm/plugins/README.md
@@ -27,8 +27,8 @@ Types of plugins that fit green-eye:
recorded events.
3. **Virtualization-environment updates** — replay/record wrappers, QEMU
argument tweaks, device-model shims.
-4. **AI/harness integration** — export sessions to analysis services;
- **MCP servers are planned as a plugin** (see `docs/08-ROADMAP.md`), not
- part of v0.1.
+4. **AI/harness integration** — export sessions to analysis services; the
+ **MCP server is implemented as a bundled plugin** (`mcp_server.py`, see
+ `docs/20-MCP-SERVER.md`), stdlib-only and read-only by default.
The example plugin is `example_plugin.py`.
diff --git a/debug/risc-v-gsp-rm-vm/plugins/mcp_server.py b/debug/risc-v-gsp-rm-vm/plugins/mcp_server.py
new file mode 100644
index 0000000..b5e8f90
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/plugins/mcp_server.py
@@ -0,0 +1,1155 @@
+"""green-eye MCP server plugin (roadmap R3).
+
+A stdlib-only Model Context Protocol server (JSON-RPC 2.0) that exposes the
+*active* green-eye session as MCP tools. Two ways to run it:
+
+* **plugin mode** - load it into a live session and serve that session::
+
+ ./greeneye.sh attach --plugin plugins/mcp_server.py
+ greeneye> mcp-serve [--allow-write] [--allow-inject]
+
+* **standalone mode** - launch its own session and serve it::
+
+ python3 plugins/mcp_server.py --stdio [--kernel <elf>]
+ [--backend qemu|native] [--record] [--plugins PATH]
+
+Transport is newline-delimited JSON-RPC over stdin/stdout by default; an
+optional TCP mode binds to loopback only (``--tcp 127.0.0.1:PORT``). stdout
+carries protocol frames only - all logs go to stderr.
+
+Safety: read/observe tools are always available; memory writes and remote
+calls require ``--allow-write`` and service injection requires
+``--allow-inject``. Capabilities owned by other components (the QEMU-only
+harnesses, the native trace) are probed defensively and return a structured
+``not_available`` tool error when absent. See ``docs/20-MCP-SERVER.md``.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import socket
+import subprocess
+import sys
+from typing import Iterable
+
+_TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if _TOOL_ROOT not in sys.path: # `python3 plugins/mcp_server.py` must import greeneye
+ sys.path.insert(0, _TOOL_ROOT)
+
+from greeneye.errors import GreenEyeError # noqa: E402
+
+SERVER_NAME = "greeneye-mcp"
+JSONRPC_VERSION = "2.0"
+SUPPORTED_PROTOCOLS = ("2024-11-05", "2025-03-26", "2025-06-18")
+DEFAULT_PROTOCOL = "2024-11-05"
+
+PARSE_ERROR = -32700
+INVALID_REQUEST = -32600
+METHOD_NOT_FOUND = -32601
+INVALID_PARAMS = -32602
+
+_DUMPS = os.path.join(_TOOL_ROOT, "dumps")
+_PLUGINS_DIR = os.path.join(_TOOL_ROOT, "plugins")
+_DEMO_ELF = os.path.join(_TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+_FWIMAGE = os.path.join(
+ os.path.dirname(_TOOL_ROOT), "firmware", "57X-GSP", "570.144", "gsp-570.144.fwimage.bin"
+)
+MUTATION_SCRIPT = os.path.join(_TOOL_ROOT, "harness", "mutation_harness.py")
+MATRIX_SCRIPT = os.path.join(_TOOL_ROOT, "harness", "portability_matrix.py")
+LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1")
+
+#: Per-response size caps so a single tool call can never flood a client.
+MAX_READ_BYTES = 1 << 20
+MAX_HEXDUMP_BYTES = 1 << 20
+MAX_SNAPSHOT_BYTES = 1 << 24
+
+
+class McpError(Exception):
+ """A JSON-RPC level error (unknown method/tool, bad params)."""
+
+ def __init__(self, code: int, message: str, data=None):
+ super().__init__(message)
+ self.code = code
+ self.message = message
+ self.data = data
+
+
+class ToolError(Exception):
+ """A tool-execution error returned inside a tools/call result (isError)."""
+
+ def __init__(self, message: str, *, code: str = "error", **details):
+ super().__init__(message)
+ self.payload = {"error": code, "message": message, **details}
+
+
+def _unavailable(message: str, **details) -> ToolError:
+ return ToolError(message, code="not_available", **details)
+
+
+# ---------------------------------------------------------------------------
+# tool catalogue
+# ---------------------------------------------------------------------------
+
+def _address(description: str) -> dict:
+ return {"type": ["string", "integer"], "description": description}
+
+
+def _integer(description: str, default=None, minimum=None, maximum=None) -> dict:
+ schema: dict = {"type": "integer", "description": description}
+ if default is not None:
+ schema["default"] = default
+ if minimum is not None:
+ schema["minimum"] = minimum
+ if maximum is not None:
+ schema["maximum"] = maximum
+ return schema
+
+
+def _number(description: str, default=None) -> dict:
+ schema: dict = {"type": "number", "description": description}
+ if default is not None:
+ schema["default"] = default
+ return schema
+
+
+def _string(description: str, default=None) -> dict:
+ schema: dict = {"type": "string", "description": description}
+ if default is not None:
+ schema["default"] = default
+ return schema
+
+
+def _boolean(description: str, default=None) -> dict:
+ schema: dict = {"type": "boolean", "description": description}
+ if default is not None:
+ schema["default"] = default
+ return schema
+
+
+def _tool(name: str, description: str, properties: dict | None = None, required: Iterable[str] = ()) -> dict:
+ schema: dict = {"type": "object", "properties": properties or {}}
+ if required:
+ schema["required"] = list(required)
+ return {"name": name, "description": description, "inputSchema": schema}
+
+
+TOOLS: list[dict] = [
+ _tool("session_info", "Active session state: backend, PC, symbols, recording, breakpoints/watchpoints, enabled capabilities."),
+ _tool(
+ "symbols_resolve",
+ "Resolve a symbol name, 'symbol+0xNN' expression or hex address against the session symbol DB.",
+ {"target": _address("symbol name, symbol+offset or address")},
+ ("target",),
+ ),
+ _tool(
+ "bp_set",
+ "Set a software or hardware breakpoint at a symbol/address; hits are recorded in the session.",
+ {"target": _address("symbol or address"), "kind": {"type": "string", "enum": ["sw", "hw"], "default": "sw"}},
+ ("target",),
+ ),
+ _tool("bp_clear", "Clear every breakpoint at a symbol/address.", {"target": _address("symbol or address")}, ("target",)),
+ _tool(
+ "watch_set",
+ "Set a read/write/access watchpoint; hits are recorded in the session.",
+ {
+ "target": _address("symbol or address"),
+ "size": _integer("watched region size in bytes", 8, 1, 4096),
+ "kind": {"type": "string", "enum": ["w", "r", "a"], "default": "w"},
+ },
+ ("target",),
+ ),
+ _tool(
+ "run",
+ "Resume and service hooks/watchpoints for a wall-clock budget (bounded; default 1.0s).",
+ {
+ "duration": _number("wall-clock budget in seconds", 1.0),
+ "max_stops": _integer("stop budget (optional)", None, 1),
+ "timeout": _number("alias for duration when duration is null"),
+ },
+ ),
+ _tool(
+ "run_until",
+ "Run until execution reaches a target address/symbol, servicing intervening stops.",
+ {"target": _address("symbol or address"), "timeout": _number("seconds to wait", 5.0)},
+ ("target",),
+ ),
+ _tool("step", "Single-step the target n times, dispatching hooks and watchpoints.", {"count": _integer("instructions", 1, 1, 10000)}),
+ _tool(
+ "read_mem",
+ "Read memory and return lowercase hex.",
+ {"addr": _address("address or symbol"), "size": _integer("bytes to read", 64, 1, MAX_READ_BYTES)},
+ ("addr",),
+ ),
+ _tool(
+ "write_mem",
+ "Write hex bytes to target memory. Requires --allow-write at server startup.",
+ {"addr": _address("address or symbol"), "hex": _string("hex bytes, e.g. '01 02 ff'")},
+ ("addr", "hex"),
+ ),
+ _tool(
+ "hexdump",
+ "Symbol-annotated hexdump of target memory.",
+ {"addr": _address("address or symbol"), "size": _integer("bytes", 64, 1, MAX_HEXDUMP_BYTES)},
+ ("addr",),
+ ),
+ _tool(
+ "snapshot",
+ "Capture a memory region into the recording (zlib + sha256) and return its digest.",
+ {"addr": _address("address or symbol"), "size": _integer("bytes", None, 1, MAX_SNAPSHOT_BYTES), "label": _string("snapshot label", "mcp")},
+ ("addr", "size"),
+ ),
+ _tool(
+ "call_function",
+ "Remote-call a target function (Frida-like). Requires --allow-write at server startup.",
+ {"target": _address("symbol or address"), "args": {"type": "array", "items": {"type": "integer"}, "description": "a0..a7"}, "timeout": _number("seconds", 5.0)},
+ ("target",),
+ ),
+ _tool(
+ "inject_service",
+ "Load and invoke an injected RV64 service blob. Requires --allow-inject at server startup.",
+ {
+ "service": _string("path to the service ELF"),
+ "slot": _integer("demo handler slot", 6, 0, 7),
+ "load_addr": _address("load address (optional)"),
+ "cmd": _integer("service command", 1, 0),
+ "arg0": _integer("argument 0", 0, 0),
+ "arg1": _integer("argument 1", 0, 0),
+ "path": {"type": "string", "enum": ["remote", "firmware", "both"], "default": "remote"},
+ "settle": _number("seconds to settle before the firmware mailbox call", 0.25),
+ },
+ ("service",),
+ ),
+ _tool(
+ "trace_start",
+ "Start the native exec/access trace (native backend, recording session only).",
+ {
+ "exec": _boolean("record exec events", True),
+ "start": _address("only PCs >= start"),
+ "end": _address("only PCs <= end"),
+ "max_events": _integer("ring budget (0 = unlimited)"),
+ },
+ ),
+ _tool("trace_stop", "Stop the native trace, flush it to blobs/trace.jsonl and return event counts."),
+ _tool("analyze_current", "Render the symbol-annotated summary of the active recording.", {"max_rows": _integer("table row cap", 40, 1, 1000)}),
+ _tool(
+ "compare_sessions",
+ "A/B two recorded session directories at the symbolic event level.",
+ {"a": _string("session A directory"), "b": _string("session B directory"), "symbols": _boolean("resolve with the active symbol DB", True), "max_rows": _integer("table row cap", 40, 1, 1000)},
+ ("a", "b"),
+ ),
+ _tool(
+ "export_hits",
+ "Export breakpoint/watchpoint hits as Ghidra's greeneye_hits.tsv.",
+ {"session": _string("session directory (default: active recording)"), "out": _string("output TSV path")},
+ ),
+ _tool(
+ "probe_firmware",
+ "Run the QEMU probe CLI on a firmware image and report the first divergence (QEMU required).",
+ {
+ "elf": _string("firmware ELF (default: the project fwimage)"),
+ "instructions": _integer("step budget (0 = wall-clock duration)", 2000, 0),
+ "duration": _number("wall-clock seconds when instructions=0", 1.0),
+ "no_relocate": _boolean("do not relocate to 0x80000000", False),
+ "symbols": _string("green-eye symbol DB JSON"),
+ "timeout": _number("subprocess timeout seconds", 300.0),
+ },
+ ),
+ _tool(
+ "mutation_run",
+ "Run harness/mutation_harness.py (QEMU-only) and return the outcome-class counts.",
+ {
+ "kernel": _string("demo/re-hosted ELF (default: active kernel or bundled demo)"),
+ "out": _string("output directory (default: dumps/mcp-mutation)"),
+ "iterations": _integer("mutation count", 10, 1, 10000),
+ "seed": _integer("deterministic seed", 1),
+ "timeout": _number("observation budget per mutation", 0.25),
+ "settle": _number("settle budget per attempt", 0.2),
+ "memory_region": _string("optional RAM region ADDR:SIZE"),
+ "allow_foreign_elf": _boolean("permit a non-demo ELF", False),
+ },
+ ),
+ _tool(
+ "portability_matrix_run",
+ "Run harness/portability_matrix.py (QEMU-only) over one or more firmware ELFs.",
+ {
+ "targets": {"type": "array", "items": {"type": "string"}, "description": "firmware ELF paths"},
+ "out": _string("output directory (default: dumps/mcp-matrix)"),
+ "probe_instructions": _integer("probe step budget", 2000, 0),
+ "scenario_duration": _number("per-phase wall budget", 0.4),
+ "symbols": _string("green-eye symbol DB JSON"),
+ "max_rows": _integer("report row cap", 40, 1, 1000),
+ },
+ ("targets",),
+ ),
+ _tool("plugins_list", "List bundled plugins, GREENEYE_PLUGINS and plugin-provided REPL commands."),
+ _tool("close", "Stop serving MCP; in standalone mode also close the launched session."),
+]
+
+_TOOL_NAMES = {tool["name"] for tool in TOOLS}
+
+
+# ---------------------------------------------------------------------------
+# server
+# ---------------------------------------------------------------------------
+
+
+class GreenEyeMCPServer:
+ """JSON-RPC 2.0 MCP server over newline-delimited streams."""
+
+ def __init__(
+ self,
+ session,
+ *,
+ allow_write: bool = False,
+ allow_inject: bool = False,
+ api=None,
+ owns_session: bool = False,
+ input_stream=None,
+ output_stream=None,
+ log_stream=None,
+ ):
+ self.session = session
+ self.allow_write = bool(allow_write)
+ self.allow_inject = bool(allow_inject)
+ self.api = api # GreenEyeAPI when loaded as a plugin (plugins_list)
+ self.owns_session = bool(owns_session)
+ self._input = input_stream if input_stream is not None else sys.stdin
+ self._output = output_stream if output_stream is not None else sys.stdout
+ self._log_stream = log_stream if log_stream is not None else sys.stderr
+ self._stop = False
+ self.session_closed = False
+ self._initialized = False
+
+ # -- transport ---------------------------------------------------------
+
+ def serve_stdio(self) -> None:
+ self._log(f"serving {self.session.name!r} over stdio (write={self.allow_write}, inject={self.allow_inject})")
+ self.serve_stream(self._input, self._output)
+ self._log("stdio server stopped")
+
+ def serve_stream(self, stream_in, stream_out) -> None:
+ for raw in stream_in:
+ if self._stop:
+ break
+ if isinstance(raw, bytes):
+ raw = raw.decode("utf-8", "replace")
+ self.process_line(raw, stream_out)
+
+ def serve_tcp(self, host: str, port: int) -> None:
+ if host not in LOOPBACK_HOSTS:
+ raise ValueError(f"refusing non-loopback bind {host!r}; MCP is local-only")
+ server = socket.socket(socket.AF_INET if ":" not in host else socket.AF_INET6, socket.SOCK_STREAM)
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ server.bind((host, port))
+ server.listen(2)
+ bound = server.getsockname()
+ self._log(f"serving {self.session.name!r} on {bound[0]}:{bound[1]} (loopback only)")
+ try:
+ while not self._stop:
+ try:
+ conn, _addr = server.accept()
+ except OSError:
+ break
+ with conn:
+ reader = conn.makefile("r", encoding="utf-8", newline="\n")
+ writer = conn.makefile("w", encoding="utf-8", newline="\n")
+ try:
+ self.serve_stream(reader, writer)
+ finally:
+ reader.close()
+ writer.close()
+ finally:
+ server.close()
+ self._log("tcp server stopped")
+
+ def process_line(self, line: str, stream_out=None) -> None:
+ stream = stream_out if stream_out is not None else self._output
+ line = line.strip()
+ if not line:
+ return
+ try:
+ message = json.loads(line)
+ except json.JSONDecodeError as exc:
+ self._write(stream, self._error(None, PARSE_ERROR, f"parse error: {exc}"))
+ return
+ try:
+ response = self.handle_message(message)
+ except McpError as exc: # defensive: protocol bugs must not kill the loop
+ self._log(f"protocol error: {exc}")
+ response = self._error(message.get("id") if isinstance(message, dict) else None, exc.code, exc.message, exc.data)
+ if response is not None:
+ self._write(stream, response)
+
+ def _write(self, stream, payload: dict) -> None:
+ stream.write(json.dumps(payload, separators=(",", ":"), default=str) + "\n")
+ stream.flush()
+
+ # -- JSON-RPC ----------------------------------------------------------
+
+ @staticmethod
+ def _result(request_id, result: dict) -> dict:
+ return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": result}
+
+ @staticmethod
+ def _error(request_id, code: int, message: str, data=None) -> dict:
+ payload = {"jsonrpc": JSONRPC_VERSION, "id": request_id, "error": {"code": code, "message": message}}
+ if data is not None:
+ payload["error"]["data"] = data
+ return payload
+
+ def handle_message(self, message) -> dict | None:
+ if not isinstance(message, dict):
+ return self._error(None, INVALID_REQUEST, "message must be a JSON object")
+ method = message.get("method")
+ request_id = message.get("id")
+ if not isinstance(method, str):
+ return self._error(request_id, INVALID_REQUEST, "missing method")
+ params = message.get("params")
+ if params is None:
+ params = {}
+ if not isinstance(params, dict):
+ return self._error(request_id, INVALID_PARAMS, "params must be an object")
+
+ if method.startswith("notifications/") or "id" not in message:
+ return None # notifications never get a response
+
+ if method == "initialize":
+ return self._result(request_id, self._initialize(params))
+ if method == "ping":
+ return self._result(request_id, {})
+ if method == "tools/list":
+ return self._result(request_id, {"tools": TOOLS})
+ if method == "tools/call":
+ try:
+ result = self._dispatch_tool(params)
+ except McpError as exc:
+ return self._error(request_id, exc.code, exc.message, exc.data)
+ return self._result(request_id, result)
+ return self._error(request_id, METHOD_NOT_FOUND, f"unknown method {method!r}", {"method": method})
+
+ def _initialize(self, params: dict) -> dict:
+ requested = params.get("protocolVersion")
+ version = requested if requested in SUPPORTED_PROTOCOLS else DEFAULT_PROTOCOL
+ self._initialized = True
+ return {
+ "protocolVersion": version,
+ "capabilities": {"tools": {"listChanged": False}},
+ "serverInfo": {"name": SERVER_NAME, "version": _version()},
+ "instructions": (
+ "green-eye session server. Read/observe tools are always on; writes need "
+ "--allow-write and injection needs --allow-inject. Never claim timing, cycles "
+ "or hardware behaviour; sessions are the evidence."
+ ),
+ }
+
+ # -- tool dispatch -----------------------------------------------------
+
+ def _dispatch_tool(self, params: dict) -> dict:
+ name = params.get("name")
+ if not isinstance(name, str):
+ raise McpError(INVALID_PARAMS, "tools/call requires a tool name")
+ handler = getattr(self, f"tool_{name}", None)
+ if handler is None or name not in _TOOL_NAMES:
+ raise McpError(INVALID_PARAMS, f"unknown tool {name!r}", {"tool": name})
+ arguments = params.get("arguments")
+ if arguments is None:
+ arguments = {}
+ if not isinstance(arguments, dict):
+ raise McpError(INVALID_PARAMS, "tool arguments must be an object")
+ try:
+ result = handler(arguments)
+ except ToolError as exc:
+ return _tool_error(exc.payload)
+ except GreenEyeError as exc:
+ return _tool_error({"error": "greeneye", "message": str(exc)})
+ except Exception as exc: # one bad call must never take the server down
+ self._log(f"tool {name} failed: {exc!r}")
+ return _tool_error({"error": "internal", "message": f"{type(exc).__name__}: {exc}"})
+ return {"content": [{"type": "text", "text": json.dumps(result, sort_keys=True, default=str)}]}
+
+ def _require_capability(self, name: str) -> None:
+ if name == "write" and not self.allow_write:
+ raise ToolError(
+ "target writes and remote calls are disabled; restart the server with --allow-write",
+ code="write_disabled",
+ )
+ if name == "inject" and not self.allow_inject:
+ raise ToolError(
+ "service injection is disabled; restart the server with --allow-inject",
+ code="inject_disabled",
+ )
+
+ def _annotate(self, addr: int) -> str:
+ return self.session.symbols.annotate(addr)
+
+ def _session_kernel(self) -> str | None:
+ target = self.session.target
+ kernel = getattr(target, "kernel", None)
+ if kernel:
+ return str(kernel)
+ return getattr(self.session.image, "path", None)
+
+ # -- tools: session ----------------------------------------------------
+
+ def tool_session_info(self, args: dict) -> dict:
+ session = self.session
+ backend = "native" if hasattr(session.client, "trace") else ("qemu" if session.target is not None else "custom")
+ return {
+ "name": session.name,
+ "backend": backend,
+ "pc": session.pc,
+ "pc_symbol": self._annotate(session.pc),
+ "symbols": len(session.symbols),
+ "recording": getattr(session.recorder, "dir", None),
+ "kernel": self._session_kernel(),
+ "closed": self.session_closed,
+ "capabilities": {
+ "write": self.allow_write,
+ "inject": self.allow_inject,
+ "trace": backend == "native",
+ },
+ "breakpoints": [
+ {"addr": bp.addr, "label": bp.label, "kind": bp.kind, "hits": bp.hits}
+ for bp in session._bps.values() # noqa: SLF001 - the MCP server is a privileged client
+ ],
+ "watches": [
+ {"addr": wp.addr, "size": wp.size, "kind": wp.kind, "hits": wp.hits}
+ for wp in session._watches.values() # noqa: SLF001
+ ],
+ "tools": len(TOOLS),
+ }
+
+ def tool_symbols_resolve(self, args: dict) -> dict:
+ addr = self.session.resolve(_require(args, "target"))
+ sym = self.session.symbols.resolve(addr)
+ return {"address": addr, "symbol": sym.name if sym else None, "annotation": self._annotate(addr)}
+
+ # -- tools: instrumentation -------------------------------------------
+
+ def tool_bp_set(self, args: dict) -> dict:
+ target = _require(args, "target")
+ kind = args.get("kind", "sw")
+ if kind not in ("sw", "hw"):
+ raise ToolError("kind must be 'sw' or 'hw'", code="invalid_arguments")
+ handle = self.session.hook(target, kind=kind, listener="mcp")
+ return {"addr": handle.addr, "label": handle.label, "kind": kind}
+
+ def tool_bp_clear(self, args: dict) -> dict:
+ addr = self.session.resolve(_require(args, "target"))
+ removed = 0
+ for bp in list(self.session._bps.values()): # noqa: SLF001
+ if bp.addr == addr:
+ self.session.remove_breakpoint(bp)
+ removed += 1
+ return {"addr": addr, "cleared": removed}
+
+ def tool_watch_set(self, args: dict) -> dict:
+ kind = args.get("kind", "w")
+ if kind not in ("w", "r", "a"):
+ raise ToolError("kind must be 'w', 'r' or 'a'", code="invalid_arguments")
+ wp = self.session.watch(
+ _require(args, "target"),
+ size=_as_int(args.get("size", 8), "size"),
+ kind=kind,
+ listener="mcp",
+ )
+ return {"addr": wp.addr, "size": wp.size, "kind": wp.kind, "label": wp.label}
+
+ # -- tools: execution --------------------------------------------------
+
+ def tool_run(self, args: dict) -> dict:
+ duration = args.get("duration", 1.0)
+ timeout = args.get("timeout")
+ if duration is None:
+ duration = timeout if timeout is not None else 1.0
+ max_stops = args.get("max_stops")
+ stops = self.session.run(
+ duration=float(duration),
+ max_stops=_as_int(max_stops, "max_stops") if max_stops is not None else None,
+ timeout=float(timeout) if timeout is not None else None,
+ )
+ return {"stops": stops, "pc": self.session.pc, "pc_symbol": self._annotate(self.session.pc)}
+
+ def tool_run_until(self, args: dict) -> dict:
+ hit = self.session.run_until(_require(args, "target"), timeout=float(args.get("timeout", 5.0)))
+ return {
+ "addr": hit.addr,
+ "kind": hit.kind,
+ "symbol": hit.symbol.name if hit.symbol else None,
+ "pc": hit.pc,
+ "pc_symbol": self._annotate(hit.pc),
+ }
+
+ def tool_step(self, args: dict) -> dict:
+ count = _as_int(args.get("count", 1), "count")
+ if not 1 <= count <= 10000:
+ raise ToolError("count must be in 1..10000", code="invalid_arguments")
+ hits = self.session.step(count)
+ return {
+ "steps": len(hits),
+ "pc": self.session.pc,
+ "pc_symbol": self._annotate(self.session.pc),
+ "hits": [
+ {"kind": hit.kind, "addr": hit.addr, "symbol": hit.symbol.name if hit.symbol else None}
+ for hit in hits
+ ],
+ }
+
+ # -- tools: memory -----------------------------------------------------
+
+ def tool_read_mem(self, args: dict) -> dict:
+ addr = self.session.resolve(_require(args, "addr"))
+ size = _bounded(_as_int(args.get("size", 64), "size"), 1, MAX_READ_BYTES, "size")
+ data = self.session.memory.read(addr, size)
+ return {"addr": addr, "size": len(data), "hex": data.hex(), "annotation": self._annotate(addr)}
+
+ def tool_write_mem(self, args: dict) -> dict:
+ self._require_capability("write")
+ addr = self.session.resolve(_require(args, "addr"))
+ data = _hex_bytes(_require(args, "hex"))
+ self.session.memory.write(addr, data)
+ return {"addr": addr, "written": len(data), "hex": data.hex(), "annotation": self._annotate(addr)}
+
+ def tool_hexdump(self, args: dict) -> dict:
+ addr = self.session.resolve(_require(args, "addr"))
+ size = _bounded(_as_int(args.get("size", 64), "size"), 1, MAX_HEXDUMP_BYTES, "size")
+ return {"addr": addr, "size": size, "text": self.session.memory.hexdump(addr, size)}
+
+ def tool_snapshot(self, args: dict) -> dict:
+ addr = self.session.resolve(_require(args, "addr"))
+ size = _bounded(_as_int(_require(args, "size"), "size"), 1, MAX_SNAPSHOT_BYTES, "size")
+ label = str(args.get("label", "mcp"))
+ data = self.session.memory.snapshot(addr, size, name=label)
+ return {
+ "label": label,
+ "addr": addr,
+ "size": len(data),
+ "sha256": hashlib.sha256(data).hexdigest(),
+ "recording": getattr(self.session.recorder, "dir", None),
+ }
+
+ def tool_call_function(self, args: dict) -> dict:
+ self._require_capability("write")
+ target = _require(args, "target")
+ call_args = args.get("args") or []
+ if not isinstance(call_args, list):
+ raise ToolError("args must be an array of integers", code="invalid_arguments")
+ if len(call_args) > 8:
+ raise ToolError("at most 8 arguments (a0..a7)", code="invalid_arguments")
+ values = [_as_int(value, "args[]") for value in call_args]
+ retval = self.session.call(target, values, timeout=float(args.get("timeout", 5.0)))
+ return {"retval": retval, "retval_hex": hex(retval), "target": str(target)}
+
+ def tool_inject_service(self, args: dict) -> dict:
+ self._require_capability("inject")
+ service_path = str(_require(args, "service"))
+ if not os.path.isfile(service_path):
+ raise ToolError(
+ f"service not found: {service_path} (build it with `make sdk`)", code="invalid_arguments"
+ )
+ try:
+ from greeneye.inject import invoke_remote, invoke_via_firmware, load_service
+ except Exception as exc:
+ raise _unavailable(f"injection support unavailable: {exc}") from exc
+ load_addr = args.get("load_addr")
+ service = load_service(
+ self.session,
+ service_path,
+ load_addr=_as_int(load_addr, "load_addr") if load_addr is not None else None,
+ slot=_as_int(args.get("slot", 6), "slot"),
+ )
+ cmd = _as_int(args.get("cmd", 1), "cmd")
+ a0 = _as_int(args.get("arg0", 0), "arg0")
+ a1 = _as_int(args.get("arg1", 0), "arg1")
+ mode = args.get("path", "remote")
+ if mode not in ("remote", "firmware", "both"):
+ raise ToolError("path must be 'remote', 'firmware' or 'both'", code="invalid_arguments")
+ result = {
+ "service": {
+ "name": service.name,
+ "load_addr": service.load_addr,
+ "entry_addr": service.entry_addr,
+ "slot": service.slot,
+ "size": service.size,
+ },
+ "cmd": cmd,
+ "args": [a0, a1],
+ }
+ if mode in ("remote", "both"):
+ result["remote"] = invoke_remote(self.session, service, cmd, a0, a1)
+ if mode in ("firmware", "both"):
+ self.session.run(duration=float(args.get("settle", 0.25)))
+ result["firmware"] = invoke_via_firmware(self.session, service, cmd, a0, a1)
+ return result
+
+ # -- tools: native trace ----------------------------------------------
+
+ def tool_trace_start(self, args: dict) -> dict:
+ if not hasattr(self.session.client, "trace"):
+ raise _unavailable("trace start/stop is native-backend only (start the server with --backend native)")
+ if getattr(self.session.recorder, "dir", None) is None:
+ raise _unavailable("trace_start requires a recording session (start the server/session with --record)")
+ if getattr(self.session.client, "trace", None) is not None:
+ raise ToolError("a trace is already active; call trace_stop first", code="invalid_arguments")
+ try:
+ from sim.trace import make_trace
+ except Exception as exc:
+ raise _unavailable(f"native trace module unavailable: {exc}") from exc
+ spec: dict = {"exec": bool(args.get("exec", True))}
+ for key in ("start", "end", "max_events"):
+ if args.get(key) is not None:
+ spec[key] = _as_int(args[key], key)
+ tracer = make_trace(spec)
+ self.session.client.trace = tracer
+ self.session.client._recorder = self.session.recorder # noqa: SLF001 - SimClient boundary
+ self.session.recorder.meta["trace"] = tracer.config()
+ return {"tracing": True, "config": tracer.config()}
+
+ def tool_trace_stop(self, args: dict) -> dict:
+ tracer = getattr(self.session.client, "trace", None)
+ if tracer is None:
+ raise ToolError("no active trace", code="invalid_arguments")
+ self.session.client.trace = None
+ blob = tracer.flush(self.session.recorder)
+ return {"tracing": False, "blob": blob, "counts": tracer.counts()}
+
+ # -- tools: analysis ---------------------------------------------------
+
+ def tool_analyze_current(self, args: dict) -> dict:
+ directory = getattr(self.session.recorder, "dir", None)
+ if not directory:
+ raise _unavailable("the session is not recording; start it with --record")
+ try:
+ from greeneye.analyze import load_events, load_meta, load_trace, render_summary_from
+ except Exception as exc:
+ raise _unavailable(f"analysis support unavailable: {exc}") from exc
+ # An active recording has no session.json until close(), so merge the
+ # live recorder metadata instead of requiring the closed-session files.
+ if os.path.isfile(os.path.join(directory, "session.json")):
+ meta = load_meta(directory)
+ else:
+ meta = dict(getattr(self.session.recorder, "meta", {}))
+ meta.setdefault("format", "greeneye-session/1")
+ meta.setdefault("name", self.session.name)
+ meta.setdefault("dir", directory)
+ events = load_events(directory)
+ trace = load_trace(directory, meta)
+ report = render_summary_from(
+ meta, events, symbols=self.session.symbols, max_rows=_as_int(args.get("max_rows", 40), "max_rows"), trace=trace
+ )
+ return {"session": directory, "report": report}
+
+ def tool_compare_sessions(self, args: dict) -> dict:
+ a = str(_require(args, "a"))
+ b = str(_require(args, "b"))
+ for path in (a, b):
+ if not os.path.isdir(path):
+ raise ToolError(f"session directory not found: {path}", code="invalid_arguments")
+ try:
+ from greeneye.analyze import compare_sessions
+ except Exception as exc:
+ raise _unavailable(f"analysis support unavailable: {exc}") from exc
+ symbols = self.session.symbols if args.get("symbols", True) else None
+ report = compare_sessions(a, b, symbols=symbols, max_rows=_as_int(args.get("max_rows", 40), "max_rows"))
+ return {"a": a, "b": b, "report": report}
+
+ def tool_export_hits(self, args: dict) -> dict:
+ directory = args.get("session") or getattr(self.session.recorder, "dir", None)
+ if not directory:
+ raise _unavailable("no session directory given and the active session is not recording")
+ if not os.path.isdir(str(directory)):
+ raise ToolError(f"session directory not found: {directory}", code="invalid_arguments")
+ out = args.get("out") or os.path.join(str(directory), "greeneye_hits.tsv")
+ try:
+ from greeneye.ghidra import export_hits
+ except Exception as exc:
+ raise _unavailable(f"Ghidra export unavailable: {exc}") from exc
+ path = export_hits(str(directory), str(out), symbols=self.session.symbols)
+ return {"session": str(directory), "path": path}
+
+ # -- tools: external harnesses (defensive) -----------------------------
+
+ def tool_probe_firmware(self, args: dict) -> dict:
+ elf = args.get("elf") or (_FWIMAGE if os.path.isfile(_FWIMAGE) else None)
+ if not elf:
+ raise _unavailable(f"default fwimage not found and no elf argument given ({_FWIMAGE})")
+ if not os.path.isfile(str(elf)):
+ raise ToolError(f"firmware not found: {elf}", code="invalid_arguments")
+ cmd = [sys.executable, "-m", "greeneye", "probe", str(elf), "--record"]
+ if args.get("instructions") is not None:
+ cmd += ["--instructions", str(_as_int(args["instructions"], "instructions"))]
+ elif args.get("duration") is not None:
+ cmd += ["--duration", str(float(args["duration"]))]
+ if args.get("no_relocate"):
+ cmd.append("--no-relocate")
+ if args.get("symbols"):
+ cmd += ["--symbols", str(args["symbols"])]
+ rc, stdout, stderr = self._run_cli(cmd, timeout=float(args.get("timeout", 300.0)))
+ if rc != 0:
+ raise ToolError(
+ f"probe failed (rc={rc})", code="probe_failed", elf=str(elf), stdout=stdout[-4000:], stderr=stderr[-4000:]
+ )
+ return {"elf": str(elf), "returncode": rc, "stdout": stdout[-20000:], "stderr": stderr[-4000:]}
+
+ def tool_mutation_run(self, args: dict) -> dict:
+ if not os.path.isfile(MUTATION_SCRIPT):
+ raise _unavailable(f"mutation harness not present: {MUTATION_SCRIPT}")
+ kernel = args.get("kernel") or self._session_kernel() or _DEMO_ELF
+ if not os.path.isfile(str(kernel)):
+ raise ToolError(f"kernel not found: {kernel}", code="invalid_arguments")
+ out = str(args.get("out") or os.path.join(_DUMPS, "mcp-mutation"))
+ cmd = [
+ sys.executable,
+ MUTATION_SCRIPT,
+ "--kernel",
+ str(kernel),
+ "--out",
+ out,
+ "--iterations",
+ str(_as_int(args.get("iterations", 10), "iterations")),
+ "--seed",
+ str(_as_int(args.get("seed", 1), "seed")),
+ "--timeout",
+ str(float(args.get("timeout", 0.25))),
+ "--settle",
+ str(float(args.get("settle", 0.2))),
+ ]
+ if args.get("memory_region"):
+ cmd += ["--memory-region", str(args["memory_region"])]
+ if args.get("allow_foreign_elf"):
+ cmd.append("--allow-foreign-elf")
+ rc, stdout, stderr = self._run_cli(cmd, timeout=float(args.get("subprocess_timeout", 1800.0)))
+ outcomes = _count_findings(os.path.join(out, "findings.jsonl"))
+ result = {
+ "kernel": str(kernel),
+ "out": out,
+ "returncode": rc,
+ "outcomes": outcomes,
+ "findings": sum(outcomes.values()),
+ "stdout": stdout[-4000:],
+ "stderr": stderr[-4000:],
+ }
+ if rc != 0:
+ raise ToolError("mutation harness failed", code="harness_failed", **result)
+ return result
+
+ def tool_portability_matrix_run(self, args: dict) -> dict:
+ if not os.path.isfile(MATRIX_SCRIPT):
+ raise _unavailable(f"portability matrix not present: {MATRIX_SCRIPT}")
+ targets = args.get("targets")
+ if not isinstance(targets, list) or not targets:
+ raise ToolError("targets must be a non-empty array of ELF paths", code="invalid_arguments")
+ for target in targets:
+ if not os.path.isfile(str(target)):
+ raise ToolError(f"target not found: {target}", code="invalid_arguments")
+ out = str(args.get("out") or os.path.join(_DUMPS, "mcp-matrix"))
+ cmd = [sys.executable, MATRIX_SCRIPT, "--out", out]
+ for target in targets:
+ cmd += ["--target", str(target)]
+ cmd += [
+ "--probe-instructions",
+ str(_as_int(args.get("probe_instructions", 2000), "probe_instructions")),
+ "--scenario-duration",
+ str(float(args.get("scenario_duration", 0.4))),
+ "--max-rows",
+ str(_as_int(args.get("max_rows", 40), "max_rows")),
+ ]
+ if args.get("symbols"):
+ cmd += ["--symbols", str(args["symbols"])]
+ rc, stdout, stderr = self._run_cli(cmd, timeout=float(args.get("subprocess_timeout", 3600.0)))
+ report_path = os.path.join(out, "portability-report.json")
+ summary = None
+ if os.path.isfile(report_path):
+ try:
+ with open(report_path, "r", encoding="utf-8") as fh:
+ report = json.load(fh)
+ summary = {
+ "targets": [
+ {"name": t.get("name"), "status": t.get("status"), "sha256": (t.get("sha256") or "")[:16]}
+ for t in report.get("targets", [])
+ ],
+ "comparisons": len(report.get("comparisons", [])),
+ }
+ except (OSError, ValueError) as exc:
+ summary = {"parse_error": str(exc)}
+ result = {
+ "targets": [str(target) for target in targets],
+ "out": out,
+ "returncode": rc,
+ "report": report_path if os.path.isfile(report_path) else None,
+ "summary": summary,
+ "stdout": stdout[-4000:],
+ "stderr": stderr[-4000:],
+ }
+ if rc != 0:
+ raise ToolError("portability matrix failed", code="harness_failed", **result)
+ return result
+
+ def tool_plugins_list(self, args: dict) -> dict:
+ bundled: list[str] = []
+ if os.path.isdir(_PLUGINS_DIR):
+ bundled = sorted(
+ name
+ for name in os.listdir(_PLUGINS_DIR)
+ if name.endswith(".py") and not name.startswith("_")
+ )
+ commands = sorted(self.api.commands) if self.api is not None else []
+ return {
+ "bundled": bundled,
+ "GREENEYE_PLUGINS": os.environ.get("GREENEYE_PLUGINS"),
+ "commands": commands,
+ "mcp_plugin": "mcp_server.py" in bundled,
+ }
+
+ def tool_close(self, args: dict) -> dict:
+ if self.owns_session and not self.session_closed:
+ self.session.close()
+ self.session_closed = True
+ self._stop = True
+ return {"closed": True, "session_closed": self.session_closed}
+
+ # -- helpers -----------------------------------------------------------
+
+ def _run_cli(self, cmd: list[str], timeout: float) -> tuple[int, str, str]:
+ env = dict(os.environ)
+ env.setdefault("GREENEYE_DUMPS", _DUMPS)
+ try:
+ completed = subprocess.run(
+ cmd,
+ cwd=_TOOL_ROOT,
+ env=env,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ )
+ except FileNotFoundError as exc:
+ raise _unavailable(f"cannot run {cmd[0]}: {exc}") from exc
+ except subprocess.TimeoutExpired as exc:
+ raise ToolError(f"command timed out after {timeout}s", code="timeout", cmd=cmd) from exc
+ return completed.returncode, completed.stdout or "", completed.stderr or ""
+
+ def _log(self, message: str) -> None:
+ try:
+ print(f"[greeneye-mcp] {message}", file=self._log_stream, flush=True)
+ except Exception: # a broken log stream must not break serving
+ pass
+
+
+# ---------------------------------------------------------------------------
+# small argument helpers
+# ---------------------------------------------------------------------------
+
+
+def _require(args: dict, key: str):
+ if key not in args or args[key] is None:
+ raise ToolError(f"missing required argument {key!r}", code="invalid_arguments")
+ return args[key]
+
+
+def _as_int(value, name: str) -> int:
+ if isinstance(value, bool):
+ raise ToolError(f"{name} must be an integer", code="invalid_arguments")
+ if isinstance(value, int):
+ return int(value)
+ if isinstance(value, str):
+ try:
+ return int(value, 0)
+ except ValueError as exc:
+ raise ToolError(f"{name}: cannot parse integer from {value!r}", code="invalid_arguments") from exc
+ raise ToolError(f"{name} must be an integer", code="invalid_arguments")
+
+
+def _bounded(value: int, minimum: int, maximum: int, name: str) -> int:
+ if not minimum <= value <= maximum:
+ raise ToolError(f"{name} must be in {minimum}..{maximum}", code="invalid_arguments")
+ return value
+
+
+def _hex_bytes(text: str) -> bytes:
+ cleaned = str(text).strip().replace(" ", "").replace(":", "").replace(",", "")
+ if cleaned.startswith("0x") or cleaned.startswith("0X"):
+ cleaned = cleaned[2:]
+ try:
+ return bytes.fromhex(cleaned)
+ except ValueError as exc:
+ raise ToolError(f"bad hex data: {exc}", code="invalid_arguments") from exc
+
+
+def _tool_error(payload: dict) -> dict:
+ return {
+ "content": [{"type": "text", "text": json.dumps(payload, sort_keys=True, default=str)}],
+ "isError": True,
+ }
+
+
+def _count_findings(path: str) -> dict:
+ counts: dict[str, int] = {}
+ if not os.path.isfile(path):
+ return counts
+ try:
+ with open(path, "r", encoding="utf-8") as fh:
+ for line in fh:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ outcome = json.loads(line).get("outcome", "?")
+ except ValueError:
+ continue
+ counts[outcome] = counts.get(outcome, 0) + 1
+ except OSError:
+ return counts
+ return counts
+
+
+def _version() -> str:
+ try:
+ import greeneye
+
+ return greeneye.__version__
+ except Exception: # pragma: no cover - only on a broken install
+ return "unknown"
+
+
+# ---------------------------------------------------------------------------
+# plugin entry point
+# ---------------------------------------------------------------------------
+
+
+def build_serve_parser(prog: str = "mcp-serve") -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog=prog,
+ description="serve the active green-eye session over MCP (JSON-RPC over stdio or loopback TCP)",
+ )
+ parser.add_argument("--stdio", action="store_true", help="serve over stdin/stdout (default)")
+ parser.add_argument("--tcp", metavar="HOST:PORT", type=parse_tcp, help="serve over loopback TCP instead")
+ parser.add_argument("--allow-write", action="store_true", help="enable memory writes and remote calls")
+ parser.add_argument("--allow-inject", action="store_true", help="enable service injection")
+ return parser
+
+
+def register(ge) -> None:
+ """green-eye plugin hook: add the ``mcp-serve`` command."""
+
+ def mcp_serve(session, argv: list[str]) -> int:
+ parser = build_serve_parser()
+ try:
+ options = parser.parse_args(argv)
+ except SystemExit as exc: # --help must not kill the REPL
+ return int(exc.code or 0)
+ server = GreenEyeMCPServer(
+ session,
+ allow_write=options.allow_write,
+ allow_inject=options.allow_inject,
+ api=ge,
+ owns_session=False,
+ )
+ ge.log(
+ "mcp server",
+ phase="start",
+ transport=f"tcp:{options.tcp[0]}:{options.tcp[1]}" if options.tcp else "stdio",
+ allow_write=options.allow_write,
+ allow_inject=options.allow_inject,
+ )
+ try:
+ if options.tcp:
+ server.serve_tcp(*options.tcp)
+ else:
+ server.serve_stdio()
+ finally:
+ if getattr(session.recorder, "dir", None):
+ ge.log("mcp server", phase="stop", requests="see events")
+ return 0
+
+ ge.add_command(
+ "mcp-serve",
+ mcp_serve,
+ help="serve this live session over MCP (stdlib JSON-RPC; --allow-write/--allow-inject to enable writes)",
+ )
+
+
+# ---------------------------------------------------------------------------
+# standalone entry point
+# ---------------------------------------------------------------------------
+
+
+def parse_tcp(text: str) -> tuple[str, int]:
+ host, _, port_text = text.rpartition(":")
+ host = host or "127.0.0.1"
+ if host not in LOOPBACK_HOSTS:
+ raise argparse.ArgumentTypeError(f"MCP is local-only; {host!r} is not loopback (use 127.0.0.1)")
+ try:
+ port = int(port_text)
+ except ValueError as exc:
+ raise argparse.ArgumentTypeError(f"expected HOST:PORT, got {text!r}") from exc
+ if not 0 <= port <= 65535:
+ raise argparse.ArgumentTypeError(f"port out of range: {port}")
+ return host, port
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="mcp_server", description="green-eye MCP server (roadmap R3)")
+ transport = parser.add_mutually_exclusive_group()
+ transport.add_argument("--stdio", action="store_true", help="serve over stdin/stdout (default)")
+ transport.add_argument("--tcp", metavar="HOST:PORT", type=parse_tcp, help="serve over loopback TCP instead")
+ parser.add_argument("--kernel", help="firmware ELF to launch (default: bundled demo when built)")
+ parser.add_argument("--backend", choices=("qemu", "native"), default="qemu", help="execution backend")
+ parser.add_argument("--record", nargs="?", const=True, default=None, help="record to a path (or dumps/)")
+ parser.add_argument("--name", default="mcp", help="session name")
+ parser.add_argument("--plugins", action="append", default=[], help="extra plugin path (repeatable)")
+ parser.add_argument("--allow-write", action="store_true", help="enable memory writes and remote calls")
+ parser.add_argument("--allow-inject", action="store_true", help="enable service injection")
+ return parser
+
+
+def _launch_standalone(args) -> tuple[object, object]:
+ import greeneye as ge
+ from greeneye.plugins import PluginManager
+
+ os.environ.setdefault("GREENEYE_DUMPS", _DUMPS)
+ kernel = args.kernel
+ if kernel and not os.path.isfile(kernel):
+ raise GreenEyeError(f"kernel not found: {kernel}")
+ if not kernel and os.path.isfile(_DEMO_ELF):
+ kernel = _DEMO_ELF
+ session = ge.launch(kernel=kernel, backend=args.backend, record=args.record, name=args.name)
+ manager = PluginManager(session)
+ manager.load_all(args.plugins)
+ return session, manager.api
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ session, api = _launch_standalone(args)
+ except Exception as exc:
+ print(f"greeneye-mcp error: {exc}", file=sys.stderr)
+ return 2
+ server = GreenEyeMCPServer(
+ session,
+ allow_write=args.allow_write,
+ allow_inject=args.allow_inject,
+ api=api,
+ owns_session=True,
+ )
+ try:
+ if args.tcp:
+ server.serve_tcp(*args.tcp)
+ else:
+ server.serve_stdio()
+ except KeyboardInterrupt:
+ print("[greeneye-mcp] interrupted", file=sys.stderr)
+ finally:
+ if not server.session_closed:
+ try:
+ session.close()
+ except Exception: # closing is best-effort on shutdown
+ pass
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover - process entry point
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/plugins/rmharness_plugin.py b/debug/risc-v-gsp-rm-vm/plugins/rmharness_plugin.py
new file mode 100644
index 0000000..085751c
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/plugins/rmharness_plugin.py
@@ -0,0 +1,120 @@
+"""green-eye plugin: RM host-oracle harness commands.
+
+Registers REPL commands that run the read-only Open-IOV RM tools through the
+``rmharness`` wrapper scripts and record each run into the active session:
+
+ greeneye> rmtool gpu list
+ greeneye> rmtool mem stats --format=json
+ greeneye> rmtop --once
+ greeneye> rmq
+
+Every run is recorded as a ``rm_harness_run`` event with a captured
+stdout+stderr blob (via ``ge.recorder.blob``); write/ownership operations are
+refused and recorded as ``rm_harness_skip``. The plugin is stdlib-only and
+never touches kernel modules, VFIO, mdevs or the live GPU state.
+"""
+from __future__ import annotations
+
+import hashlib
+import os
+import subprocess
+import sys
+import time
+
+_TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+_HARNESS = os.path.join(_TOOL_ROOT, "rmharness")
+_WRAPPERS = {
+ "rmtool": os.path.join(_HARNESS, "rmtool.sh"),
+ "rmtop": os.path.join(_HARNESS, "rmtop.sh"),
+ "rmq": os.path.join(_HARNESS, "rmq.sh"),
+}
+#: arguments that would name a write/ownership operation on any tool.
+_BLOCKED = frozenset({
+ "alloc", "allocate", "map", "unmap", "pin", "free", "create",
+ "destroy", "submit", "bind", "unbind", "reset", "start", "stop",
+ "load", "unload", "reboot", "write", "poke", "set",
+})
+
+
+def _blocked_arg(args):
+ for arg in args:
+ token = arg.split("=", 1)[0].lstrip("-").lower()
+ if token in _BLOCKED:
+ return arg
+ return None
+
+
+def _record(ge, tool, args, proc, duration):
+ stdout = proc.stdout or ""
+ stderr = proc.stderr or ""
+ payload = (stdout + stderr).encode("utf-8", "replace")
+ blob = None
+ try:
+ blob = ge.recorder.blob(
+ f"rmharness/{tool}-{time.time_ns()}.txt", payload)
+ except Exception:
+ blob = None
+ fields = dict(
+ plugin="rmharness",
+ tool=tool,
+ argv=list(args),
+ exit=proc.returncode,
+ duration_s=round(duration, 6),
+ stdout_sha256=hashlib.sha256(
+ stdout.encode("utf-8", "replace")).hexdigest(),
+ stderr_sha256=hashlib.sha256(
+ stderr.encode("utf-8", "replace")).hexdigest(),
+ stdout_bytes=len(stdout.encode("utf-8", "replace")),
+ stderr_bytes=len(stderr.encode("utf-8", "replace")),
+ blob=blob,
+ readonly=True,
+ )
+ ge.recorder.event("rm_harness_run", **fields)
+
+
+def _run(ge, tool, args):
+ wrapper = _WRAPPERS[tool]
+ if not os.path.isfile(wrapper):
+ print(f" rmharness: wrapper missing: {wrapper}", file=sys.stderr)
+ ge.recorder.event("rm_harness_error", plugin="rmharness",
+ tool=tool, error="wrapper missing")
+ return 2
+ bad = _blocked_arg(args)
+ if bad is not None:
+ print(f" rmharness: refused '{bad}': read-only policy "
+ f"(see docs/17-RM-HARNESS.md)", file=sys.stderr)
+ ge.recorder.event("rm_harness_skip", plugin="rmharness", tool=tool,
+ argv=list(args), reason=f"blocked argument {bad!r}")
+ return 2
+ start = time.monotonic()
+ proc = subprocess.run([wrapper, *args], capture_output=True, text=True)
+ duration = time.monotonic() - start
+ if proc.stdout:
+ print(proc.stdout, end="")
+ if proc.stderr:
+ print(proc.stderr, end="", file=sys.stderr)
+ _record(ge, tool, args, proc, duration)
+ directory = getattr(ge.recorder, "dir", None)
+ if directory:
+ print(f" rmharness: recorded {tool} run -> {directory}")
+ return proc.returncode
+
+
+def register(ge):
+ ge.log("rmharness plugin loaded; tools=" + ",".join(sorted(_WRAPPERS)))
+
+ def cmd_rmtool(session, args):
+ return _run(ge, "rmtool", args)
+
+ def cmd_rmtop(session, args):
+ return _run(ge, "rmtop", args)
+
+ def cmd_rmq(session, args):
+ return _run(ge, "rmq", args)
+
+ ge.add_command("rmtool", cmd_rmtool,
+ help="rmtool <args>: read-only RM oracle (gpu/engine/mem)")
+ ge.add_command("rmtop", cmd_rmtop,
+ help="rmtop <args>: dashboard; use --once for capture")
+ ge.add_command("rmq", cmd_rmq,
+ help="rmq: existing tools/rmq host oracle (read-only)")
diff --git a/debug/risc-v-gsp-rm-vm/rmharness/README.md b/debug/risc-v-gsp-rm-vm/rmharness/README.md
new file mode 100644
index 0000000..6d33138
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/rmharness/README.md
@@ -0,0 +1,117 @@
+# rmharness - host RM oracle integration for green-eye
+
+`rmharness` wires the Open-IOV host RM userspace tools (`rmtool`, `rmtop`,
+`rmq`) into the green-eye workflow so host ground truth can be captured next
+to simulator sessions and compared later (docs/06-ACCURACY.md M1/M6).
+
+All three tools are **read-only**: they query the host RM through
+`/dev/nvidiactl` control ioctls plus `/proc` and `/sys` reads. The harness
+never loads/unloads modules, never binds/unbinds the driver, never creates
+mdevs, never claims the GPU and never touches `/sys/fs/pstore`.
+
+## Tools
+
+| wrapper | tool | what it is for |
+|---|---|---|
+| `rmtool.sh` | `tools/rmtool` | CLI oracle: GPU/engine inventory, VRAM/heap/BAR1 stats, allowlisted NV2080 controls, GPU timer sampling |
+| `rmtop.sh` | `tools/rmtop` | ncurses dashboard; `--once` renders the same panels as table/json/csv for capture |
+| `rmq.sh` | `tools/rmq` | the original host RM oracle (device info tables); unmodified |
+
+## Layout
+
+```
+rmharness/
+ README.md this file
+ rmtool.sh rmtop.sh rmq.sh
+ build-if-needed wrappers (pass arguments through)
+ harness.sh run a wrapper, capture stdout/stderr, record a session
+ tests/test_rmharness_plugin.py
+ fake-`ge` plugin import/registration check
+```
+
+The green-eye plugin lives next to the other bundled plugins:
+`plugins/rmharness_plugin.py` (REPL commands `rmtool`, `rmtop`, `rmq`).
+
+## Build and smoke
+
+The wrappers build their tool on first use. To build explicitly:
+
+```bash
+make -C tools/rmtool
+make -C tools/rmtop
+make -C tools/rmq # already existed; unchanged
+```
+
+Safe smoke checks (no RM ioctl, no device access):
+
+```bash
+./rmharness/rmtool.sh --help
+./rmharness/rmtool.sh --version
+./rmharness/rmtool.sh gpu list # reads /proc/driver/nvidia only
+./rmharness/rmtool.sh gpu topology # reads /sys/bus/pci only
+./rmharness/rmtop.sh --once # read-only RM queries (see note)
+```
+
+`rmtop --once` and `gpu info`/`engine list`/`mem stats`/`sample timer` open a
+short-lived RM client (exactly what `nvidia-smi` does) and issue read-only
+controls. They do not disturb a running CUDA job. If the driver is not bound
+they fail cleanly. Commands that would allocate or mutate anything are refused
+by the tools themselves.
+
+## Capturing into a green-eye session
+
+```bash
+cd debug/risc-v-gsp-rm-vm
+./rmharness/harness.sh --session dumps/my-run --name before \
+ -- rmtool engine list --format=json
+./rmharness/harness.sh --session dumps/my-run -- rmtop --once
+```
+
+`harness.sh` runs the wrapper, streams stdout/stderr through, writes the
+captured streams to `<session>/blobs/`, and appends one line to
+`<session>/events.jsonl`:
+
+```json
+{"type": "rm_harness_run", "tool": "rmtool", "argv": [...], "exit": 0,
+ "duration_s": 0.02, "stdout": {"path": "blobs/...", "size": 123,
+ "sha256": "..."}, "stderr": {...}, "readonly": true}
+```
+
+Recording is additive: existing sessions are never rewritten. If the
+directory has no `session.json`, a minimal `greeneye-session/1` one is
+created. The event `t` field is `0.0` for externally recorded runs; use
+`start`/`end` for wall-clock ordering.
+
+## Plugin
+
+`plugins/rmharness_plugin.py` registers REPL commands that run the wrappers
+and record the output through the live recorder:
+
+```
+greeneye> rmtool gpu list
+greeneye> rmtool mem stats --format=json
+greeneye> rmtop --once
+greeneye> rmq
+```
+
+Arguments naming write operations (`alloc`, `map`, `pin`, `start`, ...) are
+refused and recorded as `rm_harness_skip`. With a null recorder the plugin
+still runs and prints; it just does not persist.
+
+Check it without QEMU:
+
+```bash
+python3 rmharness/tests/test_rmharness_plugin.py
+```
+
+## Not implemented / not run
+
+* `rmq` device-info tables (`FIFO_GET_DEVICE_INFO_TABLE`) and
+ `nv2080 class list` return `NV_ERR_INSUFFICIENT_PERMISSIONS` /
+ `NV_ERR_NOT_SUPPORTED` for an unprivileged client on this driver; the tools
+ report the status instead of escalating.
+* `rmtool channel/context/resserve/cudasw/resman/gsp` and sampling of
+ clocks/power/thermal are documented but not implemented in the minimal
+ read-only build; they return a clear "not implemented / refused" status.
+* Nothing in this harness was validated against a live migration or a second
+ GPU; multi-GPU selection (`-d N`) uses RM `deviceId` = minor.
diff --git a/debug/risc-v-gsp-rm-vm/rmharness/harness.sh b/debug/risc-v-gsp-rm-vm/rmharness/harness.sh
new file mode 100755
index 0000000..60fce8a
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/rmharness/harness.sh
@@ -0,0 +1,147 @@
+#!/usr/bin/env bash
+# harness.sh - run a read-only RM tool, capture stdout/stderr, and record the
+# run into a green-eye session directory (blob + events.jsonl line).
+#
+# usage: harness.sh [--session DIR] [--name LABEL] -- TOOL [ARGS...]
+# TOOL is one of: rmtool rmtop rmq
+#
+# When --session is given the harness appends a "rm_harness_run" event and
+# writes the captured streams under <session>/blobs/. Existing session files
+# are never rewritten; recording is additive. Without --session the tool runs
+# and streams pass through unchanged.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ALLOWED="rmtool rmtop rmq"
+
+usage() {
+ cat <<'EOF'
+usage: harness.sh [--session DIR] [--name LABEL] -- TOOL [ARGS...]
+
+TOOL is one of: rmtool rmtop rmq (wrapper scripts in this directory).
+Everything after -- is passed to the tool unchanged.
+EOF
+}
+
+session=""
+label=""
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --session)
+ session="${2:?--session needs a directory}"
+ shift 2
+ ;;
+ --name)
+ label="${2:?--name needs a label}"
+ shift 2
+ ;;
+ -h | --help)
+ usage
+ exit 0
+ ;;
+ --)
+ shift
+ break
+ ;;
+ -*)
+ echo "harness.sh: unknown option '$1'" >&2
+ usage >&2
+ exit 2
+ ;;
+ *)
+ break
+ ;;
+ esac
+done
+
+tool="${1:-}"
+if [[ -z "$tool" ]]; then
+ usage >&2
+ exit 2
+fi
+shift
+tool="${tool%.sh}"
+case " $ALLOWED " in
+*" $tool "*) ;;
+*)
+ echo "harness.sh: tool '$tool' is not on the read-only allowlist" \
+ "($ALLOWED)" >&2
+ exit 2
+ ;;
+esac
+
+wrapper="$HERE/$tool.sh"
+if [[ ! -x "$wrapper" ]]; then
+ echo "harness.sh: wrapper not found: $wrapper" >&2
+ exit 2
+fi
+
+argv_json="$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1:]))' \
+ "$tool" "$@")"
+out_file="$(mktemp)"
+err_file="$(mktemp)"
+trap 'rm -f "$out_file" "$err_file"' EXIT
+
+start="$(python3 -c 'import time; print(time.time())')"
+rc=0
+"$wrapper" "$@" >"$out_file" 2>"$err_file" || rc=$?
+end="$(python3 -c 'import time; print(time.time())')"
+
+cat "$out_file"
+cat "$err_file" >&2
+
+if [[ -n "$session" ]]; then
+ mkdir -p "$session/blobs"
+ stamp="$(date -u +%Y%m%dT%H%M%SZ)"
+ out_rel="blobs/rmharness-${stamp}-${tool}-stdout.txt"
+ err_rel="blobs/rmharness-${stamp}-${tool}-stderr.txt"
+ cp "$out_file" "$session/$out_rel"
+ cp "$err_file" "$session/$err_rel"
+ SESSION="$session" TOOL="$tool" ARGV_JSON="$argv_json" \
+ RC="$rc" START="$start" END="$end" LABEL="$label" \
+ OUT_REL="$out_rel" ERR_REL="$err_rel" \
+ python3 - <<'PY'
+import hashlib
+import json
+import os
+import time
+
+session = os.environ["SESSION"]
+
+
+def blob_info(rel):
+ path = os.path.join(session, rel)
+ with open(path, "rb") as fh:
+ data = fh.read()
+ return {"path": rel, "size": len(data),
+ "sha256": hashlib.sha256(data).hexdigest()}
+
+
+event = {
+ "t": 0.0,
+ "type": "rm_harness_run",
+ "tool": os.environ["TOOL"],
+ "label": os.environ["LABEL"] or None,
+ "argv": json.loads(os.environ["ARGV_JSON"]),
+ "exit": int(os.environ["RC"]),
+ "start": float(os.environ["START"]),
+ "end": float(os.environ["END"]),
+ "duration_s": round(float(os.environ["END"]) - float(os.environ["START"]), 6),
+ "stdout": blob_info(os.environ["OUT_REL"]),
+ "stderr": blob_info(os.environ["ERR_REL"]),
+ "readonly": True,
+}
+meta = os.path.join(session, "session.json")
+if not os.path.exists(meta):
+ with open(meta, "w", encoding="utf-8") as fh:
+ json.dump({"format": "greeneye-session/1", "name": "rmharness",
+ "created": time.time(),
+ "greeneye": "external-rmharness/1"}, fh, indent=1,
+ sort_keys=True)
+with open(os.path.join(session, "events.jsonl"), "a", encoding="utf-8") as fh:
+ fh.write(json.dumps(event, sort_keys=True) + "\n")
+print(f"[rmharness] recorded {os.environ['TOOL']} -> {session} "
+ f"({os.environ['OUT_REL']})")
+PY
+fi
+
+exit "$rc"
diff --git a/debug/risc-v-gsp-rm-vm/rmharness/rmq.sh b/debug/risc-v-gsp-rm-vm/rmharness/rmq.sh
new file mode 100755
index 0000000..ce0670e
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/rmharness/rmq.sh
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# rmq.sh - build-if-needed wrapper for the existing tools/rmq host RM oracle.
+# rmq itself is not modified by the harness; this only locates/builds it.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT="$(cd "$HERE/../../.." && pwd)"
+BIN="$ROOT/tools/rmq/rmq"
+# rmq has no --help flag; answer it here so the smoke check never touches the
+# device (see rmharness/README.md "Safe smoke checks").
+case "${1:-}" in
+-h | --help)
+ echo "usage: $(basename "$0") [rmq-index options] (read-only RM oracle)"
+ echo " rmq has no launcher of its own; see $ROOT/tools/rmq/."
+ echo " Built on first use from $ROOT/tools/rmq."
+ exit 0
+ ;;
+esac
+if [[ ! -x "$BIN" ]]; then
+ make -C "$ROOT/tools/rmq" >&2
+fi
+exec "$BIN" "$@"
diff --git a/debug/risc-v-gsp-rm-vm/rmharness/rmtool.sh b/debug/risc-v-gsp-rm-vm/rmharness/rmtool.sh
new file mode 100755
index 0000000..405a766
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/rmharness/rmtool.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# rmtool.sh - build-if-needed wrapper for the Open-IOV rmtool RM oracle.
+# Read-only tool; see ../docs/17-RM-HARNESS.md for the safety contract.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT="$(cd "$HERE/../../.." && pwd)"
+BIN="$ROOT/tools/rmtool/rmtool"
+if [[ ! -x "$BIN" ]]; then
+ make -C "$ROOT/tools/rmtool" >&2
+fi
+exec "$BIN" "$@"
diff --git a/debug/risc-v-gsp-rm-vm/rmharness/rmtop.sh b/debug/risc-v-gsp-rm-vm/rmharness/rmtop.sh
new file mode 100755
index 0000000..5cde6c7
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/rmharness/rmtop.sh
@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+# rmtop.sh - build-if-needed wrapper for the Open-IOV rmtop dashboard.
+# Use --once for non-interactive capture; interactive mode needs a TTY.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT="$(cd "$HERE/../../.." && pwd)"
+BIN="$ROOT/tools/rmtop/rmtop"
+if [[ ! -x "$BIN" ]]; then
+ make -C "$ROOT/tools/rmtop" >&2
+fi
+exec "$BIN" "$@"
diff --git a/debug/risc-v-gsp-rm-vm/rmharness/tests/test_rmharness_plugin.py b/debug/risc-v-gsp-rm-vm/rmharness/tests/test_rmharness_plugin.py
new file mode 100644
index 0000000..8d74f6e
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/rmharness/tests/test_rmharness_plugin.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+"""Unit-style check for plugins/rmharness_plugin.py (stdlib only).
+
+Run from the risc-v-gsp-rm-vm root:
+
+ python3 rmharness/tests/test_rmharness_plugin.py
+
+It loads the plugin with a fake ``ge`` object and verifies:
+ * register() adds the rmtool/rmtop/rmq commands;
+ * write arguments are refused and recorded as rm_harness_skip;
+ * a real ``rmtool --version`` run is recorded as rm_harness_run with a blob.
+"""
+from __future__ import annotations
+
+import importlib.util
+import os
+import unittest
+
+_HERE = os.path.dirname(os.path.abspath(__file__))
+_TOOL_ROOT = os.path.dirname(os.path.dirname(_HERE))
+_PLUGIN = os.path.join(_TOOL_ROOT, "plugins", "rmharness_plugin.py")
+
+
+class FakeRecorder:
+ def __init__(self, directory=None):
+ self.dir = directory
+ self.events = []
+ self.blobs = []
+
+ def event(self, event_type, **fields):
+ self.events.append(dict(type=event_type, **fields))
+
+ def blob(self, name, data):
+ self.blobs.append((name, data))
+ return name
+
+
+class FakeGe:
+ def __init__(self, directory=None):
+ self.recorder = FakeRecorder(directory)
+ self.commands = {}
+ self.logs = []
+
+ def log(self, message, **fields):
+ self.logs.append(message)
+
+ def add_command(self, name, fn, help=""):
+ self.commands[name] = (fn, help)
+
+
+def load_plugin():
+ spec = importlib.util.spec_from_file_location("rmharness_plugin_test",
+ _PLUGIN)
+ module = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(module)
+ return module
+
+
+class RmHarnessPluginTest(unittest.TestCase):
+ def setUp(self):
+ self.plugin = load_plugin()
+ self.ge = FakeGe()
+ self.plugin.register(self.ge)
+
+ def test_registers_commands(self):
+ self.assertEqual(sorted(self.ge.commands),
+ ["rmq", "rmtool", "rmtop"])
+ self.assertTrue(any("rmharness" in msg for msg in self.ge.logs))
+
+ def test_blocked_write_is_refused(self):
+ fn, _help = self.ge.commands["rmtool"]
+ rc = fn(None, ["mem", "alloc", "-d", "0", "--size=1G"])
+ self.assertEqual(rc, 2)
+ types = [e["type"] for e in self.ge.recorder.events]
+ self.assertIn("rm_harness_skip", types)
+ self.assertNotIn("rm_harness_run", types)
+
+ def test_readonly_run_is_recorded_with_blob(self):
+ if not os.path.isfile(
+ os.path.join(_TOOL_ROOT, "rmharness", "rmtool.sh")):
+ self.skipTest("rmharness wrapper not present")
+ fn, _help = self.ge.commands["rmtool"]
+ rc = fn(None, ["--version"])
+ self.assertEqual(rc, 0)
+ types = [e["type"] for e in self.ge.recorder.events]
+ self.assertIn("rm_harness_run", types)
+ self.assertTrue(self.ge.recorder.blobs)
+ run = [e for e in self.ge.recorder.events
+ if e["type"] == "rm_harness_run"][0]
+ self.assertTrue(run["readonly"])
+ self.assertEqual(run["tool"], "rmtool")
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=2)
diff --git a/debug/risc-v-gsp-rm-vm/sim/__init__.py b/debug/risc-v-gsp-rm-vm/sim/__init__.py
new file mode 100644
index 0000000..5d7ab1f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/__init__.py
@@ -0,0 +1,43 @@
+"""green-eye native RV64IMAC simulator (M1).
+
+Pure-Python, deterministic, stdlib-only. :class:`sim.machine.Machine` executes
+RV64IMAC from a 256 MiB RAM region with an NS16550A UART; the debugger-facing
+adapter lives in ``greeneye.sim_backend``. A machine can be given a
+``greeneye.device_map.DeviceMap`` and ``unknown_mmio="lenient"`` to serve
+unmodelled MMIO accesses from harvested first-touch data instead of faulting.
+"""
+from .decode import decode16, decode32 # noqa: F401
+from .machine import ( # noqa: F401
+ R_BP,
+ R_BUDGET,
+ R_EBREAK,
+ R_ECALL,
+ R_FAULT,
+ R_STEP,
+ R_WATCH,
+ UNKNOWN_MMIO_MODES,
+ Fault,
+ Machine,
+ SimMachine,
+ Uart16550,
+)
+from .trace import Trace, make_trace # noqa: F401
+
+__all__ = [
+ "Machine",
+ "SimMachine",
+ "Uart16550",
+ "Fault",
+ "decode16",
+ "decode32",
+ "Trace",
+ "make_trace",
+ "R_BUDGET",
+ "R_BP",
+ "R_WATCH",
+ "R_EBREAK",
+ "R_FAULT",
+ "R_STEP",
+ "R_ECALL",
+ "UNKNOWN_MMIO_MODES",
+]
diff --git a/debug/risc-v-gsp-rm-vm/sim/decode.py b/debug/risc-v-gsp-rm-vm/sim/decode.py
new file mode 100644
index 0000000..0f3ef3a
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/decode.py
@@ -0,0 +1,399 @@
+"""RV64IMAC instruction decoder (pure Python, stdlib only).
+
+The decoder canonicalises every compressed instruction into the equivalent
+32-bit operation, so breakpoints, single-step and watchpoints behave uniformly
+regardless of encoding. ``decode16``/``decode32`` return a 7-tuple
+
+ (kind, sub, rd, rs1, rs2, imm, next_pc)
+
+where ``next_pc`` is the fall-through (or branch/jump target for the
+unconditional control transfers), ``imm`` is already sign/zero-normalised for
+its use, and branch/jump targets are absolute virtual addresses. Results are
+cached per PC by the Machine; the cache is dropped when the debugger writes
+memory (M1 does not model self-modifying code).
+"""
+from __future__ import annotations
+
+MASK64 = (1 << 64) - 1
+
+# Instruction kinds.
+(
+ K_LUI,
+ K_AUIPC,
+ K_JAL,
+ K_JALR,
+ K_BRANCH,
+ K_LOAD,
+ K_STORE,
+ K_OPI,
+ K_OP,
+ K_OPI32,
+ K_OP32,
+ K_FENCE,
+ K_CSR,
+ K_ECALL,
+ K_EBREAK,
+ K_MRET,
+ K_AMO,
+ K_ILLEGAL,
+) = range(18)
+
+# ALU ops shared by K_OP and (width-truncated) K_OP32.
+(
+ A_ADD,
+ A_SLL,
+ A_SLT,
+ A_SLTU,
+ A_XOR,
+ A_SRL,
+ A_OR,
+ A_AND,
+ A_SUB,
+ A_SRA,
+ A_MUL,
+ A_MULH,
+ A_MULHSU,
+ A_MULHU,
+ A_DIV,
+ A_DIVU,
+ A_REM,
+ A_REMU,
+) = range(18)
+
+# K_OPI / K_OPI32 immediate ops (funct3 values, with srai/sraiw offset).
+I_ADDI, I_SLLI, I_SLTI, I_SLTIU, I_XORI, I_SRLI, I_ORI, I_ANDI = range(8)
+I_SRAI = 0x15
+I_ADDIW, I_SLLIW, I_SRLIW, I_SRAIW = 0, 1, 5, 0x15
+
+# LOAD/STORE funct3.
+LOAD_SIZE = (1, 2, 4, 8, 1, 2, 4)
+STORE_SIZE = (1, 2, 4, 8)
+
+_ILLEGAL = (K_ILLEGAL, 0, 0, 0, 0, 0, 0)
+
+
+def sext(value: int, bits: int) -> int:
+ sign = 1 << (bits - 1)
+ return (value & (sign - 1)) - (value & sign)
+
+
+def _bits(value: int, hi: int, lo: int) -> int:
+ return (value >> lo) & ((1 << (hi - lo + 1)) - 1)
+
+
+def _op_alu(sub: int) -> int:
+ """Map an R-type funct3 to the shared ALU op code (non-M)."""
+ return {
+ 0: A_ADD,
+ 1: A_SLL,
+ 2: A_SLT,
+ 3: A_SLTU,
+ 4: A_XOR,
+ 5: A_SRL,
+ 6: A_OR,
+ 7: A_AND,
+ }[sub]
+
+
+def decode32(pc: int, insn: int):
+ opcode = insn & 0x7F
+ rd = (insn >> 7) & 0x1F
+ funct3 = (insn >> 12) & 7
+ rs1 = (insn >> 15) & 0x1F
+ rs2 = (insn >> 20) & 0x1F
+ funct7 = (insn >> 25) & 0x7F
+ nxt = pc + 4
+
+ if opcode == 0x37: # LUI
+ return (K_LUI, 0, rd, 0, 0, (insn & 0xFFFFF000) & MASK64, nxt)
+ if opcode == 0x17: # AUIPC
+ return (K_AUIPC, 0, rd, 0, 0, sext(insn & 0xFFFFF000, 32) & MASK64, nxt)
+ if opcode == 0x6F: # JAL
+ imm = sext(
+ (_bits(insn, 31, 31) << 20)
+ | (_bits(insn, 30, 21) << 1)
+ | (_bits(insn, 20, 20) << 11)
+ | (_bits(insn, 19, 12) << 12),
+ 21,
+ )
+ return (K_JAL, 0, rd, 0, 0, (pc + imm) & MASK64, nxt)
+ if opcode == 0x67: # JALR
+ return (K_JALR, funct3, rd, rs1, 0, sext(_bits(insn, 31, 20), 12), nxt)
+ if opcode == 0x63: # BRANCH
+ imm = sext(
+ (_bits(insn, 31, 31) << 12)
+ | (_bits(insn, 30, 25) << 5)
+ | (_bits(insn, 11, 8) << 1)
+ | (_bits(insn, 7, 7) << 11),
+ 13,
+ )
+ return (K_BRANCH, funct3, 0, rs1, rs2, (pc + imm) & MASK64, nxt)
+ if opcode == 0x03: # LOAD
+ if funct3 == 7:
+ return _ILLEGAL
+ return (K_LOAD, funct3, rd, rs1, 0, sext(_bits(insn, 31, 20), 12), nxt)
+ if opcode == 0x23: # STORE
+ if funct3 > 3:
+ return _ILLEGAL
+ imm = sext((_bits(insn, 31, 25) << 5) | _bits(insn, 11, 7), 12)
+ return (K_STORE, funct3, 0, rs1, rs2, imm, nxt)
+ if opcode == 0x13: # OP-IMM
+ shamt = (insn >> 20) & 0x3F
+ top = (insn >> 26) & 0x3F
+ if funct3 == 1:
+ if top:
+ return _ILLEGAL
+ return (K_OPI, I_SLLI, rd, rs1, 0, shamt, nxt)
+ if funct3 == 5:
+ if top == 0:
+ sub = I_SRLI
+ elif top == 0x10:
+ sub = I_SRAI
+ else:
+ return _ILLEGAL
+ return (K_OPI, sub, rd, rs1, 0, shamt, nxt)
+ return (K_OPI, funct3, rd, rs1, 0, sext(_bits(insn, 31, 20), 12), nxt)
+ if opcode == 0x33: # OP
+ if funct7 == 0x01: # M extension
+ return (K_OP, A_MUL + funct3, rd, rs1, rs2, 0, nxt)
+ if funct7 == 0x00:
+ sub = _op_alu(funct3)
+ elif funct7 == 0x20 and funct3 == 0:
+ sub = A_SUB
+ elif funct7 == 0x20 and funct3 == 5:
+ sub = A_SRA
+ else:
+ return _ILLEGAL
+ return (K_OP, sub, rd, rs1, rs2, 0, nxt)
+ if opcode == 0x1B: # OP-IMM-32
+ if funct3 == 0:
+ return (K_OPI32, I_ADDIW, rd, rs1, 0, sext(_bits(insn, 31, 20), 12), nxt)
+ if funct3 == 1:
+ if (insn >> 25) & 0x7F:
+ return _ILLEGAL
+ return (K_OPI32, I_SLLIW, rd, rs1, 0, (insn >> 20) & 0x1F, nxt)
+ if funct3 == 5:
+ top = (insn >> 25) & 0x7F
+ if top == 0:
+ sub = I_SRLIW
+ elif top == 0x20:
+ sub = I_SRAIW
+ else:
+ return _ILLEGAL
+ return (K_OPI32, sub, rd, rs1, 0, (insn >> 20) & 0x1F, nxt)
+ return _ILLEGAL
+ if opcode == 0x3B: # OP-32
+ if funct7 == 0x00:
+ if funct3 == 0:
+ sub = A_ADD
+ elif funct3 == 1:
+ sub = A_SLL
+ elif funct3 == 5:
+ sub = A_SRL
+ else:
+ return _ILLEGAL
+ elif funct7 == 0x20:
+ if funct3 == 0:
+ sub = A_SUB
+ elif funct3 == 5:
+ sub = A_SRA
+ else:
+ return _ILLEGAL
+ elif funct7 == 0x01:
+ if funct3 == 0:
+ sub = A_MUL
+ elif funct3 == 4:
+ sub = A_DIV
+ elif funct3 == 5:
+ sub = A_DIVU
+ elif funct3 == 6:
+ sub = A_REM
+ elif funct3 == 7:
+ sub = A_REMU
+ else:
+ return _ILLEGAL
+ else:
+ return _ILLEGAL
+ return (K_OP32, sub, rd, rs1, rs2, 0, nxt)
+ if opcode == 0x0F: # FENCE / FENCE.I: ordering is not modelled (single core)
+ if funct3 in (0, 1):
+ return (K_FENCE, 0, 0, 0, 0, 0, nxt)
+ return _ILLEGAL
+ if opcode == 0x73: # SYSTEM
+ if funct3 == 0:
+ imm12 = insn >> 20
+ if imm12 == 0:
+ return (K_ECALL, 0, 0, 0, 0, 0, nxt)
+ if imm12 == 1:
+ return (K_EBREAK, 0, 0, 0, 0, 0, nxt)
+ if imm12 in (0x302, 0x102): # mret / sret (treated alike, M1)
+ return (K_MRET, 0, 0, 0, 0, 0, nxt)
+ return _ILLEGAL
+ return (K_CSR, funct3, rd, rs1, 0, insn >> 20, nxt)
+ if opcode == 0x2F: # AMO
+ if funct3 not in (2, 3):
+ return _ILLEGAL
+ funct5 = funct7 >> 2
+ if funct5 not in (0x00, 0x01, 0x02, 0x03, 0x04, 0x08, 0x0C, 0x10, 0x14, 0x18, 0x1C):
+ return _ILLEGAL
+ sub = funct5 | (0x40 if funct3 == 3 else 0)
+ return (K_AMO, sub, rd, rs1, rs2, 0, nxt)
+ return _ILLEGAL
+
+
+def _rvc_j_imm(insn: int) -> int:
+ return sext(
+ (_bits(insn, 12, 12) << 11)
+ | (_bits(insn, 11, 11) << 4)
+ | (_bits(insn, 10, 9) << 8)
+ | (_bits(insn, 8, 8) << 10)
+ | (_bits(insn, 7, 7) << 6)
+ | (_bits(insn, 6, 6) << 7)
+ | (_bits(insn, 5, 3) << 1)
+ | (_bits(insn, 2, 2) << 5),
+ 12,
+ )
+
+
+def _rvc_b_imm(insn: int) -> int:
+ return sext(
+ (_bits(insn, 12, 12) << 8)
+ | (_bits(insn, 11, 10) << 3)
+ | (_bits(insn, 6, 5) << 6)
+ | (_bits(insn, 4, 3) << 1)
+ | (_bits(insn, 2, 2) << 5),
+ 9,
+ )
+
+
+def decode16(pc: int, insn: int):
+ op = insn & 3
+ funct3 = (insn >> 13) & 7
+ nxt = pc + 2
+ rd = (insn >> 7) & 0x1F
+
+ if op == 0:
+ if funct3 == 0: # c.addi4spn
+ imm = (
+ ((insn >> 7) & 0x30)
+ | ((insn >> 1) & 0x3C0)
+ | ((insn >> 4) & 0x4)
+ | ((insn >> 2) & 0x8)
+ )
+ if imm == 0:
+ return _ILLEGAL
+ return (K_OPI, I_ADDI, 8 + ((insn >> 2) & 7), 2, 0, imm, nxt)
+ if funct3 == 2: # c.lw
+ imm = ((insn >> 7) & 0x38) | ((insn >> 4) & 0x4) | ((insn << 1) & 0x40)
+ return (K_LOAD, 2, 8 + ((insn >> 2) & 7), 8 + ((insn >> 7) & 7), 0, imm, nxt)
+ if funct3 == 3: # c.ld
+ imm = ((insn >> 7) & 0x38) | ((insn << 1) & 0xC0)
+ return (K_LOAD, 3, 8 + ((insn >> 2) & 7), 8 + ((insn >> 7) & 7), 0, imm, nxt)
+ if funct3 == 6: # c.sw
+ imm = ((insn >> 7) & 0x38) | ((insn >> 4) & 0x4) | ((insn << 1) & 0x40)
+ return (K_STORE, 2, 0, 8 + ((insn >> 7) & 7), 8 + ((insn >> 2) & 7), imm, nxt)
+ if funct3 == 7: # c.sd
+ imm = ((insn >> 7) & 0x38) | ((insn << 1) & 0xC0)
+ return (K_STORE, 3, 0, 8 + ((insn >> 7) & 7), 8 + ((insn >> 2) & 7), imm, nxt)
+ return _ILLEGAL
+
+ if op == 1:
+ if funct3 == 0: # c.addi / c.nop
+ imm = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6)
+ return (K_OPI, I_ADDI, rd, rd, 0, imm, nxt)
+ if funct3 == 1: # c.addiw
+ if rd == 0:
+ return _ILLEGAL
+ imm = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6)
+ return (K_OPI32, I_ADDIW, rd, rd, 0, imm, nxt)
+ if funct3 == 2: # c.li
+ imm = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6)
+ return (K_OPI, I_ADDI, rd, 0, 0, imm, nxt)
+ if funct3 == 3:
+ if rd == 2: # c.addi16sp
+ imm = sext(
+ ((insn >> 3) & 0x200)
+ | ((insn >> 2) & 0x10)
+ | ((insn << 1) & 0x40)
+ | ((insn << 4) & 0x180)
+ | ((insn << 3) & 0x20),
+ 10,
+ )
+ if imm == 0:
+ return _ILLEGAL
+ return (K_OPI, I_ADDI, 2, 2, 0, imm, nxt)
+ imm6 = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6)
+ if rd == 0 or imm6 == 0: # c.lui
+ return _ILLEGAL
+ return (K_LUI, 0, rd, 0, 0, (imm6 << 12) & MASK64, nxt)
+ if funct3 == 4:
+ sub3 = (insn >> 10) & 3
+ rd_p = 8 + ((insn >> 7) & 7)
+ if sub3 == 0: # c.srli
+ shamt = (_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2)
+ return (K_OPI, I_SRLI, rd_p, rd_p, 0, shamt, nxt)
+ if sub3 == 1: # c.srai
+ shamt = (_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2)
+ return (K_OPI, I_SRAI, rd_p, rd_p, 0, shamt, nxt)
+ if sub3 == 2: # c.andi
+ imm = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6)
+ return (K_OPI, I_ANDI, rd_p, rd_p, 0, imm, nxt)
+ f2 = (insn >> 5) & 3
+ if _bits(insn, 12, 12) == 0:
+ sub = (A_SUB, A_XOR, A_OR, A_AND)[f2]
+ return (K_OP, sub, rd_p, rd_p, 8 + ((insn >> 2) & 7), 0, nxt)
+ if f2 == 0: # c.subw
+ return (K_OP32, A_SUB, rd_p, rd_p, 8 + ((insn >> 2) & 7), 0, nxt)
+ if f2 == 1: # c.addw
+ return (K_OP32, A_ADD, rd_p, rd_p, 8 + ((insn >> 2) & 7), 0, nxt)
+ return _ILLEGAL
+ if funct3 == 5: # c.j
+ return (K_JAL, 0, 0, 0, 0, (pc + _rvc_j_imm(insn)) & MASK64, nxt)
+ if funct3 == 6: # c.beqz
+ return (K_BRANCH, 0, 0, 8 + ((insn >> 7) & 7), 0, (pc + _rvc_b_imm(insn)) & MASK64, nxt)
+ if funct3 == 7: # c.bnez
+ return (K_BRANCH, 1, 0, 8 + ((insn >> 7) & 7), 0, (pc + _rvc_b_imm(insn)) & MASK64, nxt)
+ return _ILLEGAL
+
+ if op == 2:
+ if funct3 == 0: # c.slli
+ if rd == 0:
+ return _ILLEGAL
+ shamt = (_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2)
+ return (K_OPI, I_SLLI, rd, rd, 0, shamt, nxt)
+ if funct3 == 2: # c.lwsp
+ if rd == 0:
+ return _ILLEGAL
+ imm = ((insn >> 7) & 0x20) | ((insn >> 2) & 0x1C) | ((insn << 4) & 0xC0)
+ return (K_LOAD, 2, rd, 2, 0, imm, nxt)
+ if funct3 == 3: # c.ldsp
+ if rd == 0:
+ return _ILLEGAL
+ imm = ((insn >> 7) & 0x20) | ((insn >> 2) & 0x18) | ((insn << 4) & 0x1C0)
+ return (K_LOAD, 3, rd, 2, 0, imm, nxt)
+ if funct3 == 4:
+ rs2 = (insn >> 2) & 0x1F
+ if _bits(insn, 12, 12) == 0:
+ if rs2 == 0: # c.jr
+ if rd == 0:
+ return _ILLEGAL
+ return (K_JALR, 0, 0, rd, 0, 0, nxt)
+ if rd == 0: # c.mv
+ return _ILLEGAL
+ return (K_OP, A_ADD, rd, 0, rs2, 0, nxt)
+ if rs2 == 0 and rd == 0: # c.ebreak
+ return (K_EBREAK, 0, 0, 0, 0, 0, nxt)
+ if rs2 == 0: # c.jalr
+ if rd == 0:
+ return _ILLEGAL
+ return (K_JALR, 0, 1, rd, 0, 0, nxt)
+ return (K_OP, A_ADD, rd, rd, rs2, 0, nxt) # c.add
+ if funct3 == 6: # c.swsp
+ imm = ((insn >> 7) & 0x3C) | ((insn >> 1) & 0xC0)
+ return (K_STORE, 2, 0, 2, (insn >> 2) & 0x1F, imm, nxt)
+ if funct3 == 7: # c.sdsp
+ imm = ((insn >> 7) & 0x38) | ((insn >> 1) & 0x1C0)
+ return (K_STORE, 3, 0, 2, (insn >> 2) & 0x1F, imm, nxt)
+ return _ILLEGAL
+
+ return _ILLEGAL
diff --git a/debug/risc-v-gsp-rm-vm/sim/machine.py b/debug/risc-v-gsp-rm-vm/sim/machine.py
new file mode 100644
index 0000000..8d77dd8
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/machine.py
@@ -0,0 +1,825 @@
+"""Deterministic RV64IMAC machine: RAM, NS16550A UART, debugger-visible state.
+
+The execution loop is a monolithic dispatch over the cached decode tuples from
+``sim.decode``. Everything guest-visible is a pure function of the loaded
+image and the instruction budget handed to ``run``: no host clock, no threads,
+no entropy. RAM is an anonymous ``mmap`` (lazily paged, so a 256 MiB region is
+cheap); all other addresses resolve to the UART or fault.
+"""
+from __future__ import annotations
+
+import mmap
+import struct
+
+from .decode import (
+ A_ADD,
+ A_AND,
+ A_DIV,
+ A_DIVU,
+ A_MUL,
+ A_MULH,
+ A_MULHSU,
+ A_MULHU,
+ A_OR,
+ A_REM,
+ A_REMU,
+ A_SLL,
+ A_SLT,
+ A_SLTU,
+ A_SRA,
+ A_SRL,
+ A_SUB,
+ A_XOR,
+ I_ADDI,
+ I_ADDIW,
+ I_ANDI,
+ I_ORI,
+ I_SLLI,
+ I_SLLIW,
+ I_SLTI,
+ I_SLTIU,
+ I_SRAI,
+ I_SRAIW,
+ I_SRLI,
+ I_SRLIW,
+ I_XORI,
+ K_AMO,
+ K_AUIPC,
+ K_BRANCH,
+ K_CSR,
+ K_EBREAK,
+ K_ECALL,
+ K_FENCE,
+ K_ILLEGAL,
+ K_JAL,
+ K_JALR,
+ K_LOAD,
+ K_LUI,
+ K_MRET,
+ K_OP,
+ K_OP32,
+ K_OPI,
+ K_OPI32,
+ K_STORE,
+ LOAD_SIZE,
+ MASK64,
+ STORE_SIZE,
+ decode16,
+ decode32,
+)
+
+# Stop reasons returned by Machine.run().
+R_BUDGET = 0
+R_BP = 1
+R_WATCH = 2
+R_EBREAK = 3
+R_FAULT = 4
+R_STEP = 5
+R_ECALL = 6
+
+#: Accepted ``unknown_mmio`` policies for accesses outside RAM/UART/the map.
+UNKNOWN_MMIO_MODES = ("fault", "lenient")
+
+UART_BASE = 0x10000000
+UART_SIZE = 0x100
+
+# RV64I M A C advertised in misa (MXL=2, I=bit8, M=bit12, A=bit0, C=bit2).
+MISA_RV64IMAC = 0x8000000000001105
+
+
+class Fault(Exception):
+ """Guest-visible trap: unmapped access or illegal instruction."""
+
+ def __init__(self, cause: int, tval: int = 0):
+ super().__init__(f"cause={cause:#x} tval={tval:#x}")
+ self.cause = cause
+ self.tval = tval
+
+
+class Uart16550:
+ """Behavioural NS16550A: TX is captured, LSR always reports THR empty."""
+
+ def __init__(self):
+ self.tx = bytearray()
+ self.ier = 0
+ self.lcr = 0
+ self.mcr = 0
+ self.scr = 0
+ self.dll = 0
+ self.dlm = 0
+
+ def read(self, off: int, size: int = 1) -> int:
+ if off == 0:
+ return self.dll # RBR (no RX modelled) / DLL when DLAB=1
+ if off == 1:
+ return self.ier
+ if off == 2:
+ return 0x01 # IIR: no pending interrupt
+ if off == 3:
+ return self.lcr
+ if off == 4:
+ return self.mcr
+ if off == 5:
+ return 0x60 # LSR: THRE | TEMT
+ if off == 6:
+ return 0xB0 # MSR: CTS|DSR|DCD
+ if off == 7:
+ return self.scr
+ return 0
+
+ def write(self, off: int, value: int, size: int = 1) -> None:
+ value &= 0xFF
+ if off == 0:
+ if self.lcr & 0x80:
+ self.dll = value
+ else:
+ self.tx.append(value)
+ elif off == 1:
+ self.ier = value
+ elif off == 3:
+ self.lcr = value
+ elif off == 4:
+ self.mcr = value
+ elif off == 7:
+ self.scr = value
+ # FCR (2) and reserved offsets are write-only/no-ops.
+
+ def text(self) -> str:
+ return self.tx.decode("utf-8", "replace")
+
+
+def _signed64(value: int) -> int:
+ return value - 0x10000000000000000 if value & 0x8000000000000000 else value
+
+
+def _signed32(value: int) -> int:
+ value &= 0xFFFFFFFF
+ return value - 0x100000000 if value & 0x80000000 else value
+
+
+def _sext32(value: int) -> int:
+ value &= 0xFFFFFFFF
+ return (value ^ 0x80000000) - 0x80000000
+
+
+def _watch_match(watchers, ea: int, size: int, rw_bit: int):
+ end = ea + size
+ for wa, we, wk, mask in watchers:
+ if (mask & rw_bit) and ea < we and wa < end:
+ return wk
+ return None
+
+
+class Machine:
+ """One deterministic RV64IMAC hart plus RAM and UART.
+
+ ``device_map`` is a :class:`greeneye.device_map.DeviceMap` (or any object
+ with the same ``lookup``/``record`` surface) describing modelled MMIO.
+ ``unknown_mmio`` selects what happens to an access that is neither RAM,
+ UART nor in the map: ``"fault"`` (default, M1 parity) stops as before;
+ ``"lenient"`` returns the mapped behaviour or zero, records the access in
+ :attr:`mmio_log`, counts it in :attr:`mmio_unknown`, and, with
+ ``learn_mmio=True``, writes the observation back into the map. Nothing is
+ logged or counted when lenient mode is off, and debugger ``read_mem``/
+ ``write_mem`` stay strict.
+ """
+
+ def __init__(
+ self,
+ memory_mb: int = 256,
+ ram_base: int = 0x80000000,
+ device_map=None,
+ unknown_mmio: str = "fault",
+ learn_mmio: bool = False,
+ bias: int = 0,
+ ):
+ if unknown_mmio not in UNKNOWN_MMIO_MODES:
+ raise ValueError(f"unknown_mmio must be one of {UNKNOWN_MMIO_MODES}, got {unknown_mmio!r}")
+ self.ram_base = ram_base
+ self.ram_size = memory_mb * 1024 * 1024
+ self.ram = mmap.mmap(-1, self.ram_size)
+ self.uart = Uart16550()
+ self.device_map = device_map
+ self.unknown_mmio = unknown_mmio
+ self.learn_mmio = bool(learn_mmio)
+ self.bias = bias # PC relocation for learned-map firmware addresses
+ self.mmio_log: list[dict] = []
+ self.mmio_unknown = 0
+ self.x = [0] * 32
+ self.pc = ram_base
+ self.insns = 0
+ self.csrs: dict[int, int] = {0x301: MISA_RV64IMAC}
+ self._cache: dict[int, tuple] = {}
+ self._u16 = struct.Struct("<H")
+ self._u32 = struct.Struct("<I")
+ self._i8 = struct.Struct("<b")
+ self._i16 = struct.Struct("<h")
+ self._i32 = struct.Struct("<i")
+ self._u64 = struct.Struct("<Q")
+ self._u16p = self._u16.pack_into
+ self._u32p = self._u32.pack_into
+ self._u64p = self._u64.pack_into
+
+ # -- image loading -----------------------------------------------------
+
+ def load_segments(self, plan, entry: int) -> None:
+ """Place ``(addr, bytes)`` PT_LOAD results and set the PC."""
+ for addr, data in plan:
+ offset = addr - self.ram_base
+ if offset < 0 or offset + len(data) > self.ram_size:
+ raise Fault(1, addr)
+ self.ram[offset : offset + len(data)] = data
+ self.pc = entry & MASK64
+ self.invalidate()
+
+ def invalidate(self) -> None:
+ """Drop the decode cache after debugger-visible code changes."""
+ self._cache.clear()
+
+ # -- register access (debugger surface) --------------------------------
+
+ def reg(self, index: int) -> int:
+ if index == 0:
+ return 0
+ return self.x[index]
+
+ def set_reg(self, index: int, value: int) -> None:
+ if index != 0:
+ self.x[index] = value & MASK64
+
+ # -- raw memory access (debugger surface) ------------------------------
+
+ def read_mem(self, addr: int, size: int) -> bytes:
+ out = bytearray()
+ while size > 0:
+ if self.ram_base <= addr < self.ram_base + self.ram_size:
+ take = min(size, self.ram_base + self.ram_size - addr)
+ off = addr - self.ram_base
+ out += self.ram[off : off + take]
+ addr += take
+ size -= take
+ elif UART_BASE <= addr < UART_BASE + UART_SIZE:
+ out.append(self.uart.read(addr - UART_BASE))
+ addr += 1
+ size -= 1
+ else:
+ raise Fault(5, addr)
+ return bytes(out)
+
+ def write_mem(self, addr: int, data: bytes) -> None:
+ self.invalidate()
+ offset = 0
+ while offset < len(data):
+ here = addr + offset
+ remaining = len(data) - offset
+ if self.ram_base <= here < self.ram_base + self.ram_size:
+ take = min(remaining, self.ram_base + self.ram_size - here)
+ ro = here - self.ram_base
+ self.ram[ro : ro + take] = data[offset : offset + take]
+ offset += take
+ elif UART_BASE <= here < UART_BASE + UART_SIZE:
+ self.uart.write(here - UART_BASE, data[offset])
+ offset += 1
+ else:
+ raise Fault(7, here)
+
+ # -- MMIO (guest side) -------------------------------------------------
+
+ def _mapped_mmio(self, addr: int, size: int, write: bool) -> tuple[bool, int]:
+ """Consult the device map; ``(handled, value)`` for a modelled access."""
+ dm = self.device_map
+ if dm is None:
+ return False, 0
+ spec = dm.lookup(addr, size, write)
+ if spec == "unimplemented":
+ return False, 0
+ if spec.startswith("value:"):
+ return True, int(spec[6:], 16)
+ return True, 0 # "zero": mapped but no read value observed
+
+ def _log_unknown(self, pc: int, addr: int, size: int, rw: str, value: int = 0) -> None:
+ """Lenient fallback: log, count, and optionally learn one access."""
+ self.mmio_unknown += 1
+ self.mmio_log.append({"pc": pc, "addr": addr, "size": size, "rw": rw})
+ if self.learn_mmio:
+ if self.device_map is None:
+ from greeneye.device_map import DeviceMap # lazy: sim stays usable alone
+
+ self.device_map = DeviceMap(meta={"source": "learn"})
+ self.device_map.record(addr, size, rw, value, pc=pc, bias=self.bias, source="trace")
+
+ def _mmio_load(self, addr: int, size: int, pc: int = 0) -> int:
+ if UART_BASE <= addr < UART_BASE + UART_SIZE:
+ return self.uart.read(addr - UART_BASE)
+ handled, value = self._mapped_mmio(addr, size, False)
+ if handled:
+ return value
+ if self.unknown_mmio == "lenient":
+ self._log_unknown(pc, addr, size, "r")
+ return 0
+ raise Fault(5, addr)
+
+ def _mmio_store(self, addr: int, size: int, value: int, pc: int = 0) -> None:
+ if UART_BASE <= addr < UART_BASE + UART_SIZE:
+ self.uart.write(addr - UART_BASE, value)
+ return
+ handled, _ = self._mapped_mmio(addr, size, True)
+ if handled:
+ if self.learn_mmio and self.device_map is not None:
+ self.device_map.record(addr, size, "w", value, pc=pc, bias=self.bias, source="trace")
+ return
+ if self.unknown_mmio == "lenient":
+ self._log_unknown(pc, addr, size, "w", value)
+ return
+ raise Fault(7, addr)
+
+ # -- execution ---------------------------------------------------------
+
+ def step(self, watchers=(), trace=None):
+ return self.run(1, breakpoints=(), watchers=watchers, single_step=True, trace=trace)
+
+ def run(self, budget: int, breakpoints=(), watchers=(), single_step: bool = False,
+ trace=None):
+ """Execute up to ``budget`` instructions.
+
+ Returns ``(reason, pc, a, b)``; ``a``/``b`` carry the watched address
+ and kind (R_WATCH) or the trap cause and tval (R_FAULT). When
+ ``trace`` is a :class:`sim.trace.Trace`, every executed instruction
+ and every completed memory access in its address range is recorded.
+ """
+ x = self.x
+ ram = self.ram
+ base = self.ram_base
+ limit = self.ram_size
+ cache = self._cache
+ i8 = self._i8.unpack_from
+ i16 = self._i16.unpack_from
+ i32 = self._i32.unpack_from
+ u16 = self._u16.unpack_from
+ u32 = self._u32.unpack_from
+ u64 = self._u64.unpack_from
+ u16p = self._u16p
+ u32p = self._u32p
+ u64p = self._u64p
+ has_bps = bool(breakpoints)
+ pc = self.pc
+ n = 0
+ reason = R_BUDGET
+ a = b = 0
+ traced = trace is not None
+ active = False
+
+ while n < budget:
+ if has_bps and pc in breakpoints:
+ reason = R_BP
+ a = pc
+ break
+
+ off = pc - base
+ if off < 0 or off + 4 > limit:
+ reason = R_FAULT
+ a = 1
+ b = pc
+ break
+ hw = ram[off] | (ram[off + 1] << 8)
+ if hw & 3 == 3:
+ d = cache.get(pc)
+ if d is None:
+ d = decode32(pc, hw | (ram[off + 2] << 16) | (ram[off + 3] << 24))
+ cache[pc] = d
+ else:
+ d = cache.get(pc)
+ if d is None:
+ d = decode16(pc, hw)
+ cache[pc] = d
+ kind, sub, rd, r1, r2, imm, nxt = d
+ n += 1
+ if traced:
+ insn_pc = pc
+ active = trace.begin(insn_pc, self.insns + n)
+
+ # -- loads / stores (most frequent in firmware hot loops) ------
+ if kind == K_LOAD:
+ ea = (x[r1] + imm) & MASK64
+ size = LOAD_SIZE[sub]
+ if watchers:
+ hit = _watch_match(watchers, ea, size, 2)
+ if hit is not None:
+ reason = R_WATCH
+ a = ea
+ b = hit
+ break
+ o = ea - base
+ if 0 <= o <= limit - size:
+ if active:
+ trace.mem(insn_pc, ea, size, False, self.insns + n)
+ if sub == 3:
+ if rd:
+ x[rd] = u64(ram, o)[0]
+ elif sub == 2:
+ if rd:
+ x[rd] = i32(ram, o)[0]
+ elif sub == 4:
+ if rd:
+ x[rd] = ram[o]
+ elif sub == 0:
+ if rd:
+ x[rd] = i8(ram, o)[0]
+ elif sub == 5:
+ if rd:
+ x[rd] = u16(ram, o)[0]
+ elif sub == 6:
+ if rd:
+ x[rd] = u32(ram, o)[0]
+ else:
+ if rd:
+ x[rd] = i16(ram, o)[0]
+ pc = nxt
+ else:
+ try:
+ v = self._mmio_load(ea, size, pc)
+ except Fault as f:
+ reason = R_FAULT
+ a = f.cause
+ b = f.tval
+ break
+ if rd:
+ x[rd] = v
+ if active:
+ trace.mem(insn_pc, ea, size, False, self.insns + n)
+ pc = nxt
+ continue
+
+ if kind == K_STORE:
+ ea = (x[r1] + imm) & MASK64
+ size = STORE_SIZE[sub]
+ if watchers:
+ hit = _watch_match(watchers, ea, size, 1)
+ if hit is not None:
+ reason = R_WATCH
+ a = ea
+ b = hit
+ break
+ o = ea - base
+ value = x[r2]
+ if 0 <= o <= limit - size:
+ if active:
+ trace.mem(insn_pc, ea, size, True, self.insns + n)
+ if sub == 3:
+ u64p(ram, o, value)
+ elif sub == 2:
+ u32p(ram, o, value & 0xFFFFFFFF)
+ elif sub == 1:
+ u16p(ram, o, value & 0xFFFF)
+ else:
+ ram[o] = value & 0xFF
+ pc = nxt
+ else:
+ try:
+ self._mmio_store(ea, size, value, pc)
+ except Fault as f:
+ reason = R_FAULT
+ a = f.cause
+ b = f.tval
+ break
+ if active:
+ trace.mem(insn_pc, ea, size, True, self.insns + n)
+ pc = nxt
+ continue
+
+ # -- control flow ----------------------------------------------
+ if kind == K_BRANCH:
+ va = x[r1]
+ vb = x[r2]
+ if sub == 0:
+ take = va == vb
+ elif sub == 1:
+ take = va != vb
+ elif sub == 4:
+ take = _signed64(va) < _signed64(vb)
+ elif sub == 5:
+ take = _signed64(va) >= _signed64(vb)
+ elif sub == 6:
+ take = va < vb
+ else:
+ take = va >= vb
+ pc = imm if take else nxt
+ elif kind == K_JAL:
+ if rd:
+ x[rd] = nxt
+ pc = imm
+ elif kind == K_JALR:
+ target = (x[r1] + imm) & MASK64
+ target &= ~1
+ if rd:
+ x[rd] = nxt
+ pc = target
+ elif kind == K_AUIPC:
+ if rd:
+ x[rd] = (pc + imm) & MASK64
+ pc = nxt
+ elif kind == K_LUI:
+ if rd:
+ x[rd] = imm
+ pc = nxt
+
+ # -- integer ALU -----------------------------------------------
+ elif kind == K_OPI:
+ va = x[r1] if r1 else 0
+ if sub == I_ADDI:
+ rv = (va + imm) & MASK64
+ elif sub == I_SLLI:
+ rv = (va << (imm & 63)) & MASK64
+ elif sub == I_SLTI:
+ rv = 1 if _signed64(va) < imm else 0
+ elif sub == I_SLTIU:
+ rv = 1 if va < (imm & MASK64) else 0
+ elif sub == I_XORI:
+ rv = va ^ (imm & MASK64)
+ elif sub == I_ORI:
+ rv = va | (imm & MASK64)
+ elif sub == I_ANDI:
+ rv = va & (imm & MASK64)
+ elif sub == I_SRLI:
+ rv = va >> (imm & 63)
+ else: # I_SRAI
+ rv = (_signed64(va) >> (imm & 63)) & MASK64
+ if rd:
+ x[rd] = rv
+ pc = nxt
+
+ elif kind == K_OP:
+ va = x[r1]
+ vb = x[r2]
+ if sub == A_ADD:
+ rv = (va + vb) & MASK64
+ elif sub == A_SLL:
+ rv = (va << (vb & 63)) & MASK64
+ elif sub == A_SLT:
+ rv = 1 if _signed64(va) < _signed64(vb) else 0
+ elif sub == A_SLTU:
+ rv = 1 if va < vb else 0
+ elif sub == A_XOR:
+ rv = va ^ vb
+ elif sub == A_SRL:
+ rv = va >> (vb & 63)
+ elif sub == A_OR:
+ rv = va | vb
+ elif sub == A_AND:
+ rv = va & vb
+ elif sub == A_SUB:
+ rv = (va - vb) & MASK64
+ elif sub == A_SRA:
+ rv = (_signed64(va) >> (vb & 63)) & MASK64
+ elif sub == A_MUL:
+ rv = (va * vb) & MASK64
+ elif sub == A_MULH:
+ rv = ((_signed64(va) * _signed64(vb)) >> 64) & MASK64
+ elif sub == A_MULHSU:
+ rv = ((_signed64(va) * vb) >> 64) & MASK64
+ elif sub == A_MULHU:
+ rv = ((va * vb) >> 64) & MASK64
+ elif sub == A_DIV:
+ sa, sb = _signed64(va), _signed64(vb)
+ if sb == 0:
+ rv = MASK64
+ elif sa == -(1 << 63) and sb == -1:
+ rv = 1 << 63
+ else:
+ rv = (abs(sa) // abs(sb)) & MASK64
+ if (sa < 0) != (sb < 0):
+ rv = (-rv) & MASK64
+ elif sub == A_DIVU:
+ rv = MASK64 if vb == 0 else va // vb
+ elif sub == A_REM:
+ sa, sb = _signed64(va), _signed64(vb)
+ if sb == 0 or (sa == -(1 << 63) and sb == -1):
+ rv = 0
+ else:
+ rv = (abs(sa) % abs(sb)) & MASK64
+ if sa < 0:
+ rv = (-rv) & MASK64
+ else: # A_REMU
+ rv = va if vb == 0 else va % vb
+ if rd:
+ x[rd] = rv
+ pc = nxt
+
+ elif kind == K_OPI32:
+ va = x[r1] if r1 else 0
+ if sub == I_ADDIW:
+ rv = (va + imm) & 0xFFFFFFFF
+ elif sub == I_SLLIW:
+ rv = (va << (imm & 31)) & 0xFFFFFFFF
+ elif sub == I_SRLIW:
+ rv = (va & 0xFFFFFFFF) >> (imm & 31)
+ else: # I_SRAIW
+ rv = (_signed32(va) >> (imm & 31)) & 0xFFFFFFFF
+ if rd:
+ x[rd] = _sext32(rv) & MASK64
+ pc = nxt
+
+ elif kind == K_OP32:
+ va = x[r1]
+ vb = x[r2]
+ if sub == A_ADD:
+ rv = (va + vb) & 0xFFFFFFFF
+ elif sub == A_SUB:
+ rv = (va - vb) & 0xFFFFFFFF
+ elif sub == A_SLL:
+ rv = (va << (vb & 31)) & 0xFFFFFFFF
+ elif sub == A_SRL:
+ rv = (va & 0xFFFFFFFF) >> (vb & 31)
+ elif sub == A_SRA:
+ rv = (_signed32(va) >> (vb & 31)) & 0xFFFFFFFF
+ elif sub == A_MUL:
+ rv = (va * vb) & 0xFFFFFFFF
+ elif sub == A_DIV:
+ sa, sb = _signed32(va), _signed32(vb)
+ if sb == 0:
+ rv = 0xFFFFFFFF
+ elif sa == -(1 << 31) and sb == -1:
+ rv = 1 << 31
+ else:
+ rv = (abs(sa) // abs(sb)) & 0xFFFFFFFF
+ if (sa < 0) != (sb < 0):
+ rv = (-rv) & 0xFFFFFFFF
+ elif sub == A_DIVU:
+ b32 = vb & 0xFFFFFFFF
+ rv = 0xFFFFFFFF if b32 == 0 else (va & 0xFFFFFFFF) // b32
+ elif sub == A_REM:
+ sa, sb = _signed32(va), _signed32(vb)
+ if sb == 0 or (sa == -(1 << 31) and sb == -1):
+ rv = 0
+ else:
+ rv = (abs(sa) % abs(sb)) & 0xFFFFFFFF
+ if sa < 0:
+ rv = (-rv) & 0xFFFFFFFF
+ else: # A_REMU
+ b32 = vb & 0xFFFFFFFF
+ rv = (va & 0xFFFFFFFF) if b32 == 0 else (va & 0xFFFFFFFF) % b32
+ if rd:
+ x[rd] = _sext32(rv) & MASK64
+ pc = nxt
+
+ # -- system / atomics / traps ----------------------------------
+ elif kind == K_FENCE:
+ pc = nxt
+
+ elif kind == K_CSR:
+ csr = imm
+ old = self.csrs.get(csr, 0)
+ if sub in (5, 6, 7):
+ src = r1 # zimm
+ if sub == 5:
+ if rd:
+ x[rd] = old
+ self.csrs[csr] = src
+ else:
+ if rd:
+ x[rd] = old
+ if src:
+ self.csrs[csr] = (old | src) if sub == 6 else (old & ~src)
+ else:
+ src = x[r1]
+ if sub == 1:
+ if rd:
+ x[rd] = old
+ self.csrs[csr] = src & MASK64
+ elif sub == 2:
+ if rd:
+ x[rd] = old
+ if r1:
+ self.csrs[csr] = (old | src) & MASK64
+ elif sub == 3:
+ if rd:
+ x[rd] = old
+ if r1:
+ self.csrs[csr] = (old & ~src) & MASK64
+ else:
+ reason = R_FAULT
+ a = 2
+ b = csr
+ break
+ pc = nxt
+
+ elif kind == K_AMO:
+ width = 8 if sub & 0x40 else 4
+ op5 = sub & 0x3F
+ ea = x[r1] & MASK64
+ if watchers:
+ hit = _watch_match(watchers, ea, width, 1)
+ if hit is not None:
+ reason = R_WATCH
+ a = ea
+ b = hit
+ break
+ try:
+ old = self._guest_load(ea, width, pc)
+ if active:
+ trace.mem(insn_pc, ea, width, False, self.insns + n)
+ if op5 == 0x02: # lr
+ if rd:
+ x[rd] = old
+ elif op5 == 0x03: # sc
+ self._guest_store(ea, width, x[r2], pc)
+ if active:
+ trace.mem(insn_pc, ea, width, True, self.insns + n)
+ if rd:
+ x[rd] = 0
+ else:
+ rhs = x[r2]
+ mask = 0xFFFFFFFF if width == 4 else MASK64
+ ov = old & mask
+ rv = rhs & mask
+ if op5 == 0x00:
+ new = (ov + rv) & mask
+ elif op5 == 0x01:
+ new = rv
+ elif op5 == 0x04:
+ new = ov ^ rv
+ elif op5 == 0x08:
+ new = ov | rv
+ elif op5 == 0x0C:
+ new = ov & rv
+ elif op5 == 0x10:
+ new = ov if _signed64(ov) < _signed64(rv) else rv
+ elif op5 == 0x14:
+ new = ov if _signed64(ov) > _signed64(rv) else rv
+ elif op5 == 0x18:
+ new = ov if ov < rv else rv
+ else: # 0x1C
+ new = ov if ov > rv else rv
+ self._guest_store(ea, width, new, pc)
+ if active:
+ trace.mem(insn_pc, ea, width, True, self.insns + n)
+ if rd:
+ x[rd] = (_sext32(old) & MASK64) if width == 4 else old
+ pc = nxt
+ except Fault as f:
+ reason = R_FAULT
+ a = f.cause
+ b = f.tval
+ break
+
+ elif kind == K_EBREAK:
+ if single_step:
+ pc = nxt
+ else:
+ reason = R_EBREAK
+ a = pc
+ break
+
+ elif kind == K_ECALL:
+ if single_step:
+ pc = nxt
+ else:
+ reason = R_ECALL
+ a = pc
+ break
+
+ elif kind == K_MRET:
+ target = self.csrs.get(0x341, 0) & MASK64
+ if target:
+ pc = target
+ else:
+ reason = R_FAULT
+ a = 2
+ b = 0
+ break
+
+ else: # K_ILLEGAL
+ reason = R_FAULT
+ a = 2
+ b = 0
+ break
+
+ self.pc = pc
+ self.insns += n
+ if single_step and reason == R_BUDGET:
+ reason = R_STEP
+ return (reason, pc, a, b)
+
+ # -- guest memory helpers (AMO) ----------------------------------------
+
+ def _guest_load(self, addr: int, width: int, pc: int = 0) -> int:
+ off = addr - self.ram_base
+ if 0 <= off <= self.ram_size - width:
+ if width == 8:
+ return self._u64.unpack_from(self.ram, off)[0]
+ return self._u32.unpack_from(self.ram, off)[0]
+ return self._mmio_load(addr, width, pc)
+
+ def _guest_store(self, addr: int, width: int, value: int, pc: int = 0) -> None:
+ off = addr - self.ram_base
+ if 0 <= off <= self.ram_size - width:
+ if width == 8:
+ self._u64p(self.ram, off, value)
+ else:
+ self._u32p(self.ram, off, value & 0xFFFFFFFF)
+ return
+ self._mmio_store(addr, width, value, pc)
+
+
+#: Task-facing alias: the native simulator machine is :class:`Machine`.
+SimMachine = Machine
diff --git a/debug/risc-v-gsp-rm-vm/sim/trace.py b/debug/risc-v-gsp-rm-vm/sim/trace.py
new file mode 100644
index 0000000..04373b1
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/trace.py
@@ -0,0 +1,145 @@
+"""Native execution/access trace (roadmap R4): the in-process equivalent of
+``qemu/greeneye_trace.c``.
+
+The QEMU TCG plugin records every translated instruction and every memory
+access as JSONL. This module does the same from inside
+:class:`sim.machine.Machine`, with the same knobs and line shape:
+
+* ``exec=on|off`` - emit one ``exec`` line per executed instruction;
+* ``start=``/``end=`` - only instrument PCs in the inclusive range (matching
+ the plugin, which filters at translation time, so memory accesses are also
+ filtered by the *instruction* PC);
+* a bounded ring budget - once full, further lines are counted as ``dropped``
+ instead of growing without limit (deterministically).
+
+Lines are JSON objects in the TCG plugin's schema (``t``/``pc``/``addr``/
+``size``, hex strings), plus a ``vt`` virtual-time field (the machine's
+instruction count). Nothing here reads the host clock, so the payload is
+byte-identical for identical execution.
+"""
+from __future__ import annotations
+
+#: Default cap on buffered trace lines (~50 bytes each in memory).
+DEFAULT_MAX_EVENTS = 1_000_000
+
+
+class Trace:
+ """Collects compact exec/access lines for one native session."""
+
+ def __init__(self, *, exec_enabled: bool = True, start: int | None = None,
+ end: int | None = None, max_events: int | None = None):
+ self.exec_enabled = bool(exec_enabled)
+ self.start = start
+ self.end = end
+ self.max_events = DEFAULT_MAX_EVENTS if max_events is None else max(0, int(max_events))
+ self.execs = 0
+ self.reads = 0
+ self.writes = 0
+ self.events = 0
+ self.dropped = 0
+ self._lines: list[str] = []
+ self._flushed = False
+
+ # -- configuration -----------------------------------------------------
+
+ def in_range(self, pc: int) -> bool:
+ if self.start is not None and pc < self.start:
+ return False
+ if self.end is not None and pc > self.end:
+ return False
+ return True
+
+ def config(self) -> dict:
+ return {
+ "exec": self.exec_enabled,
+ "start": self.start,
+ "end": self.end,
+ "max_events": self.max_events,
+ }
+
+ def counts(self) -> dict:
+ return {
+ "exec": self.execs,
+ "read": self.reads,
+ "write": self.writes,
+ "events": self.events,
+ "dropped": self.dropped,
+ }
+
+ # -- collection (called from Machine.run's hot loop) --------------------
+
+ def begin(self, pc: int, vt: int) -> bool:
+ """Called once per executed instruction; returns access-capture."""
+ if not self.in_range(pc):
+ return False
+ if self.exec_enabled:
+ self._append(f'{{"t":"exec","vcpu":0,"pc":"0x{pc:x}","vt":{vt}}}')
+ self.execs += 1
+ return True
+
+ def mem(self, pc: int, addr: int, size: int, is_write: bool, vt: int) -> None:
+ kind = "write" if is_write else "read"
+ if is_write:
+ self.writes += 1
+ else:
+ self.reads += 1
+ self._append(
+ f'{{"t":"{kind}","vcpu":0,"addr":"0x{addr:x}","size":{size},'
+ f'"pc":"0x{pc:x}","vt":{vt}}}'
+ )
+
+ def _append(self, line: str) -> None:
+ self.events += 1
+ if self.max_events and len(self._lines) >= self.max_events:
+ self.dropped += 1
+ return
+ self._lines.append(line)
+
+ # -- output ------------------------------------------------------------
+
+ def payload(self) -> bytes:
+ """The complete JSONL document, including the plugin's summary line."""
+ body = "\n".join(self._lines)
+ if body:
+ body += "\n"
+ summary = (
+ f'{{"t":"summary","insns":{self.execs},"mem":{self.reads + self.writes},'
+ f'"events":{self.events},"dropped":{self.dropped},'
+ f'"exec":{str(self.exec_enabled).lower()},'
+ f'"start":{_json_addr(self.start)},"end":{_json_addr(self.end)}}}'
+ )
+ return (body + summary + "\n").encode("utf-8")
+
+ def flush(self, recorder) -> str | None:
+ """Write the trace to ``recorder`` as ``blobs/trace.jsonl`` (once)."""
+ if self._flushed:
+ return None
+ self._flushed = True
+ if recorder is None or getattr(recorder, "dir", None) is None:
+ return None
+ rel = recorder.blob("trace.jsonl", self.payload())
+ recorder.meta["trace_blob"] = rel
+ recorder.meta["trace"] = {**self.config(), **self.counts()}
+ return rel
+
+
+def make_trace(spec):
+ """Normalise a trace spec (None/False, True, dict or Trace) to a Trace."""
+ if spec is None or spec is False:
+ return None
+ if isinstance(spec, Trace):
+ return spec
+ if spec is True:
+ return Trace()
+ if isinstance(spec, dict):
+ return Trace(
+ exec_enabled=spec.get("exec", spec.get("exec_enabled", True)),
+ start=spec.get("start"),
+ end=spec.get("end"),
+ max_events=spec.get("max_events", spec.get("max")),
+ )
+ raise ValueError(f"trace spec must be None, bool, dict or Trace, not {type(spec).__name__}")
+
+
+def _json_addr(value: int | None) -> str:
+ return "null" if value is None else f"\"0x{value:x}\""
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_bridge.py b/debug/risc-v-gsp-rm-vm/tests/test_bridge.py
index 472bc49..7eeb46c 100644
--- a/debug/risc-v-gsp-rm-vm/tests/test_bridge.py
+++ b/debug/risc-v-gsp-rm-vm/tests/test_bridge.py
@@ -9,11 +9,20 @@ import unittest
import greeneye as ge
from greeneye.gdbridge import GhidraBridge
+from greeneye.ghidra import render_ghidra_command
HERE = os.path.dirname(os.path.abspath(__file__))
DEMO_ELF = os.path.join(HERE, "..", "firmware", "uproc_demo", "build", "uproc_demo.elf")
+class GhidraCommandTests(unittest.TestCase):
+ def test_render_command_names_project_program_and_hits(self):
+ cmd = render_ghidra_command("/proj", "P", "gsp.bin", "/tmp/hits.tsv")
+ self.assertIn("analyzeHeadless /proj P", cmd)
+ self.assertIn("-process gsp.bin", cmd)
+ self.assertIn("MarkGreenEyeHits.java /tmp/hits.tsv", cmd)
+
+
def qemu_available() -> bool:
return bool(os.environ.get("GREENEYE_QEMU") or shutil.which("qemu-system-riscv64"))
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_core.py b/debug/risc-v-gsp-rm-vm/tests/test_core.py
index 907a2eb..f02b2e6 100644
--- a/debug/risc-v-gsp-rm-vm/tests/test_core.py
+++ b/debug/risc-v-gsp-rm-vm/tests/test_core.py
@@ -3,10 +3,12 @@ from __future__ import annotations
import json
import os
+import struct
import tempfile
import unittest
-from greeneye.elf import EM_RISCV, parse_elf, load_elf
+from greeneye.elf import EM_RISCV, parse_elf, load_elf, write_relocated_elf
+from greeneye.errors import GreenEyeError
from greeneye.structs import Field, Schema, decode, render, schema_from_json
from greeneye.symbols import Symbol, SymbolDB
@@ -36,6 +38,19 @@ class ElfTests(unittest.TestCase):
with self.assertRaises(Exception):
parse_elf(b"not an elf")
+ def test_write_relocated_elf(self):
+ if not os.path.exists(DEMO_ELF):
+ self.skipTest("demo firmware not built")
+ image = load_elf(DEMO_ELF)
+ with tempfile.TemporaryDirectory(prefix="greeneye-reloc-") as tmp:
+ out = os.path.join(tmp, "relocated.elf")
+ path = write_relocated_elf(DEMO_ELF, out, 0x1000)
+ with open(path, "rb") as fh:
+ data = fh.read()
+ self.assertEqual(struct.unpack_from("<Q", data, 24)[0], image.entry + 0x1000)
+ moved = parse_elf(data)
+ self.assertEqual(moved.entry, image.entry + 0x1000)
+
class SymbolTests(unittest.TestCase):
def setUp(self):
@@ -50,6 +65,11 @@ class SymbolTests(unittest.TestCase):
self.assertEqual(self.db.annotate(0x1010), "0x1010 alpha+0x10")
self.assertEqual(self.db.lookup("beta"), 0x1200)
+ def test_by_subsystem(self):
+ self.db.add(Symbol(addr=0x2000, name="gamma", size=8, source="test", subsystem="core"))
+ self.assertEqual([s.name for s in self.db.by_subsystem("core")], ["gamma"])
+ self.assertEqual(self.db.by_subsystem("nope"), [])
+
def test_names_tsv(self):
with tempfile.NamedTemporaryFile("w", suffix=".tsv", delete=False) as fh:
fh.write("# comment\n0x00279000\t_DT_INIT\tghidra\n0x00279020\tFUN_x\n")
@@ -89,6 +109,26 @@ class SymbolTests(unittest.TestCase):
finally:
os.unlink(path)
+ def test_json_list_payload(self):
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
+ json.dump([{"addr": "0x2000", "name": "listed", "size": 4}], fh)
+ path = fh.name
+ try:
+ self.assertEqual(self.db.load_json(path), 1)
+ self.assertEqual(self.db.lookup("listed"), 0x2000)
+ finally:
+ os.unlink(path)
+
+ def test_json_bad_input_raises_greeneye_error(self):
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
+ fh.write("not json")
+ path = fh.name
+ try:
+ with self.assertRaises(GreenEyeError):
+ self.db.load_json(path)
+ finally:
+ os.unlink(path)
+
class StructTests(unittest.TestCase):
SCHEMA = {
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_device_map.py b/debug/risc-v-gsp-rm-vm/tests/test_device_map.py
new file mode 100644
index 0000000..a38085a
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_device_map.py
@@ -0,0 +1,325 @@
+"""Device-map (H2.1/H2.2 start) tests: harvest, merge, query, lenient MMIO.
+
+Unit tests use synthetic QEMU logs and tiny hand-assembled RV64 programs, so
+they need neither QEMU nor a firmware build. The real-image test is bounded
+(200k instructions, no QEMU) and skips when the TU104 570.144 fwimage is
+absent.
+"""
+from __future__ import annotations
+
+import json
+import os
+import tempfile
+import unittest
+
+from greeneye.device_map import (
+ SCHEMA,
+ DeviceMap,
+ harvest_from_text,
+ harvest_probe_session,
+)
+from greeneye.errors import GreenEyeError
+from sim.machine import R_BUDGET, R_EBREAK, R_FAULT, Machine
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.normpath(os.path.join(HERE, ".."))
+DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+REAL_IMAGE = os.path.normpath(
+ os.path.join(
+ HERE, "..", "..", "..", "firmware", "57X-GSP", "570.144", "gsp-570.144.fwimage.bin"
+ )
+)
+
+BASE = 0x80000000
+TOUCH_A = 0x2000000000000A00 # QEMU probe first-touch address (570.144)
+TOUCH_B = 0x2000000000000100
+BIAS = 0x80000000
+
+# lui t0,0x20000; slli t0,t0,32; addi t0,t0,0x600; addi t0,t0,0x400;
+# lw a0,0(t0); sw a0,0(t0); ebreak
+PROGRAM_LOAD_STORE = bytes.fromhex(
+ "b702002093920202938202609382024003a5020023a0a20073001000"
+)
+# ... lw a0,0(t0); lui t1,0x20000; slli t1,t1,32; addi t1,t1,0x100;
+# sd a0,0(t1); ebreak (two distinct unknown addresses)
+PROGRAM_TWO_TOUCHES = bytes.fromhex(
+ "b702002093920202938202609382024003a502003703002013130302130303102330a30073001000"
+)
+
+SAMPLE_LOG = """\
+Invalid read at addr 0x2000000000000A00, size 4, region '(null)', reason: rejected
+riscv_cpu_do_interrupt: hart:0, async:0, cause:0000000000000005, epc:0x0000000084009014, tval:0x2000000000000a00, desc=fault_load
+Invalid read at addr 0x2000000000000A00, size 4, region '(null)', reason: rejected
+riscv_cpu_do_interrupt: hart:0, async:0, cause:0000000000000005, epc:0x0000000084009014, tval:0x2000000000000a00, desc=fault_load
+Invalid write at addr 0x2000000000001000, size 8, region '(null)', reason: rejected
+riscv_cpu_do_interrupt: hart:0, async:0, cause:0000000000000007, epc:0x0000000084009020, tval:0x2000000000001000, desc=fault_store
+"""
+
+
+def _run_program(program: bytes, **kwargs):
+ machine = Machine(**kwargs)
+ machine.load_segments([(BASE, program)], BASE)
+ return machine, machine.run(100)
+
+
+class HarvestTests(unittest.TestCase):
+ def test_harvest_from_text_fields(self):
+ dm = harvest_from_text(SAMPLE_LOG, bias=BIAS)
+ entries = {(e.address, e.width, e.kind): e for e in dm.entries()}
+ self.assertEqual(set(entries), {(TOUCH_A, 4, "r"), (0x2000000000001000, 8, "w")})
+ read = entries[(TOUCH_A, 4, "r")]
+ self.assertEqual(read.hits, 2)
+ self.assertEqual(read.first_epc, 0x84009014)
+ self.assertEqual(read.first_epc_fw, 0x4009014)
+ self.assertEqual(read.source, "probe")
+ self.assertIn("fault_load", read.notes)
+ write = entries[(0x2000000000001000, 8, "w")]
+ self.assertEqual(write.hits, 1)
+ self.assertEqual(write.first_epc_fw, 0x4009020)
+ self.assertEqual(dm.meta["bias"], BIAS)
+
+ def test_harvest_skips_fetch_faults(self):
+ log = (
+ "riscv_cpu_do_interrupt: hart:0, async:0, cause:0000000000000001, "
+ "epc:0x0000000000000000, tval:0x0, desc=fault_fetch\n"
+ )
+ self.assertEqual(len(harvest_from_text(log, bias=BIAS)), 0)
+
+ def test_harvest_probe_session_blob(self):
+ with tempfile.TemporaryDirectory() as root:
+ os.makedirs(os.path.join(root, "blobs"))
+ with open(os.path.join(root, "blobs", "qemu.log"), "w", encoding="utf-8") as fh:
+ fh.write(SAMPLE_LOG)
+ session = {
+ "format": "greeneye-session/1",
+ "bias": BIAS,
+ "entry": 0x4009000,
+ "firmware": "/nonexistent/gsp-570.144.fwimage.bin",
+ "qemu_log_blob": "blobs/qemu.log",
+ "qemu_fault_count": 3,
+ }
+ with open(os.path.join(root, "session.json"), "w", encoding="utf-8") as fh:
+ json.dump(session, fh)
+ dm = harvest_probe_session(root)
+ self.assertEqual(len(dm), 2)
+ self.assertEqual(dm.meta["bias"], BIAS)
+ self.assertEqual(dm.meta["qemu_fault_count"], 3)
+ self.assertNotIn("firmware_sha256", dm.meta) # firmware absent: honest to omit
+
+ def test_harvest_probe_session_first_fault_fallback(self):
+ with tempfile.TemporaryDirectory() as root:
+ session = {
+ "bias": BIAS,
+ "qemu_first_fault": {
+ "epc": 0x84009014,
+ "cause": 5,
+ "desc": "fault_load",
+ "access": {"rw": "read", "access_addr": TOUCH_A, "size": 4,
+ "region": "(null)", "reason": "rejected"},
+ },
+ }
+ with open(os.path.join(root, "session.json"), "w", encoding="utf-8") as fh:
+ json.dump(session, fh)
+ dm = harvest_probe_session(root)
+ self.assertEqual(len(dm), 1)
+ self.assertEqual(dm.lookup(TOUCH_A, 4), "zero") # observed, no value recorded
+
+ def test_harvest_bias_override(self):
+ with tempfile.TemporaryDirectory() as root:
+ session = {
+ "qemu_first_fault": {
+ "epc": 0x84009014,
+ "cause": 5,
+ "desc": "fault_load",
+ "access": {"rw": "read", "access_addr": TOUCH_A, "size": 4,
+ "region": "(null)", "reason": "rejected"},
+ },
+ }
+ with open(os.path.join(root, "session.json"), "w", encoding="utf-8") as fh:
+ json.dump(session, fh)
+ dm = harvest_probe_session(root, bias=BIAS)
+ entry = dm.entries()[0]
+ self.assertEqual(entry.first_epc, 0x84009014)
+ self.assertEqual(entry.first_epc_fw, 0x4009014)
+ self.assertEqual(dm.meta["bias"], BIAS)
+
+ def test_harvest_missing_session_raises(self):
+ with tempfile.TemporaryDirectory() as root:
+ with self.assertRaises(GreenEyeError):
+ harvest_probe_session(os.path.join(root, "nope"))
+
+
+class MapIoTests(unittest.TestCase):
+ def _map(self) -> DeviceMap:
+ dm = DeviceMap(meta={"firmware": "gsp.bin", "bias": BIAS})
+ dm.record(TOUCH_A, 4, "r", pc=0x84009014, bias=BIAS, source="probe")
+ dm.record(0x2000, 8, "w", value=7, pc=0x84009020, bias=BIAS, source="manual")
+ return dm
+
+ def test_roundtrip_is_byte_identical(self):
+ dm = self._map()
+ with tempfile.TemporaryDirectory() as root:
+ path = os.path.join(root, "map.json")
+ dm.save(path)
+ first = open(path, "rb").read()
+ again = DeviceMap.load(path)
+ again.save(path)
+ self.assertEqual(open(path, "rb").read(), first)
+ self.assertEqual(again.to_dict(), dm.to_dict())
+ self.assertEqual(again.meta["bias"], BIAS)
+
+ def test_entries_are_deterministically_ordered(self):
+ dm = DeviceMap()
+ for addr in (0x3000, 0x1000, 0x2000):
+ dm.record(addr, 4, "r")
+ self.assertEqual([e.address for e in dm.entries()], [0x1000, 0x2000, 0x3000])
+
+ def test_merge_sums_hits_and_unions_values(self):
+ left = DeviceMap()
+ left.record(0x1000, 4, "r", value=1)
+ left.record(0x1000, 4, "r", value=1)
+ right = DeviceMap(meta={"source": "manual"})
+ right.record(0x1000, 4, "r", value=2)
+ right.record(0x2000, 8, "w")
+ merged = left.merge(right)
+ entries = {(e.address, e.kind): e for e in merged.entries()}
+ self.assertEqual(entries[(0x1000, "r")].hits, 3)
+ self.assertEqual(entries[(0x1000, "r")].values, (1, 2))
+ self.assertEqual(entries[(0x2000, "w")].hits, 1)
+ self.assertEqual(merged.meta["source"], "manual")
+
+ def test_rejects_unknown_schema(self):
+ with self.assertRaises(GreenEyeError):
+ DeviceMap.from_json(json.dumps({"schema": "greeneye-device-map/2", "entries": []}))
+ self.assertEqual(SCHEMA, "greeneye-device-map/1")
+
+
+class LookupTests(unittest.TestCase):
+ def test_unimplemented_zero_and_value(self):
+ dm = DeviceMap()
+ self.assertEqual(dm.lookup(0x1234, 4), "unimplemented")
+ dm.record(0x2000, 4, "r", value=0xAB)
+ self.assertEqual(dm.lookup(0x2000, 4), "value:0xab")
+ self.assertEqual(dm.lookup(0x2000, 8), "value:0xab") # width fallback
+ dm.record(0x3000, 4, "r", value=None)
+ self.assertEqual(dm.lookup(0x3000, 4), "zero")
+
+ def test_write_and_opposite_direction(self):
+ dm = DeviceMap()
+ dm.record(0x2000, 4, "r", value=5)
+ self.assertEqual(dm.lookup(0x2000, 4, write=True), "zero") # absorb
+ dm.record(0x3000, 4, "w", value=9)
+ self.assertEqual(dm.lookup(0x3000, 4, write=True), "zero")
+ self.assertEqual(dm.lookup(0x3000, 4), "zero") # write-only reads back zero
+
+
+class LenientMachineTests(unittest.TestCase):
+ def test_strict_default_faults_without_logging(self):
+ machine, result = _run_program(PROGRAM_LOAD_STORE)
+ self.assertEqual(result[0], R_FAULT)
+ self.assertEqual(result[1], BASE + 16) # PC of the faulting load
+ self.assertEqual(result[2], 5) # load access fault
+ self.assertEqual(result[3], TOUCH_A)
+ self.assertEqual(machine.unknown_mmio, "fault")
+ self.assertEqual(machine.mmio_log, [])
+ self.assertEqual(machine.mmio_unknown, 0)
+
+ def test_bad_policy_rejected(self):
+ with self.assertRaises(ValueError):
+ Machine(unknown_mmio="ignore")
+
+ def test_lenient_returns_zero_and_logs(self):
+ machine, result = _run_program(PROGRAM_TWO_TOUCHES, unknown_mmio="lenient")
+ self.assertEqual(result[0], R_EBREAK)
+ self.assertEqual(machine.x[10], 0) # load returned 0
+ self.assertEqual(machine.mmio_unknown, 2)
+ self.assertEqual(
+ machine.mmio_log,
+ [
+ {"pc": BASE + 16, "addr": TOUCH_A, "size": 4, "rw": "r"},
+ {"pc": BASE + 32, "addr": TOUCH_B, "size": 8, "rw": "w"},
+ ],
+ )
+ self.assertIsNone(machine.device_map) # no learning requested
+
+ def test_lenient_learns_observed_values(self):
+ machine, result = _run_program(
+ PROGRAM_LOAD_STORE, unknown_mmio="lenient", learn_mmio=True
+ )
+ self.assertEqual(result[0], R_EBREAK)
+ self.assertEqual(machine.mmio_unknown, 1) # the store is served once learned
+ entry = machine.device_map.entries()[0]
+ self.assertEqual((entry.address, entry.kind, entry.values), (TOUCH_A, "r", (0,)))
+ self.assertEqual(entry.first_epc_fw, BASE + 16) # bias 0: PC unchanged
+ self.assertEqual(machine.device_map.lookup(TOUCH_A, 4), "value:0x0")
+
+ def test_map_serves_mapped_values_even_in_strict_mode(self):
+ dm = DeviceMap()
+ dm.record(TOUCH_A, 4, "r", value=0x12345678)
+ machine, result = _run_program(PROGRAM_LOAD_STORE, device_map=dm)
+ self.assertEqual(result[0], R_EBREAK)
+ self.assertEqual(machine.x[10], 0x12345678)
+ self.assertEqual(machine.mmio_unknown, 0)
+
+ def test_ram_and_uart_are_unaffected(self):
+ # lui t0,0x10000; addi a0,zero,'A'; sb a0,0(t0); lbu a1,5(t0); ebreak
+ program = bytes.fromhex("b7020010130510042380a20083c5520073001000")
+ machine, result = _run_program(program, unknown_mmio="lenient")
+ self.assertEqual(result[0], R_EBREAK)
+ self.assertEqual(machine.uart.text(), "A")
+ self.assertEqual(machine.x[11], 0x60) # LSR: THRE | TEMT
+ self.assertEqual(machine.mmio_unknown, 0)
+
+
+class LaunchNativeTests(unittest.TestCase):
+ @unittest.skipUnless(os.path.isfile(DEMO_ELF), "demo firmware absent")
+ def test_launch_native_accepts_lenient_options(self):
+ from greeneye.sim_backend import launch_native
+
+ with tempfile.TemporaryDirectory() as root:
+ map_path = os.path.join(root, "map.json")
+ DeviceMap(meta={"firmware": "demo"}).save(map_path)
+ session = launch_native(
+ kernel=DEMO_ELF,
+ device_map=map_path,
+ unknown_mmio="lenient",
+ name="unittest-device-map",
+ )
+ try:
+ self.assertEqual(session.target.machine.unknown_mmio, "lenient")
+ self.assertEqual(session.target.mmio_unknown, 0)
+ self.assertEqual(session.target.mmio_log, [])
+ finally:
+ session.close()
+
+
+@unittest.skipUnless(os.path.isfile(REAL_IMAGE), "TU104 570.144 fwimage absent")
+class RealImageLenientTests(unittest.TestCase):
+ def test_first_touch_matches_qemu_probe(self):
+ from greeneye.elf import load_elf
+
+ image = load_elf(REAL_IMAGE)
+ dm = DeviceMap(meta={"bias": BIAS})
+ machine = Machine(
+ device_map=dm, unknown_mmio="lenient", learn_mmio=True, bias=BIAS
+ )
+ machine.load_segments(image.flat_load_plan(bias=BIAS), image.relocated_entry(bias=BIAS))
+ self.assertEqual(machine.pc, 0x84009000)
+ reason, pc, _, _ = machine.run(200_000)
+ self.assertGreaterEqual(machine.mmio_unknown, 1)
+ self.assertEqual(
+ machine.mmio_log[0],
+ {"pc": 0x84009014, "addr": TOUCH_A, "size": 4, "rw": "r"},
+ )
+ # Lenient execution runs past the QEMU first fault to the budget.
+ self.assertEqual(reason, R_BUDGET)
+ self.assertEqual(machine.insns, 200_000)
+ self.assertEqual(pc, 0x84009070)
+ entry = dm.entries()[0]
+ self.assertEqual(entry.first_epc_fw, 0x4009014)
+ self.assertEqual(entry.values, (0,))
+ self.assertEqual(dm.meta["bias"], BIAS)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_discovery.py b/debug/risc-v-gsp-rm-vm/tests/test_discovery.py
new file mode 100644
index 0000000..4a1cfc2
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_discovery.py
@@ -0,0 +1,214 @@
+"""Integration tests for unknown-call discovery and reachability (docs/19).
+
+The integration tests drive the two harness CLIs as subprocesses against the
+bundled demo firmware and skip cleanly when qemu-system-riscv64 or the demo
+ELF is absent. The pure-function tests need neither.
+"""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.abspath(os.path.join(HERE, ".."))
+DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+DISCOVERY = os.path.join(TOOL_ROOT, "harness", "control_discovery.py")
+REACH = os.path.join(TOOL_ROOT, "harness", "reach.py")
+if TOOL_ROOT not in sys.path:
+ sys.path.insert(0, TOOL_ROOT)
+
+from harness.common import qemu_available # noqa: E402
+from harness.control_discovery import ( # noqa: E402
+ DEMO_CASE_HANDLERS,
+ classify_outcome,
+ generate_probes,
+ pattern_args,
+)
+
+MASK64 = (1 << 64) - 1
+
+
+def _run(args: list[str], timeout: float = 120.0) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ [sys.executable] + args,
+ capture_output=True,
+ text=True,
+ cwd=TOOL_ROOT,
+ timeout=timeout,
+ )
+
+
+def _read_findings(out: str) -> list[dict]:
+ with open(os.path.join(out, "findings.jsonl"), encoding="utf-8") as fh:
+ return [json.loads(line) for line in fh if line.strip()]
+
+
+class DiscoveryUnitTests(unittest.TestCase):
+ def test_pattern_args_deterministic(self):
+ self.assertEqual(pattern_args("zero", 1, 3), (0, 0, 0))
+ self.assertEqual(pattern_args("boundary", 1, 3), (MASK64, 1, 0x7FFF_FFFF_FFFF_FFFF))
+ self.assertEqual(pattern_args("sentinel", 5, 3), pattern_args("sentinel", 5, 3))
+ self.assertNotEqual(pattern_args("sentinel", 5, 3), pattern_args("sentinel", 5, 4))
+ self.assertTrue(all(0 <= value <= MASK64 for value in pattern_args("sentinel", 9, 1)))
+
+ def test_generate_probes_order_and_cap(self):
+ probes = generate_probes(4, ["zero", "sentinel"], seed=1)
+ self.assertEqual([probe.cmd for probe in probes], [1, 1, 2, 2, 3, 3, 4, 4])
+ self.assertEqual([probe.pattern for probe in probes[0:2]], ["zero", "sentinel"])
+ self.assertEqual(len(generate_probes(64, ["zero", "sentinel", "boundary"], seed=1, iterations=5)), 5)
+
+ def test_classify_outcome_priority(self):
+ base = dict(cmd=6, is_injected=False, handler="rpc_fault", dispatch_seen=True, call_retval=None)
+ self.assertEqual(classify_outcome(faults=[{"cause": 3}], signals=[], settle_ok=True, **base), "trap")
+ self.assertEqual(classify_outcome(faults=[{"cause": 5}], signals=[], settle_ok=True, **base), "fault")
+ self.assertEqual(classify_outcome(faults=[], signals=[], settle_ok=True, **base), "known-handler")
+ self.assertEqual(
+ classify_outcome(
+ cmd=16, is_injected=True, handler="rpc_ping", dispatch_seen=True, call_retval=None,
+ faults=[], signals=[], settle_ok=True,
+ ),
+ "injected-slot",
+ )
+ self.assertEqual(
+ classify_outcome(
+ cmd=7, is_injected=False, handler=None, dispatch_seen=True, call_retval=None,
+ faults=[], signals=[], settle_ok=True,
+ ),
+ "default-path",
+ )
+ self.assertEqual(
+ classify_outcome(
+ cmd=7, is_injected=False, handler=None, dispatch_seen=False, call_retval=None,
+ faults=[], signals=[], settle_ok=True,
+ ),
+ "no-progress",
+ )
+ self.assertEqual(classify_outcome(faults=[], signals=[], settle_ok=False, **base), "settle-failed")
+
+
+@unittest.skipUnless(qemu_available(), "qemu-system-riscv64 not installed")
+@unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)")
+class DiscoveryIntegrationTests(unittest.TestCase):
+ def test_sweep_classifies_known_and_unknown(self):
+ with tempfile.TemporaryDirectory(prefix="discovery-sweep-") as out:
+ result = _run(
+ [
+ DISCOVERY,
+ "--out", out,
+ "--max-id", "32",
+ "--patterns", "zero",
+ "--seed", "5",
+ "--timeout", "0.25",
+ "--settle", "1.0",
+ ]
+ )
+ self.assertEqual(result.returncode, 0, result.stderr[-2000:])
+ findings = _read_findings(out)
+ self.assertEqual(len(findings), 32)
+ by_cmd: dict[int, dict] = {finding["cmd"]: finding for finding in findings}
+
+ # Known commands 1..6 are attributed to their handlers; zero args
+ # make ECHO fault (NULL string) and FAULT trap (ebreak).
+ for cmd, name in DEMO_CASE_HANDLERS.items():
+ self.assertEqual(by_cmd[cmd]["handler"], name, f"cmd {cmd} handler")
+ for cmd in (1, 3, 4, 5):
+ self.assertEqual(by_cmd[cmd]["outcome"], "known-handler")
+ self.assertEqual(by_cmd[2]["outcome"], "fault")
+ self.assertEqual(by_cmd[6]["outcome"], "trap")
+
+ # At least one default/unused id reaches the catch-all with ~id.
+ self.assertEqual(by_cmd[7]["outcome"], "default-path")
+ self.assertEqual(by_cmd[7]["effects"]["after"]["result"], (~7) & MASK64)
+ self.assertEqual(by_cmd[32]["outcome"], "default-path")
+
+ # Injected-service dispatch: 16..21 alias handler slots 0..5.
+ self.assertEqual(by_cmd[16]["outcome"], "injected-slot")
+ self.assertEqual(by_cmd[16]["handler"], "rpc_ping")
+ self.assertEqual(by_cmd[21]["outcome"], "trap")
+
+ report_path = os.path.join(out, "control-discovery.md")
+ self.assertTrue(os.path.exists(report_path))
+ with open(report_path, encoding="utf-8") as fh:
+ report = fh.read()
+ self.assertIn("TOOL IN DEVELOPMENT - NOT READY YET!", report)
+ self.assertIn("Unknown / undiscovered ids", report)
+ self.assertIn("Injected-slot dispatch ids", report)
+ self.assertIn("rpc_fault", report)
+ self.assertTrue(os.path.isdir(os.path.join(out, "sessions")))
+
+ def test_sweep_deterministic(self):
+ base = [
+ DISCOVERY,
+ "--max-id", "8",
+ "--patterns", "zero",
+ "--seed", "11",
+ "--timeout", "0.25",
+ "--settle", "1.0",
+ ]
+ with tempfile.TemporaryDirectory(prefix="discovery-det-a-") as out_a, tempfile.TemporaryDirectory(
+ prefix="discovery-det-b-"
+ ) as out_b:
+ first = _run(base + ["--out", out_a])
+ second = _run(base + ["--out", out_b])
+ self.assertEqual(first.returncode, 0, first.stderr[-2000:])
+ self.assertEqual(second.returncode, 0, second.stderr[-2000:])
+ with open(os.path.join(out_a, "findings.jsonl"), encoding="utf-8") as fa, open(
+ os.path.join(out_b, "findings.jsonl"), encoding="utf-8"
+ ) as fb:
+ self.assertEqual(fa.read(), fb.read(), "same seed/params must replay byte-for-byte")
+
+ def test_rm_surface_exits_cleanly(self):
+ with tempfile.TemporaryDirectory(prefix="discovery-rm-") as out:
+ result = _run([DISCOVERY, "--surface", "rm", "--out", out])
+ self.assertEqual(result.returncode, 0, result.stderr[-2000:])
+ self.assertIn("skipped", result.stdout)
+ self.assertFalse(os.path.exists(os.path.join(out, "findings.jsonl")))
+
+ def test_reach_rpc_ping_via_call(self):
+ with tempfile.TemporaryDirectory(prefix="reach-ping-") as out:
+ result = _run([REACH, "--target", "rpc_ping", "--method", "call", "--args", "0x41", "--out", out])
+ self.assertEqual(result.returncode, 0, result.stderr[-2000:])
+ with open(os.path.join(out, "reach-report.json"), encoding="utf-8") as fh:
+ report = json.load(fh)
+ self.assertTrue(report["reachable"])
+ self.assertEqual(report["method"], "call")
+ self.assertEqual(report["entry"]["symbol"], "rpc_ping")
+ self.assertEqual(report["effects"]["retval"], 0x41 ^ 0xBEEF)
+ self.assertTrue(os.path.exists(os.path.join(out, "probe.json")))
+ with open(os.path.join(out, "probe.json"), encoding="utf-8") as fh:
+ probe = json.load(fh)
+ self.assertEqual(probe["method"], "call")
+ self.assertEqual(probe["args"], [0x41])
+
+ def test_reach_rpc_sum_via_run(self):
+ with tempfile.TemporaryDirectory(prefix="reach-sum-") as out:
+ result = _run([REACH, "--target", "rpc_sum", "--method", "run", "--out", out])
+ self.assertEqual(result.returncode, 0, result.stderr[-2000:])
+ with open(os.path.join(out, "reach-report.json"), encoding="utf-8") as fh:
+ report = json.load(fh)
+ self.assertTrue(report["reachable"])
+ self.assertEqual(report["method"], "run")
+ self.assertEqual(report["entry"]["symbol"], "rpc_sum")
+ self.assertTrue(report["prior_events"], "the self-test hook history must be recorded")
+
+ def test_reach_rpc_fault_via_trigger(self):
+ with tempfile.TemporaryDirectory(prefix="reach-fault-") as out:
+ result = _run([REACH, "--target", "rpc_fault", "--method", "trigger", "--out", out])
+ self.assertEqual(result.returncode, 0, result.stderr[-2000:])
+ with open(os.path.join(out, "reach-report.json"), encoding="utf-8") as fh:
+ report = json.load(fh)
+ self.assertTrue(report["reachable"])
+ self.assertEqual(report["method"], "trigger")
+ self.assertEqual(report["trigger"]["id"], 6)
+ self.assertEqual(report["trigger"]["handler"], "rpc_fault")
+ self.assertEqual(report["required_args"]["cmd"], 6)
+ self.assertTrue(report["effects"]["trap"], "rpc_fault must show the ebreak trap")
+ self.assertTrue(os.path.exists(os.path.join(out, "probe.json")))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_harness.py b/debug/risc-v-gsp-rm-vm/tests/test_harness.py
new file mode 100644
index 0000000..55938ae
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_harness.py
@@ -0,0 +1,144 @@
+"""Tests for the green-eye automation harness (roadmap R5/R6, H4.1/H4.3).
+
+The integration tests drive the two harness CLIs as subprocesses against the
+bundled demo firmware and skip cleanly when qemu-system-riscv64 or the demo
+ELF is absent. The pure-function tests need neither.
+"""
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.abspath(os.path.join(HERE, ".."))
+DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+MATRIX = os.path.join(TOOL_ROOT, "harness", "portability_matrix.py")
+MUTATION = os.path.join(TOOL_ROOT, "harness", "mutation_harness.py")
+if TOOL_ROOT not in sys.path:
+ sys.path.insert(0, TOOL_ROOT)
+
+from harness.common import qemu_available # noqa: E402
+from harness.mutation_harness import Mutation, expected_for, expected_ok, generate_mutations # noqa: E402
+from harness.portability_matrix import counter_delta # noqa: E402
+
+
+def _run(args: list[str], timeout: float = 600.0) -> subprocess.CompletedProcess:
+ return subprocess.run(
+ [sys.executable] + args,
+ capture_output=True,
+ text=True,
+ cwd=TOOL_ROOT,
+ timeout=timeout,
+ )
+
+
+class HarnessUnitTests(unittest.TestCase):
+ def test_generate_mutations_deterministic(self):
+ first = generate_mutations(9, seed=5)
+ second = generate_mutations(9, seed=5)
+ self.assertEqual([m.to_json() for m in first], [m.to_json() for m in second])
+ self.assertEqual(
+ {m.strategy for m in first},
+ {"cmd-invalid", "arg-boundary", "bit-flip", "buffer-length"},
+ )
+ self.assertNotEqual([m.to_json() for m in first], [m.to_json() for m in generate_mutations(9, seed=6)])
+
+ def test_memory_region_strategy(self):
+ mutations = generate_mutations(5, seed=1, region=(0x80001000, 64))
+ region = [m for m in mutations if m.strategy == "mem-region"]
+ self.assertEqual(len(region), 1)
+ self.assertEqual(region[0].region["addr"], 0x80001000)
+ self.assertTrue(0 <= region[0].region["offset"] < 64)
+
+ def test_demo_oracle(self):
+ before = {"result": 0x30, "last_rpc": 5}
+ ping = Mutation(0, "bit-flip", cmd=1, arg0=0x1234)
+ self.assertEqual(expected_for(ping, before, 0x80000800)["value"], 0x1234 ^ 0xBEEF)
+ fill = Mutation(1, "arg-boundary", cmd=3, arg1=1 << 40)
+ self.assertEqual(expected_for(fill, before, 0)["value"], 256)
+ invalid = Mutation(2, "cmd-invalid", cmd=13)
+ after = {"result": (~13) & ((1 << 64) - 1), "last_rpc": 13}
+ self.assertTrue(expected_ok(expected_for(invalid, before, 0), invalid, before, after))
+ idle = Mutation(3, "cmd-invalid", cmd=0)
+ unchanged = {"result": 0x30, "last_rpc": 5}
+ self.assertTrue(expected_ok(expected_for(idle, before, 0), idle, before, unchanged))
+
+ def test_counter_delta_zero_for_identical_profiles(self):
+ rows = counter_delta({"rpc_ping": 2}, {"rpc_ping": 2})
+ self.assertTrue(all(row["delta"] == 0 for row in rows))
+
+
+@unittest.skipUnless(qemu_available(), "qemu-system-riscv64 not installed")
+@unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)")
+class HarnessIntegrationTests(unittest.TestCase):
+ def test_portability_matrix_self_comparison(self):
+ with tempfile.TemporaryDirectory(prefix="harness-matrix-") as out:
+ result = _run(
+ [
+ MATRIX,
+ "--target",
+ DEMO_ELF,
+ "--target",
+ DEMO_ELF,
+ "--out",
+ out,
+ "--probe-instructions",
+ "200",
+ "--scenario-duration",
+ "0.2",
+ ]
+ )
+ self.assertEqual(result.returncode, 0, result.stderr[-2000:])
+ with open(os.path.join(out, "portability-report.json"), encoding="utf-8") as fh:
+ report = json.load(fh)
+ self.assertEqual([t["status"] for t in report["targets"]], ["ok", "ok"])
+ self.assertEqual(len(report["comparisons"]), 1)
+ comparison = report["comparisons"][0]
+ self.assertTrue(comparison["all_zero"], comparison["deltas"])
+ for rows in comparison["deltas"].values():
+ for row in rows:
+ self.assertEqual(row["delta"], 0)
+ self.assertTrue(os.path.exists(os.path.join(out, "portability-report.md")))
+ self.assertTrue(all(t["scenario"]["event_counts"] for t in report["targets"]))
+
+ def test_mutation_run_and_seed_determinism(self):
+ with tempfile.TemporaryDirectory(prefix="harness-mut-a-") as out_a, tempfile.TemporaryDirectory(
+ prefix="harness-mut-b-"
+ ) as out_b:
+ base = [
+ MUTATION,
+ "--kernel",
+ DEMO_ELF,
+ "--iterations",
+ "12",
+ "--seed",
+ "7",
+ "--timeout",
+ "0.3",
+ "--settle",
+ "0.1",
+ ]
+ first = _run(base + ["--out", out_a])
+ self.assertEqual(first.returncode, 0, first.stderr[-2000:])
+ second = _run(base + ["--out", out_b])
+ self.assertEqual(second.returncode, 0, second.stderr[-2000:])
+ findings_path = os.path.join(out_a, "findings.jsonl")
+ self.assertTrue(os.path.exists(findings_path))
+ with open(findings_path, encoding="utf-8") as fh:
+ findings = [json.loads(line) for line in fh if line.strip()]
+ self.assertEqual(len(findings), 12)
+ self.assertTrue({finding["outcome"] for finding in findings})
+ self.assertTrue(all("mutation" in finding and "detail" in finding for finding in findings))
+ self.assertTrue(os.path.exists(os.path.join(out_a, "summary.md")))
+ with open(findings_path, encoding="utf-8") as fa, open(
+ os.path.join(out_b, "findings.jsonl"), encoding="utf-8"
+ ) as fb:
+ self.assertEqual(fa.read(), fb.read(), "the same seed must replay byte-for-byte")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_mcp.py b/debug/risc-v-gsp-rm-vm/tests/test_mcp.py
new file mode 100644
index 0000000..614d397
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_mcp.py
@@ -0,0 +1,431 @@
+"""Tests for the green-eye MCP server plugin (roadmap R3).
+
+The protocol and tool tests drive an in-memory Session over a stub RSP-shaped
+client, so they need no QEMU and no simulator. A standalone subprocess test
+launches the native backend end-to-end and skips when that backend is absent.
+"""
+from __future__ import annotations
+
+import io
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.abspath(os.path.join(HERE, ".."))
+PLUGINS_DIR = os.path.join(TOOL_ROOT, "plugins")
+MCP_SERVER = os.path.join(PLUGINS_DIR, "mcp_server.py")
+DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+for _path in (TOOL_ROOT, PLUGINS_DIR):
+ if _path not in sys.path:
+ sys.path.insert(0, _path)
+
+import mcp_server as mcp # noqa: E402
+from greeneye.rsp import StopReply # noqa: E402
+from greeneye.session import Session # noqa: E402
+from greeneye.symbols import Symbol, SymbolDB # noqa: E402
+
+BASE = 0x80000000
+
+
+class StubClient:
+ """RSP-shaped in-memory client: enough for Session without an emulator."""
+
+ def __init__(self, size: int = 0x20000):
+ self.base = BASE
+ self.mem = bytearray(size)
+ self.regs = {"pc": BASE, "ra": 0, "sp": BASE + size, "a0": 0, "a1": 0}
+ self.sw_bps: set[int] = set()
+ self.hw_bps: set[int] = set()
+ self.watches: dict[tuple[int, int, str], None] = {}
+ self.closed = False
+ self.features = "swbreak+;hwbreak+"
+
+ def _span(self, addr: int, size: int) -> int:
+ start = addr - self.base
+ if start < 0 or start + size > len(self.mem):
+ raise ValueError(f"out of range: {addr:#x}+{size}")
+ return start
+
+ def read_mem(self, addr: int, size: int, chunk: int = 0) -> bytes:
+ start = self._span(addr, size)
+ return bytes(self.mem[start : start + size])
+
+ def write_mem(self, addr: int, data: bytes, chunk: int = 0) -> None:
+ start = self._span(addr, len(data))
+ self.mem[start : start + len(data)] = data
+
+ def read_registers(self) -> dict[str, int]:
+ return dict(self.regs)
+
+ def read_reg(self, name: str) -> int:
+ return int(self.regs.get(str(name), 0))
+
+ def write_reg(self, name: str, value: int) -> None:
+ self.regs[str(name)] = int(value)
+
+ @property
+ def pc(self) -> int:
+ return self.regs["pc"]
+
+ def set_sw_break(self, addr: int, kind: int = 4) -> None:
+ self.sw_bps.add(addr)
+
+ def clear_sw_break(self, addr: int, kind: int = 4) -> None:
+ self.sw_bps.discard(addr)
+
+ def set_hw_break(self, addr: int, kind: int = 4) -> None:
+ self.hw_bps.add(addr)
+
+ def clear_hw_break(self, addr: int, kind: int = 4) -> None:
+ self.hw_bps.discard(addr)
+
+ def set_watch(self, addr: int, size: int = 8, kind: str = "w") -> None:
+ self.watches[(addr, size, kind)] = None
+
+ def clear_watch(self, addr: int, size: int = 8, kind: str = "w") -> None:
+ self.watches.pop((addr, size, kind), None)
+
+ def continue_(self, timeout: float | None = None) -> StopReply:
+ return StopReply(raw="T05", signal=5)
+
+ def step(self, timeout: float | None = None) -> StopReply:
+ return StopReply(raw="T05", signal=5)
+
+ def interrupt(self, timeout: float | None = None) -> StopReply:
+ return StopReply(raw="T02", signal=2)
+
+ def close(self) -> None:
+ self.closed = True
+
+
+class McpTestCase(unittest.TestCase):
+ def setUp(self):
+ self.client = StubClient()
+ symbols = SymbolDB()
+ symbols.add(Symbol(addr=BASE + 0x100, name="rpc_ping", size=0x20, source="test"))
+ symbols.add(Symbol(addr=BASE + 0x200, name="gsp_ctrl", size=0x40, source="test"))
+ self.session = Session(client=self.client, symbols=symbols, record=False, name="mcp-test")
+ self.addCleanup(self.session.close)
+
+ def make_server(self, **kw):
+ self.inp = io.StringIO()
+ self.out = io.StringIO()
+ self.log = io.StringIO()
+ options = dict(input_stream=self.inp, output_stream=self.out, log_stream=self.log)
+ options.update(kw)
+ return mcp.GreenEyeMCPServer(self.session, **options)
+
+ def request(self, server, method, params=None, request_id=1):
+ message = {"jsonrpc": "2.0", "id": request_id, "method": method}
+ if params is not None:
+ message["params"] = params
+ return server.handle_message(message)
+
+ def call_tool(self, server, name, **arguments):
+ response = self.request(server, "tools/call", {"name": name, "arguments": arguments}, request_id=9)
+ self.assertIn("result", response, response)
+ result = response["result"]
+ payload = json.loads(result["content"][0]["text"])
+ return payload, result.get("isError", False)
+
+
+class ProtocolTests(McpTestCase):
+ def test_initialize(self):
+ response = self.request(
+ self.make_server(),
+ "initialize",
+ {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}},
+ )
+ result = response["result"]
+ self.assertEqual(response["jsonrpc"], "2.0")
+ self.assertEqual(result["protocolVersion"], "2024-11-05")
+ self.assertEqual(result["serverInfo"]["name"], "greeneye-mcp")
+ self.assertIn("tools", result["capabilities"])
+
+ def test_initialize_negotiation_and_fallback(self):
+ server = self.make_server()
+ self.assertEqual(
+ self.request(server, "initialize", {"protocolVersion": "2025-03-26"})["result"]["protocolVersion"],
+ "2025-03-26",
+ )
+ self.assertEqual(
+ self.request(server, "initialize", {"protocolVersion": "1999-01-01"})["result"]["protocolVersion"],
+ mcp.DEFAULT_PROTOCOL,
+ )
+
+ def test_initialized_notification_has_no_response(self):
+ server = self.make_server()
+ self.assertIsNone(server.handle_message({"jsonrpc": "2.0", "method": "notifications/initialized"}))
+
+ def test_ping(self):
+ self.assertEqual(self.request(self.make_server(), "ping")["result"], {})
+
+ def test_tools_list_catalogue(self):
+ tools = self.request(self.make_server(), "tools/list")["result"]["tools"]
+ names = [tool["name"] for tool in tools]
+ self.assertEqual(len(names), 24)
+ self.assertEqual(set(names), mcp._TOOL_NAMES)
+ for tool in tools:
+ self.assertEqual(tool["inputSchema"]["type"], "object")
+ self.assertTrue(tool["description"])
+
+ def test_unknown_method(self):
+ response = self.request(self.make_server(), "no/such")
+ self.assertEqual(response["error"]["code"], mcp.METHOD_NOT_FOUND)
+ self.assertEqual(response["id"], 1)
+
+ def test_unknown_tool(self):
+ response = self.request(self.make_server(), "tools/call", {"name": "nope", "arguments": {}})
+ self.assertEqual(response["error"]["code"], mcp.INVALID_PARAMS)
+
+ def test_tools_call_before_initialize_is_allowed(self):
+ payload, is_error = self.call_tool(self.make_server(), "session_info")
+ self.assertFalse(is_error)
+ self.assertEqual(payload["name"], "mcp-test")
+
+ def test_parse_error_is_reported(self):
+ server = self.make_server()
+ server.process_line("{not json")
+ line = self.out.getvalue().strip()
+ self.assertEqual(json.loads(line)["error"]["code"], mcp.PARSE_ERROR)
+
+ def test_serve_stream_end_to_end_and_no_stdout_pollution(self):
+ server = self.make_server()
+ lines = [
+ json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}),
+ json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}),
+ json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
+ json.dumps(
+ {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "session_info", "arguments": {}}}
+ ),
+ ]
+ server.serve_stream(io.StringIO("\n".join(lines) + "\n"), self.out)
+ raw = self.out.getvalue()
+ responses = [json.loads(line) for line in raw.splitlines()]
+ self.assertEqual([response["id"] for response in responses], [1, 2, 3])
+ self.assertTrue(all(response["jsonrpc"] == "2.0" for response in responses))
+ self.assertNotIn("[greeneye-mcp]", raw)
+ self.assertNotIn("[greeneye-mcp]", self.inp.getvalue())
+
+
+class ReadOnlyToolTests(McpTestCase):
+ def test_symbols_resolve(self):
+ payload, is_error = self.call_tool(self.make_server(), "symbols_resolve", target="rpc_ping")
+ self.assertFalse(is_error)
+ self.assertEqual(payload["address"], BASE + 0x100)
+ self.assertEqual(payload["symbol"], "rpc_ping")
+
+ def test_read_mem_and_hexdump(self):
+ self.client.mem[0x10:0x14] = b"\xde\xad\xbe\xef"
+ server = self.make_server()
+ payload, is_error = self.call_tool(server, "read_mem", addr=BASE + 0x10, size=4)
+ self.assertFalse(is_error)
+ self.assertEqual(payload["hex"], "deadbeef")
+ payload, is_error = self.call_tool(server, "hexdump", addr=BASE + 0x10, size=4)
+ self.assertFalse(is_error)
+ self.assertIn("de ad be ef", payload["text"])
+
+ def test_bp_set_and_clear(self):
+ server = self.make_server()
+ payload, is_error = self.call_tool(server, "bp_set", target="rpc_ping")
+ self.assertFalse(is_error)
+ self.assertIn(BASE + 0x100, self.client.sw_bps)
+ payload, is_error = self.call_tool(server, "bp_clear", target="rpc_ping")
+ self.assertFalse(is_error)
+ self.assertEqual(payload["cleared"], 1)
+ self.assertNotIn(BASE + 0x100, self.client.sw_bps)
+
+ def test_watch_set(self):
+ payload, is_error = self.call_tool(self.make_server(), "watch_set", target="gsp_ctrl", size=8, kind="w")
+ self.assertFalse(is_error)
+ self.assertEqual(payload["addr"], BASE + 0x200)
+ self.assertIn((BASE + 0x200, 8, "w"), self.client.watches)
+
+ def test_snapshot_digest(self):
+ payload, is_error = self.call_tool(self.make_server(), "snapshot", addr=BASE, size=16, label="unit")
+ self.assertFalse(is_error)
+ self.assertEqual(payload["size"], 16)
+ self.assertEqual(len(payload["sha256"]), 64)
+
+ def test_session_info_capabilities(self):
+ payload, is_error = self.call_tool(
+ self.make_server(allow_write=True, allow_inject=True), "session_info"
+ )
+ self.assertFalse(is_error)
+ self.assertEqual(payload["name"], "mcp-test")
+ self.assertTrue(payload["capabilities"]["write"])
+ self.assertTrue(payload["capabilities"]["inject"])
+ self.assertFalse(payload["capabilities"]["trace"])
+
+ def test_plugins_list(self):
+ payload, is_error = self.call_tool(self.make_server(), "plugins_list")
+ self.assertFalse(is_error)
+ self.assertIn("mcp_server.py", payload["bundled"])
+
+ def test_analyze_current_reads_active_recording(self):
+ with tempfile.TemporaryDirectory(prefix="mcp-rec-") as directory:
+ session = Session(client=StubClient(), symbols=SymbolDB(), record=directory, name="active")
+ try:
+ server = mcp.GreenEyeMCPServer(
+ session,
+ input_stream=io.StringIO(),
+ output_stream=io.StringIO(),
+ log_stream=io.StringIO(),
+ )
+ payload, is_error = self.call_tool(server, "analyze_current")
+ self.assertFalse(is_error)
+ self.assertIn("# green-eye session summary", payload["report"])
+ finally:
+ session.close()
+
+
+class GatingTests(McpTestCase):
+ def test_write_mem_refused_without_allow_write(self):
+ server = self.make_server()
+ payload, is_error = self.call_tool(server, "write_mem", addr=BASE + 0x10, hex="0102")
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "write_disabled")
+ self.assertEqual(bytes(self.client.mem[0x10:0x12]), b"\x00\x00")
+
+ def test_write_mem_enabled(self):
+ server = self.make_server(allow_write=True)
+ payload, is_error = self.call_tool(server, "write_mem", addr=BASE + 0x10, hex="0x01 02")
+ self.assertFalse(is_error)
+ self.assertEqual(payload["written"], 2)
+ self.assertEqual(bytes(self.client.mem[0x10:0x12]), b"\x01\x02")
+
+ def test_call_function_refused_without_allow_write(self):
+ payload, is_error = self.call_tool(self.make_server(), "call_function", target="rpc_ping", args=[1])
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "write_disabled")
+
+ def test_inject_refused_without_allow_inject(self):
+ payload, is_error = self.call_tool(self.make_server(), "inject_service", service="/nonexistent.elf")
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "inject_disabled")
+
+ def test_inject_missing_file_after_gate(self):
+ payload, is_error = self.call_tool(
+ self.make_server(allow_inject=True), "inject_service", service="/nonexistent.elf"
+ )
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "invalid_arguments")
+
+
+class AvailabilityTests(McpTestCase):
+ def test_analyze_current_without_recording(self):
+ payload, is_error = self.call_tool(self.make_server(), "analyze_current")
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "not_available")
+
+ def test_trace_start_on_non_native_backend(self):
+ payload, is_error = self.call_tool(self.make_server(), "trace_start")
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "not_available")
+
+ def test_trace_stop_without_active_trace(self):
+ payload, is_error = self.call_tool(self.make_server(), "trace_stop")
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "invalid_arguments")
+
+ def test_mutation_missing_script_is_structured(self):
+ server = self.make_server()
+ original = mcp.MUTATION_SCRIPT
+ mcp.MUTATION_SCRIPT = "/nonexistent/mutation_harness.py"
+ try:
+ payload, is_error = self.call_tool(server, "mutation_run")
+ finally:
+ mcp.MUTATION_SCRIPT = original
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "not_available")
+
+ def test_portability_missing_script_is_structured(self):
+ server = self.make_server()
+ original = mcp.MATRIX_SCRIPT
+ mcp.MATRIX_SCRIPT = "/nonexistent/portability_matrix.py"
+ try:
+ payload, is_error = self.call_tool(server, "portability_matrix_run", targets=["/x.elf"])
+ finally:
+ mcp.MATRIX_SCRIPT = original
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "not_available")
+
+ def test_probe_missing_elf(self):
+ payload, is_error = self.call_tool(self.make_server(), "probe_firmware", elf="/nonexistent/fw.elf")
+ self.assertTrue(is_error)
+ self.assertEqual(payload["error"], "invalid_arguments")
+
+
+class CloseAndTransportTests(McpTestCase):
+ def test_close_stops_serving_but_leaves_plugin_session(self):
+ server = self.make_server(owns_session=False)
+ payload, is_error = self.call_tool(server, "close")
+ self.assertFalse(is_error)
+ self.assertFalse(payload["session_closed"])
+ self.assertTrue(server._stop)
+ self.assertFalse(self.client.closed)
+
+ def test_close_closes_owned_session(self):
+ server = self.make_server(owns_session=True)
+ payload, is_error = self.call_tool(server, "close")
+ self.assertFalse(is_error)
+ self.assertTrue(payload["session_closed"])
+ self.assertTrue(self.client.closed)
+
+ def test_parse_tcp_is_loopback_only(self):
+ self.assertEqual(mcp.parse_tcp("127.0.0.1:8765"), ("127.0.0.1", 8765))
+ self.assertEqual(mcp.parse_tcp("localhost:0")[0], "localhost")
+ with self.assertRaises(Exception):
+ mcp.parse_tcp("0.0.0.0:8765")
+
+
+class StandaloneEntryTests(unittest.TestCase):
+ @unittest.skipUnless(os.path.isfile(MCP_SERVER), "mcp_server.py missing")
+ def test_help(self):
+ result = subprocess.run(
+ [sys.executable, MCP_SERVER, "--help"], capture_output=True, text=True, cwd=TOOL_ROOT
+ )
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("--stdio", result.stdout)
+ self.assertIn("--allow-write", result.stdout)
+
+ @unittest.skipUnless(os.path.isfile(MCP_SERVER), "mcp_server.py missing")
+ def test_stdio_native_session(self):
+ try:
+ import greeneye.sim_backend # noqa: F401
+ except Exception as exc:
+ self.skipTest(f"native backend unavailable: {exc}")
+ with tempfile.TemporaryDirectory(prefix="mcp-native-") as dumps:
+ env = dict(os.environ, GREENEYE_DUMPS=dumps)
+ requests = [
+ {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05"}},
+ {"jsonrpc": "2.0", "method": "notifications/initialized"},
+ {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "session_info", "arguments": {}}},
+ {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "close", "arguments": {}}},
+ ]
+ payload = "".join(json.dumps(request) + "\n" for request in requests)
+ result = subprocess.run(
+ [sys.executable, MCP_SERVER, "--stdio", "--backend", "native", "--record"],
+ input=payload,
+ capture_output=True,
+ text=True,
+ cwd=TOOL_ROOT,
+ env=env,
+ timeout=180,
+ )
+ self.assertEqual(result.returncode, 0, result.stderr[-2000:])
+ responses = [json.loads(line) for line in result.stdout.splitlines() if line.strip()]
+ self.assertEqual(len(responses), 3)
+ by_id = {response.get("id"): response for response in responses}
+ info = json.loads(by_id[2]["result"]["content"][0]["text"])
+ self.assertEqual(info["backend"], "native")
+ self.assertFalse(by_id[3]["result"].get("isError", False))
+ self.assertTrue(any(name.endswith("-mcp") for name in os.listdir(dumps)))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_sim_backend.py b/debug/risc-v-gsp-rm-vm/tests/test_sim_backend.py
new file mode 100644
index 0000000..a0635ab
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_sim_backend.py
@@ -0,0 +1,244 @@
+"""Native (M1) backend tests: demo parity with QEMU and engine determinism.
+
+The native simulator is pure Python and always available; every test still
+skips cleanly when the demo ELF cannot be loaded. The differential class
+additionally requires qemu-system-riscv64 and compares the scripted
+hook/watch/leave profile of both backends event for event.
+"""
+from __future__ import annotations
+
+import os
+import shutil
+import tempfile
+import unittest
+
+import greeneye as ge
+from greeneye.errors import RspTimeout
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.join(HERE, "..")
+DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+RPC_HANDLERS = ["rpc_ping", "rpc_echo", "rpc_fill", "rpc_sum", "rpc_state"]
+
+
+def native_available() -> bool:
+ try:
+ import greeneye.sim_backend # noqa: F401
+ except Exception:
+ return False
+ return True
+
+
+def qemu_available() -> bool:
+ return bool(os.environ.get("GREENEYE_QEMU") or shutil.which("qemu-system-riscv64"))
+
+
+def demo_available() -> bool:
+ return os.path.exists(DEMO_ELF) and native_available()
+
+
+def launch(backend: str, record=None, name: str = "sim"):
+ return ge.launch(kernel=DEMO_ELF, backend=backend, record=record, name=name)
+
+
+@unittest.skipUnless(native_available(), "native simulator unavailable")
+class NativeIsaTests(unittest.TestCase):
+ """Exercise RV64M/A/C paths the demo firmware does not reach."""
+
+ # Assembled with llvm-mc -triple=riscv64 -mattr=+m,+a,+c (encodings are
+ # data, so this runs without a toolchain):
+ # li a0,6; li a1,7; mul a2,a0,a1; div a5,a1,a0; rem a6,a1,a0;
+ # srai a4,a1,1; subw a3,a0,a1; lui s0,0x12345; addi sp,sp,-16;
+ # sd a0,8(sp); ld s1,8(sp); lh s2,8(sp); lbu s3,8(sp);
+ # amoadd.d s4,a0,(sp); lr.d s5,(sp); sc.d s6,a0,(sp);
+ # li t0,1; c.addi t0,3; c.mv a2,t0; c.andi a2,15; ebreak
+ PROGRAM = bytes.fromhex(
+ "".join(
+ """
+ 19 45 9d 45 33 06 b5 02 b3 c7 a5 02 33 e8 a5 02 13 d7 15 40
+ bb 06 b5 40 37 54 34 12 41 11 2a e4 a2 64 03 19 81 00 83 49 81 00
+ 2f 3a a1 00 af 3a 01 10 2f 3b a1 18 85 42 8d 02 16 86 3d 8a 02 90
+ """.split()
+ )
+ )
+ BASE = 0x80000000
+ STACK = 0x80004000
+
+ def test_m_a_c_execution(self):
+ from sim.machine import Machine
+
+ machine = Machine()
+ machine.load_segments([(self.BASE, self.PROGRAM)], self.BASE)
+ machine.set_reg(2, self.STACK) # sp
+ reason, pc, _, _ = machine.run(1000)
+ self.assertEqual(reason, 3) # R_EBREAK: stops before executing it
+ self.assertEqual(pc, self.BASE + len(self.PROGRAM) - 2)
+ expected = {
+ 5: 4, # t0: c.addi
+ 8: 0x12345000, # s0: lui
+ 9: 6, # s1: c.sdsp/c.ldsp round-trip
+ 10: 6, # a0
+ 11: 7, # a1
+ 12: 4, # a2: mul then c.mv/c.andi
+ 13: 0xFFFFFFFFFFFFFFFF, # a3: subw sign extension
+ 14: 3, # a4: srai
+ 15: 1, # a5: div
+ 16: 1, # a6: rem
+ 18: 6, # s2: lh
+ 19: 6, # s3: lbu
+ 20: 0, # s4: amoadd old value
+ 21: 6, # s5: lr
+ 22: 0, # s6: sc success
+ 2: self.STACK - 16, # c.addi16sp
+ }
+ got = {index: machine.x[index] for index in expected}
+ self.assertEqual(got, expected)
+ self.assertEqual(machine.read_mem(self.STACK - 16, 8), (6).to_bytes(8, "little"))
+
+
+@unittest.skipUnless(demo_available(), "demo firmware or native simulator unavailable")
+class NativeSmokeTests(unittest.TestCase):
+ def test_start_sequence_sets_stack_and_enters_main(self):
+ session = launch("native", name="unittest-sim-start")
+ try:
+ session.step(4) # auipc sp / addi sp / auipc ra / jalr main
+ stack_top = next(s.value for s in session.image.symbols if s.name == "_stack_top")
+ self.assertEqual(session.pc, session.symbols.lookup("main"))
+ self.assertEqual(session.read_regs()["sp"], stack_top)
+ finally:
+ session.close()
+
+ def test_boot_hook_watch_snapshot_analyze(self):
+ root = tempfile.mkdtemp(prefix="greeneye-sim-")
+ session = launch("native", record=root, name="unittest-sim-smoke")
+ hits = []
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ session.hook("rpc_sum", on_enter=lambda hit: hits.append(hit))
+ watch_hits = []
+ session.watch(ctrl + 16, size=8, kind="w", callback=lambda hit: watch_hits.append(hit))
+ session.run(duration=0.6)
+ self.assertTrue(session.target.wait_for_uart("GREEN-EYE UPROC DEMO", timeout=1.0))
+ self.assertGreaterEqual(len(hits), 1, "rpc_sum hook never fired")
+ self.assertGreaterEqual(len(watch_hits), 7, "rpc_count watchpoint never fired")
+ self.assertEqual(watch_hits[0].addr, ctrl + 16)
+ self.assertEqual(watch_hits[0].watch.kind, "w")
+ after = session.snapshot(ctrl, 0x188, "ctrl")
+ self.assertEqual(int.from_bytes(after[0:4], "little"), 0x47535031)
+ self.assertGreaterEqual(int.from_bytes(after[16:24], "little"), 7)
+ finally:
+ directory = getattr(session.recorder, "dir", None)
+ session.close()
+ from greeneye.analyze import render_summary
+
+ report = render_summary(directory)
+ self.assertIn("rpc_sum", report)
+ self.assertIn("watch", report)
+
+ def test_hook_args_and_leave_value(self):
+ session = launch("native", name="unittest-sim-hookargs")
+ events = []
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ session.hook(
+ "rpc_sum",
+ on_enter=lambda hit: events.append(("enter", hit.arg(0), hit.arg(1))),
+ on_leave=lambda hit: events.append(("leave", hit.retval)),
+ )
+ session.run(duration=0.2)
+ # Self-test: rpc_dispatch(4, ctl->buf, 16, 0) after fill(0x5a, 32).
+ self.assertIn(("enter", ctrl + 0x48, 16), events)
+ self.assertIn(("leave", 16 * 0x5A), events)
+ finally:
+ session.close()
+
+ def test_call_and_run_until(self):
+ session = launch("native", name="unittest-sim-call")
+ try:
+ before = session.pc
+ self.assertEqual(session.call("rpc_ping", (0x1111,)), 0x1111 ^ 0xBEEF)
+ self.assertEqual(session.pc, before)
+ hit = session.run_until("rpc_sum", timeout=2.0)
+ self.assertEqual(hit.kind, "breakpoint")
+ self.assertEqual(hit.addr, session.symbols.lookup("rpc_sum"))
+ finally:
+ session.close()
+
+ def test_run_until_timeout(self):
+ session = launch("native", name="unittest-sim-timeout")
+ try:
+ with self.assertRaises(RspTimeout):
+ session.run_until("rpc_fault", timeout=0.05) # self-test never calls it
+ finally:
+ session.close()
+
+
+@unittest.skipUnless(demo_available(), "demo firmware or native simulator unavailable")
+class NativeDeterminismTests(unittest.TestCase):
+ def _scripted_profile(self):
+ """Drive fixed instruction budgets: no host clock in the result."""
+ session = ge.launch(kernel=DEMO_ELF, backend="native", name="unittest-sim-determinism")
+ events = []
+ stops = []
+ try:
+ session.hook(
+ "rpc_sum",
+ on_enter=lambda hit: events.append(("enter", hit.arg(0), hit.arg(1))),
+ on_leave=lambda hit: events.append(("leave", hit.retval)),
+ )
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ session.watch(ctrl + 16, size=8, kind="w")
+ for _ in range(20):
+ try:
+ stop = session.client.continue_(timeout=0.02) # exactly 20k insns
+ except RspTimeout:
+ stops.append(("budget", session.pc))
+ continue
+ session._dispatch_stop(stop)
+ stops.append(("stop", session.pc))
+ ctrl_image = session.read_regs()
+ snapshot = session.client.read_mem(ctrl, 0x188)
+ finally:
+ session.close()
+ return events, stops, ctrl_image["pc"], snapshot
+
+ def test_two_runs_identical(self):
+ first = self._scripted_profile()
+ second = self._scripted_profile()
+ self.assertEqual(first, second)
+
+
+@unittest.skipUnless(demo_available() and qemu_available(), "native simulator and QEMU required")
+class DifferentialTests(unittest.TestCase):
+ """Run one scripted scenario on both backends and compare the profiles."""
+
+ def _profile(self, backend: str):
+ session = launch(backend, name=f"unittest-diff-{backend}")
+ events = []
+ watch_hits = []
+ try:
+ for name in RPC_HANDLERS:
+ session.hook(
+ name,
+ on_enter=lambda hit, n=name: events.append(("enter", n, hit.arg(0), hit.arg(1))),
+ on_leave=lambda hit, n=name: events.append(("leave", n, hit.retval)),
+ )
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ session.watch(ctrl + 16, size=8, kind="w", callback=lambda hit: watch_hits.append(hit.addr))
+ session.run(duration=0.3)
+ uart = session.target.read_uart()
+ bp_counts = [handle.hits for handle in session._bps.values()]
+ finally:
+ session.close()
+ return events, len(watch_hits), bp_counts, uart.splitlines()[0] if uart else ""
+
+ def test_hook_watch_profile_parity(self):
+ qemu = self._profile("qemu")
+ native = self._profile("native")
+ self.assertEqual(qemu, native)
+ self.assertEqual(native[1], 8) # 7 self-test dispatches + ctrl init
+ self.assertEqual(native[2], [1, 1, 1, 1, 3])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_sim_trace.py b/debug/risc-v-gsp-rm-vm/tests/test_sim_trace.py
new file mode 100644
index 0000000..96934e9
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_sim_trace.py
@@ -0,0 +1,224 @@
+"""Native R4 trace tests: collection, filters, session blob, analyzer, CLI.
+
+The engine tests are pure (no QEMU, no demo build needed); the session/CLI
+tests skip cleanly when the demo ELF is absent.
+"""
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import os
+import tempfile
+import unittest
+
+import greeneye as ge
+from greeneye.analyze import render_summary, render_summary_from
+from greeneye.errors import RspTimeout
+from sim.machine import Machine
+from sim.trace import Trace
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.join(HERE, "..")
+DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+
+# lui a0,0x80000; addi a1,zero,0x2a; sw a1,0(a0); lw a2,0(a0); ebreak
+PROGRAM = bytes.fromhex("37 05 00 80 93 05 a0 02 23 20 b5 00 03 26 05 00 73 00 10 00")
+BASE = 0x80000000
+
+
+def demo_available() -> bool:
+ return os.path.exists(DEMO_ELF)
+
+
+def records(payload: bytes) -> list[dict]:
+ return [json.loads(line) for line in payload.decode().splitlines() if line.strip()]
+
+
+def read_blob(session_dir: str) -> bytes:
+ with open(os.path.join(session_dir, "blobs", "trace.jsonl"), "rb") as fh:
+ return fh.read()
+
+
+class TraceEngineTests(unittest.TestCase):
+ def _machine(self):
+ machine = Machine()
+ machine.load_segments([(BASE, PROGRAM)], BASE)
+ return machine
+
+ def test_exec_and_access_lines(self):
+ trace = Trace()
+ reason, _, _, _ = self._machine().run(100, trace=trace)
+ self.assertEqual(reason, 3) # R_EBREAK, stopped at the trap
+ self.assertEqual(trace.counts(), {
+ "exec": 5, "read": 1, "write": 1, "events": 7, "dropped": 0,
+ })
+ lines = records(trace.payload())
+ self.assertEqual(lines[-1]["t"], "summary")
+ self.assertEqual(lines[-1]["insns"], 5)
+ self.assertEqual(lines[-1]["mem"], 2)
+ self.assertEqual(
+ [(r["t"], r["pc"]) for r in lines[:-1] if r["t"] == "exec"],
+ [("exec", "0x80000000"), ("exec", "0x80000004"), ("exec", "0x80000008"),
+ ("exec", "0x8000000c"), ("exec", "0x80000010")],
+ )
+ write = next(r for r in lines if r["t"] == "write")
+ read = next(r for r in lines if r["t"] == "read")
+ self.assertEqual((write["addr"], write["size"], write["pc"], write["vt"]), ("0x80000000", 4, "0x80000008", 3))
+ self.assertEqual((read["addr"], read["size"], read["pc"], read["vt"]), ("0x80000000", 4, "0x8000000c", 4))
+
+ def test_address_filters_and_exec_off(self):
+ trace = Trace(exec_enabled=False, start=BASE + 8, end=BASE + 8)
+ self._machine().run(100, trace=trace)
+ lines = records(trace.payload())
+ self.assertEqual([r["t"] for r in lines], ["write", "summary"])
+ self.assertEqual(lines[0]["pc"], "0x80000008") # the in-range store only
+ self.assertFalse(lines[1]["exec"])
+ self.assertEqual(lines[1]["start"], "0x80000008")
+
+ def test_ring_budget_is_deterministic(self):
+ first = Trace(max_events=3)
+ second = Trace(max_events=3)
+ self._machine().run(100, trace=first)
+ self._machine().run(100, trace=second)
+ self.assertEqual(first.payload(), second.payload())
+ self.assertEqual(first.counts()["events"], 7)
+ self.assertEqual(first.counts()["dropped"], 4)
+
+ def test_trace_none_path_unchanged(self):
+ machine = self._machine()
+ machine.run(100) # the default fast path still works untouched
+ self.assertEqual(machine.x[10], 0x80000000)
+ self.assertEqual(machine.x[11], 0x2A)
+ self.assertEqual(machine.x[12], 0x2A)
+
+
+@unittest.skipUnless(demo_available(), "demo firmware unavailable")
+class SessionTraceTests(unittest.TestCase):
+ def _recorded_trace(self, trace) -> tuple[str, bytes]:
+ session_dir = tempfile.mkdtemp(prefix="greeneye-trace-")
+ session = ge.launch(kernel=DEMO_ELF, backend="native", record=session_dir,
+ name="unittest-sim-trace", trace=trace)
+ try:
+ try:
+ session.client.continue_(timeout=0.02) # exactly 20k instructions
+ except RspTimeout:
+ pass
+ finally:
+ session.close()
+ return session_dir, read_blob(session_dir)
+
+ def test_blob_recorded_and_analyzed(self):
+ session_dir, blob = self._recorded_trace({"exec": True})
+ meta = json.load(open(os.path.join(session_dir, "session.json"), encoding="utf-8"))
+ self.assertEqual(meta["trace_blob"], "blobs/trace.jsonl")
+ self.assertTrue(meta["trace"]["exec"])
+ lines = blob.decode().splitlines()
+ self.assertTrue(lines[0].startswith('{"t":"exec"'))
+ self.assertIn('"t":"summary"', lines[-1])
+ events = [json.loads(line) for line in open(os.path.join(session_dir, "events.jsonl"), encoding="utf-8")]
+ self.assertTrue(any(e.get("type") == "blob_saved" for e in events))
+ self.assertFalse(any(e.get("type") in ("exec", "read", "write") for e in events),
+ "trace volume must stay out of events.jsonl")
+ report = render_summary(session_dir)
+ self.assertIn("## Execution trace", report)
+ self.assertIn("unique PCs", report)
+ self.assertIn("### Accessed pages", report)
+
+ def test_trace_is_deterministic(self):
+ _, first = self._recorded_trace(True)
+ _, second = self._recorded_trace(True)
+ self.assertEqual(first, second)
+
+ def test_exec_off_records_accesses_only(self):
+ session_dir, blob = self._recorded_trace({"exec": False})
+ lines = blob.decode().splitlines()
+ self.assertFalse(any('"t":"exec"' in line for line in lines))
+ self.assertTrue(any('"t":"read"' in line or '"t":"write"' in line for line in lines))
+ report = render_summary(session_dir)
+ self.assertIn("exec=off", report)
+
+ def test_launch_trace_implies_recording(self):
+ old = os.environ.get("GREENEYE_DUMPS")
+ os.environ["GREENEYE_DUMPS"] = tempfile.mkdtemp(prefix="greeneye-trace-dumps-")
+ try:
+ session = ge.launch(kernel=DEMO_ELF, backend="native", name="unittest-sim-trace-auto", trace=True)
+ try:
+ directory = session.recorder.dir
+ self.assertIsNotNone(directory)
+ finally:
+ session.close()
+ self.assertTrue(os.path.exists(os.path.join(directory, "blobs", "trace.jsonl")))
+ finally:
+ if old is None:
+ os.environ.pop("GREENEYE_DUMPS", None)
+ else:
+ os.environ["GREENEYE_DUMPS"] = old
+
+ def test_render_from_folded_events(self):
+ meta = {"name": "synthetic", "format": "greeneye-session/1", "trace": {"exec": True}}
+ events = [
+ {"type": "exec", "pc": 0x80000010, "vt": 1},
+ {"type": "exec", "pc": 0x80000010, "vt": 2},
+ {"type": "read", "pc": 0x80000010, "addr": 0x80000100, "size": 4},
+ {"type": "write", "pc": 0x80000014, "addr": 0x80000200, "size": 8},
+ {"type": "access", "pc": 0x80000018, "addr": 0x80000200, "size": 4, "rw": "w"},
+ ]
+ report = render_summary_from(meta, events)
+ self.assertIn("## Execution trace", report)
+ self.assertIn("unique PCs: 1", report)
+ self.assertIn("1 reads (1 pages)", report)
+ self.assertIn("2 writes (1 pages)", report)
+ self.assertIn("| `0x80000000` | 1 | 2 |", report)
+
+
+@unittest.skipUnless(demo_available(), "demo firmware unavailable")
+class CliTraceTests(unittest.TestCase):
+ def test_info_reports_native(self):
+ from greeneye import cli
+
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ code = cli.main(["info"])
+ self.assertEqual(code, 0)
+ self.assertIn("native simulator: available", out.getvalue())
+ self.assertIn("native trace: available", out.getvalue())
+
+ def test_record_native_trace_cli(self):
+ from greeneye import cli
+
+ session_dir = tempfile.mkdtemp(prefix="greeneye-trace-cli-")
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ code = cli.main([
+ "record", "--kernel", DEMO_ELF, "--backend", "native",
+ "--duration", "0.05", "--trace", "--record", session_dir,
+ ])
+ self.assertEqual(code, 0)
+ summary = open(os.path.join(session_dir, "summary.md"), encoding="utf-8").read()
+ self.assertIn("## Execution trace", summary)
+ lines = read_blob(session_dir).decode().splitlines()
+ self.assertTrue(any('"t":"exec"' in line for line in lines))
+
+ def test_record_trace_address_filter_cli(self):
+ from greeneye import cli
+
+ session_dir = tempfile.mkdtemp(prefix="greeneye-trace-cli-filter-")
+ with contextlib.redirect_stdout(io.StringIO()):
+ code = cli.main([
+ "record", "--kernel", DEMO_ELF, "--backend", "native",
+ "--duration", "0.05", "--trace", "--trace-exec", "off",
+ "--trace-start", "0x80000000", "--trace-end", "0x800000ff",
+ "--record", session_dir,
+ ])
+ self.assertEqual(code, 0)
+ for line in read_blob(session_dir).decode().splitlines():
+ record = json.loads(line)
+ if record["t"] in ("read", "write"):
+ pc = int(record["pc"], 16)
+ self.assertLessEqual(pc, 0x800000FF)
+ self.assertNotEqual(record["t"], "exec")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_value_demos.py b/debug/risc-v-gsp-rm-vm/tests/test_value_demos.py
new file mode 100644
index 0000000..7640e13
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_value_demos.py
@@ -0,0 +1,107 @@
+"""End-to-end tests for the value-validation demos (docs/25).
+
+Runs the fastest demos as subprocesses exactly as a developer would, parses
+their machine-readable ``RESULT_JSON`` and asserts the answers, then checks
+that ``run_all.py`` emits its aggregate results JSON.
+
+Skipped cleanly when qemu-system-riscv64 or the bundled demo firmware is
+absent; the demos that use the native backend still need the demo ELF.
+"""
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import subprocess
+import sys
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.dirname(HERE)
+DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+DEMO_DIR = os.path.join(TOOL_ROOT, "harness", "value_demos")
+RESULTS_JSON = os.path.join(DEMO_DIR, "artifacts", "results.json")
+
+
+def qemu_available() -> bool:
+ return bool(os.environ.get("GREENEYE_QEMU") or shutil.which("qemu-system-riscv64"))
+
+
+def _run_demo(script: str, backend: str, timeout: float = 60.0) -> dict:
+ env = dict(os.environ)
+ env["VALUE_DEMO_BACKEND"] = backend
+ proc = subprocess.run(
+ [sys.executable, os.path.join(DEMO_DIR, script)],
+ cwd=TOOL_ROOT,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ env=env,
+ )
+ for line in reversed(proc.stdout.splitlines()):
+ if line.startswith("RESULT_JSON: "):
+ return json.loads(line[len("RESULT_JSON: ") :])
+ raise AssertionError(
+ f"{script}: no RESULT_JSON (exit {proc.returncode})\n"
+ f"stdout tail:\n{proc.stdout[-1200:]}\nstderr tail:\n{proc.stderr[-1200:]}"
+ )
+
+
+@unittest.skipUnless(qemu_available(), "qemu-system-riscv64 not installed")
+@unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)")
+class ValueDemoTests(unittest.TestCase):
+ def test_s01_reply_consumer(self):
+ result = _run_demo("s01_reply_consumer.py", "qemu")
+ self.assertTrue(result["success"], result)
+ replies = {r["cmd"]: r for r in result["replies"]}
+ self.assertEqual(replies[1]["result"], 0x1234 ^ 0xBEEF)
+ self.assertTrue(replies[1]["consumed"])
+ self.assertEqual(replies[7]["result"], (~7) & 0xFFFFFFFFFFFFFFFF)
+ self.assertFalse(replies[6]["consumed"], "FAULT must trap before the reply store")
+ self.assertGreater(result["elapsed_s"], 0)
+
+ def test_s04_run_until_function(self):
+ result = _run_demo("s04_run_until_function.py", "native")
+ self.assertTrue(result["success"], result)
+ self.assertTrue(result["natural_path"]["reachable"])
+ self.assertTrue(result["before_trigger"]["timeout"])
+ self.assertTrue(result["after_trigger"]["reachable"])
+ self.assertEqual(result["after_trigger"]["dispatch_args"][0]["cmd"], 6)
+ self.assertEqual(result["direct_call"]["result"], 0x41 ^ 0xBEEF)
+
+ def test_s05_snapshot_diff(self):
+ result = _run_demo("s05_snapshot_diff.py", "native")
+ self.assertTrue(result["success"], result)
+ self.assertTrue({"result", "rpc_count", "last_rpc", "buf"}.issubset(set(result["changed_field_names"])))
+ self.assertEqual(result["after"]["result"], 15)
+
+ def test_s09_ghidra_join(self):
+ result = _run_demo("s09_grabbing_a_ghidra_address.py", "qemu")
+ self.assertTrue(result["success"], result)
+ self.assertEqual(result["hits"], [int(result["runtime_addr"], 16)])
+ self.assertEqual(int(result["ghidra_addr"], 16) + result["bias"], int(result["runtime_addr"], 16))
+ self.assertIn("rpc_fault", result["greeneye_hits_tsv"])
+
+ def test_run_all_emits_results_json(self):
+ proc = subprocess.run(
+ [sys.executable, os.path.join(DEMO_DIR, "run_all.py"), "--only", "s01,s04,s05"],
+ cwd=TOOL_ROOT,
+ capture_output=True,
+ text=True,
+ timeout=120,
+ env={**os.environ, "VALUE_DEMO_BACKEND": "native"},
+ )
+ self.assertEqual(proc.returncode, 0, proc.stdout[-1500:] + proc.stderr[-1500:])
+ self.assertTrue(os.path.exists(RESULTS_JSON), "run_all.py did not write results.json")
+ with open(RESULTS_JSON, "r", encoding="utf-8") as fh:
+ payload = json.load(fh)
+ self.assertEqual(payload["format"], "greeneye-value-demos-results/1")
+ self.assertEqual(payload["demos_selected"], ["s01", "s04", "s05"])
+ self.assertEqual(payload["demos_failed"], [])
+ self.assertEqual(len(payload["demos_passed"]), 3)
+ for result in payload["results"]:
+ self.assertGreater(result["runner_elapsed_s"], 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_version_diff.py b/debug/risc-v-gsp-rm-vm/tests/test_version_diff.py
new file mode 100644
index 0000000..df704df
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_version_diff.py
@@ -0,0 +1,333 @@
+"""Tests for version_diff / ci_release (roadmap H4.1, planned docs/21).
+
+Pure-function tests need neither QEMU nor the demo; the self-diff integration
+test boots the demo twice and skips cleanly when qemu-system-riscv64 or the
+demo ELF is absent.
+"""
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import os
+import sys
+import tempfile
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.abspath(os.path.join(HERE, ".."))
+DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+if TOOL_ROOT not in sys.path:
+ sys.path.insert(0, TOOL_ROOT)
+
+from harness.ci_release import main as ci_release_main # noqa: E402
+from harness.common import SymbolDB, qemu_available # noqa: E402
+from harness.version_diff import ( # noqa: E402
+ LOAD_ADDR,
+ Rm610GateError,
+ VersionDiffOptions,
+ check_rm610_gate,
+ corpus_diff,
+ corpus_section,
+ image_diff,
+ inventory_diff,
+ inventory_records,
+ is_rm610_label,
+ load_any_target,
+ load_symbol_sources,
+ load_three_column_export,
+ looks_like_three_column_export,
+ main as version_diff_main,
+ rm610_approved,
+ run_version_diff,
+ shape_payload,
+)
+
+NOP = b"\x13\x00\x00\x00" # addi x0, x0, 0
+
+
+def _run_main(main_fn, argv: list[str]) -> tuple[int, str, str]:
+ out, err = io.StringIO(), io.StringIO()
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ rc = main_fn(argv)
+ return rc, out.getvalue(), err.getvalue()
+
+
+def _write(path: str, text: str) -> str:
+ with open(path, "w", encoding="utf-8") as fh:
+ fh.write(text)
+ return path
+
+
+class Rm610GateTests(unittest.TestCase):
+ def test_label_detection(self):
+ for label in ("rm610", "RM610", "rm-610-fw.bin", "gsp_rm_610.elf", "release-RM610"):
+ self.assertTrue(is_rm610_label(label), label)
+ for label in ("570.144", "uproc_demo", "gsp-570.211.fwimage.bin", ""):
+ self.assertFalse(is_rm610_label(label), label)
+
+ def test_refused_by_default(self):
+ with self.assertRaises(Rm610GateError) as ctx:
+ check_rm610_gate(["rm610"], environ={})
+ message = str(ctx.exception)
+ self.assertIn("docs/23", message)
+ self.assertIn("RM610-APPROVED", message)
+ self.assertIn("no target file was opened", message)
+
+ def test_token_and_env_approve(self):
+ check_rm610_gate(["rm610"], approval_token="RM610-APPROVED", environ={})
+ check_rm610_gate(["rm_610"], environ={"GREENEYE_RM610_APPROVED": "1"})
+ self.assertTrue(rm610_approved("RM610-APPROVED", {}))
+ self.assertTrue(rm610_approved(None, {"GREENEYE_RM610_APPROVED": "1"}))
+ with self.assertRaises(Rm610GateError):
+ check_rm610_gate(["rm610"], approval_token="rm610-approved", environ={})
+ with self.assertRaises(Rm610GateError):
+ check_rm610_gate(["rm610"], environ={"GREENEYE_RM610_APPROVED": "0"})
+
+ def test_other_labels_are_not_gated(self):
+ check_rm610_gate(["570.144", "uproc_demo"], environ={})
+
+
+class InventoryDiffTests(unittest.TestCase):
+ def test_matching_discipline(self):
+ records_a = [
+ {"name": "keep", "addr": 0x100, "size": 16, "source": "elf"},
+ {"name": "gone", "addr": 0x180, "size": 8, "source": "elf"},
+ {"name": "FUN_00000200", "addr": 0x200, "size": 12, "source": "elf"},
+ ]
+ records_b = [
+ {"name": "keep", "addr": 0x110, "size": 16, "source": "elf"},
+ {"name": "newfn", "addr": 0x190, "size": 8, "source": "elf"},
+ {"name": "FUN_00000210", "addr": 0x210, "size": 12, "source": "elf"},
+ ]
+ diff = inventory_diff(records_a, records_b)
+ self.assertEqual(diff["shift"], 0x10)
+ self.assertEqual([row["name"] for row in diff["moved"]], ["keep"])
+ self.assertEqual(diff["matched_shifted"], 1)
+ self.assertEqual(diff["totals"]["added"], 1)
+ self.assertEqual(diff["totals"]["removed"], 1)
+ self.assertEqual(diff["added"][0]["name"], "newfn")
+ self.assertEqual(diff["removed"][0]["name"], "gone")
+
+ def test_resize_and_self_diff(self):
+ records = [
+ {"name": "a", "addr": 0x10, "size": 4, "source": "elf"},
+ {"name": "FUN_00000020", "addr": 0x20, "size": 4, "source": "elf"},
+ ]
+ resized = [dict(row) for row in records]
+ resized[1]["size"] = 8
+ diff = inventory_diff(records, resized)
+ self.assertEqual(diff["resized_anonymous"][0]["new_size"], 8)
+ zero = inventory_diff(records, records)
+ self.assertEqual(zero["totals"]["added"], 0)
+ self.assertEqual(zero["totals"]["removed"], 0)
+ self.assertEqual(zero["totals"]["moved"], 0)
+ self.assertEqual(zero["totals"]["resized_named"], 0)
+
+
+class SymbolSourceTests(unittest.TestCase):
+ def test_three_column_export(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = _write(
+ os.path.join(tmp, "symbols.tsv"),
+ "addr\tname\tsize\n0x80000010\trpc_ping\t16\n0x80000020\trpc_sum\t32\n",
+ )
+ self.assertTrue(looks_like_three_column_export(path))
+ db = SymbolDB()
+ self.assertEqual(load_three_column_export(db, path, bias=0), 2)
+ self.assertEqual(db.get("rpc_ping").size, 16)
+ self.assertEqual(db.get("rpc_sum").addr, 0x80000020)
+
+ def test_names_tsv_is_not_mistaken_for_export(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = _write(os.path.join(tmp, "names.tsv"), "0x80000010\trpc_ping\tghidra\n")
+ self.assertFalse(looks_like_three_column_export(path))
+
+ @unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)")
+ def test_sources_merge_on_demo(self):
+ loaded = load_any_target(DEMO_ELF)
+ self.assertTrue(loaded.ok)
+ db, sources, _ = load_symbol_sources(loaded)
+ self.assertTrue(any(source.startswith("elf:") for source in sources))
+ self.assertTrue(len(inventory_records(db)) > 0)
+
+
+class TargetShapeTests(unittest.TestCase):
+ def test_missing_and_incompatible(self):
+ missing = load_any_target("/nonexistent/nope.elf")
+ self.assertEqual(missing.kind, "missing")
+ self.assertFalse(missing.ok)
+ with tempfile.TemporaryDirectory() as tmp:
+ bad = os.path.join(tmp, "bad.elf")
+ with open(bad, "wb") as fh:
+ fh.write(b"\x7fELF" + b"\x00" * 48)
+ loaded = load_any_target(bad)
+ self.assertEqual(loaded.kind, "unsupported")
+ self.assertIn("EI_CLASS", loaded.reason or "")
+
+ def test_raw_target_shape(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "raw.bin")
+ with open(path, "wb") as fh:
+ fh.write(NOP * 16)
+ loaded = load_any_target(path)
+ self.assertEqual(loaded.kind, "raw")
+ self.assertEqual(loaded.bias, LOAD_ADDR)
+ payload = shape_payload(loaded)
+ self.assertEqual(payload["span"], 64)
+ self.assertEqual(payload["sections"], [])
+ self.assertEqual(payload["loads"][0]["vaddr"], 0)
+
+ @unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)")
+ def test_self_shape_and_inventory_zero(self):
+ old, new = load_any_target(DEMO_ELF), load_any_target(DEMO_ELF)
+ image = image_diff(old, new, max_rows=40)
+ self.assertEqual(image["status"], "ok")
+ self.assertTrue(image["same"])
+ old_db, _, _ = load_symbol_sources(old)
+ new_db, _, _ = load_symbol_sources(new)
+ diff = inventory_diff(inventory_records(old_db), inventory_records(new_db))
+ self.assertEqual(diff["shift"], 0)
+ self.assertEqual(diff["totals"]["added"], 0)
+ self.assertEqual(diff["totals"]["removed"], 0)
+ self.assertEqual(diff["totals"]["moved"], 0)
+ self.assertEqual(diff["totals"]["resized_named"], 0)
+
+
+class CorpusTests(unittest.TestCase):
+ def test_corpus_diff(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ a = _write(
+ os.path.join(tmp, "a.tsv"),
+ "addr\tfile_addr\tsize\tname\tbucket\tline\tstatus\n"
+ "0x1000\t0x80000000\t16\trpc_ping\tb\t0\tok\n",
+ )
+ b = _write(
+ os.path.join(tmp, "b.tsv"),
+ "addr\tfile_addr\tsize\tname\tbucket\tline\tstatus\n"
+ "0x1000\t0x80000010\t16\trpc_ping\tb\t0\tok\n",
+ )
+ diff = corpus_diff(a, b)
+ self.assertEqual(diff["shift"], 0x10)
+ self.assertEqual(diff["matched_named"], 1)
+
+ def test_not_supplied(self):
+ options = VersionDiffOptions(old="old.elf", new="new.elf", out="/tmp/unused")
+ section = corpus_section(options, options.out)
+ self.assertEqual(section["status"], "skipped")
+ self.assertIn("not supplied", section["reason"])
+
+
+class RunVersionDiffTests(unittest.TestCase):
+ def test_missing_target_skips_cleanly(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ new = os.path.join(tmp, "new.bin")
+ with open(new, "wb") as fh:
+ fh.write(NOP * 16)
+ out = os.path.join(tmp, "out")
+ options = VersionDiffOptions(old=os.path.join(tmp, "missing.elf"), new=new, out=out,
+ probe_instructions=8)
+ report = run_version_diff(options)
+ for name in ("image", "symbols", "probe", "behavior"):
+ self.assertEqual(report["sections"][name]["status"], "skipped", name)
+ self.assertTrue(report["sections"][name]["reason"], name)
+ self.assertIn("not supplied", report["sections"]["corpus"]["reason"])
+ self.assertTrue(os.path.exists(os.path.join(out, "version-diff.json")))
+ self.assertTrue(os.path.exists(os.path.join(out, "version-diff.md")))
+
+ def test_gate_refused_before_any_file_open(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ out = os.path.join(tmp, "out")
+ rc, _, err = _run_main(
+ version_diff_main,
+ ["--old", os.path.join(tmp, "rm610.elf"), "--new", os.path.join(tmp, "new.elf"),
+ "--out", out],
+ )
+ self.assertEqual(rc, 3)
+ self.assertIn("docs/23", err)
+ self.assertFalse(os.path.exists(out), "the gate must fire before --out is created")
+
+ def test_gate_passes_with_token(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ new = os.path.join(tmp, "new.bin")
+ with open(new, "wb") as fh:
+ fh.write(NOP * 16)
+ out = os.path.join(tmp, "out")
+ rc, _, _ = _run_main(
+ version_diff_main,
+ ["--old", os.path.join(tmp, "rm610.bin"), "--new", new, "--release", "rm610",
+ "--out", out, "--approval-token", "RM610-APPROVED", "--probe-instructions", "4"],
+ )
+ self.assertEqual(rc, 0)
+ self.assertTrue(os.path.exists(os.path.join(out, "version-diff.json")))
+
+
+class CiReleaseTests(unittest.TestCase):
+ def test_dry_run_exits_zero_and_touches_nothing(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ out = os.path.join(tmp, "bundle")
+ rc, stdout, _ = _run_main(
+ ci_release_main,
+ ["--old", os.path.join(tmp, "old.elf"), "--new", os.path.join(tmp, "new.elf"),
+ "--out", out, "--dry-run"],
+ )
+ self.assertEqual(rc, 0)
+ self.assertIn("version_diff", stdout)
+ self.assertIn("portability_matrix", stdout)
+ self.assertIn("mutation_smoke", stdout)
+ self.assertFalse(os.path.exists(out))
+
+ def test_dry_run_rm610_refused(self):
+ rc, _, err = _run_main(
+ ci_release_main,
+ ["--old", "rm610.elf", "--new", "new.elf", "--dry-run"],
+ )
+ self.assertEqual(rc, 3)
+ self.assertIn("docs/23", err)
+
+ def test_help_is_clean(self):
+ for main_fn in (ci_release_main, version_diff_main):
+ with contextlib.redirect_stdout(io.StringIO()) as buf, self.assertRaises(SystemExit) as ctx:
+ main_fn(["--help"])
+ self.assertEqual(ctx.exception.code, 0)
+ self.assertIn("usage", buf.getvalue())
+
+
+@unittest.skipUnless(qemu_available(), "qemu-system-riscv64 not installed")
+@unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)")
+class VersionDiffQemuTests(unittest.TestCase):
+ def test_self_diff_zero_inventory_and_behavior(self):
+ with tempfile.TemporaryDirectory(prefix="vdiff-self-") as tmp:
+ options = VersionDiffOptions(
+ old=DEMO_ELF,
+ new=DEMO_ELF,
+ out=tmp,
+ probe_instructions=200,
+ behavior_duration=0.2,
+ max_rows=20,
+ )
+ report = run_version_diff(options)
+ self.assertTrue(report["sections"]["image"]["same"])
+ symbols = report["sections"]["symbols"]
+ self.assertEqual(symbols["status"], "ok")
+ self.assertEqual(symbols["shift"], 0)
+ self.assertEqual(symbols["totals"]["added"], 0)
+ self.assertEqual(symbols["totals"]["removed"], 0)
+ self.assertEqual(symbols["totals"]["moved"], 0)
+ self.assertEqual(symbols["totals"]["resized_named"], 0)
+ probe = report["sections"]["probe"]
+ self.assertEqual(probe["status"], "ok")
+ self.assertFalse(probe["changed"])
+ behavior = report["sections"]["behavior"]
+ self.assertEqual(behavior["status"], "ok")
+ self.assertTrue(behavior["all_zero"], behavior["deltas"])
+ for rows in behavior["deltas"].values():
+ for row in rows:
+ self.assertEqual(row["delta"], 0)
+ self.assertTrue(os.path.exists(os.path.join(tmp, "version-diff.md")))
+ with open(os.path.join(tmp, "version-diff.json"), encoding="utf-8") as fh:
+ self.assertEqual(json.load(fh)["format"], "greeneye-version-diff/1")
+
+
+if __name__ == "__main__":
+ unittest.main()