summaryrefslogtreecommitdiffstats
path: root/debug
diff options
context:
space:
mode:
authorArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-02 08:36:35 -0400
committerArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-02 08:36:35 -0400
commit31e27d455969d33190d28917ef98f034888d217b (patch)
treec0c4dc18e78e6a1702fcda43bc2806e7cfb7c931 /debug
parentc155ee8de5b642cbd407bf2231041f17ebe43734 (diff)
downloadopen-iov-31e27d455969d33190d28917ef98f034888d217b.tar.gz
open-iov-31e27d455969d33190d28917ef98f034888d217b.zip
debug: add green-eye GSP/uproc RISC-V VM runtime analysis tooling
Adds open-iov/debug/risc-v-gsp-rm-vm: a GDB-free instrumentation stack that boots uproc/GSP firmware in qemu-system-riscv64 and provides Frida-like hooks, breakpoints, watchpoints, memory dumps, structure decoding, snapshot diffs, recorded sessions, declarative probes, a plugin API and a Ghidra symbol/hit bridge. Verified: 25 tests green (including a live QEMU smoke test); the demo uproc boots with correct hook/leave/watch/schema/snapshot/fault evidence; the optional TCG trace plugin builds and recorded 15M trace lines in 3 s; the TU104 570.144 fwimage probe reports its first exception (load access fault at firmware vaddr 0x4009014 reading 0x2000000000000A00). Documentation: lean tool docs under debug/risc-v-gsp-rm-vm/docs/00-10, a new docs/05-operations/03-risc-v-gsp-rm-vm.md debugging document, and '# TOOL IN DEVELOPMENT - NOT READY YET!' markers in docs/README.md, the onboarding tooling table and the debugging workflows doc. Vendors qemu-plugin.h from QEMU v8.2.2 (GPL-2.0-or-later), with its license header intact, so the optional TCG plugin builds without a QEMU source tree.
Diffstat (limited to 'debug')
-rw-r--r--debug/risc-v-gsp-rm-vm/.gitignore8
-rw-r--r--debug/risc-v-gsp-rm-vm/Makefile31
-rw-r--r--debug/risc-v-gsp-rm-vm/README.md77
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md98
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/01-DESIGN.md179
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/02-USAGE.md168
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md219
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md123
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md105
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md136
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/07-PORTABILITY-PLAYBOOKS.md126
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md116
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/09-GSP-FIRMWARE-NOTES.md104
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/10-RELATED-WORK.md76
-rw-r--r--debug/risc-v-gsp-rm-vm/examples/probes/demo_probes.json25
-rw-r--r--debug/risc-v-gsp-rm-vm/examples/scripts/dump_diff.py33
-rw-r--r--debug/risc-v-gsp-rm-vm/examples/scripts/trace_rpc.py29
-rw-r--r--debug/risc-v-gsp-rm-vm/firmware/uproc_demo/Makefile34
-rw-r--r--debug/risc-v-gsp-rm-vm/firmware/uproc_demo/ge_uart.h38
-rw-r--r--debug/risc-v-gsp-rm-vm/firmware/uproc_demo/gsp_ctrl.schema.json19
-rw-r--r--debug/risc-v-gsp-rm-vm/firmware/uproc_demo/linker.ld54
-rw-r--r--debug/risc-v-gsp-rm-vm/firmware/uproc_demo/main.c222
-rw-r--r--debug/risc-v-gsp-rm-vm/firmware/uproc_demo/start.S10
-rw-r--r--debug/risc-v-gsp-rm-vm/ghidra/ExportForGreenEye.java46
-rw-r--r--debug/risc-v-gsp-rm-vm/ghidra/MarkGreenEyeHits.java80
-rw-r--r--debug/risc-v-gsp-rm-vm/ghidra/README.md39
-rwxr-xr-xdebug/risc-v-gsp-rm-vm/greeneye.sh8
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/__init__.py115
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/__main__.py3
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/analyze.py293
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/cli.py688
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/elf.py322
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/errors.py30
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/ghidra.py80
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/memory.py190
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/plugins.py121
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/probes.py166
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/record.py156
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/rsp.py583
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/session.py562
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/structs.py197
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/symbols.py313
-rw-r--r--debug/risc-v-gsp-rm-vm/greeneye/target.py223
-rw-r--r--debug/risc-v-gsp-rm-vm/plugins/README.md34
-rw-r--r--debug/risc-v-gsp-rm-vm/plugins/example_plugin.py45
-rw-r--r--debug/risc-v-gsp-rm-vm/qemu/Makefile16
-rw-r--r--debug/risc-v-gsp-rm-vm/qemu/README.md28
-rw-r--r--debug/risc-v-gsp-rm-vm/qemu/greeneye_trace.c128
-rwxr-xr-xdebug/risc-v-gsp-rm-vm/qemu/launch.sh5
-rw-r--r--debug/risc-v-gsp-rm-vm/qemu/qemu-plugin.h711
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/__init__.py0
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_core.py131
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_memory_record.py101
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_probe.py65
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_rsp.py175
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_smoke_qemu.py78
56 files changed, 7762 insertions, 0 deletions
diff --git a/debug/risc-v-gsp-rm-vm/.gitignore b/debug/risc-v-gsp-rm-vm/.gitignore
new file mode 100644
index 0000000..3a97916
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/.gitignore
@@ -0,0 +1,8 @@
+# Recorded sessions and built artifacts are never tracked.
+dumps/
+firmware/uproc_demo/build/
+qemu/*.so
+qemu/*.o
+__pycache__/
+*.pyc
+.run/
diff --git a/debug/risc-v-gsp-rm-vm/Makefile b/debug/risc-v-gsp-rm-vm/Makefile
new file mode 100644
index 0000000..a5103f5
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/Makefile
@@ -0,0 +1,31 @@
+# green-eye convenience targets. The real work lives in the package.
+PYTHON ?= python3
+
+.PHONY: all demo test demo-run plugin probe-real clean info
+
+all: demo
+
+demo:
+ $(MAKE) -C firmware/uproc_demo
+
+test: demo
+ $(PYTHON) -m unittest discover -s tests -t .
+
+demo-run: demo
+ ./greeneye.sh demo --no-build
+
+info:
+ ./greeneye.sh info
+
+# Optional TCG plugin (needs the QEMU plugin headers; see qemu/README.md).
+plugin:
+ $(MAKE) -C qemu
+
+# Run the real GSP fwimage for a bounded window and report first divergence.
+probe-real:
+ ./greeneye.sh probe
+
+clean:
+ $(MAKE) -C firmware/uproc_demo clean
+ rm -rf dumps/* qemu/*.so qemu/*.o
+ find . -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null || true
diff --git a/debug/risc-v-gsp-rm-vm/README.md b/debug/risc-v-gsp-rm-vm/README.md
new file mode 100644
index 0000000..66f53fa
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/README.md
@@ -0,0 +1,77 @@
+# green-eye — GSP runtime analysis in a RISC-V QEMU VM
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!**
+> Experimental tooling for simulating and inspecting the GSP firmware's
+> runtime memory, for `gsp-rm-vfio` accuracy measurements and GSP/uproc
+> reverse engineering. It is a development-only, out-of-tree tool: never a
+> dependency of the shipped driver and never present in a guest.
+
+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.
+
+Start with [`docs/00-OVERVIEW.md`](docs/00-OVERVIEW.md).
+
+## Quick start
+
+```bash
+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 info # environment / tooling check
+```
+
+Interactively instrument a session:
+
+```bash
+./greeneye.sh attach --kernel firmware/uproc_demo/build/uproc_demo.elf
+greeneye> bp rpc_sum
+greeneye> watch gsp_ctrl+0x10 8 w
+greeneye> x 0x80000840 64
+greeneye> c 2
+```
+
+Or launch a target separately and attach to it by port:
+
+```bash
+./greeneye.sh launch --kernel <elf> # prints the gdbstub port, keeps QEMU alive
+./greeneye.sh attach --port <port> --kernel <elf>
+```
+
+Requirements: QEMU (`qemu-system-misc` on Ubuntu gives
+`qemu-system-riscv64`), Python 3.10+, clang + lld for the demo firmware.
+Everything else is Python stdlib.
+
+## Layout
+
+```
+greeneye/ Python package: RSP client, session, memory, symbols,
+ structures, recording, analysis, probes, plugins, Ghidra bridge
+firmware/uproc_demo/RV64 bare-metal demo "uproc" (mailbox RPC, state machine, trap)
+qemu/ launch helper + optional TCG plugin for full memory tracing
+ghidra/ Ghidra scripts (export symbols, annotate hit addresses)
+plugins/ example plugin + authoring guide
+examples/ Python instrumentation scripts and declarative probe specs
+tests/ unit tests + QEMU integration smoke test
+docs/ full documentation set (design, scripting, plugins, accuracy,
+ portability playbooks, roadmap, GSP firmware notes)
+dumps/ recorded sessions (gitignored)
+```
+
+## Documentation map
+
+| Doc | Content |
+|---|---|
+| [00-OVERVIEW](docs/00-OVERVIEW.md) | purpose, status, quickstart |
+| [01-DESIGN](docs/01-DESIGN.md) | architecture and data model |
+| [02-USAGE](docs/02-USAGE.md) | CLI and REPL recipes |
+| [03-SCRIPTING](docs/03-SCRIPTING.md) | Python API + probe specs |
+| [04-PLUGINS](docs/04-PLUGINS.md) | writing green-eye plugins |
+| [05-GHIDRA-BRIDGE](docs/05-GHIDRA-BRIDGE.md) | static/dynamic address join |
+| [06-ACCURACY](docs/06-ACCURACY.md) | simulation fidelity and measurement |
+| [07-PORTABILITY-PLAYBOOKS](docs/07-PORTABILITY-PLAYBOOKS.md) | new GSP/device/arch runs |
+| [08-ROADMAP](docs/08-ROADMAP.md) | MCP, custom simulator, device model |
+| [09-GSP-FIRMWARE-NOTES](docs/09-GSP-FIRMWARE-NOTES.md) | uproc/falcon/NV-RISCV facts |
+| [10-RELATED-WORK](docs/10-RELATED-WORK.md) | Frida/GDB/QEMU-plugin comparison |
diff --git a/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md b/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md
new file mode 100644
index 0000000..a9f4251
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md
@@ -0,0 +1,98 @@
+# 00 — green-eye overview
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!**
+> Experimental tooling for simulating and inspecting the GSP firmware's
+> runtime memory, for `gsp-rm-vfio` accuracy measurements and GSP/uproc
+> reverse engineering. Interfaces and file formats may change without notice.
+> It is development-only: never shipped, never a dependency of the emulation,
+> never present in a guest.
+
+## What problem it solves
+
+Open-IOV emulates the GSP interface and (in the physical backend) boots the
+real GSP firmware. Both efforts need the same missing capability: to *watch
+the firmware do things* — which handler runs, what it reads and writes, what
+it returns, how its state machine evolves — without a physical GPU, without
+WPR2/hazard exposure, and repeatably.
+
+green-eye runs uproc/GSP firmware in `qemu-system-riscv64` and gives the
+developer Frida-like instrumentation over its memory and execution:
+
+* breakpoints and enter/leave **hooks** (RV64 ABI aware),
+* **watchpoints** on firmware variables,
+* **memory** read/write/hexdump/search/scan,
+* **structure decoding** from schemas,
+* **snapshot + diff** of memory regions,
+* **sessions**: a recorded, replayable dump of runtime memory activity,
+* **post-run analysis** with symbol annotation,
+* **plugins** for new tools, harnesses and virtualization updates,
+* a **Ghidra bridge** joining static addresses to runtime hits,
+* A/B tooling for **bare-metal vs vfio-mdev** result comparison.
+
+It is deliberately not a GDB wrapper: a pure-Python RSP client makes the
+whole stack scriptable, and QEMU is just the first execution backend.
+
+## Status (v0.1, 2026-10-02)
+
+| Capability | State |
+|---|---|
+| RV64 QEMU boot + gdbstub transport (no GDB) | working, live-verified |
+| Hooks / breakpoints / watchpoints / step / continue | working, live-verified |
+| Memory tools + struct decode + snapshot diff | working, live-verified |
+| 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) |
+| 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) |
+
+## Quick start
+
+```bash
+cd open-iov/debug/risc-v-gsp-rm-vm
+make demo # build the bundled RV64 uproc demo
+./greeneye.sh demo # canned instrumentation pass, records a session
+./greeneye.sh info # environment check
+./greeneye.sh probe # run the real GSP fwimage and report first divergence
+```
+
+Interactively:
+
+```bash
+./greeneye.sh demo --no-build --duration 0 # launches; prints the gdbstub port
+./greeneye.sh attach --port <port> --kernel firmware/uproc_demo/build/uproc_demo.elf
+```
+
+## Document map
+
+| Doc | Read it for |
+|---|---|
+| [01-DESIGN](01-DESIGN.md) | how the pieces fit, transport, data model, limits |
+| [02-USAGE](02-USAGE.md) | CLI and REPL recipes |
+| [03-SCRIPTING](03-SCRIPTING.md) | writing programs against the API |
+| [04-PLUGINS](04-PLUGINS.md) | extending green-eye |
+| [05-GHIDRA-BRIDGE](05-GHIDRA-BRIDGE.md) | static/dynamic address join |
+| [06-ACCURACY](06-ACCURACY.md) | what the simulation does and does not prove |
+| [07-PORTABILITY-PLAYBOOKS](07-PORTABILITY-PLAYBOOKS.md) | new GSP / device / architecture |
+| [08-ROADMAP](08-ROADMAP.md) | MCP, custom simulator, device model |
+| [09-GSP-FIRMWARE-NOTES](09-GSP-FIRMWARE-NOTES.md) | uproc/falcon/NV-RISCV facts used here |
+| [10-RELATED-WORK](10-RELATED-WORK.md) | Frida/GDB/QEMU-plugin comparison |
+
+## Where this fits in the Open-IOV workflow
+
+green-eye is a **development and research** tool, alongside the Ghidra corpus
+and the guest diagnostics. It does not replace either:
+
+* **Ghidra** answers *what is the code*; green-eye answers *what did it do*.
+* **Guest diagnostics** (`tools/guest-diag`) answer *what did the guest
+ driver do*; green-eye answers *what did the firmware server do*.
+* **The physical backend** (PLAN B) answers *what does real silicon do*;
+ green-eye provides a safe place to repeat, mutate and single-step the same
+ firmware when the hardware path is hazardous or unavailable.
+
+The intended accuracy loop is: run the same workload against the real
+GSP-RM (hardware or mdev), record its RPC/state behaviour, then run the
+firmware in green-eye, record the same events, and compare with `compare`
+(see 06 and 07).
diff --git a/debug/risc-v-gsp-rm-vm/docs/01-DESIGN.md b/debug/risc-v-gsp-rm-vm/docs/01-DESIGN.md
new file mode 100644
index 0000000..c5de2da
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/01-DESIGN.md
@@ -0,0 +1,179 @@
+# 01 — green-eye design
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+
+## 1. Why this shape
+
+Two facts drive the design:
+
+1. **Frida does not run here.** Frida upstream ships no RISC-V target
+ (verified against the 17.20 release assets: x86/x86_64, arm/arm64, mips,
+ macOS/Windows/QNX/Prospero — no riscv64), and its agent model requires a
+ hosted process. The GSP image is bare-metal RV64IMAC with its own libos.
+ So the instrumentation layer is built from QEMU's gdbstub and QEMU TCG
+ plugins instead (see 10 for the full comparison).
+2. **The project needs scripting, not a debugger.** The recurring workflow is
+ "run firmware, capture a symbol-annotated map of behaviour, diff it against
+ another run". A pure-Python RSP client makes that a library call; GDB
+ would make it a subprocess and a parsing problem.
+
+Design goals, in order: direct time-to-value, scriptability, extensibility,
+robustness, and honest fidelity reporting.
+
+## 2. Component map
+
+```
+ +-----------------------------+
+ | qemu-system-riscv64 (virt) |
+ | -bios none -kernel <elf> |
+ | gdbstub <-- TCP |
+ +--------------+--------------+
+ | RSP
+ greeneye/rsp.py pure-Python RSP client
+ | |
+ | registers, memory, Z0-Z4, continue/step/interrupt, qRcmd, qXfer
+ v
+ greeneye/target.py QEMU process lifecycle, loader segments, UART/qemu logs
+ greeneye/elf.py ELF64/32 parse: segments, symbols, entry
+ greeneye/symbols.py symbol DB (ELF + Ghidra TSV + JSON) + address translation
+ greeneye/session.py hooks, breakpoints, watchpoints, stop dispatch, run loop
+ greeneye/memory.py read/write/hexdump/search/snapshot/diff
+ greeneye/structs.py declarative structure schemas + render
+ greeneye/probes.py declarative probe specs + conditions
+ greeneye/record.py session dir: events.jsonl + snapshots + session.json
+ greeneye/analyze.py summary/compare reports (symbol annotated)
+ greeneye/plugins.py plugin discovery + API
+ greeneye/ghidra.py Ghidra export/import bridge
+ greeneye/cli.py info/demo/run/attach/probe/record/analyze/compare/symbols
+```
+
+## 3. Transport: the RSP client
+
+`rsp.py` implements the gdbstub subset QEMU needs:
+
+* packet framing `$<data>#<checksum>`, ack/no-ack, retransmit on bad checksum;
+* `QStartNoAckMode` (QEMU acknowledges with an **empty packet**, then
+ switches once the client acks — a bring-up bug worth remembering);
+* `qSupported`, including the stub's `PacketSize` (memory payloads are chunked
+ so hex expansion never exceeds it);
+* `qXfer:features:read:target.xml`, recursively inlining `xi:include` register
+ files; RV64 register layout with a 33×8-byte fallback;
+* `g`/`p`/`P`, `m`/`M`/`X`, `Z0-Z4`, `c`/`s`, interrupt `0x03`, `qRcmd`;
+* stop-reply parsing (`T`/`S`/`W`/`X`, `watch:`/`rwatch:`/`awatch:`,
+ `thread:`).
+
+The client is deliberately blocking and simple; async/concurrency belongs in
+the plugin layer, not the transport.
+
+## 4. Session semantics (the Frida-like core)
+
+* **Breakpoint kinds**: `sw` (default; stepped over transparently) and `hw`.
+* **Hooks**: `hook(target, on_enter, on_leave)`. `on_leave` is implemented by
+ reading `ra` on entry and arming a *return breakpoint*; nested calls at the
+ same return address are queued (LIFO). Tail calls and asynchronous
+ interrupts are not tracked — documented limitation.
+* **Watchpoints**: `Z2/Z3/Z4` (write/read/access). QEMU stops *at* the
+ accessing instruction, so green-eye temporarily clears the watch, steps one
+ instruction, and re-arms it. Without this the target livelocks on the same
+ store.
+* **Stop dispatch**: every stop reads the register file, resolves the PC to a
+ symbol, records an event, invokes matching hooks/watchpoints, and resumes.
+* **Run loop**: `run(duration=…, max_stops=…)` services events until the
+ wall-clock budget, stop budget, or target exit; the deadline path interrupts
+ **once**, dispatches that stop, and returns (never interrupt twice).
+* **Faults**: `ebreak`/traps surface as plain stops; `step()` walks past them.
+ The demo uses this to demonstrate trap capture.
+
+## 5. Address spaces and the symbol layer
+
+Three address spaces must never be confused (the CUDA docs learned this the
+hard way):
+
+| Space | Meaning | Example |
+|---|---|---|
+| file vaddr | offset in the binary file (`libcuda+0x…`) | `0x353ef0` |
+| program addr | what Ghidra shows (file vaddr + image base) | `0x453ef0` (base `0x100000`) |
+| runtime addr | link address + relocation bias | `0x80000048` |
+
+`SymbolDB` stores runtime addresses. Loaders take a `bias`:
+
+```python
+db.load_elf(image, bias=relocation) # demo firmware: bias 0
+db.load_names_tsv("names.tsv", bias=0x100000) # libcuda: file vaddr -> runtime
+db.load_index_tsv("decompiled_index.tsv") # program+file addr columns
+db.load_callgraph("callgraph.tsv") # caller/callee extras
+```
+
+`resolve()` returns the containing symbol (or nearest preceding within a
+delta), and `annotate()` renders `addr name+0xoff`.
+
+## 6. Recording format (`greeneye-session/1`)
+
+A session directory is the durable artifact:
+
+```
+dumps/<stamp>-<name>/
+ session.json # format, name, created, counts, snapshot inventory, meta
+ events.jsonl # one JSON object per event, monotonic t + fields
+ snapshots/*.bin.z # zlib-compressed region captures (sha256 recorded)
+ blobs/* # plugin/probe dumps
+ summary.md # rendered analysis (analyze/render_summary)
+```
+
+Event types: `session_start`, `image_loaded`, `hook`, `watch`, `bp_hit`,
+`leave`, `watch_hit`, `stop`, `step`, `mem_read`, `mem_write`, `snapshot`,
+`snapshot_saved`, `probe_log`, `probe_dump`, `plugin*`, `note`, `exit`,
+`session_end`. The format is append-only JSON lines so partial sessions stay
+readable, and `compare` can profile two sessions symbolically.
+
+This is the "complete map and recorded dump of runtime memory activity": the
+event stream is the map, the snapshot/blob files are the dump.
+
+## 7. Structures
+
+Schemas are JSON data (`structs.schema_from_json`) with integer, float,
+`bytes`, `cstr` and `ptr` fields plus optional `enum` names and notes. The
+demo control block schema lives at
+`firmware/uproc_demo/gsp_ctrl.schema.json`. Schemas are intentionally
+declarative so they can come from a Ghidra type export or a plugin later.
+
+## 8. Execution backends
+
+* `QemuTarget` (`virt`, RAM, NS16550A UART) is the default and the only
+ supported backend today.
+* The **kernel fast path** passes `-kernel <elf>`; QEMU loads segments and
+ sets PC.
+* The **probe path** passes each PT_LOAD segment as a raw loader device and
+ green-eye sets PC through the gdbstub — this lets a firmware linked for GPU
+ physical addresses be relocated into RAM for observation.
+* A **custom simulator** can be added as a session backend later (roadmap,
+ not v0.1) because `Session` only assumes the RSP-like operations.
+
+## 9. Ghidra bridge
+
+* Out: `greeneye symbols export --names … --index … --exports … --callgraph …`
+ builds `greeneye-symbols.json` from the project's existing TSV exports.
+* In: `greeneye.ghidra.export_hits(session_dir, out_tsv)` writes
+ `addr<TAB>hits<TAB>symbol`, and `MarkGreenEyeHits.java` annotates those
+ functions in a Ghidra project (plate comments + bookmarks).
+* Mapping a hit to source: symbols carrying `bucket`/`line` extras resolve to
+ the decompiled corpus via `greeneye.ghidra.corpus_location()`.
+
+## 10. Plugins
+
+`plugins.py` discovers modules and calls `register(ge)` with a `GreenEyeAPI`
+(`session`, `memory`, `symbols`, `recorder`, `hook`, `watch`, `on_event`,
+`add_command`, `log`). Errors are recorded as `plugin_error` and never break a
+session. MCP is a planned plugin, not an implementation (08).
+
+## 11. Known limits (be honest)
+
+1. QEMU `virt` models **no TU104**: GPU MMIO, falcon windows, WPR2, PMU,
+ timers and FB DMA do not exist. The real GSP image will fault early; the
+ `probe` command reports exactly where. Not a boot environment (06).
+2. The transport is blocking; no replay of async interrupts mid-run.
+3. Return hooks miss tail calls and interrupt-driven returns.
+4. `qRcmd` monitor commands are host-tooling only and not portable.
+5. Recorded events describe *observed* stops; instructions executed between
+ stops are invisible unless the TCG plugin is enabled or a step trace is
+ taken.
diff --git a/debug/risc-v-gsp-rm-vm/docs/02-USAGE.md b/debug/risc-v-gsp-rm-vm/docs/02-USAGE.md
new file mode 100644
index 0000000..53cf19f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/02-USAGE.md
@@ -0,0 +1,168 @@
+# 02 — green-eye usage
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+
+All commands are run from `open-iov/debug/risc-v-gsp-rm-vm/`.
+
+## 0. Requirements
+
+* `qemu-system-riscv64` (Ubuntu: `apt install qemu-system-misc`);
+* Python 3.10+ (stdlib only);
+* for the demo firmware: `clang` and `lld` (`apt install lld-18` provides
+ `/usr/lib/llvm-18/bin/ld.lld`).
+
+Check everything:
+
+```bash
+./greeneye.sh info
+make test # builds the demo then runs unit + QEMU smoke tests
+```
+
+## 1. The canned demo (fastest way to see it work)
+
+```bash
+make demo
+./greeneye.sh demo
+```
+
+It boots `firmware/uproc_demo/build/uproc_demo.elf`, installs hooks on all
+`rpc_*` handlers and a watchpoint on `rpc_count`, runs the firmware self-test,
+injects a `PING` from the host, decodes and diffs the control block, then
+injects `FAULT` to capture the `ebreak` trap. A session is recorded under
+`dumps/` and a `summary.md` is written.
+
+Options: `--no-build`, `--duration SECONDS`, `--kernel PATH`, `--record PATH`.
+
+## 2. Interactive REPL
+
+```bash
+./greeneye.sh attach --kernel firmware/uproc_demo/build/uproc_demo.elf
+```
+
+`attach` launches the target itself when `--port` is omitted. To share a
+target between terminals, launch it separately and attach by port:
+
+```bash
+./greeneye.sh launch --kernel <elf> # prints: gdbstub: 127.0.0.1:<port>
+./greeneye.sh attach --port <port> --kernel <elf>
+```
+
+REPL commands:
+
+| Command | Meaning |
+|---|---|
+| `regs` / `reg <name> [value]` | read/write registers |
+| `x <addr> [len]` | annotated hexdump |
+| `mem <addr> <len>` | raw bytes (hex) |
+| `w <addr> <hex>` / `wstr <addr> <text>` | write memory |
+| `bp <addr\|symbol> [sw\|hw]` | breakpoint that prints on hit |
+| `hook <addr\|symbol>` | enter/leave hook with printing |
+| `watch <addr\|symbol> [size] [w\|r\|a]` | watchpoint |
+| `s [n]` / `c [seconds]` | step n; continue for N seconds |
+| `snap <label> <addr> <len>` | snapshot a region into the session |
+| `sym <addr\|name>` | symbol resolution |
+| `events [n]` | tail the recorded event stream |
+| `note <text>` | annotate the session |
+| `quit` | close and exit |
+
+Plugin-provided commands appear automatically (e.g. `gehits` from the example
+plugin when loaded with `--plugin plugins/example_plugin.py`).
+
+## 3. Scripted sessions
+
+```bash
+./greeneye.sh run examples/scripts/trace_rpc.py
+./greeneye.sh run examples/scripts/dump_diff.py
+./greeneye.sh run my_script.py --kernel <elf> --plugin plugins/example_plugin.py
+```
+
+The script runs with `ge`, `s`/`session`, `memory`, `symbols`, `recorder` in
+scope and is recorded by default. See [03-SCRIPTING](03-SCRIPTING.md).
+
+## 4. Recording, analysis, comparison
+
+```bash
+./greeneye.sh record --duration 2.0 --watch gsp_ctrl+0x10:8 --snapshot gsp_ctrl:392
+./greeneye.sh analyze dumps/20261002-082721-demo
+./greeneye.sh analyze dumps/<a> --symbols symbols.json > a.md
+./greeneye.sh compare dumps/<run-a> dumps/<run-b> --symbols
+```
+
+`analyze` prints a markdown report: event counts, breakpoint hits by symbol,
+watchpoint hits, written pages, stepped coverage, snapshot inventory and a
+timeline. `compare` diffs two sessions symbolically (hit-count deltas, watch
+deltas, written-page deltas) — the A/B primitive for bare-metal vs
+vfio-mdev and old-firmware vs new-firmware runs.
+
+## 5. Probing an arbitrary firmware image
+
+```bash
+./greeneye.sh probe # the project's TU104 570.144 fwimage
+./greeneye.sh probe path/to/fw.elf --duration 0.5
+./greeneye.sh probe --instructions 2000 --no-relocate
+```
+
+Segments are flattened into a raw kernel loaded at `0x80000000`; because QEMU
+starts raw kernels at the load base, a tiny PC-relative trampoline at offset 0
+jumps to the image's real (relocated) entry. The run is bounded and the report
+shows the **first exception** (faulting instruction, access address/size,
+cause) plus the QEMU log tail. For the real GSP image expect an early
+load-access fault on an unmodelled GPU register — that is the honest result
+(see [06-ACCURACY](06-ACCURACY.md)).
+
+## 6. Symbol databases
+
+```bash
+./greeneye.sh symbols export \
+ --names ../../userspace/cuda/570.144/annotations/names.tsv \
+ --index <run>/analysis/decompiled/decompiled_index.tsv \
+ --callgraph <run>/analysis/elf-index/callgraph.tsv \
+ --bias 0x100000 -o symbols-570.json
+./greeneye.sh symbols resolve --db symbols-570.json 0x453ef0
+```
+
+`--bias` normalises file vaddrs to runtime addresses (`0x100000` is the
+libcuda Ghidra image base; use 0 for firmware linked at its load address).
+
+## 7. Plugins
+
+```bash
+./greeneye.sh plugins
+./greeneye.sh run examples/scripts/trace_rpc.py --plugin plugins/example_plugin.py
+GREENEYE_PLUGINS=/path/to/plugins ./greeneye.sh demo
+```
+
+See [04-PLUGINS](04-PLUGINS.md).
+
+## 8. Make targets
+
+| Target | Action |
+|---|---|
+| `make demo` | build the demo firmware |
+| `make test` | build + unit tests + live QEMU smoke |
+| `make demo-run` | `greeneye demo --no-build` |
+| `make probe-real` | run the TU104 fwimage probe |
+| `make plugin` | build the optional TCG plugin |
+| `make clean` | remove builds, dumps and `__pycache__` |
+
+## 9. Troubleshooting
+
+* **`qemu-system-riscv64 not found`** — install `qemu-system-misc` or set
+ `GREENEYE_QEMU`.
+* **`gdbstub port not ready`** — QEMU exited early; the error includes its
+ stderr. Check the ELF actually loads (`./greeneye.sh info`).
+* **A run seems stuck** — sessions never `pkill -f greeneye` (it matches the
+ invoking shell). Use `pkill -9 -f '[q]emu-system-riscv64'` to clean up
+ leftover emulators.
+* **Target stops immediately at `0x1000`** — the firmware was not passed to
+ QEMU; check the `-kernel` path (known historical bug, fixed).
+* **`Permission denied` on `/dev/kvm`** — not needed here; QEMU `virt` may
+ fall back to TCG. For speed, ensure the user can use KVM.
+
+## 10. Safety
+
+green-eye touches no GPU, no mdev, no kernel module and no host driver. It
+runs unprivileged QEMU instances on loopback gdbstub ports. The only host
+side effects are temporary directories under `/tmp` and `dumps/` under the
+tool tree. It is safe to run alongside the CUDA/PLAN-B work, but keep an eye
+on RAM/CPU if large snapshots are recorded.
diff --git a/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md b/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md
new file mode 100644
index 0000000..c677c8d
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md
@@ -0,0 +1,219 @@
+# 03 — green-eye scripting
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+
+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.
+
+## 1. Attach or launch
+
+```python
+import greeneye as ge
+
+# Launch the bundled demo (QEMU -kernel fast path), recording by default.
+s = ge.launch(kernel="firmware/uproc_demo/build/uproc_demo.elf", record=True, name="my-run")
+
+# Or attach to an already-running gdbstub (e.g. started by `greeneye launch`).
+# s = ge.attach(port=4444, record="dumps/attach-run")
+
+try:
+ ...
+finally:
+ s.close() # clears breakpoints/watchpoints, kills owned QEMU
+```
+
+`launch(..., keep_alive=True)` leaves QEMU running after `close()`.
+
+## 2. Symbols
+
+```python
+sym = s.symbols.get("rpc_sum") # Symbol | None
+addr = s.symbols.lookup("rpc_sum") # int, raises if missing
+s.symbols.annotate(0x80000048) # "0x80000048 rpc_ping"
+s.symbols.resolve(0x80000050) # Symbol (containing or nearest)
+s.resolve("rpc_sum") # int | int passthrough | "0x..." prefix
+```
+
+Symbols come from ELF (demo), Ghidra `names.tsv`/`decompiled_index.tsv`
+(`symbols export`), or native JSON.
+
+## 3. Hooks and breakpoints
+
+```python
+events = []
+
+def on_sum_enter(hit):
+ events.append(("enter", hit.arg(0), hit.arg(1))) # a0, a1
+
+def on_sum_leave(hit):
+ events.append(("leave", hit.retval)) # a0 at return
+
+handle = s.hook("rpc_sum", on_enter=on_sum_enter, on_leave=on_sum_leave)
+handle.hits, handle.label, handle.addr
+handle.detach()
+
+bp = s.breakpoint(0x800000fe, callback=lambda hit: print(hit), temporary=True, kind="sw")
+s.remove_breakpoint(bp)
+```
+
+`Hit` carries `kind`, `addr`, `regs` (full register dict), `symbol`,
+`retval` (leave hits), `watch`, `stop`, `session`, plus helpers
+`hit.arg(n)` and `hit.label()`.
+
+A hook's `on_leave` is realised as a return breakpoint; the callback's
+`hit.addr`/`hit.symbol` are the hooked function (not the return site), and
+`hit.retval` is `a0`. Tail calls and interrupt returns are not tracked.
+
+## 4. Watchpoints
+
+```python
+def on_write(hit):
+ print(f"{hit.label()} written; pc={hit.pc:#x}")
+
+wp = s.watch("gsp_ctrl+0x10", size=8, kind="w", callback=on_write) # w|r|a
+wp.hits
+s.unwatch(wp)
+```
+
+The session transparently steps over the triggering store (required with
+QEMU; see 01 §4).
+
+## 5. Memory
+
+```python
+s.memory.read(0x80000840, 64)
+s.memory.write_u64(0x80000860, 1) # mailbox = PING
+s.memory.hexdump(0x80000840, 128) # annotated
+s.memory.search_string(0x80000000, 0x10000, "GREEN")
+s.memory.search_u32(0x80000000, 0x10000, 0x47535031)
+s.memory.read_cstring(0x800007d0)
+s.memory.snapshot(0x80000840, 392, name="ctrl")
+```
+
+`Memory.read`/`write` record `mem_read`/`mem_write` events; snapshots are
+saved and hashed by the recorder.
+
+## 6. Structures
+
+```python
+import json
+from greeneye.structs import schema_from_json, render, decode
+
+schema = schema_from_json(json.load(open("firmware/uproc_demo/gsp_ctrl.schema.json")))
+values = decode(schema, s.memory.read(ctrl, schema.size), base=ctrl)
+print(render(schema, values))
+```
+
+A schema is JSON: `{"name":…, "fields":[{"name","offset","fmt","size?","enum?","note?"}]}`
+with `fmt` in `u8..u64`, `s8..s64`, `f32/f64`, `bool`, `bytes`, `cstr`,
+`ptr`.
+
+## 7. Snapshots and diffs
+
+```python
+from greeneye import Memory
+
+before = s.snapshot(ctrl, schema.size, "before")
+s.memory.write_u64(ctrl + 32, 1)
+s.run(duration=0.4)
+after = s.snapshot(ctrl, schema.size, "after")
+print(Memory.render_diff(Memory.diff(before, after, base=ctrl), s.symbols))
+```
+
+## 8. Running
+
+```python
+s.run(duration=1.0) # service events for one second
+s.run(max_stops=20) # or until 20 stops
+hits = s.step(10) # single-step and dispatch hooks
+s.stop() # interrupt and dispatch
+```
+
+`run` returns the number of stops serviced. Every stop is recorded.
+
+## 9. Declarative probes
+
+For simple instrumentation, ship a JSON spec instead of Python:
+
+```json
+{
+ "probes": [
+ {"name": "sum", "symbol": "rpc_sum", "when": "a1 > 0",
+ "on_enter": {"log": ["a0", "a1"],
+ "dump": [{"reg": "a0", "len": 32, "label": "sum-input"}],
+ "note": "sum called"},
+ "on_leave": {"log": ["retval"]}}
+ ]
+}
+```
+
+```python
+from greeneye.probes import load_spec, install
+install(s, load_spec("examples/probes/demo_probes.json"))
+s.run(duration=1.0)
+```
+
+Conditions accept register names (`a0..a7`, `pc`, `ra`, `sp`, `x0..x31`,
+`retval`), integer/hex literals and `and/or/not` with comparisons; arbitrary
+code is rejected. Actions: `log`, `dump` (save a blob), `snapshot`, `note`.
+
+## 10. Event stream and plugins
+
+```python
+s.on_event("watch_hit", lambda hit: print("wrote", hit.label()))
+s.note("starting phase 2")
+
+# In a plugin module:
+def register(ge):
+ ge.add_command("mycmd", lambda session, args: 0)
+```
+
+See [04-PLUGINS](04-PLUGINS.md).
+
+## 11. Recording and post-analysis from Python
+
+```python
+s.recorder.dir # session directory (or None)
+s.recorder.event("phase", name="p2")
+s.recorder.close() # writes session.json + summary.md
+
+from greeneye.analyze import render_summary, compare_sessions
+print(render_summary("dumps/run-a", symbols=s.symbols))
+print(compare_sessions("dumps/run-a", "dumps/run-b", symbols=s.symbols))
+```
+
+## 12. A complete worked script
+
+```python
+"""Drive the demo, capture a PING and a FAULT, and summarise."""
+import greeneye as ge
+
+s = ge.launch(kernel="firmware/uproc_demo/build/uproc_demo.elf", record=True, name="worked")
+ctrl = s.symbols.lookup("gsp_ctrl")
+hits = []
+try:
+ for name in ("rpc_ping", "rpc_sum", "rpc_fault"):
+ if s.symbols.get(name):
+ s.hook(name, on_enter=lambda h: hits.append(h.label()))
+ s.watch(ctrl + 0x10, size=8, kind="w")
+ s.run(duration=0.5) # self-test
+ s.memory.write_u64(ctrl + 0x20, 1) # PING
+ s.memory.write_u64(ctrl + 0x28, 0x1234)
+ s.run(duration=0.5)
+ s.memory.write_u64(ctrl + 0x20, 6) # FAULT -> ebreak
+ s.run(duration=1.0, max_stops=1)
+ print("pc:", hex(s.pc), s.symbols.annotate(s.pc))
+ s.step() # past the trap
+ print("hits:", hits)
+finally:
+ print("recording:", getattr(s.recorder, "dir", None))
+ s.close()
+```
+
+## 13. Errors and timeouts
+
+All green-eye errors derive from `greeneye.GreenEyeError`:
+`RspTimeout` (transport), `RspProtocolError`, `TargetError`,
+`SymbolNotFound`, `StructError`. `RspClient.timeout` (default 10 s) bounds a
+single packet read; `run`'s budget bounds the whole pass.
diff --git a/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md b/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md
new file mode 100644
index 0000000..fa0fe0c
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md
@@ -0,0 +1,123 @@
+# 04 — writing green-eye plugins
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+
+A plugin is a Python module exposing `register(ge)`. It is loaded before the
+session runs (CLI `--plugin PATH`, `GREENEYE_PLUGINS`, or the bundled
+`plugins/` directory) and can add instrumentation, CLI commands and harness
+logic without touching green-eye itself.
+
+## 1. The API object
+
+```python
+def register(ge):
+ # live objects
+ ge.session # Session
+ ge.memory # Memory
+ ge.symbols # SymbolDB
+ ge.recorder # Recorder | NullRecorder
+
+ # instrumentation
+ ge.hook(target, on_enter=None, on_leave=None, kind="sw")
+ ge.watch(target, size=8, kind="w", callback=None)
+ ge.on_event(kind, callback) # kind in {"*", "bp_hit", "watch_hit", ...}
+ ge.resolve(target) # symbol/addr -> runtime address
+
+ # CLI + logging
+ ge.add_command("name", fn, help="…") # fn(session, args: list[str]) -> int
+ ge.log("message", any="field") # recorded as a "plugin" event
+ ge.commands # dict of registered commands
+```
+
+Everything a plugin records lands in the session's `events.jsonl`, so plugin
+findings are replayable and comparable like any other event.
+
+## 2. Lifecycle and safety
+
+* `register()` runs once, immediately after the session is attached and
+ symbols are loaded; the target is still stopped, so hooks are in place
+ before the first instruction.
+* A plugin that raises is recorded as `plugin_error` and skipped; it never
+ aborts the session or the other plugins.
+* Session close detaches all hooks/watchpoints, including plugin-installed
+ ones.
+* Plugins must be stdlib-only unless the dependency is documented; they are
+ development tooling and never shipped.
+
+## 3. The four plugin classes
+
+**(a) Runtime RE tools** — add analysis that green-eye does not have:
+
+```python
+def register(ge):
+ def on_state(hit):
+ ge.recorder.event("state_machine", result=hit.retval, pc=hit.pc)
+ ge.hook("rpc_state", on_leave=on_state)
+```
+
+Useful additions: allocation graphs, ring-buffer watermarks, page-table walks,
+coverage collectors built from `on_event("stop")` plus the TCG trace, entropy
+or checksum probes over structures.
+
+**(b) Harnesses** — drive the firmware and assert:
+
+```python
+def register(ge):
+ def check(session, args):
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ before = session.memory.read_u64(ctrl + 0x10)
+ session.memory.write_u64(ctrl + 0x20, 1) # PING
+ session.run(duration=0.3)
+ after = session.memory.read_u64(ctrl + 0x10)
+ assert after == before + 1, "PING did not dispatch"
+ print("PING ok")
+ return 0
+ ge.add_command("check-ping", check)
+```
+
+**(c) Virtualization-environment updates** — alter how the target is built or
+driven: extra QEMU args, a rehosting shim for uproc sources, a device-model
+stub, a replay layer that feeds recorded events back into a run. Keep such
+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.
+
+## 4. Example plugin
+
+`plugins/example_plugin.py` demonstrates (a), (b) and logging:
+
+```python
+def register(ge):
+ ge.log("example plugin loaded")
+ def on_state(hit):
+ ge.recorder.event("plugin_state_transition", result=hit.retval)
+ if ge.symbols.get("rpc_state"):
+ ge.hook("rpc_state", on_leave=on_state)
+ ge.add_command("gehits", lambda session, args: print("…and 0"), help="…")
+```
+
+Run it with:
+
+```bash
+./greeneye.sh run examples/scripts/trace_rpc.py --plugin plugins/example_plugin.py
+```
+
+## 5. Testing a plugin
+
+1. Load it against the demo firmware (`./greeneye.sh demo --plugin …`) — the
+ session must remain green and the plugin's events must appear in
+ `events.jsonl`.
+2. Unit-test pure logic outside green-eye; keep hooks small.
+3. Never let a plugin assume a particular firmware: gate on
+ `ge.symbols.get(name)` and log a skip instead of failing.
+
+## 6. Distribution
+
+Plugins can live anywhere. For in-tree plugins that document the workflow,
+put them under `plugins/` in this tool directory; for local or
+target-specific tooling, use `GREENEYE_PLUGINS` and keep paths out of tracked
+docs (project rule).
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
new file mode 100644
index 0000000..857102e
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md
@@ -0,0 +1,105 @@
+# 05 — Ghidra bridge
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+
+Ghidra answers *what is the code*; green-eye answers *what did it do*. The
+bridge joins them in both directions: Ghidra symbols into the runtime session,
+runtime hit addresses back into the Ghidra project.
+
+## 1. Address conventions (read this first)
+
+| Space | Meaning | Transformation |
+|---|---|---|
+| file vaddr | offset in the binary (`libcuda+0x…`) | base |
+| Ghidra program addr | what the decompiler shows | file vaddr + image base (`0x100000` for libcuda) |
+| runtime addr | live process / emulator address | file vaddr + load bias |
+
+* Firmware linked at its load address: bias 0 (the demo, and a
+ relocated-to-RAM GSP image when using the probe bias).
+* CUDA `libcuda` in the host oracle: runtime base from `/proc/<pid>/maps`;
+ `--bias` normalises it if you want runtime addresses in the DB.
+* The project's `decompiled_index.tsv`/bucket TSVs carry **both** program and
+ file addresses; green-eye parses both and stores the program address in
+ `symbol.extras["program_addr"]`.
+
+## 2. Build a symbol DB from Ghidra exports
+
+```bash
+./greeneye.sh symbols export \
+ --names ../../userspace/cuda/570.144/annotations/names.tsv \
+ --index ../../userspace/cuda/570.144/run/analysis/decompiled/decompiled_index.tsv \
+ --exports ../../userspace/cuda/570.144/run/analysis/elf-index/exports.tsv \
+ --callgraph ../../userspace/cuda/570.144/run/analysis/elf-index/callgraph.tsv \
+ --bias 0 -o symbols-cuda.json
+```
+
+For the GSP firmware project (`tools/ghidra/gsp-fwimage-570.144/`), first run
+the existing export scripts (`ExportFunctionIndex.java`,
+`DecompileFunctions.java`, …) to produce TSVs, then:
+
+```bash
+./greeneye.sh symbols export --index <export.tsv> --bias 0 -o symbols-gsp.json
+```
+
+`load_index_tsv` is column-aware: it accepts
+`<program>\t<file>\t<size>\t<name>\t<bucket>\t<line>\t<status>`, the 4-column
+exports form, or a plain `<addr>\t<name>` two-column form.
+
+## 3. Runtime addresses in the session
+
+```python
+db = SymbolDB()
+db.load_json("symbols-cuda.json")
+db.load_names_tsv("names.tsv", bias=runtime_base - file_base)
+s.symbols = db # or pass symbols= to attach()/launch()
+```
+
+`resolve()` then annotates every hit with the Ghidra name, and
+`greeneye.ghidra.corpus_location(symbol, corpus_root)` returns
+`<bucket>:<line>` when the index carried that information — the pointer from a
+live hit to the decompiled body.
+
+## 4. Annotate hits back into Ghidra
+
+```bash
+./greeneye.sh analyze dumps/<run> --symbols symbols-gsp.json --out run.md
+python3 - <<'PY'
+from greeneye.ghidra import export_hits
+from greeneye.symbols import SymbolDB
+db = SymbolDB(); db.load_json("symbols-gsp.json")
+print(export_hits("dumps/<run>", "greeneye_hits.tsv", symbols=db))
+PY
+```
+
+`greeneye_hits.tsv` is `addr<TAB>hits<TAB>symbol<TAB>annotation`. Then run the
+bundled Java script in Ghidra headless:
+
+```bash
+$GHIDRA/support/analyzeHeadless <proj_dir> <project> -process <program> -noanalysis \
+ -scriptPath debug/risc-v-gsp-rm-vm/ghidra \
+ -postScript MarkGreenEyeHits.java /abs/path/greeneye_hits.tsv
+```
+
+`ExportForGreenEye.java` is the reverse helper: it walks the current program
+and writes a green-eye-compatible `addr<TAB>name<TAB>size` TSV.
+
+## 5. Breakpoints from Ghidra
+
+Two practical routes today:
+
+1. `greeneye decompose/decompile` → note the file vaddr → script
+ `s.hook("0x…")` (green-eye translates to runtime via the bias you loaded).
+2. Use `symbols export` from the Ghidra index, then `s.hook("<name>")` by
+ name — the most robust route across recompilations.
+
+A future Ghidra-side green-eye console (roadmap item) can call
+`greeneye` through its CLI to set breakpoints directly from the tool.
+
+## 6. Limits
+
+* Wrapper-generated functions are re-laid-out between driver patch versions;
+ transfer names, never raw wrapper addresses (the CUDA playbook's lesson).
+* The decompiler does not recover struct types; structure schemas for
+ green-eye decoding must be written or exported separately.
+* `decompiled_index.tsv` buckets are large and gitignored; keep symbol JSONs
+ small (name/size/subsystem only) if they must be tracked.
diff --git a/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md b/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md
new file mode 100644
index 0000000..1a1ae82
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md
@@ -0,0 +1,136 @@
+# 06 — Accuracy: what the green-eye test bed does and does not prove
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+> This document is deliberately conservative. Treat every claim without a
+> recorded A/B as unverified.
+
+## 1. Purpose
+
+green-eye exists to measure how accurately Open-IOV's GSP emulation
+(`gsp-rm-vfio` and the in-kernel emulation) reproduces the behaviour of the
+real GSP firmware, and to make the firmware itself analysable. That makes
+accuracy a first-class deliverable, not an assumption. This document defines
+the fidelity levels, the measurement contract, and the rules for honest
+conclusions.
+
+## 2. Fidelity levels
+
+| Level | Property | green-eye today |
+|---|---|---|
+| **L0 — instruction semantics** | RV64IMAC executes as on the device core | high (QEMU TCG, same ISA family) |
+| **L1 — memory behaviour** | reads/writes/ordering as firmware expects | high for RAM-resident code, *conditional* on mapping the same physical layout |
+| **L2 — time/concurrency** | cycle counts, DMA latency, interrupt timing | **not modelled** (do not claim) |
+| **L3 — device environment** | falcon windows, WPR2, CSB/TRANSCOM, PMU, timers, FB DMA, IRQ routing | **absent** (roadmap) |
+
+The practical reading: green-eye is an **execution and memory** microscope,
+not a timing or silicon model. Conclusions of the form "the firmware writes X
+before Y" are in scope; "the firmware takes N µs" is not.
+
+## 3. The real boot chain vs a bare QEMU VM
+
+The physical path the project already proved is:
+
+```
+FWSEC (falcon) -> WPR2 carve -> Booter Load (SEC2) -> RISC-V start
+ -> bootloader/appMain -> GSP-RM init -> RPC server
+```
+
+The GSP application's own entry supports this model: `_start` receives
+bootargs from the bootloader in `a0`–`a5` (bootargs PA/VA, ELF PA/size, load
+base PA, write-protected-region id), sets `stvec`, sets its stack, and calls
+`appMain` (see `uproc/gsp/src/kernel/start.S`). It expects falcon/CSB register
+windows, a TCM/MPU layout and a PMU-provided environment that `qemu virt`
+does not have.
+
+**Therefore:** loading the raw `.fwimage` into `qemu virt` cannot boot it.
+The `greeneye probe` command is designed for exactly this: it loads the
+segments, runs bounded, and reports the first divergence
+(`guest_errors,unimp,int` log + stopped PC + symbol). That report is a *memory
+and control-flow map* of what the firmware tries to touch first — useful data,
+but not a boot.
+
+**Measured first-touch (2026-10-02, TU104 570.144 image).** The firmware
+enters at relocated `0x84009000`, executes six instructions, and faults at
+firmware vaddr `0x4009014` on a 4-byte read of `0x2000000000000A00`
+(load access fault), after which it falls into an `mtvec=0` fetch loop. That
+address is the first entry for the R1 device model's register map.
+
+## 4. Re-hosting the uproc components (the higher-fidelity path)
+
+The GSP firmware contains the uproc components (`gsp`, `os/libos`,
+`nvriscv`, `sec2*`, `soe*`, `pmu_sw`, `conf_compute`, …). Re-hosting them
+means compiling a *subset* against a QEMU-virt platform shim:
+
+| Area | Can be faithful | Cannot be faithful |
+|---|---|---|
+| task/RPC logic, parsers, state machines | yes — this is ordinary code | — |
+| memory layout of app-owned structures | yes, if schemas/addresses are pinned | TCM/MPU protections |
+| libos scheduler semantics | approximated (round-robin, timers) | real interrupt latencies, partition switching |
+| register/CSB access, falcon windows | stubbed with recorded behaviour | true device semantics |
+| PMU/SEC2/ACR/Booter interactions | modelled as messages | boot ROM, secure state machines |
+
+Re-host fidelity must be stated per module in the session metadata, and every
+"this is what the firmware does" claim should be cross-checked against the
+physical RPC traces (PLAN B) before it is used to change the emulation.
+
+## 5. Measurement contract for `gsp-rm-vfio` accuracy
+
+Define these metrics once and use them everywhere:
+
+| # | Metric | How it is measured |
+|---|---|---|
+| M1 | **Inventory coverage** | set of controls/RPCs observed in a real run vs the emulation's handled set (and green-eye's observed set) |
+| M2 | **Reply agreement** | field-by-field diff of replies (real vs emulated); canonical-size and consumed-field checks included |
+| M3 | **Ordering agreement** | sequence alignment of the control/event stream (edit distance / LCS) |
+| M4 | **State agreement** | state-machine transitions observed in the firmware vs the emulation's internal state model |
+| M5 | **Error-path agreement** | injected faults produce the same status/notifier path |
+| M6 | **Memory side-effects** | read/write map agreement against the emulation's shadow state; snapshot hashes for fixed points |
+| M7 | **Timing** | *not claimed*; only order-of-events budgets (e.g. "reply before the 4 s poll") |
+
+Reference capture:
+* real hardware/backend: the project's existing RPC logs (module trace,
+ `gsp-phys-log-dump`, status-queue drains) and the static-config oracle;
+* green-eye: `events.jsonl` + snapshots, symbol-annotated.
+
+Comparison: `greeneye compare dumps/real-<run> dumps/greeneye-<run>
+--symbols`, then a short table of M1–M6 with counts. Never publish a single
+"accuracy %" number without the metric breakdown.
+
+## 6. Adversarial interoperability use
+
+green-eye is also a safe place to be adversarial: corrupt a reply, flip a
+capability bit, truncate a control payload, or inject a fault at a chosen
+firmware PC, then observe whether the emulation and the firmware agree on the
+failure path. The rules:
+
+1. The simulator's reaction is *evidence about the firmware*, not about
+ silicon. Confirm any conclusion against a physical cycle before it changes
+ the product.
+2. Keep the injected fault and the observed path in the session recording.
+3. Do not use simulator timing as a security proof (side-channel claims need
+ physical measurement).
+
+## 7. Validating the tool itself
+
+Before trusting a green-eye session:
+
+* the demo firmware self-test must pass (`greeneye demo`, `make test`);
+* the session must contain the expected `hook`/`watch` registrations and a
+ coherent `bp_hit` sequence (the analyzer highlights empty sessions);
+* the UART banner and control-block magic are cheap liveness checks;
+* golden sessions (checked-in expectations) for the demo detect regressions
+ in the toolchain itself.
+
+## 8. Known accuracy gaps (living list)
+
+1. No TU104 device model: no boot beyond the first unmapped access.
+2. No time/concurrency model; multi-core and partition-switch behaviour is
+ approximated.
+3. PMU/SEC2/ACR/Booter behaviour is message-level only.
+4. Re-hosted uproc code is compiled for `qemu virt`, so compiler/layout
+ differences with the shipped image must be accounted for when comparing
+ control flow.
+5. Interrupt delivery and MSI semantics differ; error-path M5 is the weakest
+ metric until the device model exists (08).
+
+Each gap maps to a roadmap item; do not silently ignore them in reports.
diff --git a/debug/risc-v-gsp-rm-vm/docs/07-PORTABILITY-PLAYBOOKS.md b/debug/risc-v-gsp-rm-vm/docs/07-PORTABILITY-PLAYBOOKS.md
new file mode 100644
index 0000000..5426bf1
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/07-PORTABILITY-PLAYBOOKS.md
@@ -0,0 +1,126 @@
+# 07 — Portability playbooks: new GSP, new device, new architecture
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+
+These playbooks cover the three churn axes the project expects: a new GSP
+firmware version, the same firmware on a new device, and a new GPU
+architecture (e.g. GB10 / DGX Spark / Surface-class). They reuse the
+project's existing version discipline and add green-eye's runtime layer.
+
+## 0. Common setup
+
+1. Acquire the firmware for the target (driver runfile or `linux-firmware`):
+ extract the GSP boot image (`.fwimage`) and record its hash. For TU10x,
+ `firmware/57X-GSP/<ver>/gsp-<ver>.fwimage.bin` is the extracted form.
+2. Lay down a versioned directory; never overwrite a working version.
+3. Build a symbol DB for the new image:
+ * import the fwimage into the existing Ghidra project tooling
+ (`tools/ghidra/analyze_fwimage.py`, `DecompileFunctions.java`),
+ * `./greeneye.sh symbols export --index <export.tsv> -o symbols-<ver>.json`.
+4. Record a baseline session of the previous version (see P1.4) so the
+ comparison has an anchor.
+
+## P1. New GSP firmware version (same family)
+
+**Goal:** know what changed in the firmware's runtime behaviour before the
+emulation is adjusted.
+
+1. **Static triage** — the project's existing flow: decompile the new
+ fwimage, diff the RPC dispatcher, subcommands, message layouts and version
+ strings against the previous version (`cudatool.py delta`-style discipline
+ for firmware; `diff_corpus.py` for the corpus).
+2. **Probe the new image** — `./greeneye.sh probe <new.fwimage>`: record the
+ first divergence and the first MMIO/read/write addresses. Compare with the
+ previous version's probe; changed first-touch addresses are early
+ structure-change signals.
+3. **Re-host / demo run** — if uproc sources for the family are available,
+ build the rehosted subset against the QEMU-virt shim and run the same
+ scripted stimulus used for the previous version.
+4. **Baseline + candidate sessions** — for each version capture:
+ * `greeneye record --kernel <rehosted elf> --duration N --snapshot <regions>`
+ or the scripted harness (`run`),
+ * the real-backend reference traces for the same workload.
+5. **Compare** — `./greeneye.sh compare dumps/<old> dumps/<new> --symbols`:
+ hit-count deltas per function, watchpoint deltas, written-page deltas.
+ Investigate every function whose call-count changed before touching the
+ emulation.
+6. **Metrics** — fill in M1–M6 from `06-ACCURACY.md` for the new version
+ against the real backend. Attach the table to the version-bump PR.
+7. **Acceptance** — no rung regression in the existing guest ladder, the
+ control inventory diff is explained, and every new/changed control has a
+ version-keyed handler or an explicit "not used" note.
+
+## P2. Same GSP version, new device
+
+**Goal:** prove device-profile portability (the project direction is
+"configuration + verification, not new code").
+
+1. Collect the device profile: PCI IDs, BAR layout/sizes, FB geometry,
+ engine inventory, interrupt topology, VBIOS/ROM specifics.
+2. Verify the firmware pairing: the image family (TU10x/GH100/LS10 …), FMC
+ partition manifests, and the version string; a mismatched pairing presents
+ as an emulation bug (the project learned this with 570.144 vs 570.211).
+3. `./greeneye.sh probe <device fwimage>` on the new device image; compare
+ first-divergence maps with the reference device. Differences here usually
+ point at profile data, not logic.
+4. Re-run the P1.4/1.5 loop with the device profile applied; record probe +
+ compare evidence in the portability matrix.
+5. Validate on the physical device with the supervised harness before
+ claiming support (`docs/06-portability/hardware-software-matrix.md`
+ discipline: tested and documented, not "should work").
+
+## P3. New architecture (GB10 / DGX Spark / Surface-class)
+
+**Goal:** survive a new silicon generation (likely a newer NV-RISCV core,
+newer PMU/SEC2 images and possibly RV64GC rather than RV64IMAC).
+
+1. **Confirm the ISA and image shape** — `file`/`readelf` the new fwimage:
+ ISA string, segments, entry, compressed/float extensions. Update the
+ rehost build flags (`-march=…`) accordingly; note that QEMU supports
+ RV64GC even if green-eye's demo uses IMAC.
+2. **Check the platform's boot variant** — newer platforms use FMC/manifest
+ partition models (`uproc/*_riscv/fmc`, `safety/PMU-*`, `conf_compute`
+ Init→ACR→RM flow). Record which partition model the image uses; the
+ rehost shim needs a matching entry/return convention.
+3. **PMU/SOE deltas** — diff the PMU/SOE headers and firmware
+ (`pmu_sw`, `uproc/soe*`) because the GSP environment provisions identity
+ and clock state through them. Any change here invalidates prior
+ environment assumptions.
+4. **Run the P1 loop** with the new architecture profile; expect the probe to
+ show *different* first-touch addresses (new register windows). Update the
+ device-model scope in `08-ROADMAP.md` with what the probe revealed.
+5. **Host portability** — green-eye itself is Python and QEMU; on aarch64
+ hosts install `qemu-system-misc` there and the same commands run. Keep
+ host-specific paths out of session metadata.
+
+## P4. A/B: bare metal vs vfio-mdev (the core accuracy ritual)
+
+**Goal:** quantify how closely the virtual function's emulation tracks the
+real firmware for a chosen workload.
+
+1. **Reference capture (real):**
+ * hardware path: the supervised physical backend cycle, capturing RPC
+ records/log dumps and the static-config oracle;
+ * or the vfio-mdev path with the same workload.
+ Save the reference event stream in a session-compatible form (or the
+ vendor logs plus a conversion note).
+2. **green-eye capture:** run the same workload's firmware-side stimulus in
+ the rehosted simulator; record hooks, watchpoints, snapshots.
+3. **Compare:** `greeneye compare` for hit/watch/write profiles; for RPC
+ payloads use M2 field agreement and M3 ordering (a small script or a
+ plugin can emit per-control events).
+4. **Report:** metric table (M1–M6), changed-function list, and an explicit
+ statement of L2/L3 limits from `06-ACCURACY.md`.
+5. **Escalation:** a divergence that changes emulation behaviour must be
+ reproduced on the physical backend before the fix lands; simulator-only
+ divergence that cannot be reproduced is recorded as a fidelity gap.
+
+## Evidence checklist (attach to any portability task)
+
+* firmware hash + version string + pairing notes;
+* `probe` output (first divergence, first-touch map);
+* symbol DB hash and the Ghidra export provenance;
+* session directories for baseline and candidate (or their sha256 list);
+* `compare` report;
+* M1–M6 table with the reference-capture method;
+* unexplained diffs list (empty is a claim, not a default).
diff --git a/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md b/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md
new file mode 100644
index 0000000..c8b4638
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md
@@ -0,0 +1,116 @@
+# 08 — green-eye roadmap
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+> Items here are **not** v0.1. Do not start them ad hoc; each has a gate.
+
+## R0. Product principles
+
+* green-eye stays a **development tool**: no shipped dependencies, no guest
+ presence, no kernel-module coupling.
+* Fidelity improvements must be *measurable* (metrics table in 06) and must
+ not slow the fast path (demo + probe) below usefulness.
+* Every new backend must satisfy the same `Session` contract so scripts,
+ probes, plugins and recordings survive.
+
+## R1. Platform device model (raise L3)
+
+**Why:** a bare `qemu virt` cannot boot the real fwimage; the probe shows the
+first unmapped access and stops.
+
+**Scope:** model the minimum GSP environment:
+falcon windows (FALCON/CSB/TRANSCOM registers), WPR2/FWSEC state registers
+with read-back semantics, PTIMER, PMU/SEC2 mailbox stubs, FB DMA/IOMMU
+address translation for the firmware's DMA descriptors, and interrupt
+routing. QEMU `qemu-system-riscv64` with a custom device (`-device`) is the
+preferred vehicle; TCG plugins provide the observation points.
+
+**Gate:** R1 starts only when a portability task (P2/P3) or an accuracy
+measurement (P1.6) requires booting real firmware in-simulator. Until then,
+use the probe + rehosted uproc paths.
+
+**Exit criteria:** `greeneye probe <fwimage>` reaches a stable address and
+reports "waiting for host message" rather than a fault; the first host↔GSP
+RPC records are captured in a session; M3 ordering can be measured against
+the physical backend.
+
+## R2. Custom simulator (explicitly requested; not first version)
+
+**Why:** QEMU is an excellent execution engine but a poor *workflow* engine
+for this project: no in-process API, RSP serialisation for every access,
+limited snapshotting, and a device model designed for whole boards rather
+than firmware servers. A custom simulator can be deterministic, replayable
+and scriptable at native speed.
+
+**Shape (to build later, not now):**
+* an RV64IMAC interpreter/JIT with a **pluggable memory map** (RAM, MMIO
+ hooks, DMA windows) and a **deterministic scheduler** (no realtime claim);
+* native `Session` integration — replace the RSP transport with a
+ `Backend` interface, keep hooks/watchpoints/memory/recording identical;
+* first-class **snapshot/restore** of full machine state (time-travel
+ debugging, branch-flip experiments);
+* an **event bus** feeding the existing `events.jsonl` schema;
+* a **stub DSL** for device registers (read/write scripts with state), so a
+ new GPU generation is configuration, matching the project's portability
+ direction.
+
+**Candidates to evaluate at that time:** extending QEMU TCG with a thin
+in-process backdoor, `libriscv`, `rv8`, or a from-scratch interpreter.
+Realtime is explicitly *not* a requirement; determinism and introspection are.
+
+**Gate:** R1 must have stabilised the device semantics (otherwise the custom
+simulator inherits an unvalidated model). Estimated multi-week work; schedule
+only after the guest-bridge work (PLAN B) no longer depends on it.
+
+## R3. MCP server plugin
+
+**Why:** expose green-eye sessions to AI harnesses and other agents under the
+plugin model the user requested.
+
+**Shape:** a plugin (`plugins/mcp_server.py` when built) that wraps the
+session/analysis API as MCP tools: `list_sessions`, `open_session`,
+`symbols_resolve`, `set_breakpoint`, `run`, `memory_read`, `memory_hexdump`,
+`snapshot`, `compare_sessions`, `export_hits`. Transport local stdio first;
+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.
+
+## R4. Full-memory trace (TCG plugin integration)
+
+The optional `qemu/greeneye_trace.c` plugin records instruction execution and
+memory accesses. Roadmap: build it against the vendored QEMU headers, stream
+its JSONL into the session format as `exec`/`access` events, and teach
+`analyze` to render coverage/heatmaps and written-page timelines. This is the
+"complete map" upgrade from stop-based observation to full access tracing.
+
+**Gate:** available once the QEMU plugin headers are vendored (or the build
+environment provides them). Does not block v0.1 — watchpoint recording plus
+step traces already cover the common cases.
+
+## R5. Fuzzing and mutation harness
+
+Use AFL++ QEMU mode (or a custom mutator) against the rehosted firmware's
+RPC input surface, with green-eye recording crash/trap paths and symbol
+annotation. Requires R1/R2 for stable input plumbing. Output: a corpus of
+malformed controls and the firmware's error paths, feeding the emulation's
+fail-closed rules (project security hygiene).
+
+## R6. Multi-version orchestration
+
+Scripts that, given a set of firmware images and device profiles, run the
+probe + rehost + compare pipeline for the whole matrix and emit the M1–M6
+tables. This automates `07-PORTABILITY-PLAYBOOKS` P1–P3.
+
+## R7. Ghidra two-way console
+
+A Ghidra script/plugin that talks to a live green-eye session (via the CLI or
+a local socket) to toggle breakpoints/watchpoints from the decompiler and to
+highlight hit addresses. Until then, the hit-TSV + `MarkGreenEyeHits.java`
+flow is the supported path.
+
+## Not on the green-eye roadmap
+
+* Graphics/display emulation, Windows guest internals, multi-tenant policy —
+ these belong to the main project roadmap, not to this analysis tool.
+* Replacing Ghidra: green-eye is the dynamic complement, never a decompiler.
diff --git a/debug/risc-v-gsp-rm-vm/docs/09-GSP-FIRMWARE-NOTES.md b/debug/risc-v-gsp-rm-vm/docs/09-GSP-FIRMWARE-NOTES.md
new file mode 100644
index 0000000..216bf93
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/09-GSP-FIRMWARE-NOTES.md
@@ -0,0 +1,104 @@
+# 09 — GSP/uproc firmware notes used by green-eye
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+> Exact local-source provenance is recorded in the local task record; this
+> tracked document intentionally keeps source locations generic and cites
+> public material.
+
+## 1. The microcontrollers around the GPU
+
+From the open-iov wiki **GPU Firmware** page and the linked **NVIDIA RISC-V
+Story** presentation (the project mirrors the wiki locally; the PDF is a
+public RISC-V summit deck):
+
+* **Falcon (FAst Logic CONtroller)** is NVIDIA's proprietary control
+ processor, used across >15 hardware engines for over a decade. It was
+ designed for flexibility, long memory latency, low area and security.
+ Limits that forced a successor: small addressing range, modest performance
+ (~0.67 DMIPS/MHz, ~1.4 CoreMark/MHz), no data cache, no rich OS support.
+* **NV-RISCV** is NVIDIA's own implementation of a RISC-V core chosen as the
+ next-generation falcon: more performance (>2× falcon claimed), wider
+ addressing, TCM, vectored interrupts, security features (TZ-style), with
+ area targets under 0.1 mm² at 16FF.
+* The GPU hosts several of these cores: the **GSP** (GPU System Processor)
+ running a rich firmware stack, plus security/boot engines (**SEC2**, ACR,
+ SOE) and the **PMU** microcontroller. The RISC-V Story deck documents the
+ falcon-to-RV transition and the DMA/mailbox lines between engines.
+
+## 2. GSP application structure
+
+The GSP firmware is an ELF64 RV64IMAC image (the TU104 570.144 `.fwimage` used
+by the probe has entry `0x4009000`, 33 program headers, stripped). Its
+application layer is a multi-task RTOS program:
+
+* `_start` (hand-written assembly) sets the trap vector to an early panic
+ handler, initialises the stack, pushes two zero words to help debugger
+ backtracing, and calls `appMain`. The **bootloader passes arguments in
+ a0–a5**: bootargs PA and VA, ELF PA and size, load base PA, and the
+ write-protected region id. A re-host harness must reproduce this ABI.
+* `init.c` creates the tasks and starts the RTOS; the included driver set
+ covers MPU, ODP (on-demand paging), memory, and the RISC-V system library.
+* The boot configuration selects `NVRISCV_PARTITION_SWITCH` variants, i.e.
+ images can run under a separation-kernel partition model.
+
+## 3. RTOS and libos layers
+
+Two OS layers appear in the vendor snapshots:
+
+* **SafeRTOS/NVRTOS** — the classic resident RTOS: priority scheduling,
+ timers, events/signals, IPC and DMA support; ~38 KiB TCM-resident kernel;
+ missing paging, task reset, capability security. Used by the GSP test
+ firmware partition model.
+* **LIBOS** (exokernel design, early draft) — designed to be TCM-resident
+ (projected 1–6 KiB), capability-based security, round-robin scheduler,
+ DMEM on-demand paging, module configuration so unused features are not
+ paid for. This is the direction the GSP stack evolves toward.
+
+**Implication for the simulator:** the scheduler is not the interesting part
+for RPC accuracy; the task/dispatcher semantics are. Green-eye's demo mirrors
+that shape (mailbox → dispatcher → handler → counters), and a rehost shim
+should stub the scheduler while keeping task entry points and message
+handling real.
+
+## 4. Boot and partition models
+
+* **MP-SK/FMC** (separation kernel): images are built as partitions. One
+ observed GSP test image has two partitions: a SafeRTOS first-stage
+ bootloader partition (clobbered after hand-off via partition switch), and a
+ high-sec test partition that reads performance counters, writes them to
+ shared memory and switches back.
+* **Confidential-compute skeleton**: six partition stubs — RM Proxy, RM
+ (mutually exclusive with RM Proxy), SPDM, ACR, Init and Attestation — with
+ the boot flow **Init → ACR → RM**. The vendor tooling side-loads images to
+ a live GSP with a debug command (`nvwatch …`) for bring-up.
+
+These models matter for portability: a new architecture may change the
+partition layout or boot order, and green-eye's probe is the quickest way to
+see the new first-touch sequence before a device model exists.
+
+## 5. Memory, DMA and registers
+
+* The firmware assumes **TCM** and an MPU (protection regions are generated
+ at link time); `qemu virt` has neither, so the probe loads segments into
+ RAM and faults at the first register access.
+* The engines exchange data over **CSB/TRANSCOM windows and mailboxes** with
+ DMA into host/FB memory; the physical backend work in Open-IOV already
+ models the message-queue side (status/command queues, doorbells) — that is
+ the layer green-eye can emulate first when R1 lands.
+* The GSP kernel's start path receives its memory permissions as boot args
+ from the bootloader rather than discovering them, which is why a
+ re-host should pass the same structure rather than re-derive it.
+
+## 6. How green-eye models this today (and honestly, does not)
+
+| Firmware aspect | green-eye v0.1 |
+|---|---|
+| Task/dispatcher/state-machine logic | modelled faithfully by the demo and by rehosted code |
+| RPC handler input/output behaviour | observable and recordable; schemas decode structures |
+| RTOS scheduling, interrupts, partition switch | approximated / absent |
+| Falcon/CSB/TCM/PMU registers | absent (probe reports first touch; R1 roadmap) |
+| Boot ROM chain (FWSEC/booter/SEC2) | out of scope for the VM; the physical backend owns it |
+
+Use the notes above when designing new rehost shims or a device model: the
+goal is to keep everything *above* the register/mailbox boundary real, and to
+model that boundary explicitly rather than pretend it away.
diff --git a/debug/risc-v-gsp-rm-vm/docs/10-RELATED-WORK.md b/debug/risc-v-gsp-rm-vm/docs/10-RELATED-WORK.md
new file mode 100644
index 0000000..a2a45db
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/10-RELATED-WORK.md
@@ -0,0 +1,76 @@
+# 10 — related work: why not Frida, and what each tool is for
+
+> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status.
+
+## 1. Frida
+
+**Verdict for the firmware VM: not applicable.** Upstream Frida ships no
+RISC-V target (checked against the 17.20 release assets: x86/x86_64,
+arm/arm64, mips, macOS/Windows/QNX/Prospero — no `linux-riscv64`), and
+frida-agent requires a hosted OS process. The GSP image is bare-metal RV64
+with its own libos; there is nothing to inject into, and even a hypothetical
+RISC-V port would need an OS layer the firmware does not have.
+
+**Where the Frida *idea* lives in green-eye:** the scripting model. `Session`
+offers `hook`/`watch`/`memory`/`events` with Python callbacks, which is the
+part of Frida that matters for this workflow. The difference is transport:
+RSP to QEMU instead of an injected agent.
+
+**Where Frida is applicable in the project:** host- or guest-userland x86_64
+work (e.g. the CUDA userspace layer) if a dynamic tracer is ever wanted there.
+That is outside this tool's scope; the existing ptrace tracer covers the
+guest today.
+
+## 2. GDB / gdbstub
+
+QEMU's gdbstub is the transport both tools share. GDB itself is still useful
+for *debugging green-eye* (stub behaviour, protocol traces), and `gdb
+-multiarch` can inspect the same target interactively. It is not the
+instrumentation layer here because:
+
+* every operation becomes a subprocess/parse problem instead of an API call;
+* recording, symbol annotation, structure decoding and session diffing would
+ have to be rebuilt around GDB/MI anyway;
+* the project already uses GDB Python scripts for the CUDA corpus; green-eye
+ is the equivalent for firmware, with a stronger data model.
+
+## 3. QEMU TCG plugins
+
+The correct complement, not a competitor: plugins execute inside QEMU and can
+observe **every** instruction and memory access, which gdbstub breakpoints
+cannot. green-eye's `qemu/greeneye_trace.c` emits a JSONL access trace for
+exactly this; integrating it into the session format is R4 on the roadmap.
+Rule of thumb: use green-eye hooks for semantics ("handler X ran with these
+args") and TCG plugins for coverage/heatmaps ("every write to this window").
+
+## 4. Unicorn / emulator libraries
+
+Unicorn (and similar CPU emulators) can execute RV64 code faster to set up
+than QEMU and embed cleanly in Python, but they model no board at all and
+have weaker debug infrastructure. They are candidates for the **custom
+simulator** (R2) once the required device semantics are known — the same
+reason the roadmap defers that work.
+
+## 5. `rr` / record-replay
+
+`rr` does not support RISC-V guests and records at the host-process level.
+QEMU's own record/replay (`-icount`) can provide deterministic reruns later;
+green-eye's session recording occupies a different niche (semantic events,
+not full replay) and can coexist.
+
+## 6. Comparison table
+
+| Tool | Layer | RISC-V firmware | green-eye relationship |
+|---|---|---|---|
+| Frida | userland process injection | no | conceptual model only |
+| GDB + gdbstub | execution control | yes (transport) | debugging green-eye; same stub |
+| QEMU TCG plugin | full instruction/memory trace | yes | complement (R4) |
+| green-eye | semantic instrumentation + recording + analysis | yes | this tool |
+| Unicorn/rv8/etc. | CPU emulation library | yes | R2 candidate |
+| Ghidra | static decompilation | yes | symbol/hit bridge (05) |
+
+## 7. Reading list
+
+* `docs/01-DESIGN.md` — where each layer sits.
+* `docs/05-GHIDRA-BRIDGE.md` — the static/dynamic join.
+* `docs/08-ROADMAP.md` — R1 device model, R2 custom simulator, R3 MCP.
diff --git a/debug/risc-v-gsp-rm-vm/examples/probes/demo_probes.json b/debug/risc-v-gsp-rm-vm/examples/probes/demo_probes.json
new file mode 100644
index 0000000..db623f1
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/examples/probes/demo_probes.json
@@ -0,0 +1,25 @@
+{
+ "probes": [
+ {
+ "name": "sum-nonzero",
+ "symbol": "rpc_sum",
+ "when": "a1 > 0",
+ "on_enter": {
+ "log": ["a0", "a1"],
+ "dump": [{"reg": "a0", "len": 32, "label": "sum-input"}],
+ "note": "rpc_sum with a non-zero length"
+ },
+ "on_leave": {"log": ["retval"]}
+ },
+ {
+ "name": "state-machine",
+ "symbol": "rpc_state",
+ "on_leave": {"log": ["retval"], "note": "state transition"}
+ },
+ {
+ "name": "trap",
+ "symbol": "rpc_fault",
+ "on_enter": {"note": "rpc_fault entered; the ebreak trap follows"}
+ }
+ ]
+}
diff --git a/debug/risc-v-gsp-rm-vm/examples/scripts/dump_diff.py b/debug/risc-v-gsp-rm-vm/examples/scripts/dump_diff.py
new file mode 100644
index 0000000..329ffb1
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/examples/scripts/dump_diff.py
@@ -0,0 +1,33 @@
+"""Snapshot-diff the uproc control block across a host-injected PING.
+
+Run from the tool root:
+
+ ./greeneye.sh run examples/scripts/dump_diff.py
+
+Demonstrates: symbol -> structure schema decoding, snapshot + diff, host
+stimulus via memory writes, and symbol-annotated output.
+"""
+import json
+from pathlib import Path
+
+from greeneye import Memory
+from greeneye.structs import render, schema_from_json
+
+TOOL_ROOT = Path(__file__).resolve().parents[2]
+schema = schema_from_json(json.loads((TOOL_ROOT / "firmware/uproc_demo/gsp_ctrl.schema.json").read_text()))
+ctrl = symbols.lookup("gsp_ctrl")
+
+# Let the firmware self-test settle.
+s.run(duration=0.4)
+before = s.snapshot(ctrl, schema.size, "ctrl-before-ping")
+
+# Host stimulus: PING with argument 0x1234.
+memory.write_u64(ctrl + 32, 1) # mailbox
+memory.write_u64(ctrl + 40, 0x1234)
+s.run(duration=0.4)
+after = s.snapshot(ctrl, schema.size, "ctrl-after-ping")
+
+print("decoded control block:")
+print(render(schema, schema.decode(after, base=ctrl)))
+print("\nchanged bytes:")
+print(Memory.render_diff(Memory.diff(before, after, base=ctrl), symbols))
diff --git a/debug/risc-v-gsp-rm-vm/examples/scripts/trace_rpc.py b/debug/risc-v-gsp-rm-vm/examples/scripts/trace_rpc.py
new file mode 100644
index 0000000..12b912b
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/examples/scripts/trace_rpc.py
@@ -0,0 +1,29 @@
+"""Trace every uproc RPC handler with enter/leave hooks.
+
+Run from the tool root:
+
+ ./greeneye.sh run examples/scripts/trace_rpc.py
+
+The script namespace provides ``s``/``session``, ``memory``, ``symbols``,
+``recorder`` and ``ge`` (the greeneye package).
+"""
+NAMES = ["rpc_ping", "rpc_echo", "rpc_fill", "rpc_sum", "rpc_state", "rpc_fault"]
+events = []
+
+for name in NAMES:
+ sym = symbols.get(name)
+ if sym is None:
+ print(f"symbol {name} not found")
+ continue
+ s.hook(
+ sym.addr,
+ on_enter=lambda hit: events.append(("enter", hit.label(), hit.arg(0))),
+ on_leave=lambda hit: events.append(("leave", hit.label(), hit.retval)),
+ )
+
+s.run(duration=1.0)
+
+print(f"{len(events)} hook events:")
+for kind, label, value in events:
+ print(f" {kind:5} {label:14} {value:#018x}")
+recorder.event("trace_complete", events=len(events))
diff --git a/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/Makefile b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/Makefile
new file mode 100644
index 0000000..f406a52
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/Makefile
@@ -0,0 +1,34 @@
+# green-eye uproc demo firmware (RISC-V 64, bare metal)
+#
+# Uses clang + lld (no riscv64 GCC required):
+# CC=clang, LD=ld.lld (absolute path to llvm-18 lld by default).
+# Override with: make CC=... LLD=...
+
+# `?=` does not override make's built-in CC=cc, so choose clang explicitly;
+# a command-line `make CC=...` still wins.
+CC := clang
+LLD ?= $(shell command -v ld.lld 2>/dev/null || echo /usr/lib/llvm-18/bin/ld.lld)
+BUILD := build
+TARGET := $(BUILD)/uproc_demo.elf
+
+CFLAGS := --target=riscv64-unknown-elf -march=rv64imac -mabi=lp64 \
+ -mcmodel=medany -mno-relax -ffreestanding -nostdlib -fno-builtin \
+ -fno-stack-protector -O2 -Wall -Wextra -I.
+LDFLAGS := --ld-path=$(LLD) -nostdlib -static -Wl,-T,linker.ld -Wl,--no-relax
+SRCS := start.S main.c
+
+.PHONY: all clean dump
+
+all: $(TARGET)
+
+$(TARGET): $(SRCS) linker.ld ge_uart.h
+ @mkdir -p $(BUILD)
+ $(CC) $(CFLAGS) $(SRCS) $(LDFLAGS) -o $@
+ @echo "built $@"
+
+dump: $(TARGET)
+ @llvm-objdump-18 -d --no-show-raw-insn $(TARGET) | head -80 2>/dev/null || \
+ objdump -d $(TARGET) | head -80
+
+clean:
+ rm -rf $(BUILD)
diff --git a/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/ge_uart.h b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/ge_uart.h
new file mode 100644
index 0000000..4bd2f53
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/ge_uart.h
@@ -0,0 +1,38 @@
+/* Minimal NS16550A UART helpers for the QEMU virt machine. */
+#ifndef GE_UART_H
+#define GE_UART_H
+
+typedef unsigned char u8;
+typedef unsigned int u32;
+typedef unsigned long u64;
+typedef long i64;
+
+#define UART0_BASE 0x10000000UL
+#define UART_THR (UART0_BASE + 0x00)
+#define UART_LSR (UART0_BASE + 0x05)
+
+static inline void uart_putc(char c)
+{
+ volatile u8 *lsr = (volatile u8 *)UART_LSR;
+ while ((*lsr & 0x20) == 0) {
+ /* wait for THR empty */
+ }
+ *(volatile u8 *)UART_THR = (u8)c;
+}
+
+static inline void uart_puts(const char *s)
+{
+ while (*s) {
+ uart_putc(*s++);
+ }
+}
+
+static inline void uart_hex(u64 value, int digits)
+{
+ static const char hex[] = "0123456789abcdef";
+ for (int i = (digits - 1) * 4; i >= 0; i -= 4) {
+ uart_putc(hex[(value >> i) & 0xf]);
+ }
+}
+
+#endif /* GE_UART_H */
diff --git a/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/gsp_ctrl.schema.json b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/gsp_ctrl.schema.json
new file mode 100644
index 0000000..ca18f81
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/gsp_ctrl.schema.json
@@ -0,0 +1,19 @@
+{
+ "name": "gsp_ctrl",
+ "size": 392,
+ "fields": [
+ {"name": "magic", "offset": 0, "fmt": "u32", "note": "GSP-like control block magic 0x47535031"},
+ {"name": "version", "offset": 4, "fmt": "u32"},
+ {"name": "state", "offset": 8, "fmt": "u32", "enum": {"0": "S0", "1": "S1", "2": "S2", "3": "S3"}},
+ {"name": "heartbeat", "offset": 12, "fmt": "u32"},
+ {"name": "rpc_count", "offset": 16, "fmt": "u64"},
+ {"name": "last_rpc", "offset": 24, "fmt": "u64"},
+ {"name": "mailbox", "offset": 32, "fmt": "u64", "enum": {"0": "idle", "1": "PING", "2": "ECHO", "3": "FILL", "4": "SUM", "5": "STATE", "6": "FAULT"}},
+ {"name": "arg0", "offset": 40, "fmt": "u64"},
+ {"name": "arg1", "offset": 48, "fmt": "u64"},
+ {"name": "result", "offset": 56, "fmt": "u64"},
+ {"name": "buf", "offset": 64, "fmt": "bytes", "size": 256},
+ {"name": "handler_count", "offset": 320, "fmt": "u64"},
+ {"name": "handlers", "offset": 328, "fmt": "u64", "count": 8}
+ ]
+}
diff --git a/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/linker.ld b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/linker.ld
new file mode 100644
index 0000000..7ad7af8
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/linker.ld
@@ -0,0 +1,54 @@
+/* green-eye uproc demo firmware - RISC-V 64 bare-metal test target.
+ *
+ * Mimics the shape of a GSP-side micro-service: a global control block with a
+ * magic, a handler table and counters; a mailbox-driven RPC dispatcher; a
+ * multi-stage state machine handler; and an intentional trap handler so
+ * green-eye can demonstrate breakpoint/hook/watchpoint/trap workflows.
+ *
+ * Build: make -C firmware/uproc_demo
+ */
+OUTPUT_ARCH(riscv)
+ENTRY(_start)
+
+MEMORY
+{
+ RAM (rwxa) : ORIGIN = 0x80000000, LENGTH = 32M
+}
+
+SECTIONS
+{
+ . = 0x80000000;
+
+ .text : ALIGN(4) {
+ KEEP(*(.text.start))
+ *(.text .text.*)
+ } > RAM
+
+ .rodata : ALIGN(8) {
+ *(.rodata .rodata.*)
+ } > RAM
+
+ .data : ALIGN(8) {
+ *(.data .data.*)
+ } > RAM
+
+ /* The control block gets its own section so its address is predictable. */
+ .gsp_data : ALIGN(64) {
+ KEEP(*(.gsp_data))
+ } > RAM
+
+ .bss : ALIGN(8) {
+ *(.bss .bss.*)
+ *(COMMON)
+ } > RAM
+
+ . = ALIGN(16);
+ . += 0x4000; /* 16 KiB stack */
+ _stack_top = .;
+
+ /DISCARD/ : {
+ *(.comment)
+ *(.note .note.*)
+ *(.riscv.attributes)
+ }
+}
diff --git a/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/main.c b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/main.c
new file mode 100644
index 0000000..d23b00c
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/main.c
@@ -0,0 +1,222 @@
+/* green-eye uproc demo firmware.
+ *
+ * A small, deterministic "GSP-like" service:
+ * - gsp_ctrl global control block (magic 0x47535031) in .gsp_data;
+ * - mailbox-driven RPC dispatcher with a handler table;
+ * - handlers: PING, ECHO, FILL, SUM, STATE (4-stage machine), FAULT (ebreak);
+ * - a self-test sequence at boot so a freshly attached debugger sees events;
+ * - a free-running heartbeat so memory activity is always present.
+ *
+ * No libc: memcpy/memset are provided for the compiler.
+ */
+#include "ge_uart.h"
+
+#define GSP_MAGIC 0x47535031u
+
+typedef struct {
+ u32 magic; /* +0x00 */
+ u32 version; /* +0x04 */
+ u32 state; /* +0x08 */
+ u32 heartbeat; /* +0x0c */
+ u64 rpc_count; /* +0x10 */
+ u64 last_rpc; /* +0x18 */
+ u64 mailbox; /* +0x20 */
+ u64 arg0; /* +0x28 */
+ u64 arg1; /* +0x30 */
+ u64 result; /* +0x38 */
+ char buf[256]; /* +0x40 */
+ u64 handler_count; /* +0x140 */
+ u64 handlers[8]; /* +0x148 */
+} gsp_ctrl_t;
+
+volatile gsp_ctrl_t gsp_ctrl __attribute__((section(".gsp_data"), aligned(64)));
+
+/* ---- compiler support (freestanding) ---- */
+
+void *memcpy(void *dst, const void *src, u64 n)
+{
+ u8 *d = (u8 *)dst;
+ const u8 *s = (const u8 *)src;
+ while (n--) {
+ *d++ = *s++;
+ }
+ return dst;
+}
+
+void *memset(void *dst, int value, u64 n)
+{
+ u8 *d = (u8 *)dst;
+ while (n--) {
+ *d++ = (u8)value;
+ }
+ return dst;
+}
+
+static u64 strlen_local(const char *s)
+{
+ u64 n = 0;
+ while (s[n]) {
+ n++;
+ }
+ return n;
+}
+
+/* ---- RPC handlers (kept as symbols for green-eye hooks) ---- */
+
+__attribute__((noinline)) u64 rpc_ping(u64 a0, u64 a1)
+{
+ (void)a1;
+ return a0 ^ 0xbeefu;
+}
+
+__attribute__((noinline)) u64 rpc_echo(u64 a0, u64 a1)
+{
+ (void)a1;
+ const char *src = (const char *)a0;
+ u64 n = strlen_local(src);
+ if (n > sizeof(gsp_ctrl.buf) - 1) {
+ n = sizeof(gsp_ctrl.buf) - 1;
+ }
+ memcpy((void *)gsp_ctrl.buf, src, n);
+ gsp_ctrl.buf[n] = '\0';
+ return n;
+}
+
+__attribute__((noinline)) u64 rpc_fill(u64 a0, u64 a1)
+{
+ u64 n = a1 > sizeof(gsp_ctrl.buf) ? sizeof(gsp_ctrl.buf) : a1;
+ memset((void *)gsp_ctrl.buf, (int)(a0 & 0xff), n);
+ return n;
+}
+
+__attribute__((noinline)) u64 rpc_sum(u64 a0, u64 a1)
+{
+ const u8 *p = (const u8 *)a0;
+ u64 total = 0;
+ for (u64 i = 0; i < a1; i++) {
+ total += p[i];
+ }
+ return total;
+}
+
+__attribute__((noinline)) u64 rpc_state(u64 a0, u64 a1)
+{
+ (void)a0;
+ (void)a1;
+ u64 stage = gsp_ctrl.state & 3u;
+ u64 out = 0;
+ switch (stage) {
+ case 0:
+ out = 0x10;
+ break;
+ case 1:
+ out = 0x20;
+ break;
+ case 2:
+ out = 0x30;
+ break;
+ default:
+ out = 0x40;
+ break;
+ }
+ gsp_ctrl.state = (u32)((stage + 1) & 3u);
+ return out;
+}
+
+__attribute__((noinline)) u64 rpc_fault(u64 a0, u64 a1)
+{
+ (void)a0;
+ (void)a1;
+ __asm__ volatile("ebreak"); /* debugger trap: green-eye stops here */
+ return 0xdead;
+}
+
+/* ---- dispatcher ---- */
+
+typedef u64 (*rpc_fn)(u64, u64);
+
+void rpc_dispatch(u64 cmd, u64 a0, u64 a1)
+{
+ u64 result;
+ switch (cmd) {
+ case 1:
+ result = rpc_ping(a0, a1);
+ break;
+ case 2:
+ result = rpc_echo(a0, a1);
+ break;
+ case 3:
+ result = rpc_fill(a0, a1);
+ break;
+ case 4:
+ result = rpc_sum(a0, a1);
+ break;
+ case 5:
+ result = rpc_state(a0, a1);
+ break;
+ case 6:
+ result = rpc_fault(a0, a1);
+ break;
+ default:
+ result = ~cmd;
+ break;
+ }
+ gsp_ctrl.result = result;
+ gsp_ctrl.last_rpc = cmd;
+ gsp_ctrl.rpc_count = gsp_ctrl.rpc_count + 1; /* after the handler */
+}
+
+static void install_handlers(void)
+{
+ gsp_ctrl.handlers[0] = (u64)(unsigned long)rpc_ping;
+ gsp_ctrl.handlers[1] = (u64)(unsigned long)rpc_echo;
+ gsp_ctrl.handlers[2] = (u64)(unsigned long)rpc_fill;
+ gsp_ctrl.handlers[3] = (u64)(unsigned long)rpc_sum;
+ gsp_ctrl.handlers[4] = (u64)(unsigned long)rpc_state;
+ gsp_ctrl.handlers[5] = (u64)(unsigned long)rpc_fault;
+ gsp_ctrl.handler_count = 6;
+}
+
+int main(void)
+{
+ volatile gsp_ctrl_t *ctl = &gsp_ctrl;
+ ctl->magic = GSP_MAGIC;
+ ctl->version = 1;
+ ctl->state = 0;
+ ctl->heartbeat = 0;
+ ctl->rpc_count = 0;
+ ctl->last_rpc = 0;
+ ctl->mailbox = 0;
+ ctl->result = 0;
+ ctl->buf[0] = '\0';
+ install_handlers();
+
+ uart_puts("GREEN-EYE UPROC DEMO v1\n");
+ uart_puts("MAGIC ");
+ uart_hex(ctl->magic, 8);
+ uart_puts(" CTRL@");
+ uart_hex((u64)(unsigned long)ctl, 8);
+ uart_puts("\n");
+
+ /* Deterministic self-test so freshly attached instrumentation sees events. */
+ rpc_dispatch(1, 0x1111, 0);
+ rpc_dispatch(2, (u64)(unsigned long)"hello-green-eye", 0);
+ rpc_dispatch(3, 0x5a, 32);
+ rpc_dispatch(4, (u64)(unsigned long)ctl->buf, 16);
+ rpc_dispatch(5, 0, 0);
+ rpc_dispatch(5, 0, 0);
+ rpc_dispatch(5, 0, 0);
+ uart_puts("SELFTEST DONE\n");
+
+ for (;;) {
+ ctl->heartbeat = ctl->heartbeat + 1;
+ u64 cmd = ctl->mailbox;
+ if (cmd != 0) {
+ ctl->mailbox = 0;
+ rpc_dispatch(cmd, ctl->arg0, ctl->arg1);
+ }
+ for (volatile int i = 0; i < 2000; i++) {
+ __asm__ volatile("");
+ }
+ }
+}
diff --git a/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/start.S b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/start.S
new file mode 100644
index 0000000..1341461
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/start.S
@@ -0,0 +1,10 @@
+/* green-eye uproc demo firmware - reset/entry. */
+ .section .text.start, "ax", @progbits
+ .global _start
+ .type _start, @function
+_start:
+ la sp, _stack_top
+ call main
+1: wfi
+ j 1b
+ .size _start, . - _start
diff --git a/debug/risc-v-gsp-rm-vm/ghidra/ExportForGreenEye.java b/debug/risc-v-gsp-rm-vm/ghidra/ExportForGreenEye.java
new file mode 100644
index 0000000..29cc1cd
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/ghidra/ExportForGreenEye.java
@@ -0,0 +1,46 @@
+// Export functions as a green-eye-compatible symbol TSV.
+//
+// Usage (Ghidra headless):
+// analyzeHeadless <proj> <name> -process <program> -noanalysis \
+// -scriptPath debug/risc-v-gsp-rm-vm/ghidra \
+// -postScript ExportForGreenEye.java /abs/path/ghidra-symbols.tsv
+//
+// Columns: addr (program address) <TAB> name <TAB> size.
+// green-eye's `symbols export --index` accepts this form directly.
+//
+//@category OpenIOV
+//@menupath Tools.OpenIOV.Export for green-eye
+
+import java.io.File;
+import java.io.PrintWriter;
+
+import ghidra.app.script.GhidraScript;
+import ghidra.program.model.listing.Function;
+import ghidra.program.model.listing.FunctionIterator;
+
+public class ExportForGreenEye extends GhidraScript {
+
+ @Override
+ public void run() throws Exception {
+ String[] args = getScriptArgs();
+ if (args.length < 1) {
+ println("usage: ExportForGreenEye.java <out.tsv>");
+ return;
+ }
+ File out = new File(args[0]);
+ int count = 0;
+ try (PrintWriter pw = new PrintWriter(out, "UTF-8")) {
+ pw.println("addr\tname\tsize");
+ FunctionIterator functions =
+ currentProgram.getFunctionManager().getFunctions(true);
+ while (functions.hasNext()) {
+ Function function = functions.next();
+ long addr = function.getEntryPoint().getOffset();
+ long size = function.getBody().getNumAddresses();
+ pw.printf("0x%08x\t%s\t%d%n", addr, function.getName(), size);
+ count++;
+ }
+ }
+ println("green-eye: exported " + count + " functions to " + out.getAbsolutePath());
+ }
+}
diff --git a/debug/risc-v-gsp-rm-vm/ghidra/MarkGreenEyeHits.java b/debug/risc-v-gsp-rm-vm/ghidra/MarkGreenEyeHits.java
new file mode 100644
index 0000000..c99c3e1
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/ghidra/MarkGreenEyeHits.java
@@ -0,0 +1,80 @@
+// Annotate green-eye runtime hits in the current Ghidra program.
+//
+// Input: the TSV produced by greeneye.ghidra.export_hits():
+// addr <TAB> hits <TAB> symbol <TAB> annotation
+//
+// Usage (Ghidra headless):
+// analyzeHeadless <proj> <name> -process <program> -noanalysis \
+// -scriptPath debug/risc-v-gsp-rm-vm/ghidra \
+// -postScript MarkGreenEyeHits.java /abs/path/greeneye_hits.tsv
+//
+// For each address it adds a plate comment on the containing function
+// ("green-eye: N hits") and a bookmark at the exact address.
+//
+//@category OpenIOV
+//@menupath Tools.OpenIOV.Mark green-eye hits
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+
+import ghidra.app.script.GhidraScript;
+import ghidra.program.model.address.Address;
+import ghidra.program.model.listing.Function;
+
+public class MarkGreenEyeHits extends GhidraScript {
+
+ @Override
+ public void run() throws Exception {
+ String[] args = getScriptArgs();
+ if (args.length < 1) {
+ println("usage: MarkGreenEyeHits.java <greeneye_hits.tsv>");
+ return;
+ }
+ File in = new File(args[0]);
+ int marked = 0;
+ int missed = 0;
+ try (BufferedReader reader = new BufferedReader(new FileReader(in))) {
+ String line;
+ boolean header = true;
+ while ((line = reader.readLine()) != null) {
+ if (header) {
+ header = false;
+ if (line.startsWith("addr")) {
+ continue;
+ }
+ }
+ if (line.isBlank()) {
+ continue;
+ }
+ String[] cols = line.split("\t");
+ if (cols.length < 2) {
+ continue;
+ }
+ long addrValue;
+ try {
+ addrValue = Long.parseUnsignedLong(cols[0].replace("0x", ""), 16);
+ } catch (NumberFormatException e) {
+ continue;
+ }
+ String hits = cols[1];
+ String symbol = cols.length > 2 ? cols[2] : "";
+ Address address = toAddr(addrValue);
+ Function function = getFunctionContaining(address);
+ if (function != null) {
+ String note = "green-eye: " + hits + " hit(s)"
+ + (symbol.isEmpty() ? "" : " (" + symbol + ")");
+ String existing = function.getComment();
+ function.setComment(existing == null ? note : existing + "\n" + note);
+ marked++;
+ } else {
+ missed++;
+ }
+ createBookmark(address, "Note", "green-eye",
+ hits + " runtime hit(s)" + (symbol.isEmpty() ? "" : " " + symbol));
+ }
+ }
+ println("green-eye: annotated " + marked + " functions (" + missed
+ + " addresses outside any function) from " + in.getAbsolutePath());
+ }
+}
diff --git a/debug/risc-v-gsp-rm-vm/ghidra/README.md b/debug/risc-v-gsp-rm-vm/ghidra/README.md
new file mode 100644
index 0000000..e0bb2c0
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/ghidra/README.md
@@ -0,0 +1,39 @@
+# Ghidra bridge scripts
+
+Two Java scripts run inside Ghidra (GUI or headless):
+
+| Script | Direction | Purpose |
+|---|---|---|
+| `ExportForGreenEye.java` | Ghidra → green-eye | write `addr<TAB>name<TAB>size` for every function |
+| `MarkGreenEyeHits.java` | green-eye → Ghidra | annotate functions with runtime hit counts + bookmarks |
+
+Typical loop:
+
+```bash
+# 1. Export the decompiler's view of the firmware.
+$GHIDRA/support/analyzeHeadless <proj_dir> <project> -process <program> -noanalysis \
+ -scriptPath debug/risc-v-gsp-rm-vm/ghidra \
+ -postScript ExportForGreenEye.java /tmp/ghidra-symbols.tsv
+
+# 2. Build a green-eye symbol DB from it.
+./greeneye.sh symbols export --index /tmp/ghidra-symbols.tsv -o symbols.json
+
+# 3. Run a session, then export its hits.
+python3 - <<'PY'
+from greeneye.ghidra import export_hits
+from greeneye.symbols import SymbolDB
+db = SymbolDB(); db.load_json("symbols.json")
+print(export_hits("dumps/<run>", "/tmp/greeneye_hits.tsv", symbols=db))
+PY
+
+# 4. Annotate the project.
+$GHIDRA/support/analyzeHeadless <proj_dir> <project> -process <program> -noanalysis \
+ -scriptPath debug/risc-v-gsp-rm-vm/ghidra \
+ -postScript MarkGreenEyeHits.java /tmp/greeneye_hits.tsv
+```
+
+Address convention: the exported `addr` is the **program address** (what
+Ghidra shows). `greeneye symbols export --index` understands the 3-column
+form and keeps file/program addresses separate when present (4/7-column
+project indexes). Pass `--bias` only when the exported TSV does not already
+carry runtime addresses.
diff --git a/debug/risc-v-gsp-rm-vm/greeneye.sh b/debug/risc-v-gsp-rm-vm/greeneye.sh
new file mode 100755
index 0000000..f1c5a82
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye.sh
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# green-eye launcher: run the package from this directory without installation.
+# TOOL IN DEVELOPMENT - NOT READY YET!
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+export PYTHONPATH="$HERE${PYTHONPATH:+:$PYTHONPATH}"
+export GREENEYE_DUMPS="${GREENEYE_DUMPS:-$HERE/dumps}"
+exec python3 -m greeneye "$@"
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/__init__.py b/debug/risc-v-gsp-rm-vm/greeneye/__init__.py
new file mode 100644
index 0000000..8b1c1e5
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/__init__.py
@@ -0,0 +1,115 @@
+"""green-eye: Frida-like runtime instrumentation for GSP/uproc firmware in a
+RISC-V QEMU VM.
+
+green-eye talks to QEMU's gdbstub directly (no GDB dependency) and layers a
+scriptable instrumentation API on top: breakpoints, hooks, watchpoints,
+memory inspection, structure decoding, session recording and post-run analysis.
+
+Public API (stable-ish for v0.1):
+
+ import greeneye as ge
+ s = ge.launch(kernel="firmware/uproc_demo/build/uproc_demo.elf")
+ fn = s.symbols.lookup("rpc_sum")
+ h = s.hook(fn, on_enter=lambda hit: print(hit))
+ s.run(duration=2.0)
+ s.close()
+
+Everything is development tooling; see docs/00-OVERVIEW.md.
+"""
+from __future__ import annotations
+
+__version__ = "0.1.0"
+
+from .errors import ( # noqa: F401
+ GreenEyeError,
+ RspError,
+ RspTimeout,
+ RspProtocolError,
+ SymbolNotFound,
+ TargetError,
+ StructError,
+)
+from .rsp import RspClient, StopReply, Register # noqa: F401
+from .elf import FirmwareImage, Segment, ElfSymbol, load_elf # noqa: F401
+from .symbols import Symbol, SymbolDB # noqa: F401
+from .memory import Memory # noqa: F401
+from .structs import Field, Schema, decode, schema_from_json # noqa: F401
+from .record import Recorder, NullRecorder, load_events # noqa: F401
+from .session import Session, Hit, HookHandle, Breakpoint, Watchpoint # noqa: F401
+from .target import QemuTarget # noqa: F401
+
+__all__ = [
+ "attach",
+ "launch",
+ "Session",
+ "Hit",
+ "HookHandle",
+ "Breakpoint",
+ "Watchpoint",
+ "QemuTarget",
+ "RspClient",
+ "SymbolDB",
+ "Symbol",
+ "Memory",
+ "Recorder",
+]
+
+
+def attach(
+ host: str = "127.0.0.1",
+ port: int = 4444,
+ *,
+ timeout: float = 10.0,
+ record: str | None = None,
+ symbols: SymbolDB | None = None,
+ name: str = "attach",
+) -> Session:
+ """Attach to an already-running QEMU gdbstub and return a Session."""
+ client = RspClient(host, port, timeout=timeout)
+ client.connect()
+ client.sync()
+ return Session(client=client, symbols=symbols, record=record, name=name)
+
+
+def launch(
+ *,
+ kernel: str | None = None,
+ machine: str = "virt",
+ memory_mb: int = 256,
+ gdb_port: int = 0,
+ bios: str = "none",
+ extra_args: list[str] | None = None,
+ timeout: float = 10.0,
+ record: str | None = None,
+ symbols: SymbolDB | None = None,
+ name: str = "session",
+ load_symbols: bool = True,
+ keep_alive: bool = False,
+) -> Session:
+ """Launch QEMU (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.
+ """
+ target = QemuTarget(
+ machine=machine,
+ memory_mb=memory_mb,
+ gdb_port=gdb_port,
+ bios=bios,
+ extra_args=extra_args,
+ kernel=kernel,
+ )
+ if keep_alive:
+ target.keep_alive = True
+ target.start(paused=True)
+ client = RspClient(target.host, target.port, timeout=timeout)
+ client.connect()
+ client.sync()
+
+ symdb = symbols or SymbolDB()
+ sess = Session(client=client, target=target, symbols=symdb, record=record, name=name)
+ if kernel:
+ image = load_elf(kernel)
+ sess.load_image(image, load_symbols=load_symbols)
+ return sess
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/__main__.py b/debug/risc-v-gsp-rm-vm/greeneye/__main__.py
new file mode 100644
index 0000000..eb53e2f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/__main__.py
@@ -0,0 +1,3 @@
+from .cli import main
+
+raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/analyze.py b/debug/risc-v-gsp-rm-vm/greeneye/analyze.py
new file mode 100644
index 0000000..07c24eb
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/analyze.py
@@ -0,0 +1,293 @@
+"""Post-run analysis: turn a session directory into a symbol-annotated report.
+
+The report is the "complete map" half of the deliverable: hits by function,
+watch hits, memory writes, coverage and snapshot inventory, ready for
+offline comparison between runs (bare-metal vs VFIO, old firmware vs new).
+"""
+from __future__ import annotations
+
+import os
+import re
+from collections import Counter, defaultdict
+
+from .record import load_events, load_meta
+
+_ACCESS_RE = re.compile(
+ r"Invalid (read|write) at addr (0x[0-9a-fA-F]+), size (\d+), region '([^']*)', reason: (\w+)"
+)
+_INTERRUPT_RE = re.compile(
+ r"riscv_cpu_do_interrupt: hart:(\d+), async:(\d+), cause:([0-9a-fA-F]+), "
+ r"epc:(0x[0-9a-fA-F]+), tval:(0x[0-9a-fA-F]+), desc=([\w_]+)"
+)
+
+
+def parse_qemu_faults(log_text: str) -> list[dict]:
+ """Parse QEMU ``-d guest_errors,unimp,int`` logs into fault records.
+
+ Each record pairs the most recent invalid access (if any) with the
+ exception it raised, which is exactly the "first divergence" report a
+ firmware probe needs.
+ """
+ faults: list[dict] = []
+ pending_access: dict | None = None
+ for line in log_text.splitlines():
+ access = _ACCESS_RE.search(line)
+ if access:
+ pending_access = {
+ "rw": access.group(1),
+ "access_addr": int(access.group(2), 16),
+ "size": int(access.group(3)),
+ "region": access.group(4),
+ "reason": access.group(5),
+ }
+ continue
+ interrupt = _INTERRUPT_RE.search(line)
+ if interrupt:
+ faults.append(
+ {
+ "hart": int(interrupt.group(1)),
+ "async": int(interrupt.group(2)),
+ "cause": int(interrupt.group(3), 16),
+ "epc": int(interrupt.group(4), 16),
+ "tval": int(interrupt.group(5), 16),
+ "desc": interrupt.group(6),
+ "access": pending_access,
+ }
+ )
+ pending_access = None
+ return faults
+
+
+_RISCV_CAUSES = {
+ 0: "instruction address misaligned",
+ 1: "instruction access fault",
+ 2: "illegal instruction",
+ 3: "breakpoint",
+ 4: "load address misaligned",
+ 5: "load access fault",
+ 6: "store address misaligned",
+ 7: "store access fault",
+}
+
+
+def describe_fault(fault: dict, bias: int = 0) -> str:
+ epc = fault["epc"]
+ fw_pc = epc - bias
+ cause = fault["cause"]
+ desc = _RISCV_CAUSES.get(cause & 0xFF, fault.get("desc", "exception"))
+ lines = [
+ f"first exception: {fault.get('desc', '?')} (cause {cause:#x}: {desc})",
+ f" epc: {epc:#x}" + (f" (firmware vaddr {fw_pc:#x})" if bias else ""),
+ f" tval: {fault.get('tval', 0):#x}",
+ ]
+ access = fault.get("access")
+ if access:
+ lines.insert(
+ 1,
+ f" access: {access['rw']} at {access['access_addr']:#x} "
+ f"({access['size']} bytes, reason={access['reason']})",
+ )
+ return "\n".join(lines)
+
+
+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)
+
+
+def render_summary_from(meta: dict, events: list[dict], symbols=None, max_rows: int = 40) -> str:
+ lines: list[str] = []
+ lines.append(f"# green-eye session summary: {meta.get('name', '?')}")
+ lines.append("")
+ lines.append(f"* format: `{meta.get('format', '?')}` green-eye {meta.get('greeneye', '?')}")
+ lines.append(f"* directory: `{meta.get('dir', '.')}`")
+ lines.append(f"* events: {len(events)}")
+ counts = Counter(event.get("type", "?") for event in events)
+ lines.append("* event counts: " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items())))
+ counts_meta = meta.get("counts")
+ if counts_meta:
+ lines.append("* recorder counts: " + ", ".join(f"{k}={v}" for k, v in sorted(counts_meta.items())))
+ lines.append("")
+
+ def annotate(addr):
+ if not isinstance(addr, int):
+ return ""
+ if symbols is not None:
+ return symbols.annotate(addr)
+ return f"{addr:#x}"
+
+ # Breakpoint hits
+ bp_hits = [e for e in events if e.get("type") == "bp_hit"]
+ if bp_hits:
+ by_site: Counter = Counter()
+ by_symbol: Counter = Counter()
+ for event in bp_hits:
+ key = event.get("symbol") or f"{event.get('pc', 0):#x}"
+ by_site[(event.get("pc"), key)] += 1
+ by_symbol[key] += 1
+ lines.append("## Breakpoint hits")
+ lines.append("")
+ lines.append("| hits | pc | symbol |")
+ lines.append("|---:|---|---|")
+ for (pc, key), count in by_site.most_common(max_rows):
+ lines.append(f"| {count} | `{pc:#x}` | `{key}` |")
+ lines.append("")
+
+ # Watchpoint hits
+ watch_hits = [e for e in events if e.get("type") == "watch_hit"]
+ if watch_hits:
+ by_addr: Counter = Counter()
+ for event in watch_hits:
+ by_addr[(event.get("addr"), event.get("kind"))] += 1
+ lines.append("## Watchpoint hits")
+ lines.append("")
+ lines.append("| hits | address | kind | symbol |")
+ lines.append("|---:|---|---|---|")
+ for (addr, kind), count in by_addr.most_common(max_rows):
+ sym = symbols.resolve(addr) if symbols else None
+ lines.append(f"| {count} | `{addr:#x}` | {kind} | `{sym.name if sym else ''}` |")
+ lines.append("")
+
+ # Memory writes
+ writes = [e for e in events if e.get("type") == "mem_write"]
+ if writes:
+ regions = Counter()
+ for event in writes:
+ regions[(event.get("addr", 0) >> 12) << 12] += 1
+ lines.append("## Memory writes")
+ lines.append("")
+ lines.append(f"* writes: {len(writes)}, distinct 4 KiB pages: {len(regions)}")
+ lines.append("")
+ lines.append("| page | writes | symbol |")
+ lines.append("|---|---:|---|")
+ for page, count in regions.most_common(max_rows):
+ sym = symbols.resolve(page) if symbols else None
+ lines.append(f"| `{page:#x}` | {count} | `{sym.name if sym else ''}` |")
+ lines.append("")
+
+ # Coverage
+ steps = [e for e in events if e.get("type") == "step"]
+ if steps:
+ pcs = {(e.get("pc")) for e in steps}
+ lines.append("## Stepped coverage")
+ lines.append("")
+ lines.append(f"* steps: {len(steps)}, unique PCs: {len(pcs)}")
+ by_sym = Counter()
+ for pc in pcs:
+ sym = symbols.resolve(pc) if symbols else None
+ by_sym[sym.name if sym else f"{pc:#x}"] += 1
+ lines.append("")
+ lines.append("| PCs | symbol |")
+ lines.append("|---:|---|")
+ for name, count in by_sym.most_common(max_rows):
+ lines.append(f"| {count} | `{name}` |")
+ lines.append("")
+
+ # Snapshots
+ snapshots = meta.get("snapshots") or [e for e in events if e.get("type") == "snapshot_saved"]
+ if snapshots:
+ lines.append("## Snapshots")
+ lines.append("")
+ lines.append("| label | address | size | path | sha256 |")
+ lines.append("|---|---|---:|---|---|")
+ for snap in snapshots[:max_rows]:
+ lines.append(
+ f"| {snap.get('label')} | `{snap.get('addr', 0):#x}` | {snap.get('size', 0)} | "
+ f"`{snap.get('path')}` | `{(snap.get('sha256') or '')[:16]}` |"
+ )
+ lines.append("")
+
+ # Timeline excerpt
+ notable = [e for e in events if e.get("type") in ("bp_hit", "leave", "watch_hit", "mem_write", "note", "exit")]
+ if notable:
+ lines.append("## Timeline (first hits)")
+ lines.append("")
+ for event in notable[:max_rows]:
+ t = event.get("t", 0)
+ kind = event.get("type")
+ if kind in ("bp_hit", "leave"):
+ site = event.get("symbol") or f"{event.get('pc', 0):#x}"
+ extra = ""
+ regs = event.get("regs") or {}
+ if "a0" in regs:
+ extra = f" a0={regs['a0']:#x}"
+ if kind == "leave" and "retval" in event:
+ extra += f" -> {event['retval']:#x}"
+ lines.append(f"* `{t:8.3f}s` {kind}: {site}{extra}")
+ elif kind == "watch_hit":
+ lines.append(f"* `{t:8.3f}s` watch: {annotate(event.get('addr'))} ({event.get('kind')})")
+ elif kind == "mem_write":
+ lines.append(
+ f"* `{t:8.3f}s` write: {annotate(event.get('addr'))} "
+ f"{event.get('size')}B {event.get('data', '')[:32]}"
+ )
+ elif kind == "note":
+ lines.append(f"* `{t:8.3f}s` note: {event.get('text')}")
+ elif kind == "exit":
+ lines.append(f"* `{t:8.3f}s` exit status={event.get('status')}")
+ lines.append("")
+
+ # Symbol-annotation table for automation
+ lines.append("## Annotated sites")
+ lines.append("")
+ sites = Counter()
+ for event in events:
+ for key in ("pc", "addr"):
+ value = event.get(key)
+ if isinstance(value, int) and value and event.get("type") in ("bp_hit", "watch_hit", "mem_write"):
+ sites[value] += 1
+ lines.append("| address | count | annotation |")
+ lines.append("|---|---:|---|")
+ for addr, count in sites.most_common(max_rows):
+ lines.append(f"| `{addr:#x}` | {count} | `{annotate(addr)}` |")
+ lines.append("")
+ return "\n".join(lines) + "\n"
+
+
+def compare_sessions(a_dir: str, b_dir: str, symbols=None, max_rows: int = 40) -> str:
+ """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)
+ 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):
+ bp = Counter()
+ watch = Counter()
+ writes = Counter()
+ for event in events:
+ if event.get("type") == "bp_hit":
+ bp[event.get("symbol") or f"{event.get('pc', 0):#x}"] += 1
+ elif event.get("type") == "watch_hit":
+ watch[(event.get("addr"), event.get("kind"))] += 1
+ elif event.get("type") == "mem_write":
+ writes[(event.get("addr", 0) >> 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)
+
+ lines.append("## Breakpoint profile delta (A vs B)")
+ lines.append("")
+ lines.append("| symbol | A hits | B hits | delta |")
+ lines.append("|---|---:|---:|---:|")
+ for name in sorted(set(a_bp) | set(b_bp), key=lambda k: -(a_bp.get(k, 0) + b_bp.get(k, 0)))[:max_rows]:
+ lines.append(f"| `{name}` | {a_bp.get(name, 0)} | {b_bp.get(name, 0)} | {b_bp.get(name, 0) - a_bp.get(name, 0):+d} |")
+ lines.append("")
+
+ lines.append("## Watchpoint delta")
+ lines.append("")
+ lines.append("| address | kind | A | B |")
+ lines.append("|---|---|---:|---:|")
+ for key in sorted(set(a_watch) | set(b_watch), key=lambda k: -(a_watch.get(k, 0) + b_watch.get(k, 0)))[:max_rows]:
+ lines.append(f"| `{key[0]:#x}` | {key[1]} | {a_watch.get(key, 0)} | {b_watch.get(key, 0)} |")
+ lines.append("")
+
+ lines.append("## Written pages delta")
+ lines.append("")
+ lines.append("| page | A writes | B writes |")
+ lines.append("|---|---:|---:|")
+ 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("")
+ 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
new file mode 100644
index 0000000..5bfcee2
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/cli.py
@@ -0,0 +1,688 @@
+"""green-eye command line interface.
+
+Commands:
+ info environment/tooling check
+ 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
+ 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
+ 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
+ plugins list discoverable plugins
+"""
+from __future__ import annotations
+
+import argparse
+import cmd
+import os
+import sys
+import tempfile
+import time
+from pathlib import Path
+
+import greeneye as ge
+from . import analyze as _analyze
+from . import ghidra as _ghidra
+from .elf import load_elf
+from .errors import GreenEyeError
+from .plugins import PluginManager
+from .structs import schema_from_json
+from .symbols import SymbolDB
+from .target import QemuTarget
+
+TOOL_ROOT = Path(__file__).resolve().parent.parent
+DUMPS = TOOL_ROOT / "dumps"
+DEMO_ELF = TOOL_ROOT / "firmware" / "uproc_demo" / "build" / "uproc_demo.elf"
+DEMO_SCHEMA = TOOL_ROOT / "firmware" / "uproc_demo" / "gsp_ctrl.schema.json"
+DEFAULT_FWIMAGE = TOOL_ROOT.parents[1] / "firmware" / "57X-GSP" / "570.144" / "gsp-570.144.fwimage.bin"
+
+RPC_HANDLERS = ["rpc_ping", "rpc_echo", "rpc_fill", "rpc_sum", "rpc_state", "rpc_fault"]
+
+
+# ---------------------------------------------------------------------------
+# helpers
+# ---------------------------------------------------------------------------
+
+
+def _ensure_dumps_env() -> None:
+ os.environ.setdefault("GREENEYE_DUMPS", str(DUMPS))
+
+
+def _build_demo(quiet: bool = False) -> Path:
+ import subprocess
+
+ fw_dir = TOOL_ROOT / "firmware" / "uproc_demo"
+ result = subprocess.run(
+ ["make", "-C", str(fw_dir)],
+ capture_output=quiet,
+ text=True,
+ )
+ if result.returncode != 0:
+ output = (result.stdout or "") + (result.stderr or "")
+ raise GreenEyeError(f"demo firmware build failed:\n{output}")
+ if not DEMO_ELF.exists():
+ raise GreenEyeError(f"demo build produced no {DEMO_ELF}")
+ return DEMO_ELF
+
+
+def _load_schema(path: Path):
+ import json
+
+ with open(path, "r", encoding="utf-8") as fh:
+ return schema_from_json(json.load(fh))
+
+
+def _start_session(
+ *,
+ kernel: str | None,
+ port: int | None,
+ record,
+ name: str,
+ plugins: list[str] | None = None,
+):
+ if port:
+ 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)
+ manager = PluginManager(session)
+ manager.load_all(plugins)
+ return session, manager
+
+
+def _print_session_dir(session) -> None:
+ directory = getattr(session.recorder, "dir", None)
+ if directory:
+ print(f"[green-eye] session: {directory}")
+
+
+# ---------------------------------------------------------------------------
+# commands
+# ---------------------------------------------------------------------------
+
+
+def cmd_info(args) -> int:
+ from .target import DEFAULT_QEMU
+
+ print(f"green-eye {ge.__version__}")
+ print(f"tool root: {TOOL_ROOT}")
+ qemu = os.environ.get("GREENEYE_QEMU", DEFAULT_QEMU)
+ import shutil
+
+ resolved = shutil.which(qemu) if os.path.sep not in qemu else (qemu if os.path.exists(qemu) else None)
+ print(f"qemu: {resolved or 'NOT FOUND (install qemu-system-misc)'}")
+ if resolved:
+ import subprocess
+
+ version = subprocess.run([resolved, "--version"], capture_output=True, text=True).stdout.strip()
+ print(f"qemu version: {version}")
+ for label, path in [
+ ("demo firmware", DEMO_ELF),
+ ("demo schema", DEMO_SCHEMA),
+ ("default fwimage", DEFAULT_FWIMAGE),
+ ]:
+ print(f"{label}: {path} ({'present' if Path(path).exists() else 'missing'})")
+ cuda_names = Path(TOOL_ROOT).parents[1] / "userspace" / "cuda" / "570.144" / "annotations" / "names.tsv"
+ print(f"CUDA Ghidra names.tsv: {cuda_names} ({'present' if cuda_names.exists() else 'missing'})")
+ plugins_dir = TOOL_ROOT / "plugins"
+ plugins = sorted(p.name for p in plugins_dir.glob("*.py") if not p.name.startswith("_"))
+ print(f"bundled plugins: {', '.join(plugins) or 'none'}")
+ return 0
+
+
+def cmd_demo(args) -> int:
+ _ensure_dumps_env()
+ elf = args.kernel or (_build_demo() if not args.no_build else DEMO_ELF)
+ if not Path(elf).exists():
+ 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")
+ hits: list[str] = []
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ for name in RPC_HANDLERS:
+ sym = session.symbols.get(name)
+ if sym is not None:
+ session.hook(
+ sym.addr,
+ on_enter=lambda hit: hits.append(f"enter {hit.label()} a0={hit.arg(0):#x}"),
+ on_leave=lambda hit: hits.append(f"leave {hit.label()} -> {hit.retval:#x}"),
+ )
+ session.watch(ctrl + 16, size=8, kind="w", listener="rpc_count")
+ print(f"[demo] ctrl={ctrl:#x} schema={schema.name} size={schema.size:#x}")
+ print("[demo] running the firmware self-test ...")
+ session.run(duration=args.duration)
+ banner = session.target.read_uart() if session.target else ""
+ print("[demo] uart:", banner.strip().splitlines()[0] if banner.strip() else "<empty>")
+ before = session.snapshot(ctrl, schema.size, "ctrl-after-selftest")
+
+ print("[demo] host stimulus: PING")
+ session.memory.write_u64(ctrl + 32, 1)
+ session.memory.write_u64(ctrl + 40, 0x1234)
+ session.run(duration=0.5)
+ after = session.snapshot(ctrl, schema.size, "ctrl-after-ping")
+
+ values = schema.decode(after, base=ctrl)
+ print("[demo] control block after stimulus:")
+ from .structs import render
+
+ print(render(schema, values))
+ diffs = ge.Memory.diff(before, after, base=ctrl)
+ if diffs:
+ print("[demo] snapshot diff (self-test -> ping):")
+ print(ge.Memory.render_diff(diffs, session.symbols, max_lines=16))
+
+ print("[demo] host stimulus: FAULT (ebreak) to demonstrate trap capture")
+ session.memory.write_u64(ctrl + 32, 6)
+ session.run(duration=1.0, max_stops=1)
+ pc = session.pc
+ sym = session.symbols.resolve(pc)
+ print(f"[demo] stopped at {pc:#x} {sym.name if sym else '<unknown>'} (ebreak)")
+ session.step() # step past the trap
+ print("[demo] stepped past the trap; hits:")
+ for line in hits[:24]:
+ print(" ", line)
+ print(f"[demo] total hook lines: {len(hits)}")
+ _print_session_dir(session)
+ finally:
+ session.close()
+ return 0
+
+
+def cmd_run(args) -> int:
+ _ensure_dumps_env()
+ script = Path(args.script)
+ if not script.exists():
+ raise GreenEyeError(f"script not found: {script}")
+ kernel = args.kernel
+ 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
+ )
+ namespace = {
+ "ge": ge,
+ "session": session,
+ "s": session,
+ "memory": session.memory,
+ "symbols": session.symbols,
+ "recorder": session.recorder,
+ "greeneye": ge,
+ }
+ try:
+ source = script.read_text(encoding="utf-8")
+ exec(compile(source, str(script), "exec"), namespace)
+ return 0
+ except SystemExit as exc:
+ return int(exc.code or 0)
+ finally:
+ session.close()
+
+
+# -- interactive REPL -------------------------------------------------------
+
+
+class GreenEyeRepl(cmd.Cmd):
+ intro = "green-eye interactive instrumentation. Type 'help' for commands."
+ prompt = "greeneye> "
+
+ def __init__(self, session, manager):
+ super().__init__()
+ self.session = session
+ self.manager = manager
+ self._listener_hooks = []
+
+ # utility
+ def _print_hit(self, hit):
+ print(" " + str(hit))
+
+ def _say(self, text):
+ print(text)
+
+ # commands
+ def do_regs(self, arg):
+ """regs -- dump all registers."""
+ regs = self.session.read_regs()
+ for name, value in regs.items():
+ print(f" {name:<5} = {value:#018x}")
+
+ def do_reg(self, arg):
+ """reg <name> [value] -- read or write one register."""
+ parts = arg.split()
+ if not parts:
+ return self._say("usage: reg <name> [value]")
+ if len(parts) == 1:
+ print(f" {parts[0]} = {self.session.client.read_reg(parts[0]):#x}")
+ else:
+ self.session.client.write_reg(parts[0], int(parts[1], 0))
+ self._say("ok")
+
+ def do_x(self, arg):
+ """x <addr> [len] -- hexdump memory (annotated)."""
+ parts = arg.split()
+ if not parts:
+ return self._say("usage: x <addr> [len]")
+ addr = self.session.resolve(parts[0])
+ length = int(parts[1], 0) if len(parts) > 1 else 64
+ print(self.session.memory.hexdump(addr, length))
+
+ def do_mem(self, arg):
+ """mem <addr> <len> -- raw memory read."""
+ addr, length = arg.split()
+ print(self.session.memory.read(self.session.resolve(addr), int(length, 0)).hex())
+
+ def do_w(self, arg):
+ """w <addr> <hexbytes> -- write memory."""
+ addr, data = arg.split()
+ self.session.memory.write(self.session.resolve(addr), bytes.fromhex(data))
+ self._say("ok")
+
+ def do_wstr(self, arg):
+ """wstr <addr> <text> -- write a string plus NUL."""
+ addr, _, text = arg.partition(" ")
+ self.session.memory.write(self.session.resolve(addr), text.encode() + b"\x00")
+ self._say("ok")
+
+ def do_bp(self, arg):
+ """bp <addr|symbol> [sw|hw] -- set a breakpoint that prints on hit."""
+ parts = arg.split()
+ if not parts:
+ return self._say("usage: bp <addr|symbol> [sw|hw]")
+ kind = parts[1] if len(parts) > 1 else "sw"
+ handle = self.session.hook(parts[0], on_enter=self._print_hit, kind=kind, listener="repl")
+ self._listener_hooks.append(handle)
+ self._say(f"breakpoint @ {handle.addr:#x} ({handle.label})")
+
+ def do_hook(self, arg):
+ """hook <addr|symbol> -- alias for bp with enter/leave printing."""
+ parts = arg.split()
+ if not parts:
+ return self._say("usage: hook <addr|symbol>")
+ handle = self.session.hook(
+ parts[0], on_enter=self._print_hit, on_leave=self._print_hit, listener="repl"
+ )
+ self._listener_hooks.append(handle)
+ self._say(f"hooked {handle.label}")
+
+ def do_watch(self, arg):
+ """watch <addr|symbol> [size] [w|r|a] -- set a watchpoint."""
+ parts = arg.split()
+ if not parts:
+ return self._say("usage: watch <addr|symbol> [size] [w|r|a]")
+ size = int(parts[1], 0) if len(parts) > 1 else 8
+ kind = parts[2] if len(parts) > 2 else "w"
+ wp = self.session.watch(parts[0], size=size, kind=kind, callback=self._print_hit, listener="repl")
+ self._say(f"watch {kind}@{wp.addr:#x} size={size}")
+
+ def do_s(self, arg):
+ """s [n] -- step n instructions (default 1)."""
+ count = int(arg) if arg.strip() else 1
+ for hit in self.session.step(count):
+ self._print_hit(hit)
+
+ def do_c(self, arg):
+ """c [seconds] -- continue and service events for N seconds (default 1)."""
+ seconds = float(arg) if arg.strip() else 1.0
+ stops = self.session.run(duration=seconds)
+ self._say(f"resumed for {seconds}s, serviced {stops} stops")
+
+ def do_events(self, arg):
+ """events [n] -- show the last n recorded events (default 20)."""
+ from .record import load_events
+
+ directory = getattr(self.session.recorder, "dir", None)
+ if not directory:
+ return self._say("this session is not recording")
+ events = load_events(directory)
+ for event in events[-int(arg or 20) :]:
+ print(" " + str(event))
+
+ def do_snap(self, arg):
+ """snap <label> <addr> <len> -- snapshot a memory region into the session."""
+ parts = arg.split()
+ if len(parts) < 3:
+ return self._say("usage: snap <label> <addr> <len>")
+ data = self.session.memory.snapshot(self.session.resolve(parts[1]), int(parts[2], 0), name=parts[0])
+ self._say(f"snapshot {parts[0]}: {len(data)} bytes")
+
+ def do_sym(self, arg):
+ """sym <addr|name> -- resolve a symbol or an address."""
+ target = arg.strip()
+ if not target:
+ return self._say("usage: sym <addr|name>")
+ try:
+ print(" " + self.session.symbols.annotate(self.session.resolve(target)))
+ except GreenEyeError as exc:
+ self._say(f"error: {exc}")
+
+ def do_note(self, arg):
+ """note <text> -- add a note to the session recording."""
+ self.session.note(arg)
+ self._say("noted")
+
+ def do_quit(self, arg):
+ """quit -- close the session and exit."""
+ return True
+
+ do_EOF = do_quit
+
+ def onecmd(self, line):
+ name = line.split()[0] if line.split() else ""
+ if name in self.manager.api.commands:
+ fn, _help = self.manager.api.commands[name]
+ try:
+ fn(self.session, line.split()[1:])
+ except Exception as exc:
+ self._say(f"plugin error: {exc}")
+ return False
+ if name and name not in self.get_names() and not hasattr(self, f"do_{name}"):
+ return super().onecmd(line)
+ return super().onecmd(line)
+
+ def get_names(self):
+ names = super().get_names()
+ names += [f"do_{cmd}" for cmd in self.manager.api.commands]
+ return names
+
+ def default(self, line):
+ self._say(f"unknown command: {line.split()[0] if line.split() else line!r}")
+
+
+def cmd_attach(args) -> int:
+ _ensure_dumps_env()
+ kernel = args.kernel
+ if kernel:
+ kernel = str(Path(kernel))
+ 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
+ )
+ try:
+ GreenEyeRepl(session, manager).cmdloop()
+ finally:
+ session.close()
+ return 0
+
+
+def cmd_launch(args) -> int:
+ """Start a target, print the gdbstub endpoint, and keep QEMU alive."""
+ _ensure_dumps_env()
+ kernel = args.kernel or str(_build_demo())
+ if not Path(kernel).exists():
+ raise GreenEyeError(f"kernel not found: {kernel}")
+ session = ge.launch(kernel=str(kernel), record=args.record, name="launch", keep_alive=True)
+ port = session.target.port
+ print(f"[green-eye] target running: {kernel}")
+ print(f"[green-eye] gdbstub: 127.0.0.1:{port}")
+ print(f"[green-eye] attach: ./greeneye.sh attach --port {port} --kernel {kernel}")
+ print("[green-eye] Ctrl-C stops waiting; QEMU keeps running for attach.")
+ try:
+ while True:
+ time.sleep(1)
+ except KeyboardInterrupt:
+ print("[green-eye] detached (QEMU still running)")
+ return 0
+
+
+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)
+ try:
+ for spec in args.watch or []:
+ addr_or_sym, size = (spec.split(":", 1) + ["8"])[:2]
+ session.watch(addr_or_sym, size=int(size, 0))
+ for spec in args.snapshot or []:
+ addr_or_sym, size = spec.split(":", 1)
+ session.snapshot(session.resolve(addr_or_sym), int(size, 0), name=addr_or_sym)
+ session.run(duration=args.duration)
+ print(f"[record] serviced run for {args.duration}s")
+ _print_session_dir(session)
+ finally:
+ session.close()
+ return 0
+
+
+def cmd_probe(args) -> int:
+ _ensure_dumps_env()
+ elf = Path(args.elf)
+ if not elf.exists():
+ raise GreenEyeError(f"firmware not found: {elf}")
+ image = load_elf(str(elf))
+ print(f"[probe] {image.summary()}")
+ relocate = not args.no_relocate
+ bias = (0x80000000 - image.min_vaddr) if relocate else 0
+ kernel_path = str(elf)
+ if relocate:
+ from .elf import write_flat_kernel
+
+ out = Path(tempfile.mkdtemp(prefix="greeneye-probe-")) / (elf.name + ".flat.bin")
+ kernel_path, entry_addr = write_flat_kernel(str(elf), str(out), load_addr=0x80000000)
+ size = Path(kernel_path).stat().st_size
+ print(
+ f"[probe] flat kernel {kernel_path} ({size} bytes); "
+ f"load 0x80000000 entry {entry_addr:#x} (original {image.entry:#x})"
+ )
+ target = QemuTarget(
+ kernel=kernel_path,
+ dlog="guest_errors,unimp,int",
+ memory_mb=max(256, (image.max_vaddr - image.min_vaddr) // (1024 * 1024) + 64),
+ )
+ target.start(paused=True)
+ from .rsp import RspClient
+
+ client = RspClient(target.host, target.port)
+ client.connect()
+ client.sync()
+ symbols = SymbolDB()
+ if args.symbols:
+ symbols.load_json(args.symbols)
+ session = ge.Session(
+ client=client,
+ target=target,
+ symbols=symbols,
+ record=args.record or True,
+ name=f"probe-{elf.stem}",
+ meta={"firmware": str(elf), "entry": image.entry, "bias": bias, "kernel": kernel_path},
+ )
+ session.load_image(image, bias=bias)
+ try:
+ if args.instructions:
+ print(f"[probe] stepping {args.instructions} instructions ...")
+ session.step(args.instructions)
+ else:
+ print(f"[probe] running for {args.duration}s ...")
+ session.run(duration=args.duration)
+ pc = session.pc
+ sym = session.symbols.resolve(pc)
+ print(f"[probe] stopped at {pc:#x} {sym.name if sym else '<unknown>'}")
+ from .analyze import describe_fault, parse_qemu_faults
+
+ qlog = target.read_qemu_log()
+ faults = parse_qemu_faults(qlog)
+ if faults:
+ print("[probe] " + describe_fault(faults[0], bias=bias))
+ print(f"[probe] total exceptions: {len(faults)}")
+ else:
+ print("[probe] no exception recorded (execution stayed in mapped memory)")
+ tail = qlog.strip().splitlines()
+ if tail:
+ print("[probe] qemu log tail:")
+ for line in tail[-6:]:
+ print(" ", line)
+ _print_session_dir(session)
+ finally:
+ session.close()
+ return 0
+
+
+def cmd_analyze(args) -> int:
+ symbols = None
+ if args.symbols:
+ symbols = SymbolDB()
+ symbols.load_json(args.symbols)
+ report = _analyze.render_summary(args.session, symbols=symbols)
+ if args.out:
+ Path(args.out).write_text(report, encoding="utf-8")
+ print(f"wrote {args.out}")
+ else:
+ print(report)
+ return 0
+
+
+def cmd_compare(args) -> int:
+ symbols = SymbolDB() if args.symbols else None
+ report = _analyze.compare_sessions(args.a, args.b, symbols=symbols)
+ print(report)
+ return 0
+
+
+def cmd_symbols(args) -> int:
+ if args.symbols_command == "export":
+ db = _ghidra.build_symbol_db(
+ names_tsv=args.names,
+ index_tsv=args.index,
+ exports_tsv=args.exports,
+ callgraph_tsv=args.callgraph,
+ elf=args.elf,
+ bias=args.bias,
+ )
+ db.save_json(args.out, meta={"bias": args.bias})
+ print(f"wrote {args.out}: {len(db)} symbols")
+ return 0
+ if args.symbols_command == "resolve":
+ db = SymbolDB()
+ if args.db:
+ db.load_json(args.db)
+ print(db.annotate(int(args.addr, 0)))
+ return 0
+ return 1
+
+
+def cmd_plugins(args) -> int:
+ plugins_dir = TOOL_ROOT / "plugins"
+ print("bundled plugins:")
+ for path in sorted(plugins_dir.glob("*.py")):
+ if path.name.startswith("_"):
+ continue
+ print(f" {path.name}")
+ env = os.environ.get("GREENEYE_PLUGINS")
+ if env:
+ print(f"GREENEYE_PLUGINS: {env}")
+ return 0
+
+
+# ---------------------------------------------------------------------------
+# parser
+# ---------------------------------------------------------------------------
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="greeneye", description=__doc__.splitlines()[0])
+ parser.add_argument("--version", action="version", version=f"green-eye {ge.__version__}")
+ sub = parser.add_subparsers(dest="command", required=True)
+
+ sub.add_parser("info", help="environment/tooling check").set_defaults(fn=cmd_info)
+
+ demo = sub.add_parser("demo", help="boot the demo uproc and instrument it")
+ demo.add_argument("--kernel", help="firmware ELF (default: built demo)")
+ 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)
+ demo.set_defaults(fn=cmd_demo)
+
+ run = sub.add_parser("run", help="run a Python instrumentation script")
+ run.add_argument("script")
+ run.add_argument("--kernel")
+ run.add_argument("--port", type=int)
+ run.add_argument("--record", nargs="?", const=True, default=None)
+ run.add_argument("--plugin", action="append", default=[])
+ run.set_defaults(fn=cmd_run)
+
+ attach = sub.add_parser("attach", help="interactive REPL over a gdbstub (launches if no --port)")
+ attach.add_argument("--port", type=int, help="attach to an existing gdbstub instead of launching")
+ 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=[])
+ attach.set_defaults(fn=cmd_attach)
+
+ launch = sub.add_parser("launch", help="start a target and keep it alive for attach")
+ launch.add_argument("--kernel", help="firmware ELF (default: built demo)")
+ launch.add_argument("--record", nargs="?", const=True, default=None)
+ launch.set_defaults(fn=cmd_launch)
+
+ probe = sub.add_parser("probe", help="run a firmware image and report first divergence")
+ probe.add_argument("elf", nargs="?", default=str(DEFAULT_FWIMAGE))
+ probe.add_argument("--no-relocate", action="store_true")
+ probe.add_argument("--instructions", type=int, default=0)
+ probe.add_argument("--duration", type=float, default=1.0)
+ probe.add_argument("--symbols", help="green-eye symbol DB JSON to annotate the stop")
+ probe.add_argument("--record", nargs="?", const=True, default=None)
+ probe.set_defaults(fn=cmd_probe)
+
+ record = sub.add_parser("record", help="boot and record a memory-activity session")
+ record.add_argument("--kernel")
+ record.add_argument("--name", default="record")
+ record.add_argument("--duration", type=float, default=2.0)
+ 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)
+ record.set_defaults(fn=cmd_record)
+
+ analyze = sub.add_parser("analyze", help="render a recorded session report")
+ analyze.add_argument("session")
+ analyze.add_argument("--symbols")
+ analyze.add_argument("--out")
+ analyze.set_defaults(fn=cmd_analyze)
+
+ compare = sub.add_parser("compare", help="A/B two recorded sessions")
+ compare.add_argument("a")
+ compare.add_argument("b")
+ compare.add_argument("--symbols", action="store_true")
+ compare.set_defaults(fn=cmd_compare)
+
+ symbols = sub.add_parser("symbols", help="build/inspect a symbol database")
+ symbols_sub = symbols.add_subparsers(dest="symbols_command", required=True)
+ exp = symbols_sub.add_parser("export")
+ exp.add_argument("--names")
+ exp.add_argument("--index")
+ exp.add_argument("--exports")
+ exp.add_argument("--callgraph")
+ exp.add_argument("--elf")
+ exp.add_argument("--bias", type=lambda v: int(v, 0), default=0)
+ exp.add_argument("-o", "--out", required=True)
+ exp.set_defaults(fn=cmd_symbols)
+ res = symbols_sub.add_parser("resolve")
+ res.add_argument("addr")
+ res.add_argument("--db")
+ res.set_defaults(fn=cmd_symbols)
+
+ plugins = sub.add_parser("plugins", help="list discoverable plugins")
+ plugins.set_defaults(fn=cmd_plugins)
+
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ _ensure_dumps_env()
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ return args.fn(args)
+ except GreenEyeError as exc:
+ print(f"green-eye error: {exc}", file=sys.stderr)
+ return 2
+ except KeyboardInterrupt:
+ print("interrupted", file=sys.stderr)
+ return 130
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/elf.py b/debug/risc-v-gsp-rm-vm/greeneye/elf.py
new file mode 100644
index 0000000..abfccd9
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/elf.py
@@ -0,0 +1,322 @@
+"""Minimal ELF64/ELF32 reader (stdlib only).
+
+Needed for three things:
+ * symbol resolution for the demo uproc firmware (breakpoints by name),
+ * program-header extraction so the real GSP ``.fwimage`` can be placed into
+ QEMU RAM (and relocated when a probe needs it),
+ * entry-point detection for the first-fault firmware probe.
+
+This is not a full ELF library; it parses headers, PT_LOAD segments and
+``.symtab`` symbols, which is all green-eye needs.
+"""
+from __future__ import annotations
+
+import struct
+from dataclasses import dataclass, field as _field
+
+from .errors import GreenEyeError
+
+EM_RISCV = 243
+PT_LOAD = 1
+SHT_SYMTAB = 2
+SHT_STRTAB = 3
+STT_FUNC = 2
+STT_OBJECT = 1
+STT_NOTYPE = 0
+
+
+class ElfError(GreenEyeError):
+ """Malformed or unsupported ELF file."""
+
+
+@dataclass
+class Segment:
+ p_type: int
+ p_flags: int
+ p_offset: int
+ p_vaddr: int
+ p_paddr: int
+ p_filesz: int
+ p_memsz: int
+ p_align: int
+
+ @property
+ def is_load(self) -> bool:
+ return self.p_type == PT_LOAD
+
+ @property
+ def writable(self) -> bool:
+ return bool(self.p_flags & 0x2)
+
+ @property
+ def executable(self) -> bool:
+ return bool(self.p_flags & 0x1)
+
+
+@dataclass
+class ElfSymbol:
+ name: str
+ value: int
+ size: int
+ bind: int
+ type: int
+
+ @property
+ def is_function(self) -> bool:
+ return self.type == STT_FUNC
+
+ @property
+ def is_object(self) -> bool:
+ return self.type == STT_OBJECT
+
+
+@dataclass
+class FirmwareImage:
+ path: str
+ is64: bool
+ e_machine: int
+ entry: int
+ segments: list[Segment] = _field(default_factory=list)
+ symbols: list[ElfSymbol] = _field(default_factory=list)
+ _blob: bytes | None = None
+
+ @property
+ def is_riscv(self) -> bool:
+ return self.e_machine == EM_RISCV
+
+ @property
+ def load_segments(self) -> list[Segment]:
+ return [s for s in self.segments if s.is_load]
+
+ @property
+ def min_vaddr(self) -> int:
+ loads = self.load_segments
+ return min((s.p_vaddr for s in loads), default=0)
+
+ @property
+ def max_vaddr(self) -> int:
+ loads = self.load_segments
+ return max((s.p_vaddr + s.p_memsz for s in loads), default=0)
+
+ def read(self) -> bytes:
+ if self._blob is None:
+ with open(self.path, "rb") as fh:
+ self._blob = fh.read()
+ return self._blob
+
+ def segment_bytes(self, seg: Segment) -> bytes:
+ return self.read()[seg.p_offset : seg.p_offset + seg.p_filesz]
+
+ def flat_load_plan(self, bias: int = 0) -> list[tuple[int, bytes]]:
+ """Return ``(load_addr, data)`` pairs for every PT_LOAD segment.
+
+ ``bias`` is added to each segment's virtual address (use it to place a
+ firmware linked for GPU physical addresses into QEMU RAM).
+ """
+ plan: list[tuple[int, bytes]] = []
+ for seg in self.load_segments:
+ data = bytearray(self.segment_bytes(seg))
+ if len(data) < seg.p_memsz:
+ data += b"\x00" * (seg.p_memsz - len(data))
+ plan.append((seg.p_vaddr + bias, bytes(data)))
+ return plan
+
+ def relocated_entry(self, bias: int = 0) -> int:
+ return self.entry + bias
+
+ def summary(self) -> str:
+ return (
+ f"{self.path}: ELF{'64' if self.is64 else '32'} "
+ f"machine={self.e_machine} entry={self.entry:#x} "
+ f"segments={len(self.segments)} loads={len(self.load_segments)} "
+ f"symbols={len(self.symbols)} "
+ f"vaddr=[{self.min_vaddr:#x},{self.max_vaddr:#x})"
+ )
+
+
+def load_elf(path: str) -> FirmwareImage:
+ with open(path, "rb") as fh:
+ data = fh.read()
+ return parse_elf(data, path=path)
+
+
+def parse_elf(data: bytes, path: str = "<memory>") -> FirmwareImage:
+ if len(data) < 52 or data[:4] != b"\x7fELF":
+ raise ElfError(f"{path}: not an ELF file")
+ ei_class = data[4]
+ if ei_class == 2:
+ is64 = True
+ hdr_fmt = "<HHIQQQIHHHHHH"
+ elif ei_class == 1:
+ is64 = False
+ hdr_fmt = "<HHIIIIIHHHHHH"
+ else:
+ raise ElfError(f"{path}: unknown EI_CLASS {ei_class}")
+ hdr_size = struct.calcsize(hdr_fmt)
+ fields = struct.unpack_from(hdr_fmt, data, 16)
+ (
+ _e_type,
+ e_machine,
+ _e_version,
+ e_entry,
+ e_phoff,
+ e_shoff,
+ _e_flags,
+ _e_ehsize,
+ e_phentsize,
+ e_phnum,
+ e_shentsize,
+ e_shnum,
+ e_shstrndx,
+ ) = fields
+
+ image = FirmwareImage(path=path, is64=is64, e_machine=e_machine, entry=e_entry)
+
+ if is64:
+ ph_fmt = "<IIQQQQQQ"
+ sh_fmt = "<IIQQQQIIQQ"
+ sym_fmt = "<IBBHQQ"
+ else:
+ ph_fmt = "<IIIIIIII"
+ sh_fmt = "<IIIIIIIIII"
+ sym_fmt = "<IIIBBH"
+
+ ph_size = struct.calcsize(ph_fmt)
+ for i in range(e_phnum):
+ base = e_phoff + i * e_phentsize
+ if base + ph_size > len(data):
+ break
+ p = struct.unpack_from(ph_fmt, data, base)
+ image.segments.append(Segment(*p[:8]))
+
+ # Sections -> symbols
+ sh_size = struct.calcsize(sh_fmt)
+ sections: list[tuple[int, int, int, int]] = [] # type, offset, size, link
+ for i in range(e_shnum):
+ base = e_shoff + i * e_shentsize
+ if base + sh_size > len(data):
+ break
+ sh = struct.unpack_from(sh_fmt, data, base)
+ sh_type, sh_offset, sh_size_val, sh_link = sh[1], sh[4], sh[5], sh[6]
+ sections.append((sh_type, sh_offset, sh_size_val, sh_link))
+
+ for sh_type, sh_offset, sh_size_val, sh_link in sections:
+ if sh_type != SHT_SYMTAB:
+ continue
+ strtab_off, strtab_size = 0, 0
+ if 0 <= sh_link < len(sections):
+ _, strtab_off, strtab_size, _ = sections[sh_link]
+ strtab = data[strtab_off : strtab_off + strtab_size]
+ count = sh_size_val // struct.calcsize(sym_fmt)
+ for i in range(count):
+ base = sh_offset + i * struct.calcsize(sym_fmt)
+ if base + struct.calcsize(sym_fmt) > len(data):
+ break
+ st = struct.unpack_from(sym_fmt, data, base)
+ if is64:
+ st_name, st_info, _st_other, _st_shndx, st_value, st_size_val = st
+ else:
+ st_name, st_value, st_size_val, st_info, _st_other, _st_shndx = st
+ if st_name == 0 or st_value == 0:
+ continue
+ name = _read_cstr(strtab, st_name)
+ if not name:
+ continue
+ image.symbols.append(
+ ElfSymbol(
+ name=name,
+ value=st_value,
+ size=st_size_val,
+ bind=(st_info >> 4) & 0xF,
+ type=st_info & 0xF,
+ )
+ )
+ return image
+
+
+def _read_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 write_relocated_elf(path: str, out_path: str, bias: int) -> str:
+ """Write a copy of an ELF64 whose PT_LOADs and entry are shifted by bias.
+
+ ``p_paddr`` is forced to ``p_vaddr + bias`` so the layout matches the
+ virtual addresses. Useful for formats/tools that honour e_entry.
+ """
+ with open(path, "rb") as fh:
+ data = bytearray(fh.read())
+ if len(data) < 64 or data[:4] != b"\x7fELF" or data[4] != 2:
+ raise ElfError(f"{path}: relocation requires a 64-bit ELF")
+ e_entry = struct.unpack_from("<Q", data, 24)[0]
+ e_phoff = struct.unpack_from("<Q", data, 32)[0]
+ e_phentsize = struct.unpack_from("<H", data, 54)[0]
+ e_phnum = struct.unpack_from("<H", data, 56)[0]
+ struct.pack_into("<Q", data, 24, e_entry + bias)
+ for i in range(e_phnum):
+ base = e_phoff + i * e_phentsize
+ if base + 56 > len(data):
+ break
+ p_type = struct.unpack_from("<I", data, base)[0]
+ if p_type != PT_LOAD:
+ continue
+ p_vaddr = struct.unpack_from("<Q", data, base + 16)[0]
+ struct.pack_into("<Q", data, base + 16, p_vaddr + bias)
+ struct.pack_into("<Q", data, base + 24, p_vaddr + bias)
+ with open(out_path, "wb") as fh:
+ fh.write(data)
+ return out_path
+
+
+def _trampoline(entry_offset: int) -> bytes:
+ """RV64 code at offset 0 that jumps to ``PC + entry_offset``.
+
+ QEMU's RISC-V ``-kernel`` raw/ELF path starts the CPU at the RAM base for
+ images whose e_entry it does not honour; this 8-byte stub (auipc t0, hi;
+ jalr x0, t0, lo) makes the firmware entry reachable from the base without
+ patching the image itself.
+ """
+ if not -0x80000000 <= entry_offset < 0x80000000:
+ raise ElfError("entry offset out of trampoline range")
+ hi20 = ((entry_offset + 0x800) >> 12) & 0xFFFFF
+ lo12 = entry_offset & 0xFFF
+ auipc = (hi20 << 12) | (5 << 7) | 0x17 # auipc t0, hi20
+ jalr = (lo12 << 20) | (5 << 15) | 0x67 # jalr x0, t0, lo12
+ return struct.pack("<II", auipc, jalr)
+
+
+def write_flat_kernel(
+ path: str,
+ out_path: str,
+ *,
+ load_addr: int = 0x80000000,
+ trampoline: bool = True,
+) -> tuple[str, int]:
+ """Flatten an ELF's PT_LOADs into a raw image QEMU can boot as `-kernel`.
+
+ Returns ``(out_path, entry_addr)`` where ``entry_addr = load_addr +
+ (e_entry - min_vaddr)``. When the entry is not at offset 0 a tiny
+ PC-relative trampoline is written at offset 0 (QEMU starts at the load
+ base for raw kernels). ``load_addr`` is not encoded anywhere except in
+ the returned entry address: all code is position-independent relative to
+ the image base, exactly as it executes on the device.
+ """
+ image = load_elf(path)
+ span = image.max_vaddr - image.min_vaddr
+ flat = bytearray(span)
+ for seg in image.load_segments:
+ start = seg.p_vaddr - image.min_vaddr
+ end = start + seg.p_filesz
+ flat[start:end] = image.segment_bytes(seg)
+ entry_offset = image.entry - image.min_vaddr
+ if trampoline and entry_offset != 0:
+ stub = _trampoline(entry_offset)
+ flat[0 : len(stub)] = stub
+ with open(out_path, "wb") as fh:
+ fh.write(flat)
+ return out_path, load_addr + entry_offset
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/errors.py b/debug/risc-v-gsp-rm-vm/greeneye/errors.py
new file mode 100644
index 0000000..fab5874
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/errors.py
@@ -0,0 +1,30 @@
+"""green-eye error types. Keep them few and meaningful."""
+from __future__ import annotations
+
+
+class GreenEyeError(Exception):
+ """Base class for all green-eye errors."""
+
+
+class RspError(GreenEyeError):
+ """Base class for gdbstub RSP transport errors."""
+
+
+class RspTimeout(RspError):
+ """The stub did not answer within the timeout."""
+
+
+class RspProtocolError(RspError):
+ """The stub answered with a malformed packet or an error reply."""
+
+
+class TargetError(GreenEyeError):
+ """QEMU target lifecycle/configuration error."""
+
+
+class SymbolNotFound(GreenEyeError):
+ """A requested symbol is not in the symbol database."""
+
+
+class StructError(GreenEyeError):
+ """Structure schema/decode error."""
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py b/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py
new file mode 100644
index 0000000..6986d74
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py
@@ -0,0 +1,80 @@
+"""Ghidra bridge: symbols in, hit annotations out.
+
+Two flows:
+
+*out of Ghidra*: export function names/addresses/sizes to a green-eye JSON
+symbol database (from ``names.tsv``, ``decompiled_index.tsv``, exports or ELF);
+*hits*: turn a recorded session into ``greeneye_hits.tsv``
+(``addr<TAB>hits<TAB>symbol``) that ``MarkGreenEyeHits.java`` can annotate in
+the Ghidra project.
+
+This is the static/dynamic join: Ghidra owns the address space and
+decompilation, green-eye owns what actually executed.
+"""
+from __future__ import annotations
+
+import os
+from collections import Counter
+
+from .record import load_events
+from .symbols import SymbolDB
+
+
+def build_symbol_db(
+ *,
+ names_tsv: str | None = None,
+ index_tsv: str | None = None,
+ exports_tsv: str | None = None,
+ callgraph_tsv: str | None = None,
+ elf: str | None = None,
+ bias: int = 0,
+) -> SymbolDB:
+ db = SymbolDB()
+ if elf:
+ db.load_elf(elf, bias=0) # ELF symbol values are already runtime addresses
+ if names_tsv:
+ db.load_names_tsv(names_tsv, bias=bias)
+ if index_tsv:
+ db.load_index_tsv(index_tsv, bias=bias)
+ if exports_tsv:
+ db.load_index_tsv(exports_tsv, bias=bias)
+ if callgraph_tsv:
+ db.load_callgraph(callgraph_tsv)
+ return db
+
+
+def export_hits(session_dir: str, out_tsv: str, symbols: SymbolDB | None = None) -> str:
+ events = load_events(session_dir)
+ hits: Counter = Counter()
+ for event in events:
+ if event.get("type") == "bp_hit":
+ hits[event.get("pc")] += 1
+ elif event.get("type") == "watch_hit":
+ hits[event.get("addr")] += 1
+ with open(out_tsv, "w", encoding="utf-8") as fh:
+ fh.write("addr\thits\tsymbol\tannotation\n")
+ for addr, count in sorted(hits.items(), key=lambda kv: -kv[1]):
+ if not isinstance(addr, int):
+ continue
+ sym = symbols.resolve(addr) if symbols else None
+ name = sym.name if sym else ""
+ annotation = symbols.annotate(addr) if symbols else f"{addr:#x}"
+ fh.write(f"{addr:#x}\t{count}\t{name}\t{annotation}\n")
+ return os.path.abspath(out_tsv)
+
+
+def corpus_location(symbol, corpus_root: str) -> str | None:
+ """Return ``<bucket>:<line>`` for a symbol with Ghidra index extras."""
+ bucket = symbol.extras.get("bucket")
+ line = symbol.extras.get("line")
+ if not bucket:
+ return None
+ return os.path.join(corpus_root, bucket) + (f":{line}" if line else "")
+
+
+def render_ghidra_command(project_dir: str, project_name: str, program: str, hits_tsv: str) -> str:
+ return (
+ "analyzeHeadless {proj} {name} -process {program} -noanalysis "
+ "-scriptPath <open-iov>/debug/risc-v-gsp-rm-vm/ghidra "
+ "-postScript MarkGreenEyeHits.java {hits}"
+ ).format(proj=project_dir, name=project_name, program=program, hits=hits_tsv)
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/memory.py b/debug/risc-v-gsp-rm-vm/greeneye/memory.py
new file mode 100644
index 0000000..23d9ca0
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/memory.py
@@ -0,0 +1,190 @@
+"""Memory inspection helpers: read/write, hexdump, search, snapshots, diffs."""
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+from .errors import GreenEyeError
+
+
+@dataclass
+class Diff:
+ addr: int
+ old: bytes
+ new: bytes
+
+ @property
+ def size(self) -> int:
+ return len(self.old)
+
+
+class Memory:
+ """A convenience facade over an RSP client (and optional symbol DB)."""
+
+ def __init__(self, client, symbols=None, recorder=None):
+ self.client = client
+ self.symbols = symbols
+ self.recorder = recorder
+
+ # -- basic access ------------------------------------------------------
+
+ def read(self, addr: int, size: int) -> bytes:
+ data = self.client.read_mem(addr, size)
+ if self.recorder is not None:
+ self.recorder.event("mem_read", addr=addr, size=size)
+ return data
+
+ def write(self, addr: int, data: bytes | int | str) -> None:
+ if isinstance(data, int):
+ data = bytes([data & 0xFF])
+ elif isinstance(data, str):
+ data = data.encode("utf-8")
+ self.client.write_mem(addr, data)
+ if self.recorder is not None:
+ self.recorder.event("mem_write", addr=addr, size=len(data), data=data.hex())
+
+ def read_u8(self, addr: int) -> int:
+ return self.read(addr, 1)[0]
+
+ def read_u16(self, addr: int) -> int:
+ return int.from_bytes(self.read(addr, 2), "little")
+
+ def read_u32(self, addr: int) -> int:
+ return int.from_bytes(self.read(addr, 4), "little")
+
+ def read_u64(self, addr: int) -> int:
+ return int.from_bytes(self.read(addr, 8), "little")
+
+ def write_u32(self, addr: int, value: int) -> None:
+ self.write(addr, value.to_bytes(4, "little"))
+
+ def write_u64(self, addr: int, value: int) -> None:
+ self.write(addr, value.to_bytes(8, "little"))
+
+ def read_cstring(self, addr: int, max_len: int = 256) -> str:
+ return self.read(addr, max_len).split(b"\x00", 1)[0].decode("utf-8", "replace")
+
+ def read_ptr(self, addr: int, width: int = 8) -> int:
+ return int.from_bytes(self.read(addr, width), "little")
+
+ # -- display -----------------------------------------------------------
+
+ def hexdump(self, addr: int, size: int, width: int = 16, annotate: bool = True) -> str:
+ data = self.read(addr, size)
+ lines: list[str] = []
+ if annotate and self.symbols is not None:
+ sym = self.symbols.resolve(addr)
+ if sym is not None:
+ lines.append(f"; {sym.label(addr)} (source={sym.source or '?'})")
+ for offset in range(0, len(data), width):
+ chunk = data[offset : offset + width]
+ hex_part = " ".join(f"{b:02x}" for b in chunk)
+ ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
+ lines.append(f"{addr + offset:08x} {hex_part:<{width * 3 - 1}} |{ascii_part}|")
+ return "\n".join(lines)
+
+ def hexdump_annotated(
+ self,
+ addr: int,
+ size: int,
+ struct_schema=None,
+ width: int = 16,
+ ) -> str:
+ """Hexdump plus a decoded structure overlay when a schema is given."""
+ lines = [self.hexdump(addr, size, width=width, annotate=True)]
+ if struct_schema is not None:
+ from .structs import decode, render
+
+ values = decode(struct_schema, self.read(addr, struct_schema.size), base=addr)
+ lines.append("")
+ lines.append(render(struct_schema, values))
+ return "\n".join(lines)
+
+ # -- search ------------------------------------------------------------
+
+ def search(
+ self,
+ addr: int,
+ size: int,
+ pattern: bytes | int,
+ *,
+ mask: bytes | None = None,
+ max_hits: int = 64,
+ chunk: int = 0x10000,
+ ) -> list[int]:
+ if isinstance(pattern, int):
+ pattern = bytes([pattern & 0xFF])
+ if not pattern:
+ raise GreenEyeError("empty search pattern")
+ if mask is not None and len(mask) != len(pattern):
+ raise GreenEyeError("mask length must match pattern length")
+ hits: list[int] = []
+ offset = 0
+ overlap = len(pattern) - 1
+ carry = b""
+ while offset < size and len(hits) < max_hits:
+ want = min(chunk, size - offset)
+ blob = carry + self.read(addr + offset - len(carry), want + len(carry)) if carry else self.read(addr + offset, want)
+ base = addr + offset - len(carry)
+ for index in range(0, len(blob) - len(pattern) + 1):
+ candidate = blob[index : index + len(pattern)]
+ if mask is None:
+ if candidate == pattern:
+ hits.append(base + index)
+ else:
+ if all((candidate[i] & mask[i]) == (pattern[i] & mask[i]) for i in range(len(pattern))):
+ hits.append(base + index)
+ if len(hits) >= max_hits:
+ break
+ carry = blob[-overlap:] if overlap else b""
+ offset += want
+ return hits
+
+ def search_u32(self, addr: int, size: int, value: int, **kw) -> list[int]:
+ return self.search(addr, size, value.to_bytes(4, "little"), **kw)
+
+ def search_string(self, addr: int, size: int, text: str, **kw) -> list[int]:
+ return self.search(addr, size, text.encode("utf-8"), **kw)
+
+ # -- capture / diff ----------------------------------------------------
+
+ def snapshot(self, addr: int, size: int, name: str = "region") -> bytes:
+ data = self.client.read_mem(addr, size)
+ if self.recorder is not None:
+ path = self.recorder.snapshot(name, addr, data)
+ self.recorder.event("snapshot", label=name, addr=addr, size=size, path=path)
+ return data
+
+ @staticmethod
+ def diff(old: bytes, new: bytes, base: int = 0) -> list[Diff]:
+ length = min(len(old), len(new))
+ changed = [i for i in range(length) if old[i] != new[i]]
+ if not changed:
+ return []
+ # coalesce adjacent bytes into runs
+ runs: list[Diff] = []
+ start = changed[0]
+ prev = changed[0]
+ for index in changed[1:]:
+ if index == prev + 1:
+ prev = index
+ continue
+ runs.append(Diff(base + start, old[start : prev + 1], new[start : prev + 1]))
+ start = prev = index
+ runs.append(Diff(base + start, old[start : prev + 1], new[start : prev + 1]))
+ return runs
+
+ @staticmethod
+ def render_diff(diffs: list[Diff], symbols=None, max_lines: int = 64) -> str:
+ lines: list[str] = []
+ for diff in diffs[:max_lines]:
+ old = " ".join(f"{b:02x}" for b in diff.old[:8])
+ new = " ".join(f"{b:02x}" for b in diff.new[:8])
+ label = ""
+ if symbols is not None:
+ sym = symbols.resolve(diff.addr)
+ if sym is not None:
+ label = f" ; {sym.label(diff.addr)}"
+ lines.append(f"{diff.addr:08x} {old:<24} -> {new:<24}{label}")
+ if len(diffs) > max_lines:
+ lines.append(f"... {len(diffs) - max_lines} more changed runs")
+ return "\n".join(lines)
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/plugins.py b/debug/risc-v-gsp-rm-vm/greeneye/plugins.py
new file mode 100644
index 0000000..af4545f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/plugins.py
@@ -0,0 +1,121 @@
+"""green-eye plugin system.
+
+A plugin is a normal Python module that exposes::
+
+ def register(ge):
+ ge.log("my plugin loaded")
+ ge.add_command("mycmd", run_mycmd, help="…")
+ ge.hook("rpc_sum", on_enter=lambda hit: ge.log(f"sum({hit.arg(0)})"))
+
+Plugins can extend green-eye with:
+
+* new CLI commands (including an MCP server later - roadmap);
+* runtime RE tools (extra watchpoint strategies, structure probes, coverage);
+* virtualisation-environment updates (e.g. record/replay harnesses);
+* AI-harness integration and reporting.
+
+Discovery order:
+
+1. explicit ``--plugin PATH`` arguments;
+2. colon-separated paths in ``GREENEYE_PLUGINS``;
+3. ``<tool>/plugins/*.py`` (the bundled examples).
+"""
+from __future__ import annotations
+
+import importlib.util
+import os
+import sys
+from typing import Callable
+
+
+class GreenEyeAPI:
+ """Handle passed to ``register(ge)``."""
+
+ def __init__(self, session, cli=None):
+ self.session = session
+ self.cli = cli
+ self.commands: dict[str, tuple[Callable, str]] = {}
+ self.loaded: list[str] = []
+
+ # -- convenience --------------------------------------------------------
+
+ @property
+ def memory(self):
+ return self.session.memory
+
+ @property
+ def symbols(self):
+ return self.session.symbols
+
+ @property
+ def recorder(self):
+ return self.session.recorder
+
+ def log(self, message: str, **fields) -> None:
+ self.session.recorder.event("plugin", message=message, **fields)
+
+ def hook(self, target, on_enter=None, on_leave=None, **kw):
+ return self.session.hook(target, on_enter=on_enter, on_leave=on_leave, **kw)
+
+ def watch(self, target, size: int = 8, kind: str = "w", callback=None, **kw):
+ return self.session.watch(target, size=size, kind=kind, callback=callback, **kw)
+
+ def on_event(self, kind: str, callback) -> None:
+ self.session.on_event(kind, callback)
+
+ def resolve(self, target):
+ return self.session.resolve(target)
+
+ def add_command(self, name: str, fn: Callable, help: str = "") -> None:
+ self.commands[name] = (fn, help)
+
+ def run_command(self, name: str, args: list[str]) -> int:
+ fn, _help = self.commands[name]
+ return int(fn(self.session, args) or 0)
+
+
+class PluginManager:
+ def __init__(self, session, cli=None):
+ self.session = session
+ self.api = GreenEyeAPI(session, cli=cli)
+ self.loaded: list[str] = []
+
+ def default_paths(self) -> list[str]:
+ here = os.path.dirname(os.path.abspath(__file__))
+ bundled = os.path.join(os.path.dirname(here), "plugins")
+ paths = []
+ env = os.environ.get("GREENEYE_PLUGINS", "")
+ paths += [p for p in env.split(os.pathsep) if p]
+ if os.path.isdir(bundled):
+ paths.append(bundled)
+ return paths
+
+ def load_all(self, explicit: list[str] | None = None) -> list[str]:
+ for path in (explicit or []) + self.default_paths():
+ if os.path.isdir(path):
+ for entry in sorted(os.listdir(path)):
+ if entry.endswith(".py") and not entry.startswith("_"):
+ self._load_file(os.path.join(path, entry))
+ elif path.endswith(".py") and os.path.isfile(path):
+ self._load_file(path)
+ return self.loaded
+
+ def _load_file(self, path: str) -> None:
+ name = os.path.splitext(os.path.basename(path))[0]
+ mod_name = f"greeneye_plugin_{name}"
+ try:
+ spec = importlib.util.spec_from_file_location(mod_name, path)
+ if spec is None or spec.loader is None:
+ return
+ module = importlib.util.module_from_spec(spec)
+ sys.modules[mod_name] = module
+ spec.loader.exec_module(module)
+ register = getattr(module, "register", None)
+ if register is None:
+ self.session.recorder.event("plugin_error", plugin=name, error="no register(ge)")
+ return
+ register(self.api)
+ self.loaded.append(name)
+ self.session.recorder.event("plugin_loaded", plugin=name)
+ except Exception as exc: # plugins must never break the session
+ self.session.recorder.event("plugin_error", plugin=name, error=repr(exc))
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/probes.py b/debug/risc-v-gsp-rm-vm/greeneye/probes.py
new file mode 100644
index 0000000..5837bab
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/probes.py
@@ -0,0 +1,166 @@
+"""Declarative probe specifications (the Frida-script-like layer).
+
+A probe spec is JSON so it can be generated by another tool (or an AI
+harness) and replayed unchanged:
+
+ {
+ "probes": [
+ {
+ "name": "sum-entry",
+ "symbol": "rpc_sum",
+ "when": "a1 > 0",
+ "on_enter": {
+ "log": ["a0", "a1", "a2"],
+ "dump": [{"reg": "a0", "len": 32, "label": "sum-arg"}],
+ "note": "sum called"
+ },
+ "on_leave": {"log": ["retval"]}
+ }
+ ]
+ }
+
+Conditions are evaluated over a register dictionary (``a0``..``a7``, ``pc``,
+``ra``, or raw ``x10`` names). The evaluator is deliberately tiny: it only
+accepts comparisons/boolean arithmetic over those names and integer
+literals, never arbitrary Python.
+"""
+from __future__ import annotations
+
+import json
+import re
+
+from .errors import GreenEyeError
+
+_ALLOWED_CHARS = re.compile(r"^[\s0-9a-fA-FxX<>=!()+\-*/%&|^~]*$")
+_NAME_RE = re.compile(r"\b[A-Za-z_][A-Za-z0-9_]*\b")
+_ALLOWED_NAMES = re.compile(
+ r"^(retval|pc|ra|sp|gp|tp|fp|and|or|not|True|False|None"
+ r"|[a-z][0-9]?|x[0-9]{1,2}|[stga][0-9]{1,2})$"
+)
+
+
+def load_spec(path: str) -> dict:
+ with open(path, "r", encoding="utf-8") as fh:
+ spec = json.load(fh)
+ if "probes" not in spec or not isinstance(spec["probes"], list):
+ raise GreenEyeError("probe spec must be an object with a 'probes' list")
+ return spec
+
+
+def compile_condition(expr: str | None):
+ if not expr:
+ return None
+ for name in _NAME_RE.findall(expr):
+ if not _ALLOWED_NAMES.match(name):
+ raise GreenEyeError(f"probe condition references disallowed name {name!r}")
+ if not _ALLOWED_CHARS.match(_NAME_RE.sub("0", expr)):
+ raise GreenEyeError(f"probe condition contains disallowed characters: {expr!r}")
+ code = compile(expr, "<probe-when>", "eval")
+
+ def check(regs: dict) -> bool:
+ scope = {
+ **{k: v for k, v in regs.items() if isinstance(v, int)},
+ "retval": regs.get("a0", 0),
+ }
+ try:
+ return bool(eval(code, {"__builtins__": {}}, scope)) # noqa: S307 - whitelisted above
+ except Exception:
+ return False
+
+ return check
+
+
+def install(session, spec: dict) -> list:
+ """Install every probe in the spec; returns the HookHandles."""
+ handles = []
+ for entry in spec["probes"]:
+ target = entry.get("symbol") or entry.get("addr")
+ if target is None:
+ raise GreenEyeError(f"probe {entry.get('name', '?')}: needs 'symbol' or 'addr'")
+ when = compile_condition(entry.get("when"))
+ name = entry.get("name", str(target))
+ on_enter_actions = entry.get("on_enter")
+ on_leave_actions = entry.get("on_leave")
+
+ def make_enter(name=name, when=when, actions=on_enter_actions):
+ def callback(hit):
+ if when is not None and not when(hit.regs):
+ return
+ _run_actions(hit, name, actions)
+
+ return callback
+
+ def make_leave(name=name, actions=on_leave_actions):
+ def callback(hit):
+ _run_actions(hit, name, actions, is_leave=True)
+
+ return callback
+
+ handle = session.hook(
+ target,
+ on_enter=make_enter() if on_enter_actions else None,
+ on_leave=make_leave() if on_leave_actions else None,
+ listener=f"probe:{name}",
+ )
+ session.note(f"probe installed: {name}", target=str(target))
+ handles.append(handle)
+ return handles
+
+
+def _run_actions(hit, probe_name: str, actions: dict | None, is_leave: bool = False) -> None:
+ session = hit.session
+ if actions is None:
+ return
+ values = {}
+ for expr in actions.get("log", []):
+ values[expr] = _read_value(hit, expr)
+ if values:
+ session.recorder.event(
+ "probe_log",
+ probe=probe_name,
+ phase="leave" if is_leave else "enter",
+ addr=hit.addr,
+ symbol=hit.symbol.name if hit.symbol else None,
+ values=values,
+ )
+ for dump in actions.get("dump", []):
+ addr = _read_value(hit, dump.get("reg", "a0"))
+ if isinstance(addr, str):
+ continue
+ length = int(dump.get("len", 64))
+ label = dump.get("label", f"{probe_name}-dump")
+ try:
+ data = session.memory.read(addr & 0xFFFFFFFFFFFFFFFF, length)
+ except Exception as exc:
+ session.recorder.event("probe_dump_error", probe=probe_name, addr=addr, error=str(exc))
+ continue
+ path = session.recorder.blob(f"{label}-{addr:#x}.bin", data)
+ session.recorder.event(
+ "probe_dump",
+ probe=probe_name,
+ name=label,
+ addr=addr,
+ size=len(data),
+ path=path,
+ preview=data[:32].hex(),
+ )
+ for snapshot in actions.get("snapshot", []):
+ addr = _read_value(hit, snapshot.get("reg", "a0"))
+ if isinstance(addr, str):
+ continue
+ session.snapshot(addr & 0xFFFFFFFFFFFFFFFF, int(snapshot.get("len", 4096)), name=snapshot.get("label", probe_name))
+ if actions.get("note"):
+ session.note(actions["note"], probe=probe_name, addr=hit.addr)
+
+
+def _read_value(hit, expr: str):
+ if expr == "retval":
+ return hit.retval if hit.retval is not None else hit.regs.get("a0", 0)
+ if expr in hit.regs:
+ return hit.regs[expr]
+ if expr.startswith("0x"):
+ return int(expr, 16)
+ try:
+ return int(expr, 0)
+ except ValueError:
+ return f"<unknown:{expr}>"
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/record.py b/debug/risc-v-gsp-rm-vm/greeneye/record.py
new file mode 100644
index 0000000..bf70e58
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/record.py
@@ -0,0 +1,156 @@
+"""Session recording: events.jsonl + snapshots + summary.
+
+A recorded session is the durable artifact green-eye produces: a complete,
+symbol-annotatable map of what the firmware did (hook hits, watch hits,
+memory writes, snapshots, coverage steps) that can be replayed offline,
+diffed between runs (bare-metal vs VFIO-mdev, old GSP vs new GSP) and
+annotated back into Ghidra.
+"""
+from __future__ import annotations
+
+import json
+import os
+import time
+import zlib
+
+FORMAT = "greeneye-session/1"
+
+
+class NullRecorder:
+ dir: str | None = None
+
+ def event(self, event_type: str, **fields) -> None:
+ pass
+
+ def snapshot(self, label: str, addr: int, data: bytes) -> None:
+ pass
+
+ def blob(self, name: str, data: bytes) -> None:
+ pass
+
+ def close(self) -> None:
+ pass
+
+
+class Recorder:
+ def __init__(self, root: str, *, name: str = "session", meta: dict | None = None):
+ self.dir = root
+ os.makedirs(self.dir, exist_ok=True)
+ self.name = name
+ self.meta = dict(meta or {})
+ self.meta.update({"format": FORMAT, "name": name, "created": time.time(), "greeneye": _version()})
+ self._events_path = os.path.join(self.dir, "events.jsonl")
+ self._fh = open(self._events_path, "w", encoding="utf-8")
+ self._t0 = time.monotonic()
+ self._counts: dict[str, int] = {}
+ self._snapshots: list[dict] = []
+ self._closed = False
+
+ @classmethod
+ def new(cls, *, base: str | None = None, name: str = "session", meta: dict | None = None) -> "Recorder":
+ base = base or os.environ.get("GREENEYE_DUMPS") or os.path.join(os.getcwd(), "dumps")
+ stamp = time.strftime("%Y%m%d-%H%M%S")
+ return cls(os.path.join(base, f"{stamp}-{name}"), name=name, meta=meta)
+
+ # -- writing -----------------------------------------------------------
+
+ def event(self, event_type: str, **fields) -> None:
+ if self._closed:
+ return
+ record = {"t": round(time.monotonic() - self._t0, 6), "type": event_type}
+ for key, value in fields.items():
+ record[key] = _jsonable(value)
+ self._fh.write(json.dumps(record, sort_keys=True) + "\n")
+ self._counts[event_type] = self._counts.get(event_type, 0) + 1
+
+ def snapshot(self, label: str, addr: int, data: bytes) -> str:
+ rel = os.path.join("snapshots", f"{label}-{addr:#x}-{len(data)}B.bin")
+ path = os.path.join(self.dir, rel)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "wb") as fh:
+ fh.write(zlib.compress(data))
+ info = {"label": label, "addr": addr, "size": len(data), "path": rel, "sha256": _sha(data)}
+ self._snapshots.append(info)
+ self.event("snapshot_saved", **info)
+ return rel
+
+ def blob(self, name: str, data: bytes) -> str:
+ rel = os.path.join("blobs", name)
+ path = os.path.join(self.dir, rel)
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ with open(path, "wb") as fh:
+ fh.write(data)
+ self.event("blob_saved", path=rel, size=len(data))
+ return rel
+
+ # -- closing -----------------------------------------------------------
+
+ def close(self, write_summary: bool = True) -> None:
+ if self._closed:
+ return
+ self.event("session_end")
+ self._fh.close()
+ self._closed = True
+ self.meta["counts"] = self._counts
+ self.meta["snapshots"] = self._snapshots
+ with open(os.path.join(self.dir, "session.json"), "w", encoding="utf-8") as fh:
+ json.dump(self.meta, fh, indent=1, sort_keys=True)
+ if write_summary:
+ try:
+ from .analyze import render_summary
+
+ report = render_summary(self.dir)
+ with open(os.path.join(self.dir, "summary.md"), "w", encoding="utf-8") as fh:
+ fh.write(report)
+ except Exception as exc: # pragma: no cover - never break close
+ with open(os.path.join(self.dir, "summary-error.txt"), "w", encoding="utf-8") as fh:
+ fh.write(f"summary failed: {exc}\n")
+
+ def __enter__(self) -> "Recorder":
+ return self
+
+ def __exit__(self, *exc) -> None:
+ self.close()
+
+
+def load_events(session_dir: str) -> list[dict]:
+ path = session_dir
+ if os.path.isdir(session_dir):
+ path = os.path.join(session_dir, "events.jsonl")
+ events: list[dict] = []
+ with open(path, "r", encoding="utf-8") as fh:
+ for line in fh:
+ line = line.strip()
+ if line:
+ events.append(json.loads(line))
+ return events
+
+
+def load_meta(session_dir: str) -> dict:
+ with open(os.path.join(session_dir, "session.json"), "r", encoding="utf-8") as fh:
+ return json.load(fh)
+
+
+def _jsonable(value):
+ if isinstance(value, bytes):
+ return value.hex()
+ if isinstance(value, (set, tuple)):
+ return list(value)
+ if isinstance(value, dict):
+ return {str(k): _jsonable(v) for k, v in value.items()}
+ return value
+
+
+def _sha(data: bytes) -> str:
+ import hashlib
+
+ return hashlib.sha256(data).hexdigest()
+
+
+def _version() -> str:
+ try:
+ from . import __version__
+
+ return __version__
+ except Exception: # pragma: no cover
+ return "unknown"
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/rsp.py b/debug/risc-v-gsp-rm-vm/greeneye/rsp.py
new file mode 100644
index 0000000..0263c63
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/rsp.py
@@ -0,0 +1,583 @@
+"""Pure-Python GDB Remote Serial Protocol (RSP) client for QEMU's gdbstub.
+
+green-eye deliberately avoids GDB: the whole instrumentation stack is a Python
+library, so sessions, probes and plugins are scriptable end to end. This
+module implements the RSP subset QEMU needs: packet framing/acks, no-ack mode,
+registers (with layout read from ``target.xml``), memory, software/hardware
+breakpoints, watchpoints, continue/step/interrupt and monitor commands.
+
+Tested against QEMU 8.2 (``qemu-system-riscv64``).
+"""
+from __future__ import annotations
+
+import socket
+import time
+from dataclasses import dataclass, field as _field
+
+from .errors import RspProtocolError, RspTimeout
+
+# ---------------------------------------------------------------------------
+# Packet framing
+# ---------------------------------------------------------------------------
+
+
+def checksum(data: bytes) -> int:
+ """RSP 8-bit checksum: sum of bytes mod 256."""
+ return sum(data) & 0xFF
+
+
+def encode_packet(data: bytes) -> bytes:
+ """Frame a payload as ``$<data>#<checksum>``."""
+ return b"$" + data + b"#" + f"{checksum(data):02x}".encode("ascii")
+
+
+def _hex_value(payload: str) -> int:
+ try:
+ return int(payload, 16)
+ except ValueError as exc: # pragma: no cover - defensive
+ raise RspProtocolError(f"malformed hex value: {payload!r}") from exc
+
+
+# ---------------------------------------------------------------------------
+# Stop replies
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class StopReply:
+ """A parsed stop reply (``T``/``S``/``W``/``X`` packet)."""
+
+ raw: str
+ signal: int | None = None
+ thread: str | None = None
+ regs: dict[int, int] = _field(default_factory=dict)
+ watch_addr: int | None = None
+ watch_kind: str | None = None
+ exit_status: int | None = None
+
+ @property
+ def is_exit(self) -> bool:
+ return self.raw[:1] in ("W", "X")
+
+ @property
+ def is_stop(self) -> bool:
+ return self.raw[:1] in ("T", "S")
+
+ def __str__(self) -> str: # pragma: no cover - cosmetic
+ if self.is_exit:
+ return f"exit(status={self.exit_status})"
+ bits = [f"signal={self.signal}"]
+ if self.watch_addr is not None:
+ bits.append(f"{self.watch_kind}@{self.watch_addr:#x}")
+ if self.thread:
+ bits.append(f"thread={self.thread}")
+ return "stop(" + ", ".join(bits) + ")"
+
+
+def parse_stop_reply(raw: str) -> StopReply:
+ """Parse a stop packet body (without the leading channel)."""
+ reply = StopReply(raw=raw)
+ if not raw:
+ return reply
+ head = raw[0]
+ if head in ("T", "S"):
+ if len(raw) >= 3:
+ try:
+ reply.signal = int(raw[1:3], 16)
+ except ValueError:
+ pass
+ rest = raw[3:]
+ for item in rest.split(";"):
+ if not item or ":" not in item:
+ continue
+ key, value = item.split(":", 1)
+ if key == "thread":
+ reply.thread = value
+ elif key in ("watch", "rwatch", "awatch"):
+ reply.watch_kind = key
+ reply.watch_addr = _hex_value(value)
+ else:
+ try:
+ reply.regs[int(key, 16)] = _hex_value(value)
+ except ValueError:
+ continue
+ elif head in ("W", "X"):
+ if len(raw) >= 3:
+ try:
+ reply.exit_status = int(raw[1:3], 16)
+ except ValueError:
+ pass
+ return reply
+
+
+# ---------------------------------------------------------------------------
+# Register layout
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class Register:
+ number: int
+ name: str
+ bitsize: int
+ offset: int = 0
+
+ @property
+ def nbytes(self) -> int:
+ return (self.bitsize + 7) // 8
+
+
+#: Fallback layout for RV64 when target.xml is unavailable: x0..x31 + pc.
+RV64_FALLBACK_REGS = [
+ Register(n, name, 64, n * 8)
+ for n, name in enumerate(
+ ["x0", "ra", "sp", "gp", "tp", "t0", "t1", "t2"]
+ + [f"s{i}" for i in range(0, 2)]
+ + [f"a{i}" for i in range(0, 8)]
+ + [f"s{i}" for i in range(2, 12)]
+ + [f"t{i}" for i in range(3, 7)]
+ + ["pc"]
+ )
+]
+
+
+# ---------------------------------------------------------------------------
+# Client
+# ---------------------------------------------------------------------------
+
+
+class RspClient:
+ """A blocking RSP client over TCP."""
+
+ def __init__(self, host: str = "127.0.0.1", port: int = 4444, timeout: float = 10.0):
+ self.host = host
+ self.port = port
+ self.timeout = timeout
+ self.sock: socket.socket | None = None
+ self.no_ack = False
+ self.packet_size = 0x1000
+ self.registers: list[Register] = []
+ self._by_name: dict[str, Register] = {}
+ self._by_number: dict[int, Register] = {}
+ self.features: str = ""
+
+ # -- lifecycle ---------------------------------------------------------
+
+ def connect(self, retries: int = 1, retry_delay: float = 0.1) -> None:
+ last: Exception | None = None
+ for _ in range(max(1, retries)):
+ try:
+ self.sock = socket.create_connection((self.host, self.port), timeout=self.timeout)
+ self.sock.settimeout(self.timeout)
+ return
+ except OSError as exc:
+ last = exc
+ time.sleep(retry_delay)
+ raise RspTimeout(f"cannot connect to gdbstub {self.host}:{self.port}: {last}")
+
+ def close(self) -> None:
+ if self.sock is not None:
+ try:
+ self.sock.close()
+ except OSError:
+ pass
+ self.sock = None
+
+ @property
+ def connected(self) -> bool:
+ return self.sock is not None
+
+ def __enter__(self) -> "RspClient":
+ if not self.connected:
+ self.connect()
+ return self
+
+ def __exit__(self, *exc) -> None:
+ self.close()
+
+ # -- low level I/O -----------------------------------------------------
+
+ def _send_raw(self, data: bytes) -> None:
+ if self.sock is None:
+ raise RspTimeout("not connected")
+ self.sock.sendall(data)
+
+ def _read_exact(self, count: int, deadline: float) -> bytes:
+ if self.sock is None:
+ raise RspTimeout("not connected")
+ chunks = bytearray()
+ while len(chunks) < count:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise RspTimeout(f"timeout waiting for {count} bytes")
+ self.sock.settimeout(remaining)
+ try:
+ part = self.sock.recv(count - len(chunks))
+ except socket.timeout as exc:
+ raise RspTimeout(f"timeout waiting for {count} bytes") from exc
+ if not part:
+ raise RspProtocolError("gdbstub closed the connection")
+ chunks += part
+ return bytes(chunks)
+
+ def _send_packet(self, payload: bytes) -> None:
+ packet = encode_packet(payload)
+ self._send_raw(packet)
+ if self.no_ack:
+ return
+ deadline = time.monotonic() + self.timeout
+ while True:
+ ack = self._read_exact(1, deadline)
+ if ack == b"+":
+ return
+ if ack == b"-":
+ self._send_raw(packet)
+ continue
+ # Some stubs answer with a stop packet instead of an ack when a
+ # run-control command stops immediately; push it back for the
+ # packet reader by buffering is complex, so treat as protocol
+ # error only if it is not a start-of-packet marker.
+ if ack == b"$":
+ raise RspProtocolError("unexpected packet while waiting for ack")
+
+ def _read_packet(self, timeout: float | None = None) -> str:
+ eff = self.timeout if timeout is None else timeout
+ deadline = time.monotonic() + eff
+ # Skip stale acks and interrupt notifications until a packet starts.
+ while True:
+ byte = self._read_exact(1, deadline)
+ if byte == b"$":
+ break
+ if byte == b"+":
+ continue
+ if byte == b"\x03":
+ return "S02" # async interrupt notification
+ body = bytearray()
+ while True:
+ byte = self._read_exact(1, deadline)
+ if byte == b"#":
+ break
+ body += byte
+ got = self._read_exact(2, deadline)
+ try:
+ expected = int(got.decode("ascii"), 16)
+ except ValueError as exc:
+ raise RspProtocolError(f"malformed checksum bytes {got!r}") from exc
+ if checksum(bytes(body)) != expected:
+ if not self.no_ack:
+ self._send_raw(b"-")
+ raise RspProtocolError(f"bad checksum for {bytes(body)!r}")
+ if not self.no_ack:
+ self._send_raw(b"+")
+ return bytes(body).decode("latin1")
+
+ def command(self, payload: str | bytes, timeout: float | None = None) -> str:
+ """Send one packet and return the reply payload."""
+ data = payload.encode("ascii") if isinstance(payload, str) else payload
+ self._send_packet(data)
+ return self._read_packet(timeout)
+
+ # -- handshake ---------------------------------------------------------
+
+ def sync(self) -> None:
+ """Negotiate features and register layout; consume the initial stop."""
+ # QEMU acknowledges QStartNoAckMode with an EMPTY packet ($#00), then
+ # switches to no-ack mode once the client acks that reply.
+ try:
+ self._send_packet(b"QStartNoAckMode")
+ reply = self._read_packet()
+ if reply in ("", "OK"):
+ self.no_ack = True
+ except RspProtocolError:
+ pass # older stub: stay in ack mode
+
+ self.packet_size = 0x1000
+ try:
+ self.features = self.command("qSupported:multiprocess+;swbreak+;hwbreak+;qXfer:features:read+")
+ for token in self.features.split(";"):
+ if token.startswith("PacketSize="):
+ try:
+ self.packet_size = int(token.split("=", 1)[1], 16)
+ except ValueError:
+ pass
+ except RspProtocolError:
+ self.features = ""
+
+ # Consume the initial stop reply (target is paused).
+ try:
+ self.command("?")
+ except RspProtocolError:
+ pass
+ # Select CPU 0 for register access in system mode.
+ for selection in ("Hg0", "Hc-1"):
+ try:
+ self.command(selection)
+ except RspProtocolError:
+ pass
+ self._load_registers()
+
+ def _load_registers(self) -> None:
+ self.registers = []
+ try:
+ xml = self._read_target_xml()
+ except RspProtocolError:
+ xml = ""
+ if xml:
+ self.registers = _parse_target_xml(xml)
+ if not self.registers:
+ self.registers = list(RV64_FALLBACK_REGS)
+ self._by_name = {r.name: r for r in self.registers}
+ self._by_number = {r.number: r for r in self.registers}
+
+ def _read_target_xml(self, depth: int = 0) -> str:
+ """Fetch target.xml and recursively inline its xi:include files."""
+ return self._collect_annex("target.xml", depth)
+
+ def _collect_annex(self, annex: str, depth: int) -> str:
+ import re
+
+ try:
+ xml = self._read_xfer("features", annex)
+ except RspProtocolError:
+ return ""
+ if depth >= 4:
+ return xml
+ parts = [xml]
+ for href in re.findall(r'xi:include[^>]*href="([^"]+)"', xml):
+ if href.startswith(("http:", "https:", "file:")):
+ continue
+ child = self._collect_annex(href, depth + 1)
+ if child:
+ parts.append(child)
+ return "\n".join(parts)
+
+ def _read_xfer(self, kind: str, annex: str) -> str:
+ data = bytearray()
+ offset = 0
+ while True:
+ reply = self.command(f"qXfer:{kind}:read:{annex}:{offset:x},fff")
+ if reply.startswith("E"):
+ raise RspProtocolError(f"qXfer {kind}:{annex} failed: {reply}")
+ if not reply or reply[0] not in ("l", "m"):
+ raise RspProtocolError(f"qXfer {kind}:{annex} malformed: {reply[:16]!r}")
+ chunk = reply[1:].encode("latin1")
+ data += chunk
+ offset += len(chunk)
+ if reply[0] == "l":
+ break
+ return data.decode("latin1", "replace")
+
+ # -- registers ---------------------------------------------------------
+
+ def read_registers(self) -> dict[str, int]:
+ payload = self.command("g")
+ raw = _unhex(payload)
+ out: dict[str, int] = {}
+ for reg in self.registers:
+ start = reg.offset
+ chunk = raw[start : start + reg.nbytes]
+ if len(chunk) < reg.nbytes:
+ break
+ out[reg.name] = int.from_bytes(chunk, "little", signed=False)
+ return out
+
+ def read_reg(self, name_or_number: str | int) -> int:
+ reg = self._reg(name_or_number)
+ reply = self.command(f"p{reg.number:x}")
+ if reply.startswith("E"):
+ raise RspProtocolError(f"read register {reg.name} failed: {reply}")
+ return int.from_bytes(_unhex(reply), "little", signed=False)
+
+ def write_reg(self, name_or_number: str | int, value: int) -> None:
+ reg = self._reg(name_or_number)
+ payload = value.to_bytes(reg.nbytes, "little", signed=False).hex()
+ reply = self.command(f"P{reg.number:x}={payload}")
+ if reply != "OK":
+ raise RspProtocolError(f"write register {reg.name} failed: {reply}")
+
+ def _reg(self, name_or_number: str | int) -> Register:
+ if isinstance(name_or_number, int):
+ if name_or_number in self._by_number:
+ return self._by_number[name_or_number]
+ # allow ABI-numbered convenience: 0..31 map to x0..x31
+ if name_or_number < len(self.registers):
+ return self.registers[name_or_number]
+ raise RspProtocolError(f"unknown register number {name_or_number}")
+ if name_or_number in self._by_name:
+ return self._by_name[name_or_number]
+ aliases = {"pc": "pc", "a0": "a0", "return": "ra"}
+ name_or_number = aliases.get(name_or_number, name_or_number)
+ if name_or_number in self._by_name:
+ return self._by_name[name_or_number]
+ raise RspProtocolError(f"unknown register {name_or_number!r}")
+
+ @property
+ def pc(self) -> int:
+ return self.read_reg("pc")
+
+ # -- memory ------------------------------------------------------------
+
+ def _mem_chunk(self) -> int:
+ # Each data byte costs two hex chars; leave room for the header.
+ return max(64, (self.packet_size - 128) // 2)
+
+ def read_mem(self, addr: int, size: int, chunk: int = 0) -> bytes:
+ if chunk <= 0:
+ chunk = self._mem_chunk()
+ out = bytearray()
+ while len(out) < size:
+ want = min(chunk, size - len(out))
+ here = addr + len(out)
+ reply = self.command(f"m{here:x},{want:x}")
+ if reply.startswith("E"):
+ raise RspProtocolError(f"memory read {here:#x}+{want:#x} failed: {reply}")
+ data = _unhex(reply)
+ if not data:
+ raise RspProtocolError(f"short memory read at {here:#x}")
+ out += data
+ return bytes(out)
+
+ def write_mem(self, addr: int, data: bytes, chunk: int = 0) -> None:
+ if chunk <= 0:
+ chunk = self._mem_chunk()
+ view = memoryview(data)
+ offset = 0
+ while offset < len(view):
+ part = bytes(view[offset : offset + chunk])
+ here = addr + offset
+ reply = self.command(f"M{here:x},{len(part):x}:{part.hex()}")
+ if reply != "OK":
+ raise RspProtocolError(f"memory write {here:#x} failed: {reply}")
+ offset += len(part)
+
+ def read_u32(self, addr: int) -> int:
+ return int.from_bytes(self.read_mem(addr, 4), "little")
+
+ def read_u64(self, addr: int) -> int:
+ return int.from_bytes(self.read_mem(addr, 8), "little")
+
+ def write_u32(self, addr: int, value: int) -> None:
+ self.write_mem(addr, value.to_bytes(4, "little"))
+
+ def write_u64(self, addr: int, value: int) -> None:
+ self.write_mem(addr, value.to_bytes(8, "little"))
+
+ 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:
+ reply = self.command(f"Z0,{addr:x},{kind:x}")
+ if reply != "OK":
+ raise RspProtocolError(f"set software breakpoint {addr:#x} failed: {reply}")
+
+ def clear_sw_break(self, addr: int, kind: int = 4) -> None:
+ reply = self.command(f"z0,{addr:x},{kind:x}")
+ if reply != "OK":
+ raise RspProtocolError(f"clear software breakpoint {addr:#x} failed: {reply}")
+
+ def set_hw_break(self, addr: int, kind: int = 4) -> None:
+ reply = self.command(f"Z1,{addr:x},{kind:x}")
+ if reply != "OK":
+ raise RspProtocolError(f"set hardware breakpoint {addr:#x} failed: {reply}")
+
+ def clear_hw_break(self, addr: int, kind: int = 4) -> None:
+ reply = self.command(f"z1,{addr:x},{kind:x}")
+ if reply != "OK":
+ raise RspProtocolError(f"clear hardware breakpoint {addr:#x} failed: {reply}")
+
+ _WATCH_KINDS = {"w": 2, "r": 3, "a": 4}
+
+ def set_watch(self, addr: int, size: int = 8, kind: str = "w") -> None:
+ code = self._WATCH_KINDS[kind]
+ reply = self.command(f"Z{code},{addr:x},{size:x}")
+ if reply != "OK":
+ raise RspProtocolError(f"set watchpoint {kind}@{addr:#x} failed: {reply}")
+
+ def clear_watch(self, addr: int, size: int = 8, kind: str = "w") -> None:
+ code = self._WATCH_KINDS[kind]
+ reply = self.command(f"z{code},{addr:x},{size:x}")
+ if reply != "OK":
+ raise RspProtocolError(f"clear watchpoint {kind}@{addr:#x} failed: {reply}")
+
+ # -- run control -------------------------------------------------------
+
+ def continue_(self, timeout: float | None = None) -> StopReply:
+ self._send_packet(b"c")
+ raw = self._read_packet(timeout)
+ return parse_stop_reply(raw)
+
+ def step(self, timeout: float | None = None) -> StopReply:
+ self._send_packet(b"s")
+ raw = self._read_packet(timeout)
+ return parse_stop_reply(raw)
+
+ def interrupt(self, timeout: float | None = None) -> StopReply:
+ self._send_raw(b"\x03")
+ raw = self._read_packet(timeout)
+ return parse_stop_reply(raw)
+
+ # -- monitor -----------------------------------------------------------
+
+ def monitor(self, cmd: str) -> str:
+ payload = ("qRcmd," + cmd.encode("ascii").hex()).encode("ascii")
+ self._send_packet(payload)
+ out = bytearray()
+ while True:
+ reply = self._read_packet()
+ if reply == "OK":
+ break
+ if reply.startswith("E"):
+ raise RspProtocolError(f"monitor {cmd!r} failed: {reply}")
+ if reply.startswith("O"):
+ out += bytes.fromhex(reply[1:])
+ else: # pragma: no cover - defensive
+ raise RspProtocolError(f"unexpected monitor reply: {reply[:32]!r}")
+ return out.decode("utf-8", "replace")
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _unhex(payload: str) -> bytes:
+ if len(payload) % 2:
+ payload = payload[:-1] # tolerate odd trailing nibble
+ try:
+ return bytes.fromhex(payload)
+ except ValueError as exc:
+ raise RspProtocolError(f"malformed hex payload: {payload[:32]!r}") from exc
+
+
+_RE_REG = None
+
+
+def _parse_target_xml(xml: str) -> list[Register]:
+ import re
+
+ global _RE_REG
+ if _RE_REG is None:
+ _RE_REG = re.compile(r"<reg\b[^>]*?/?>", re.IGNORECASE)
+ attrs_re = re.compile(r'(\w+)\s*=\s*"([^"]*)"')
+ regs: list[Register] = []
+ next_number = 0
+ offset = 0
+ for match in _RE_REG.finditer(xml):
+ attrs = {k: v for k, v in attrs_re.findall(match.group(0))}
+ name = attrs.get("name")
+ if not name:
+ continue
+ bitsize = int(attrs.get("bitsize", "64"))
+ number = int(attrs["regnum"], 0) if "regnum" in attrs else next_number
+ if "offset" in attrs:
+ try:
+ offset = int(attrs["offset"], 0)
+ except ValueError:
+ pass
+ regs.append(Register(number=number, name=name, bitsize=bitsize, offset=offset))
+ offset += (bitsize + 7) // 8
+ next_number = number + 1
+ return regs
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/session.py b/debug/risc-v-gsp-rm-vm/greeneye/session.py
new file mode 100644
index 0000000..7ad6444
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/session.py
@@ -0,0 +1,562 @@
+"""green-eye instrumentation session: hooks, breakpoints, watchpoints, events.
+
+This is the Frida-like core. It is deliberately transport-agnostic; the RSP
+client is the default backend, and a Session can also be constructed around a
+plugin-supplied backend later.
+"""
+from __future__ import annotations
+
+import time
+from dataclasses import dataclass, field as _field
+from typing import Callable
+
+from .errors import GreenEyeError, RspTimeout, SymbolNotFound
+from .memory import Memory
+from .record import NullRecorder, Recorder
+from .rsp import RspClient, StopReply
+from .symbols import Symbol, SymbolDB
+
+
+@dataclass
+class Hit:
+ """Everything known about one stop event."""
+
+ kind: str # breakpoint | leave | watchpoint | step | fault | exit | stop
+ addr: int
+ regs: dict = _field(default_factory=dict)
+ symbol: Symbol | None = None
+ retval: int | None = None
+ watch: "Watchpoint | None" = None
+ stop: StopReply | None = None
+ session: "Session | None" = None
+ data: dict = _field(default_factory=dict)
+
+ @property
+ def pc(self) -> int:
+ return self.regs.get("pc", self.addr)
+
+ def arg(self, index: int) -> int:
+ """RISC-V ABI argument a0..a7 -> x10..x17."""
+ return self.regs.get(f"a{index}", 0)
+
+ def label(self) -> str:
+ if self.symbol is not None:
+ return self.symbol.label(self.addr)
+ return f"{self.addr:#x}"
+
+ def __str__(self) -> str: # pragma: no cover - cosmetic
+ base = f"[{self.kind}] {self.label()}"
+ if self.kind in ("breakpoint", "leave"):
+ base += f" a0={self.arg(0):#x} a1={self.arg(1):#x}"
+ if self.kind == "leave" and self.retval is not None:
+ base += f" -> {self.retval:#x}"
+ if self.kind == "watchpoint" and self.watch is not None:
+ base += f" {self.watch.kind}@{self.watch.addr:#x}"
+ return base
+
+
+@dataclass
+class Breakpoint:
+ addr: int
+ kind: str = "sw" # sw | hw
+ temporary: bool = False
+ purpose: str = "user" # user | leave
+ on_enter: Callable[[Hit], None] | None = None
+ on_leave: Callable[[Hit], None] | None = None
+ symbol: Symbol | None = None
+ attached: bool = True
+ hits: int = 0
+ listener: str | None = None
+
+ @property
+ def label(self) -> str:
+ return self.symbol.label(self.addr) if self.symbol else f"{self.addr:#x}"
+
+
+@dataclass
+class Watchpoint:
+ addr: int
+ size: int = 8
+ kind: str = "w" # w | r | a
+ callback: Callable[[Hit], None] | None = None
+ symbol: Symbol | None = None
+ hits: int = 0
+ listener: str | None = None
+
+ @property
+ def label(self) -> str:
+ return self.symbol.label(self.addr) if self.symbol else f"{self.addr:#x}"
+
+
+class HookHandle:
+ def __init__(self, session: "Session", breakpoint: Breakpoint):
+ self._session = session
+ self._bp = breakpoint
+
+ @property
+ def addr(self) -> int:
+ return self._bp.addr
+
+ @property
+ def hits(self) -> int:
+ return self._bp.hits
+
+ @property
+ def label(self) -> str:
+ return self._bp.label
+
+ def detach(self) -> None:
+ self._session.remove_breakpoint(self._bp)
+
+
+class Session:
+ def __init__(
+ self,
+ *,
+ client: RspClient,
+ target=None,
+ symbols: SymbolDB | None = None,
+ record: str | bool | None = None,
+ name: str = "session",
+ meta: dict | None = None,
+ ):
+ self.client = client
+ self.target = target
+ self.symbols = symbols or SymbolDB()
+ self.name = name
+ self.image = None
+ self._owns_target = target is not None
+ if isinstance(record, str):
+ self.recorder = Recorder(record, name=name, meta=meta or {})
+ elif record:
+ self.recorder = Recorder.new(name=name, meta=meta or {})
+ else:
+ self.recorder = NullRecorder()
+ self.memory = Memory(client, symbols=self.symbols, recorder=self.recorder)
+ self._bps: dict[int, Breakpoint] = {}
+ self._bp_seq = 0
+ self._bps_by_addr: dict[int, list[Breakpoint]] = {}
+ self._watches: dict[tuple[int, int, str], Watchpoint] = {}
+ self._watches_by_addr: dict[int, Watchpoint] = {}
+ self._leave_pending: dict[int, list[Breakpoint]] = {}
+ self._stopped = True
+ self._event_hooks: list[tuple[str, Callable[[Hit], None]]] = []
+ self.recorder.event("session_start", name=name)
+
+ # -- convenience proxies ----------------------------------------------
+
+ def load_image(
+ self,
+ image,
+ *,
+ write_segments: bool = False,
+ set_pc: bool = False,
+ bias: int = 0,
+ load_symbols: bool = True,
+ source: str = "elf",
+ ):
+ """Register a firmware image with the session.
+
+ ``write_segments`` copies every PT_LOAD through the RSP client (for
+ targets started without their loader devices); ``set_pc`` points the
+ CPU at the (relocated) entry. With QEMU's ``-kernel`` fast path both
+ are already handled by QEMU, so the default only loads symbols.
+ """
+ self.image = image
+ if load_symbols:
+ self.symbols.load_elf(image, bias=bias, source=source)
+ if write_segments:
+ for addr, data in image.flat_load_plan(bias=bias):
+ self.client.write_mem(addr, data)
+ if set_pc:
+ self.client.write_reg("pc", image.relocated_entry(bias=bias))
+ self.recorder.event(
+ "image_loaded",
+ path=getattr(image, "path", None),
+ entry=image.entry,
+ bias=bias,
+ write_segments=write_segments,
+ )
+ return image
+
+ @property
+ def pc(self) -> int:
+ return self.client.pc
+
+ def read_regs(self) -> dict[str, int]:
+ return self.client.read_registers()
+
+ def snapshot(self, addr: int, size: int, name: str = "region") -> bytes:
+ return self.memory.snapshot(addr, size, name=name)
+
+ def note(self, text: str, **fields) -> None:
+ self.recorder.event("note", text=text, **fields)
+
+ # -- symbol helpers ----------------------------------------------------
+
+ def resolve(self, target: int | str) -> int:
+ if isinstance(target, int):
+ return target
+ if isinstance(target, str):
+ if target.lower().startswith("0x"):
+ return int(target, 16)
+ sym = self.symbols.get(target)
+ if sym is None:
+ raise SymbolNotFound(f"symbol {target!r} not found in symbol DB")
+ return sym.addr
+ raise GreenEyeError(f"cannot resolve target {target!r}")
+
+ def symbol_at(self, addr: int) -> Symbol | None:
+ return self.symbols.resolve(addr)
+
+ # -- breakpoints / hooks ----------------------------------------------
+
+ def hook(
+ self,
+ target: int | str,
+ on_enter: Callable[[Hit], None] | None = None,
+ on_leave: Callable[[Hit], None] | None = None,
+ *,
+ kind: str = "sw",
+ temporary: bool = False,
+ symbol: Symbol | None = None,
+ listener: str | None = None,
+ ) -> HookHandle:
+ addr = self.resolve(target)
+ sym = symbol or self.symbols.resolve(addr)
+ bp = Breakpoint(
+ addr=addr,
+ kind=kind,
+ temporary=temporary,
+ on_enter=on_enter,
+ on_leave=on_leave,
+ symbol=sym,
+ listener=listener,
+ )
+ self.add_breakpoint(bp)
+ self.recorder.event(
+ "hook",
+ addr=addr,
+ symbol=sym.name if sym else None,
+ kind=kind,
+ on_leave=bool(on_leave),
+ listener=listener,
+ )
+ return HookHandle(self, bp)
+
+ def breakpoint(self, target: int | str, callback: Callable[[Hit], None] | None = None, **kw) -> Breakpoint:
+ addr = self.resolve(target)
+ bp = Breakpoint(addr=addr, on_enter=callback, symbol=self.symbols.resolve(addr), **kw)
+ self.add_breakpoint(bp)
+ return bp
+
+ def add_breakpoint(self, bp: Breakpoint) -> None:
+ self._bp_seq += 1
+ self._bps[self._bp_seq] = bp
+ self._bps_by_addr.setdefault(bp.addr, []).append(bp)
+ if bp.kind == "hw":
+ self.client.set_hw_break(bp.addr)
+ else:
+ self.client.set_sw_break(bp.addr)
+
+ def remove_breakpoint(self, bp: Breakpoint) -> None:
+ for key, candidate in list(self._bps.items()):
+ if candidate is bp:
+ del self._bps[key]
+ lst = self._bps_by_addr.get(bp.addr, [])
+ if bp in lst:
+ lst.remove(bp)
+ if not lst:
+ self._bps_by_addr.pop(bp.addr, None)
+ self._leave_pending.pop(bp.addr, None)
+ try:
+ if bp.kind == "hw":
+ self.client.clear_hw_break(bp.addr)
+ else:
+ self.client.clear_sw_break(bp.addr)
+ except Exception:
+ pass
+ bp.attached = False
+
+ def on_event(self, kind: str, callback: Callable[[Hit], None]) -> None:
+ self._event_hooks.append((kind, callback))
+
+ # -- watchpoints -------------------------------------------------------
+
+ def watch(
+ self,
+ target: int | str,
+ size: int = 8,
+ kind: str = "w",
+ callback: Callable[[Hit], None] | None = None,
+ listener: str | None = None,
+ ) -> Watchpoint:
+ addr = self.resolve(target)
+ wp = Watchpoint(
+ addr=addr,
+ size=size,
+ kind=kind,
+ callback=callback,
+ symbol=self.symbols.resolve(addr),
+ listener=listener,
+ )
+ key = (addr, size, kind)
+ if key in self._watches:
+ raise GreenEyeError(f"watchpoint {kind}@{addr:#x}/{size} already exists")
+ self._watches[key] = wp
+ self._watches_by_addr[addr] = wp
+ self.client.set_watch(addr, size=size, kind=kind)
+ self.recorder.event(
+ "watch",
+ addr=addr,
+ size=size,
+ kind=kind,
+ symbol=wp.symbol.name if wp.symbol else None,
+ listener=listener,
+ )
+ return wp
+
+ def unwatch(self, wp: Watchpoint) -> None:
+ key = (wp.addr, wp.size, wp.kind)
+ self._watches.pop(key, None)
+ self._watches_by_addr.pop(wp.addr, None)
+ try:
+ self.client.clear_watch(wp.addr, size=wp.size, kind=wp.kind)
+ except Exception:
+ pass
+
+ # -- execution ---------------------------------------------------------
+
+ def step(self, count: int = 1) -> list[Hit]:
+ hits: list[Hit] = []
+ for _ in range(count):
+ stop = self.client.step()
+ hit = self._dispatch_stop(stop, from_step=True)
+ if hit is not None:
+ hits.append(hit)
+ if stop.is_exit:
+ break
+ return hits
+
+ def run(
+ self,
+ duration: float | None = None,
+ max_stops: int | None = None,
+ timeout: float | None = None,
+ ) -> int:
+ """Resume and service events until the deadline/budget/exit.
+
+ ``duration`` (or ``timeout`` when duration is None) is a wall-clock
+ budget: when it expires the target is interrupted once, the resulting
+ stop is dispatched, and the call returns. Returns the number of stops
+ serviced.
+ """
+ limit = duration if duration is not None else timeout
+ deadline = time.monotonic() + limit if limit is not None else None
+ stops = 0
+ while True:
+ if deadline is not None:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ break # budget spent while stopped: leave the target stopped
+ remaining = max(0.001, remaining)
+ else:
+ remaining = None
+ interrupted = False
+ try:
+ stop = self.client.continue_(timeout=remaining)
+ except RspTimeout:
+ if deadline is None:
+ raise
+ stop = self.client.interrupt()
+ interrupted = True
+ stops += 1
+ self._dispatch_stop(stop)
+ if stop.is_exit:
+ break
+ if max_stops is not None and stops >= max_stops:
+ break
+ if interrupted or (deadline is not None and time.monotonic() >= deadline):
+ break
+ return stops
+
+ def stop(self) -> Hit | None:
+ stop = self.client.interrupt()
+ return self._dispatch_stop(stop)
+
+ # -- stop dispatch -----------------------------------------------------
+
+ def _dispatch_stop(self, stop: StopReply, from_step: bool = False) -> Hit | None:
+ if stop.is_exit:
+ hit = Hit(kind="exit", addr=0, stop=stop, session=self)
+ self.recorder.event("exit", status=stop.exit_status)
+ self._fire_event_hooks(hit)
+ return hit
+
+ regs = self.client.read_registers()
+ pc = regs.get("pc", 0)
+ sym = self.symbols.resolve(pc)
+
+ if stop.watch_addr is not None:
+ wp = self._watches_by_addr.get(stop.watch_addr)
+ hit = Hit(
+ kind="watchpoint",
+ addr=stop.watch_addr,
+ regs=regs,
+ symbol=self.symbols.resolve(stop.watch_addr),
+ watch=wp,
+ stop=stop,
+ session=self,
+ )
+ if wp is not None:
+ wp.hits += 1
+ if wp.callback is not None:
+ wp.callback(hit)
+ self.recorder.event(
+ "watch_hit",
+ addr=stop.watch_addr,
+ kind=wp.kind if wp else stop.watch_kind,
+ size=wp.size if wp else None,
+ symbol=hit.symbol.name if hit.symbol else None,
+ pc=pc,
+ regs=_json_regs(regs),
+ )
+ self._fire_event_hooks(hit)
+ # QEMU stops *at* the instruction that performed the watched
+ # access. Step it once with the watchpoint temporarily cleared so
+ # resuming does not re-trigger on the same store forever.
+ try:
+ if wp is not None:
+ self.client.clear_watch(wp.addr, size=wp.size, kind=wp.kind)
+ self.client.step()
+ except RspTimeout:
+ pass
+ finally:
+ if wp is not None:
+ try:
+ self.client.set_watch(wp.addr, size=wp.size, kind=wp.kind)
+ except Exception:
+ pass
+ return hit
+
+ hit = Hit(
+ kind="step" if from_step else "stop",
+ addr=pc,
+ regs=regs,
+ symbol=sym,
+ stop=stop,
+ session=self,
+ )
+
+ bps = list(self._bps_by_addr.get(pc, []))
+ leave_queue = self._leave_pending.get(pc, [])
+ if not bps and not leave_queue:
+ self.recorder.event(
+ "stop",
+ pc=pc,
+ symbol=sym.name if sym else None,
+ signal=stop.signal,
+ reason="step" if from_step else "halt",
+ )
+ self._fire_event_hooks(hit)
+ return hit
+
+ hit.kind = "breakpoint"
+ self.recorder.event(
+ "bp_hit",
+ pc=pc,
+ addr=pc,
+ symbol=sym.name if sym else None,
+ signal=stop.signal,
+ regs=_json_regs(regs),
+ )
+ for bp in bps:
+ bp.hits += 1
+ if bp.on_enter is not None:
+ bp.on_enter(hit)
+ if bp.on_leave is not None and not bp.temporary:
+ self._arm_leave(regs.get("ra", 0), bp)
+ if bp.temporary:
+ self.remove_breakpoint(bp)
+
+ # Return hooks: pop one pending hook for this return address.
+ if leave_queue:
+ bp = leave_queue.pop()
+ if not leave_queue:
+ self._leave_pending.pop(pc, None)
+ for candidate in list(self._bps_by_addr.get(pc, [])):
+ if candidate.purpose == "leave":
+ self.remove_breakpoint(candidate)
+ hit.kind = "leave"
+ hit.retval = regs.get("a0", 0)
+ if bp.symbol is not None:
+ hit.symbol = bp.symbol
+ hit.addr = bp.addr
+ if bp.on_leave is not None:
+ bp.on_leave(hit)
+
+ # Software breakpoints need a single-step over them before resuming.
+ self._step_over(pc)
+ self._fire_event_hooks(hit)
+ return hit
+
+ def _arm_leave(self, retaddr: int, bp: Breakpoint) -> None:
+ if not retaddr:
+ return
+ queue = self._leave_pending.get(retaddr)
+ if queue is None:
+ queue = []
+ self._leave_pending[retaddr] = queue
+ temp = Breakpoint(
+ addr=retaddr,
+ kind="sw",
+ temporary=True,
+ purpose="leave",
+ symbol=self.symbols.resolve(retaddr),
+ )
+ self.add_breakpoint(temp)
+ queue.append(bp)
+
+ def _step_over(self, pc: int) -> None:
+ sw = [bp for bp in self._bps_by_addr.get(pc, []) if bp.kind == "sw"]
+ if not sw:
+ return
+ for bp in sw:
+ self.client.clear_sw_break(pc)
+ self.client.step()
+ for bp in sw:
+ if bp.attached:
+ self.client.set_sw_break(pc)
+
+ def _fire_event_hooks(self, hit: Hit) -> None:
+ for kind, callback in self._event_hooks:
+ if kind in (hit.kind, "*"):
+ callback(hit)
+
+ # -- lifecycle ---------------------------------------------------------
+
+ def close(self, kill_target: bool | None = None) -> None:
+ try:
+ for bp in list(self._bps.values()):
+ self.remove_breakpoint(bp)
+ for wp in list(self._watches.values()):
+ self.unwatch(wp)
+ except Exception:
+ pass
+ self.client.close()
+ if kill_target is None:
+ kill_target = self._owns_target
+ if kill_target and self.target is not None and not getattr(self.target, "keep_alive", False):
+ self.target.kill()
+ self.recorder.close()
+ self._owns_target = False
+
+ def __enter__(self) -> "Session":
+ return self
+
+ def __exit__(self, *exc) -> None:
+ self.close()
+
+
+def _json_regs(regs: dict[str, int]) -> dict[str, int]:
+ return {name: value for name, value in regs.items() if isinstance(value, int)}
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/structs.py b/debug/risc-v-gsp-rm-vm/greeneye/structs.py
new file mode 100644
index 0000000..3081bc9
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/structs.py
@@ -0,0 +1,197 @@
+"""Declarative structure decoding for firmware memory.
+
+A schema is plain data (JSON-friendly), so the same description can come from
+the repo, a Ghidra export, or a plugin:
+
+ {
+ "name": "gsp_ctrl",
+ "fields": [
+ {"name": "magic", "offset": 0, "fmt": "u32"},
+ {"name": "state", "offset": 8, "fmt": "u32", "enum": {"0": "IDLE", "1": "RUN"}},
+ {"name": "buf", "offset": 64, "fmt": "bytes", "size": 256},
+ {"name": "name", "offset": 64, "fmt": "cstr", "size": 32}
+ ]
+ }
+
+Supported formats: u8/u16/u32/u64, s8/s16/s32/s64, f32/f64, bool, bytes,
+cstr, ptr (u64 by default; override with ``size``).
+"""
+from __future__ import annotations
+
+import struct as _struct
+from dataclasses import dataclass, field as _field
+
+from .errors import StructError
+
+_INT_FORMATS = {
+ "u8": ("B", 1),
+ "u16": ("H", 2),
+ "u32": ("I", 4),
+ "u64": ("Q", 8),
+ "s8": ("b", 1),
+ "s16": ("h", 2),
+ "s32": ("i", 4),
+ "s64": ("q", 8),
+ "bool": ("B", 1),
+}
+_FLOAT_FORMATS = {"f32": ("f", 4), "f64": ("d", 8)}
+
+
+@dataclass
+class Field:
+ name: str
+ offset: int
+ fmt: str
+ size: int = 0
+ enum: dict | None = None
+ note: str | None = None
+ count: int = 1
+
+ @property
+ def nbytes(self) -> int:
+ if self.fmt in _INT_FORMATS:
+ return _INT_FORMATS[self.fmt][1]
+ if self.fmt in _FLOAT_FORMATS:
+ return _FLOAT_FORMATS[self.fmt][1]
+ if self.fmt in ("bytes", "cstr"):
+ return self.size
+ if self.fmt == "ptr":
+ return self.size or 8
+ if self.fmt == "u64x":
+ return 8
+ raise StructError(f"field {self.name!r}: unknown format {self.fmt!r}")
+
+
+@dataclass
+class Schema:
+ name: str
+ fields: list[Field] = _field(default_factory=list)
+ size: int = 0
+
+ def __post_init__(self) -> None:
+ computed = max((f.offset + f.nbytes for f in self.fields), default=0)
+ if not self.size:
+ self.size = computed
+ elif self.size < computed:
+ raise StructError(
+ f"schema {self.name!r}: declared size {self.size:#x} < fields end {computed:#x}"
+ )
+ self.validate()
+
+ def validate(self) -> None:
+ seen: set[str] = set()
+ for field in self.fields:
+ if field.name in seen:
+ raise StructError(f"schema {self.name!r}: duplicate field {field.name!r}")
+ seen.add(field.name)
+ if field.offset < 0:
+ raise StructError(f"schema {self.name!r}: negative offset for {field.name!r}")
+ if field.nbytes <= 0:
+ raise StructError(f"schema {self.name!r}: zero-size field {field.name!r}")
+
+ def decode(self, data: bytes, base: int = 0) -> dict:
+ if len(data) < self.size:
+ raise StructError(
+ f"schema {self.name!r}: need {self.size} bytes, got {len(data)}"
+ )
+ out: dict = {}
+ for field in self.fields:
+ out[field.name] = _decode_field(field, data, base)
+ return out
+
+
+def _decode_field(field: Field, data: bytes, base: int):
+ start = field.offset
+ if field.fmt in _INT_FORMATS:
+ code, width = _INT_FORMATS[field.fmt]
+ value = _struct.unpack_from("<" + code, data, start)[0]
+ if field.fmt == "bool":
+ value = bool(value)
+ return value
+ if field.fmt in _FLOAT_FORMATS:
+ code, _width = _FLOAT_FORMATS[field.fmt]
+ return _struct.unpack_from("<" + code, data, start)[0]
+ if field.fmt == "ptr":
+ return int.from_bytes(data[start : start + field.nbytes], "little")
+ if field.fmt == "bytes":
+ return bytes(data[start : start + field.size])
+ if field.fmt == "cstr":
+ raw = data[start : start + field.size]
+ return raw.split(b"\x00", 1)[0].decode("utf-8", "replace")
+ raise StructError(f"unknown format {field.fmt!r}")
+
+
+def decode(schema: Schema, data: bytes, base: int = 0) -> dict:
+ return schema.decode(data, base=base)
+
+
+def schema_from_json(payload: dict) -> Schema:
+ if not isinstance(payload, dict) or "fields" not in payload:
+ raise StructError("schema JSON must be an object with a 'fields' list")
+ fields = []
+ for item in payload["fields"]:
+ try:
+ fields.append(
+ Field(
+ name=item["name"],
+ offset=int(item["offset"]),
+ fmt=item.get("fmt", "bytes"),
+ size=int(item.get("size", 0)),
+ enum=item.get("enum"),
+ note=item.get("note"),
+ count=int(item.get("count", 1)),
+ )
+ )
+ except KeyError as exc:
+ raise StructError(f"schema field missing key: {exc}") from exc
+ return Schema(name=payload.get("name", "schema"), fields=fields, size=int(payload.get("size", 0)))
+
+
+def schema_to_json(schema: Schema) -> dict:
+ return {
+ "name": schema.name,
+ "size": schema.size,
+ "fields": [
+ {
+ k: v
+ for k, v in {
+ "name": f.name,
+ "offset": f.offset,
+ "fmt": f.fmt,
+ "size": f.size or None,
+ "enum": f.enum,
+ "note": f.note,
+ }.items()
+ if v is not None
+ }
+ for f in schema.fields
+ ],
+ }
+
+
+def render(schema: Schema, values: dict, indent: str = " ") -> str:
+ lines = [f"{schema.name} ({{base}}+{schema.size:#x} bytes)"]
+ width = max((len(f.name) for f in schema.fields), default=0)
+ for field in schema.fields:
+ value = values.get(field.name)
+ lines.append(f"{indent}{field.name:<{width}} @+0x{field.offset:03x} = {_format_value(field, value)}")
+ if field.note:
+ lines.append(f"{indent}{'':<{width}} ; {field.note}")
+ return "\n".join(lines)
+
+
+def _format_value(field: Field, value) -> str:
+ if isinstance(value, bool):
+ return str(value)
+ if isinstance(value, float):
+ return f"{value:g}"
+ if isinstance(value, int):
+ text = f"{value:#x}" if not field.enum else f"{value} ({field.enum.get(str(value), '?')})"
+ if field.enum and field.fmt not in ("bool",):
+ text = f"{value:#x} ({field.enum.get(str(value), field.enum.get(value, '?'))})"
+ return text
+ if isinstance(value, (bytes, bytearray)):
+ head = bytes(value[:16]).hex(" ")
+ suffix = " ..." if len(value) > 16 else ""
+ return f"[{len(value)}B] {head}{suffix}"
+ return repr(value)
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/symbols.py b/debug/risc-v-gsp-rm-vm/greeneye/symbols.py
new file mode 100644
index 0000000..7b2e9dd
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/symbols.py
@@ -0,0 +1,313 @@
+"""Symbol resolution and address translation for green-eye.
+
+Inputs supported (all optional, merged into one database):
+
+* ELF ``.symtab`` symbols (demo firmware);
+* the project's Ghidra exports:
+ - ``names.tsv`` ``<file_vaddr>\t<name>[\t<source>]`` (comments with #),
+ - ``decompiled_index.tsv`` / bucket TSVs
+ ``<program_addr>\t<file_addr>\t<size>\t<name>\t<bucket>\t<line>\t<status>``,
+ - ``exports.tsv`` ``addr\tfile_addr\tsize\tname``,
+* green-eye native JSON (``greeneye-symbols.json``).
+
+Address conventions (matching the project docs):
+ * Ghidra program address = file vaddr + image base (``0x100000`` for libcuda);
+ * runtime address = file vaddr + runtime load bias;
+ * for firmware images linked at their load address the bias is 0.
+
+``SymbolDB`` stores runtime addresses by default; each loader takes a ``bias``
+so a relocated firmware or a different image base can be normalised.
+"""
+from __future__ import annotations
+
+import bisect
+import json
+import os
+from dataclasses import dataclass, field as _field
+
+from .errors import SymbolNotFound
+
+
+@dataclass
+class Symbol:
+ addr: int
+ name: str
+ size: int = 0
+ source: str = ""
+ subsystem: str | None = None
+ extras: dict = _field(default_factory=dict)
+
+ @property
+ def end(self) -> int:
+ return self.addr + self.size if self.size else self.addr
+
+ def contains(self, addr: int) -> bool:
+ if self.size:
+ return self.addr <= addr < self.addr + self.size
+ return addr == self.addr
+
+ def label(self, addr: int | None = None) -> str:
+ if addr is None or addr == self.addr or not self.size:
+ return self.name
+ return f"{self.name}+{addr - self.addr:#x}"
+
+ def to_json(self) -> dict:
+ out = {"addr": self.addr, "name": self.name}
+ if self.size:
+ out["size"] = self.size
+ if self.source:
+ out["source"] = self.source
+ if self.subsystem:
+ out["subsystem"] = self.subsystem
+ if self.extras:
+ out.update(self.extras)
+ return out
+
+
+class SymbolDB:
+ def __init__(self) -> None:
+ self._symbols: list[Symbol] = []
+ self._sorted = False
+ self._by_name: dict[str, Symbol] = {}
+ self._addr_index: list[int] = []
+
+ # -- loading -----------------------------------------------------------
+
+ def add(self, symbol: Symbol) -> None:
+ self._symbols.append(symbol)
+ self._sorted = False
+ existing = self._by_name.get(symbol.name)
+ if existing is None or (symbol.source and not existing.source):
+ self._by_name[symbol.name] = symbol
+
+ def load_elf(self, image, bias: int = 0, source: str = "elf", only_code: bool = True) -> int:
+ """Load ``.symtab`` symbols from a FirmwareImage/ELF path."""
+ from .elf import ElfSymbol, load_elf
+
+ if isinstance(image, str):
+ image = load_elf(image)
+ count = 0
+ for sym in image.symbols: # type: ignore[union-attr]
+ if only_code and not (sym.is_function or sym.is_object):
+ continue
+ self.add(
+ Symbol(
+ addr=sym.value + bias,
+ name=sym.name,
+ size=sym.size,
+ source=source,
+ extras={"type": "func" if sym.is_function else "object"},
+ )
+ )
+ count += 1
+ return count
+
+ def load_names_tsv(self, path: str, bias: int = 0) -> int:
+ """Load the project's Ghidra ``names.tsv`` (file vaddrs)."""
+ 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) < 2:
+ continue
+ try:
+ addr = int(cols[0], 16)
+ except ValueError:
+ continue
+ self.add(
+ Symbol(addr=addr + bias, name=cols[1], source=cols[2] if len(cols) > 2 else "ghidra-names")
+ )
+ count += 1
+ return count
+
+ def load_index_tsv(self, path: str, bias: int = 0) -> int:
+ """Load ``decompiled_index.tsv``/bucket TSVs/exports.tsv heuristically."""
+ 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 cols[0] in ("addr", "caller"):
+ continue
+ if len(cols) >= 7:
+ program, file_addr, size, name, bucket, lineno, status = cols[:7]
+ addr = _parse_hex(file_addr)
+ extras = {"program_addr": _parse_hex(program), "bucket": bucket, "line": lineno, "status": status}
+ elif len(cols) >= 4:
+ program, file_addr, size, name = cols[:4]
+ addr = _parse_hex(file_addr)
+ extras = {"program_addr": _parse_hex(program)}
+ elif len(cols) >= 2:
+ addr = _parse_hex(cols[0])
+ name = cols[1]
+ size = "0"
+ extras = {}
+ else:
+ continue
+ if addr is None or not name:
+ continue
+ try:
+ size_val = int(size, 0)
+ except ValueError:
+ size_val = 0
+ self.add(
+ Symbol(addr=addr + bias, name=name, size=size_val, source="ghidra-index", extras=extras)
+ )
+ count += 1
+ return count
+
+ def load_callgraph(self, path: str) -> int:
+ """Attach caller/callee lists from ``callgraph.tsv`` (program addrs)."""
+ by_program: dict[int, Symbol] = {}
+ for sym in self._symbols:
+ prog = sym.extras.get("program_addr")
+ if prog is not None:
+ by_program[int(prog)] = sym
+ if not by_program:
+ return 0
+ for sym in self._symbols:
+ sym.extras.setdefault("callers", [])
+ sym.extras.setdefault("callees", [])
+ 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("#") or line.startswith("caller"):
+ continue
+ cols = line.split("\t")
+ if len(cols) < 2:
+ continue
+ caller, callee = _parse_hex(cols[0]), _parse_hex(cols[1])
+ if caller is None or callee is None:
+ continue
+ if caller in by_program:
+ by_program[caller].extras["callees"].append(callee)
+ count += 1
+ if callee in by_program:
+ by_program[callee].extras["callers"].append(caller)
+ 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 [])
+ count = 0
+ for item in symbols:
+ try:
+ addr = int(item["addr"], 0) if isinstance(item["addr"], str) else int(item["addr"])
+ except (KeyError, TypeError, ValueError):
+ continue
+ extras = {k: v for k, v in item.items() if k not in ("addr", "name", "size", "source", "subsystem")}
+ self.add(
+ Symbol(
+ addr=addr,
+ name=item.get("name", f"sub_{addr:x}"),
+ size=int(item.get("size", 0)),
+ source=item.get("source", "json"),
+ subsystem=item.get("subsystem"),
+ extras=extras,
+ )
+ )
+ count += 1
+ return count
+
+ def save_json(self, path: str, meta: dict | None = None) -> None:
+ self._ensure_sorted()
+ payload = {
+ "format": "greeneye-symbols/1",
+ "count": len(self._symbols),
+ "meta": meta or {},
+ "symbols": [s.to_json() for s in self._symbols],
+ }
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(payload, fh, indent=1, sort_keys=True)
+
+ # -- queries -----------------------------------------------------------
+
+ def _ensure_sorted(self) -> None:
+ if self._sorted:
+ return
+ self._symbols.sort(key=lambda s: (s.addr, -s.size))
+ self._addr_index = [s.addr for s in self._symbols]
+ self._sorted = True
+
+ def __len__(self) -> int:
+ return len(self._symbols)
+
+ def __iter__(self):
+ return iter(self._symbols)
+
+ def get(self, name: str) -> Symbol | None:
+ return self._by_name.get(name)
+
+ def lookup(self, name: str) -> int:
+ sym = self._by_name.get(name)
+ if sym is None:
+ raise SymbolNotFound(f"symbol {name!r} not found")
+ return sym.addr
+
+ def resolve(self, addr: int, max_delta: int = 0x10000) -> Symbol | None:
+ """Resolve an address to a containing or nearest preceding symbol."""
+ self._ensure_sorted()
+ if not self._symbols:
+ return None
+ pos = bisect.bisect_right(self._addr_index, addr) - 1
+ if pos < 0:
+ return None
+ candidate = self._symbols[pos]
+ if candidate.contains(addr):
+ return candidate
+ if candidate.size and addr < candidate.addr + candidate.size:
+ return candidate
+ # nearest-within-delta fallback for zero-size symbols (Ghidra names)
+ if not candidate.size and 0 <= addr - candidate.addr <= max_delta:
+ return candidate
+ return None
+
+ def annotate(self, addr: int, max_delta: int = 0x10000) -> str:
+ sym = self.resolve(addr, max_delta=max_delta)
+ if sym is None:
+ return f"{addr:#x} <unknown>"
+ return f"{addr:#x} {sym.label(addr)}"
+
+ def nearest(self, addr: int, max_delta: int = 0x10000) -> Symbol | None:
+ 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 [s for s in self._symbols if s.subsystem == subsystem]
+
+
+def _parse_hex(value: str) -> int | None:
+ value = value.strip()
+ if not value:
+ return None
+ try:
+ return int(value, 16)
+ except ValueError:
+ try:
+ return int(value, 0)
+ except ValueError:
+ return None
+
+
+def translation_for(
+ *,
+ file_vaddr: int,
+ program_addr: int | None = None,
+ runtime_addr: int | None = None,
+) -> dict:
+ """Explain the three address spaces for a debug session (docs aid)."""
+ out = {"file_vaddr": file_vaddr}
+ if program_addr is not None:
+ out["image_base"] = program_addr - file_vaddr
+ if runtime_addr is not None:
+ out["load_bias"] = runtime_addr - file_vaddr
+ return out
diff --git a/debug/risc-v-gsp-rm-vm/greeneye/target.py b/debug/risc-v-gsp-rm-vm/greeneye/target.py
new file mode 100644
index 0000000..efcb1d1
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/greeneye/target.py
@@ -0,0 +1,223 @@
+"""QEMU target lifecycle for green-eye.
+
+Owns the ``qemu-system-riscv64`` process, the gdbstub endpoint, the serial
+capture and the QEMU trace log. Both firmware paths are supported:
+
+* ``kernel=`` fast path - QEMU loads an ELF linked for RAM and sets the PC;
+* ``segments=`` probe path - each PT_LOAD segment is placed with a loader
+ device (raw), so a firmware linked for GPU physical addresses can be
+ relocated into RAM and observed.
+"""
+from __future__ import annotations
+
+import os
+import shutil
+import signal
+import socket
+import subprocess
+import tempfile
+import time
+
+from .errors import TargetError
+
+DEFAULT_QEMU = "qemu-system-riscv64"
+
+
+def find_qemu() -> str:
+ env = os.environ.get("GREENEYE_QEMU")
+ if env:
+ return env
+ found = shutil.which(DEFAULT_QEMU)
+ if not found:
+ raise TargetError(
+ f"{DEFAULT_QEMU} not found. Install it (Ubuntu: qemu-system-misc) "
+ "or set GREENEYE_QEMU to the binary."
+ )
+ return found
+
+
+def free_port() -> int:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.bind(("127.0.0.1", 0))
+ return sock.getsockname()[1]
+
+
+class QemuTarget:
+ def __init__(
+ self,
+ *,
+ machine: str = "virt",
+ memory_mb: int = 256,
+ gdb_port: int = 0,
+ bios: str = "none",
+ extra_args: list[str] | None = None,
+ qemu: str | None = None,
+ kernel: str | None = None,
+ segments: list[tuple[int, bytes]] | None = None,
+ dlog: str | None = None,
+ uart_log: str | None = None,
+ qemu_log: str | None = None,
+ workdir: str | None = None,
+ keep_alive: bool = False,
+ ):
+ self.qemu = qemu or find_qemu()
+ self.machine = machine
+ self.memory_mb = memory_mb
+ self.host = "127.0.0.1"
+ self.port = gdb_port or free_port()
+ self.bios = bios
+ self.extra_args = list(extra_args or [])
+ self.kernel = os.path.abspath(kernel) if kernel else None
+ self.segments = list(segments or [])
+ self.dlog = dlog
+ self.workdir = workdir or tempfile.mkdtemp(prefix="greeneye-qemu-")
+ os.makedirs(self.workdir, exist_ok=True)
+ self.uart_log = uart_log or os.path.join(self.workdir, "uart.log")
+ self.qemu_log = qemu_log or os.path.join(self.workdir, "qemu.log")
+ self.keep_alive = keep_alive
+ self.process: subprocess.Popen | None = None
+ self._segment_files: list[str] = []
+ self._cmd: list[str] = []
+
+ # -- lifecycle ---------------------------------------------------------
+
+ def _build_command(self, paused: bool) -> list[str]:
+ cmd = [
+ self.qemu,
+ "-machine",
+ self.machine,
+ "-m",
+ f"{self.memory_mb}M",
+ "-display",
+ "none",
+ "-monitor",
+ "none",
+ "-serial",
+ f"file:{self.uart_log}",
+ "-gdb",
+ f"tcp:{self.host}:{self.port}",
+ ]
+ if self.bios:
+ cmd += ["-bios", self.bios]
+ if paused:
+ cmd += ["-S"]
+ if self.kernel:
+ cmd += ["-kernel", self.kernel]
+ for index, (addr, blob) in enumerate(self.segments):
+ seg_path = os.path.join(self.workdir, f"segment-{index:02d}-{addr:#x}.bin")
+ with open(seg_path, "wb") as fh:
+ fh.write(blob)
+ self._segment_files.append(seg_path)
+ cmd += ["-device", f"loader,file={seg_path},addr={addr:#x},force-raw=on"]
+ if self.dlog:
+ cmd += ["-d", self.dlog, "-D", self.qemu_log]
+ cmd += self.extra_args
+ return cmd
+
+ def start(self, paused: bool = True, timeout: float = 15.0) -> "QemuTarget":
+ self._cmd = self._build_command(paused)
+ try:
+ self.process = subprocess.Popen(
+ self._cmd,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.PIPE,
+ cwd=self.workdir,
+ start_new_session=True,
+ )
+ except OSError as exc:
+ raise TargetError(f"cannot start QEMU: {exc}") from exc
+ try:
+ self.wait_for_port(timeout=timeout)
+ except TargetError:
+ self.kill()
+ raise
+ return self
+
+ def wait_for_port(self, timeout: float = 15.0) -> None:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if self.process is not None and self.process.poll() is not None:
+ stderr = b""
+ if self.process.stderr is not None:
+ try:
+ stderr = self.process.stderr.read() or b""
+ except OSError:
+ pass
+ raise TargetError(
+ f"QEMU exited early rc={self.process.returncode}: "
+ f"{stderr.decode('utf-8', 'replace')[-2000:]}"
+ )
+ try:
+ with socket.create_connection((self.host, self.port), timeout=0.25):
+ return
+ except OSError:
+ time.sleep(0.05)
+ raise TargetError(f"gdbstub port {self.port} not ready after {timeout}s")
+
+ def alive(self) -> bool:
+ return self.process is not None and self.process.poll() is None
+
+ def kill(self, timeout: float = 5.0) -> None:
+ if self.process is None:
+ return
+ if self.process.poll() is None:
+ try:
+ os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
+ except (ProcessLookupError, PermissionError):
+ try:
+ self.process.terminate()
+ except ProcessLookupError:
+ pass
+ try:
+ self.process.wait(timeout=timeout)
+ except subprocess.TimeoutExpired:
+ try:
+ os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
+ except (ProcessLookupError, PermissionError):
+ try:
+ self.process.kill()
+ except ProcessLookupError:
+ pass
+ self.process.wait(timeout=timeout)
+ if self.process.stderr is not None:
+ try:
+ self.process.stderr.close()
+ except OSError:
+ pass
+ self.process = None
+
+ # -- observation -------------------------------------------------------
+
+ def read_uart(self) -> str:
+ try:
+ with open(self.uart_log, "rb") as fh:
+ return fh.read().decode("utf-8", "replace")
+ except FileNotFoundError:
+ return ""
+
+ def wait_for_uart(self, marker: str, timeout: float = 5.0, interval: float = 0.05) -> bool:
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ if marker in self.read_uart():
+ return True
+ time.sleep(interval)
+ return False
+
+ def read_qemu_log(self) -> str:
+ try:
+ with open(self.qemu_log, "rb") as fh:
+ return fh.read().decode("utf-8", "replace")
+ except FileNotFoundError:
+ return ""
+
+ @property
+ def command_line(self) -> list[str]:
+ return list(self._cmd)
+
+ def shell_command(self) -> str:
+ import shlex
+
+ return " ".join(shlex.quote(part) for part in self._cmd)
+
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
+ return f"QemuTarget(port={self.port}, alive={self.alive()})"
diff --git a/debug/risc-v-gsp-rm-vm/plugins/README.md b/debug/risc-v-gsp-rm-vm/plugins/README.md
new file mode 100644
index 0000000..aa9bc72
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/plugins/README.md
@@ -0,0 +1,34 @@
+# green-eye plugins
+
+A plugin is a Python module exposing `register(ge)`:
+
+```python
+def register(ge):
+ ge.log("my plugin loaded")
+ ge.add_command("mycmd", mycmd, help="does a thing")
+ ge.hook("rpc_sum", on_enter=lambda hit: ge.log(f"sum a0={hit.arg(0):#x}"))
+```
+
+`ge` provides:
+* `session`, `memory`, `symbols`, `recorder` — the live objects;
+* `hook(target, on_enter=, on_leave=)` / `watch(target, size=, kind=, callback=)`;
+* `on_event(kind, callback)` — react to every stop of a kind;
+* `add_command(name, fn, help=)` — new REPL commands (`fn(session, args) -> int`);
+* `log(message, **fields)` — recorded plugin notes.
+
+Discovery: `--plugin PATH` (file or directory), then `GREENEYE_PLUGINS`
+(colon-separated), then the bundled `plugins/` directory. A plugin that raises
+is recorded as `plugin_error` and never breaks the session.
+
+Types of plugins that fit green-eye:
+1. **Runtime RE tools** — new watchpoint strategies, coverage collectors,
+ structure probes, heap/allocator graphs.
+2. **Harnesses** — stimulus drivers that poke mailboxes/rings and assert on
+ 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.
+
+The example plugin is `example_plugin.py`.
diff --git a/debug/risc-v-gsp-rm-vm/plugins/example_plugin.py b/debug/risc-v-gsp-rm-vm/plugins/example_plugin.py
new file mode 100644
index 0000000..0bd382e
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/plugins/example_plugin.py
@@ -0,0 +1,45 @@
+"""Example green-eye plugin.
+
+Load it explicitly with ``--plugin plugins/example_plugin.py`` (the bundled
+``plugins/`` directory is discovered automatically), or set
+``GREENEYE_PLUGINS=/path/to/plugins``.
+
+It demonstrates the three plugin extension points:
+ * a CLI command (``gehits``),
+ * runtime instrumentation (a hook on ``rpc_state`` logging transitions),
+ * session notes / event stream contributions.
+"""
+from __future__ import annotations
+
+
+def register(ge):
+ ge.log("example plugin loaded")
+
+ state_seen: list[int] = []
+
+ def on_state(hit):
+ state_seen.append(hit.retval or 0)
+ ge.recorder.event(
+ "plugin_state_transition",
+ plugin="example",
+ step=len(state_seen),
+ result=hit.retval,
+ a0=hit.arg(0),
+ )
+
+ sym = ge.symbols.get("rpc_state")
+ if sym is not None:
+ ge.hook("rpc_state", on_leave=on_state)
+ ge.log("hooked rpc_state for transition logging")
+ else:
+ ge.log("rpc_state not in symbol DB; hook skipped")
+
+ def cmd_gehits(session, args):
+ """gehits -- print plugin-observed state transitions."""
+ print(f" observed {len(state_seen)} rpc_state transitions: {state_seen}")
+ directory = getattr(session.recorder, "dir", None)
+ if directory:
+ print(f" session: {directory}")
+ return 0
+
+ ge.add_command("gehits", cmd_gehits, help="print plugin-observed rpc_state transitions")
diff --git a/debug/risc-v-gsp-rm-vm/qemu/Makefile b/debug/risc-v-gsp-rm-vm/qemu/Makefile
new file mode 100644
index 0000000..8537cbb
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/qemu/Makefile
@@ -0,0 +1,16 @@
+# Optional QEMU TCG plugin: full instruction/memory activity tracing.
+# Requires the QEMU plugin API header (vendored as qemu-plugin.h, GPL-2.0+).
+
+CC ?= gcc
+HEADER := qemu-plugin.h
+TARGET := greeneye-trace.so
+
+CFLAGS ?= -O2 -fPIC -Wall -Wextra -fvisibility=hidden -I.
+
+all: $(TARGET)
+
+$(TARGET): greeneye_trace.c $(HEADER)
+ $(CC) $(CFLAGS) -shared -o $@ greeneye_trace.c
+
+clean:
+ rm -f $(TARGET) *.o
diff --git a/debug/risc-v-gsp-rm-vm/qemu/README.md b/debug/risc-v-gsp-rm-vm/qemu/README.md
new file mode 100644
index 0000000..a6be332
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/qemu/README.md
@@ -0,0 +1,28 @@
+# green-eye QEMU helpers
+
+`greeneye_trace.c` is an optional QEMU TCG plugin that records **every**
+instruction (opt-in) and every memory access as JSONL:
+
+```bash
+make -C qemu # builds qemu/greeneye-trace.so
+qemu-system-riscv64 -machine virt -m 256M -display none -serial file:/tmp/uart.log \
+ -bios none -kernel <elf> -S -gdb tcp:127.0.0.1:4444 \
+ -plugin qemu/greeneye-trace.so,path=/tmp/ge-trace.jsonl,start=0x80000000,end=0x80010000,exec=off
+```
+
+This observes more than green-eye's gdbstub layer can: memory accesses
+between stops, instruction coverage, exact access sizes. Use it when you need
+a *complete* memory-activity map; use green-eye hooks/watchpoints when you
+need semantics (arguments, returns, structure decoding).
+
+The plugin header `qemu-plugin.h` is vendored from QEMU v8.2.2
+(`include/qemu/qemu-plugin.h`, GPL-2.0-or-later) so the plugin builds on
+systems where the QEMU source tree is not installed. If you prefer your
+distro's header, point `CFLAGS` at it instead.
+
+`launch.sh` starts a target through the package and keeps it alive for
+`greeneye attach`:
+
+```bash
+qemu/launch.sh --kernel firmware/uproc_demo/build/uproc_demo.elf
+```
diff --git a/debug/risc-v-gsp-rm-vm/qemu/greeneye_trace.c b/debug/risc-v-gsp-rm-vm/qemu/greeneye_trace.c
new file mode 100644
index 0000000..a20748a
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/qemu/greeneye_trace.c
@@ -0,0 +1,128 @@
+/*
+ * green-eye TCG trace plugin - full instruction/memory activity recording.
+ *
+ * This is the "complete map" upgrade from stop-based observation: QEMU calls
+ * the plugin for every translated instruction and every memory access. Output
+ * is JSONL (one object per line), ready to be folded into a green-eye session:
+ *
+ * {"t":"exec","vcpu":0,"pc":"0x80000194"}
+ * {"t":"write","vcpu":0,"addr":"0x80000850","size":8}
+ * {"t":"read","vcpu":0,"addr":"0x10000005","size":1}
+ * {"t":"summary","insns":12345,"mem":678}
+ *
+ * Arguments (all optional):
+ * path=FILE output file (default greeneye-trace.jsonl)
+ * exec=on|off record instruction execution (default off; heavy)
+ * start=0xADDR only instrument PCs >= start
+ * end=0xADDR only instrument PCs <= end
+ *
+ * Build: make -C qemu (needs qemu-plugin.h, vendored here)
+ * Run: qemu-system-riscv64 ... \
+ * -plugin qemu/greeneye-trace.so,path=/tmp/ge-trace.jsonl,exec=on
+ *
+ * License: GPL-2.0-or-later (matches QEMU's plugin API header).
+ */
+#include <inttypes.h>
+#include <stdbool.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include <qemu-plugin.h>
+
+QEMU_PLUGIN_EXPORT int qemu_plugin_version = QEMU_PLUGIN_VERSION;
+
+static FILE *trace_fp;
+static bool trace_exec;
+static uint64_t start_pc;
+static uint64_t end_pc;
+static uint64_t insn_count;
+static uint64_t mem_count;
+
+static void vcpu_insn_exec(unsigned int vcpu_index, void *userdata)
+{
+ uint64_t pc = (uint64_t)(uintptr_t)userdata;
+ insn_count++;
+ fprintf(trace_fp, "{\"t\":\"exec\",\"vcpu\":%u,\"pc\":\"0x%" PRIx64 "\"}\n",
+ vcpu_index, pc);
+}
+
+static void vcpu_mem(unsigned int vcpu_index, qemu_plugin_meminfo_t info,
+ uint64_t vaddr, void *userdata)
+{
+ bool is_store = qemu_plugin_mem_is_store(info);
+ unsigned int shift = qemu_plugin_mem_size_shift(info);
+ (void)userdata;
+ mem_count++;
+ fprintf(trace_fp,
+ "{\"t\":\"%s\",\"vcpu\":%u,\"addr\":\"0x%" PRIx64 "\",\"size\":%u}\n",
+ is_store ? "write" : "read", vcpu_index, vaddr, 1u << shift);
+}
+
+static void vcpu_tb_trans(qemu_plugin_id_t id, struct qemu_plugin_tb *tb)
+{
+ size_t n = qemu_plugin_tb_n_insns(tb);
+ (void)id;
+ for (size_t i = 0; i < n; i++) {
+ struct qemu_plugin_insn *insn = qemu_plugin_tb_get_insn(tb, i);
+ uint64_t pc = qemu_plugin_insn_vaddr(insn);
+ if (start_pc && pc < start_pc) {
+ continue;
+ }
+ if (end_pc && pc > end_pc) {
+ continue;
+ }
+ if (trace_exec) {
+ qemu_plugin_register_vcpu_insn_exec_cb(
+ insn, vcpu_insn_exec, QEMU_PLUGIN_CB_NO_REGS,
+ (void *)(uintptr_t)pc);
+ }
+ qemu_plugin_register_vcpu_mem_cb(insn, vcpu_mem, QEMU_PLUGIN_CB_NO_REGS,
+ QEMU_PLUGIN_MEM_RW, NULL);
+ }
+}
+
+static void plugin_exit(qemu_plugin_id_t id, void *userdata)
+{
+ (void)id;
+ (void)userdata;
+ if (trace_fp) {
+ fprintf(trace_fp,
+ "{\"t\":\"summary\",\"insns\":%" PRIu64 ",\"mem\":%" PRIu64 "}\n",
+ insn_count, mem_count);
+ fclose(trace_fp);
+ trace_fp = NULL;
+ }
+ qemu_plugin_outs("green-eye trace complete\n");
+}
+
+QEMU_PLUGIN_EXPORT int qemu_plugin_install(qemu_plugin_id_t id,
+ const qemu_info_t *info, int argc,
+ char **argv)
+{
+ const char *path = "greeneye-trace.jsonl";
+ (void)info;
+
+ for (int i = 0; i < argc; i++) {
+ if (strncmp(argv[i], "path=", 5) == 0) {
+ path = argv[i] + 5;
+ } else if (strncmp(argv[i], "exec=", 5) == 0) {
+ trace_exec = strcmp(argv[i] + 5, "on") == 0
+ || strcmp(argv[i] + 5, "true") == 0;
+ } else if (strncmp(argv[i], "start=", 6) == 0) {
+ start_pc = strtoull(argv[i] + 6, NULL, 0);
+ } else if (strncmp(argv[i], "end=", 4) == 0) {
+ end_pc = strtoull(argv[i] + 4, NULL, 0);
+ }
+ }
+
+ trace_fp = fopen(path, "w");
+ if (!trace_fp) {
+ qemu_plugin_outs("green-eye: cannot open trace output\n");
+ return -1;
+ }
+ qemu_plugin_register_vcpu_tb_trans_cb(id, vcpu_tb_trans);
+ qemu_plugin_register_atexit_cb(id, plugin_exit, NULL);
+ qemu_plugin_outs("green-eye trace plugin armed\n");
+ return 0;
+}
diff --git a/debug/risc-v-gsp-rm-vm/qemu/launch.sh b/debug/risc-v-gsp-rm-vm/qemu/launch.sh
new file mode 100755
index 0000000..54460db
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/qemu/launch.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+# Start a green-eye target and keep it alive for `greeneye attach`.
+set -euo pipefail
+HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+exec "$HERE/../greeneye.sh" launch "$@"
diff --git a/debug/risc-v-gsp-rm-vm/qemu/qemu-plugin.h b/debug/risc-v-gsp-rm-vm/qemu/qemu-plugin.h
new file mode 100644
index 0000000..4daab6e
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/qemu/qemu-plugin.h
@@ -0,0 +1,711 @@
+/*
+ * Copyright (C) 2017, Emilio G. Cota <cota@braap.org>
+ * Copyright (C) 2019, Linaro
+ *
+ * License: GNU GPL, version 2 or later.
+ * See the COPYING file in the top-level directory.
+ *
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#ifndef QEMU_QEMU_PLUGIN_H
+#define QEMU_QEMU_PLUGIN_H
+
+#include <inttypes.h>
+#include <stdbool.h>
+#include <stddef.h>
+
+/*
+ * For best performance, build the plugin with -fvisibility=hidden so that
+ * QEMU_PLUGIN_LOCAL is implicit. Then, just mark qemu_plugin_install with
+ * QEMU_PLUGIN_EXPORT. For more info, see
+ * https://gcc.gnu.org/wiki/Visibility
+ */
+#if defined _WIN32 || defined __CYGWIN__
+ #ifdef CONFIG_PLUGIN
+ #define QEMU_PLUGIN_EXPORT __declspec(dllimport)
+ #define QEMU_PLUGIN_API __declspec(dllexport)
+ #else
+ #define QEMU_PLUGIN_EXPORT __declspec(dllexport)
+ #define QEMU_PLUGIN_API __declspec(dllimport)
+ #endif
+ #define QEMU_PLUGIN_LOCAL
+#else
+ #define QEMU_PLUGIN_EXPORT __attribute__((visibility("default")))
+ #define QEMU_PLUGIN_LOCAL __attribute__((visibility("hidden")))
+ #define QEMU_PLUGIN_API
+#endif
+
+/**
+ * typedef qemu_plugin_id_t - Unique plugin ID
+ */
+typedef uint64_t qemu_plugin_id_t;
+
+/*
+ * Versioning plugins:
+ *
+ * The plugin API will pass a minimum and current API version that
+ * QEMU currently supports. The minimum API will be incremented if an
+ * API needs to be deprecated.
+ *
+ * The plugins export the API they were built against by exposing the
+ * symbol qemu_plugin_version which can be checked.
+ */
+
+extern QEMU_PLUGIN_EXPORT int qemu_plugin_version;
+
+#define QEMU_PLUGIN_VERSION 1
+
+/**
+ * struct qemu_info_t - system information for plugins
+ *
+ * This structure provides for some limited information about the
+ * system to allow the plugin to make decisions on how to proceed. For
+ * example it might only be suitable for running on some guest
+ * architectures or when under full system emulation.
+ */
+typedef struct qemu_info_t {
+ /** @target_name: string describing architecture */
+ const char *target_name;
+ /** @version: minimum and current plugin API level */
+ struct {
+ int min;
+ int cur;
+ } version;
+ /** @system_emulation: is this a full system emulation? */
+ bool system_emulation;
+ union {
+ /** @system: information relevant to system emulation */
+ struct {
+ /** @system.smp_vcpus: initial number of vCPUs */
+ int smp_vcpus;
+ /** @system.max_vcpus: maximum possible number of vCPUs */
+ int max_vcpus;
+ } system;
+ };
+} qemu_info_t;
+
+/**
+ * qemu_plugin_install() - Install a plugin
+ * @id: this plugin's opaque ID
+ * @info: a block describing some details about the guest
+ * @argc: number of arguments
+ * @argv: array of arguments (@argc elements)
+ *
+ * All plugins must export this symbol which is called when the plugin
+ * is first loaded. Calling qemu_plugin_uninstall() from this function
+ * is a bug.
+ *
+ * Note: @info is only live during the call. Copy any information we
+ * want to keep. @argv remains valid throughout the lifetime of the
+ * loaded plugin.
+ *
+ * Return: 0 on successful loading, !0 for an error.
+ */
+QEMU_PLUGIN_EXPORT int qemu_plugin_install(qemu_plugin_id_t id,
+ const qemu_info_t *info,
+ int argc, char **argv);
+
+/**
+ * typedef qemu_plugin_simple_cb_t - simple callback
+ * @id: the unique qemu_plugin_id_t
+ *
+ * This callback passes no information aside from the unique @id.
+ */
+typedef void (*qemu_plugin_simple_cb_t)(qemu_plugin_id_t id);
+
+/**
+ * typedef qemu_plugin_udata_cb_t - callback with user data
+ * @id: the unique qemu_plugin_id_t
+ * @userdata: a pointer to some user data supplied when the callback
+ * was registered.
+ */
+typedef void (*qemu_plugin_udata_cb_t)(qemu_plugin_id_t id, void *userdata);
+
+/**
+ * typedef qemu_plugin_vcpu_simple_cb_t - vcpu callback
+ * @id: the unique qemu_plugin_id_t
+ * @vcpu_index: the current vcpu context
+ */
+typedef void (*qemu_plugin_vcpu_simple_cb_t)(qemu_plugin_id_t id,
+ unsigned int vcpu_index);
+
+/**
+ * typedef qemu_plugin_vcpu_udata_cb_t - vcpu callback
+ * @vcpu_index: the current vcpu context
+ * @userdata: a pointer to some user data supplied when the callback
+ * was registered.
+ */
+typedef void (*qemu_plugin_vcpu_udata_cb_t)(unsigned int vcpu_index,
+ void *userdata);
+
+/**
+ * qemu_plugin_uninstall() - Uninstall a plugin
+ * @id: this plugin's opaque ID
+ * @cb: callback to be called once the plugin has been removed
+ *
+ * Do NOT assume that the plugin has been uninstalled once this function
+ * returns. Plugins are uninstalled asynchronously, and therefore the given
+ * plugin receives callbacks until @cb is called.
+ *
+ * Note: Calling this function from qemu_plugin_install() is a bug.
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_uninstall(qemu_plugin_id_t id, qemu_plugin_simple_cb_t cb);
+
+/**
+ * qemu_plugin_reset() - Reset a plugin
+ * @id: this plugin's opaque ID
+ * @cb: callback to be called once the plugin has been reset
+ *
+ * Unregisters all callbacks for the plugin given by @id.
+ *
+ * Do NOT assume that the plugin has been reset once this function returns.
+ * Plugins are reset asynchronously, and therefore the given plugin receives
+ * callbacks until @cb is called.
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_reset(qemu_plugin_id_t id, qemu_plugin_simple_cb_t cb);
+
+/**
+ * qemu_plugin_register_vcpu_init_cb() - register a vCPU initialization callback
+ * @id: plugin ID
+ * @cb: callback function
+ *
+ * The @cb function is called every time a vCPU is initialized.
+ *
+ * See also: qemu_plugin_register_vcpu_exit_cb()
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_register_vcpu_init_cb(qemu_plugin_id_t id,
+ qemu_plugin_vcpu_simple_cb_t cb);
+
+/**
+ * qemu_plugin_register_vcpu_exit_cb() - register a vCPU exit callback
+ * @id: plugin ID
+ * @cb: callback function
+ *
+ * The @cb function is called every time a vCPU exits.
+ *
+ * See also: qemu_plugin_register_vcpu_init_cb()
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_register_vcpu_exit_cb(qemu_plugin_id_t id,
+ qemu_plugin_vcpu_simple_cb_t cb);
+
+/**
+ * qemu_plugin_register_vcpu_idle_cb() - register a vCPU idle callback
+ * @id: plugin ID
+ * @cb: callback function
+ *
+ * The @cb function is called every time a vCPU idles.
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_register_vcpu_idle_cb(qemu_plugin_id_t id,
+ qemu_plugin_vcpu_simple_cb_t cb);
+
+/**
+ * qemu_plugin_register_vcpu_resume_cb() - register a vCPU resume callback
+ * @id: plugin ID
+ * @cb: callback function
+ *
+ * The @cb function is called every time a vCPU resumes execution.
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_register_vcpu_resume_cb(qemu_plugin_id_t id,
+ qemu_plugin_vcpu_simple_cb_t cb);
+
+/** struct qemu_plugin_tb - Opaque handle for a translation block */
+struct qemu_plugin_tb;
+/** struct qemu_plugin_insn - Opaque handle for a translated instruction */
+struct qemu_plugin_insn;
+
+/**
+ * enum qemu_plugin_cb_flags - type of callback
+ *
+ * @QEMU_PLUGIN_CB_NO_REGS: callback does not access the CPU's regs
+ * @QEMU_PLUGIN_CB_R_REGS: callback reads the CPU's regs
+ * @QEMU_PLUGIN_CB_RW_REGS: callback reads and writes the CPU's regs
+ *
+ * Note: currently unused, plugins cannot read or change system
+ * register state.
+ */
+enum qemu_plugin_cb_flags {
+ QEMU_PLUGIN_CB_NO_REGS,
+ QEMU_PLUGIN_CB_R_REGS,
+ QEMU_PLUGIN_CB_RW_REGS,
+};
+
+enum qemu_plugin_mem_rw {
+ QEMU_PLUGIN_MEM_R = 1,
+ QEMU_PLUGIN_MEM_W,
+ QEMU_PLUGIN_MEM_RW,
+};
+
+/**
+ * typedef qemu_plugin_vcpu_tb_trans_cb_t - translation callback
+ * @id: unique plugin id
+ * @tb: opaque handle used for querying and instrumenting a block.
+ */
+typedef void (*qemu_plugin_vcpu_tb_trans_cb_t)(qemu_plugin_id_t id,
+ struct qemu_plugin_tb *tb);
+
+/**
+ * qemu_plugin_register_vcpu_tb_trans_cb() - register a translate cb
+ * @id: plugin ID
+ * @cb: callback function
+ *
+ * The @cb function is called every time a translation occurs. The @cb
+ * function is passed an opaque qemu_plugin_type which it can query
+ * for additional information including the list of translated
+ * instructions. At this point the plugin can register further
+ * callbacks to be triggered when the block or individual instruction
+ * executes.
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_register_vcpu_tb_trans_cb(qemu_plugin_id_t id,
+ qemu_plugin_vcpu_tb_trans_cb_t cb);
+
+/**
+ * qemu_plugin_register_vcpu_tb_exec_cb() - register execution callback
+ * @tb: the opaque qemu_plugin_tb handle for the translation
+ * @cb: callback function
+ * @flags: does the plugin read or write the CPU's registers?
+ * @userdata: any plugin data to pass to the @cb?
+ *
+ * The @cb function is called every time a translated unit executes.
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_register_vcpu_tb_exec_cb(struct qemu_plugin_tb *tb,
+ qemu_plugin_vcpu_udata_cb_t cb,
+ enum qemu_plugin_cb_flags flags,
+ void *userdata);
+
+/**
+ * enum qemu_plugin_op - describes an inline op
+ *
+ * @QEMU_PLUGIN_INLINE_ADD_U64: add an immediate value uint64_t
+ *
+ * Note: currently only a single inline op is supported.
+ */
+
+enum qemu_plugin_op {
+ QEMU_PLUGIN_INLINE_ADD_U64,
+};
+
+/**
+ * qemu_plugin_register_vcpu_tb_exec_inline() - execution inline op
+ * @tb: the opaque qemu_plugin_tb handle for the translation
+ * @op: the type of qemu_plugin_op (e.g. ADD_U64)
+ * @ptr: the target memory location for the op
+ * @imm: the op data (e.g. 1)
+ *
+ * Insert an inline op to every time a translated unit executes.
+ * Useful if you just want to increment a single counter somewhere in
+ * memory.
+ *
+ * Note: ops are not atomic so in multi-threaded/multi-smp situations
+ * you will get inexact results.
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_register_vcpu_tb_exec_inline(struct qemu_plugin_tb *tb,
+ enum qemu_plugin_op op,
+ void *ptr, uint64_t imm);
+
+/**
+ * qemu_plugin_register_vcpu_insn_exec_cb() - register insn execution cb
+ * @insn: the opaque qemu_plugin_insn handle for an instruction
+ * @cb: callback function
+ * @flags: does the plugin read or write the CPU's registers?
+ * @userdata: any plugin data to pass to the @cb?
+ *
+ * The @cb function is called every time an instruction is executed
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_register_vcpu_insn_exec_cb(struct qemu_plugin_insn *insn,
+ qemu_plugin_vcpu_udata_cb_t cb,
+ enum qemu_plugin_cb_flags flags,
+ void *userdata);
+
+/**
+ * qemu_plugin_register_vcpu_insn_exec_inline() - insn execution inline op
+ * @insn: the opaque qemu_plugin_insn handle for an instruction
+ * @op: the type of qemu_plugin_op (e.g. ADD_U64)
+ * @ptr: the target memory location for the op
+ * @imm: the op data (e.g. 1)
+ *
+ * Insert an inline op to every time an instruction executes. Useful
+ * if you just want to increment a single counter somewhere in memory.
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_register_vcpu_insn_exec_inline(struct qemu_plugin_insn *insn,
+ enum qemu_plugin_op op,
+ void *ptr, uint64_t imm);
+
+/**
+ * qemu_plugin_tb_n_insns() - query helper for number of insns in TB
+ * @tb: opaque handle to TB passed to callback
+ *
+ * Returns: number of instructions in this block
+ */
+QEMU_PLUGIN_API
+size_t qemu_plugin_tb_n_insns(const struct qemu_plugin_tb *tb);
+
+/**
+ * qemu_plugin_tb_vaddr() - query helper for vaddr of TB start
+ * @tb: opaque handle to TB passed to callback
+ *
+ * Returns: virtual address of block start
+ */
+QEMU_PLUGIN_API
+uint64_t qemu_plugin_tb_vaddr(const struct qemu_plugin_tb *tb);
+
+/**
+ * qemu_plugin_tb_get_insn() - retrieve handle for instruction
+ * @tb: opaque handle to TB passed to callback
+ * @idx: instruction number, 0 indexed
+ *
+ * The returned handle can be used in follow up helper queries as well
+ * as when instrumenting an instruction. It is only valid for the
+ * lifetime of the callback.
+ *
+ * Returns: opaque handle to instruction
+ */
+QEMU_PLUGIN_API
+struct qemu_plugin_insn *
+qemu_plugin_tb_get_insn(const struct qemu_plugin_tb *tb, size_t idx);
+
+/**
+ * qemu_plugin_insn_data() - return ptr to instruction data
+ * @insn: opaque instruction handle from qemu_plugin_tb_get_insn()
+ *
+ * Note: data is only valid for duration of callback. See
+ * qemu_plugin_insn_size() to calculate size of stream.
+ *
+ * Returns: pointer to a stream of bytes containing the value of this
+ * instructions opcode.
+ */
+QEMU_PLUGIN_API
+const void *qemu_plugin_insn_data(const struct qemu_plugin_insn *insn);
+
+/**
+ * qemu_plugin_insn_size() - return size of instruction
+ * @insn: opaque instruction handle from qemu_plugin_tb_get_insn()
+ *
+ * Returns: size of instruction in bytes
+ */
+QEMU_PLUGIN_API
+size_t qemu_plugin_insn_size(const struct qemu_plugin_insn *insn);
+
+/**
+ * qemu_plugin_insn_vaddr() - return vaddr of instruction
+ * @insn: opaque instruction handle from qemu_plugin_tb_get_insn()
+ *
+ * Returns: virtual address of instruction
+ */
+QEMU_PLUGIN_API
+uint64_t qemu_plugin_insn_vaddr(const struct qemu_plugin_insn *insn);
+
+/**
+ * qemu_plugin_insn_haddr() - return hardware addr of instruction
+ * @insn: opaque instruction handle from qemu_plugin_tb_get_insn()
+ *
+ * Returns: hardware (physical) target address of instruction
+ */
+QEMU_PLUGIN_API
+void *qemu_plugin_insn_haddr(const struct qemu_plugin_insn *insn);
+
+/**
+ * typedef qemu_plugin_meminfo_t - opaque memory transaction handle
+ *
+ * This can be further queried using the qemu_plugin_mem_* query
+ * functions.
+ */
+typedef uint32_t qemu_plugin_meminfo_t;
+/** struct qemu_plugin_hwaddr - opaque hw address handle */
+struct qemu_plugin_hwaddr;
+
+/**
+ * qemu_plugin_mem_size_shift() - get size of access
+ * @info: opaque memory transaction handle
+ *
+ * Returns: size of access in ^2 (0=byte, 1=16bit, 2=32bit etc...)
+ */
+QEMU_PLUGIN_API
+unsigned int qemu_plugin_mem_size_shift(qemu_plugin_meminfo_t info);
+/**
+ * qemu_plugin_mem_is_sign_extended() - was the access sign extended
+ * @info: opaque memory transaction handle
+ *
+ * Returns: true if it was, otherwise false
+ */
+QEMU_PLUGIN_API
+bool qemu_plugin_mem_is_sign_extended(qemu_plugin_meminfo_t info);
+/**
+ * qemu_plugin_mem_is_big_endian() - was the access big endian
+ * @info: opaque memory transaction handle
+ *
+ * Returns: true if it was, otherwise false
+ */
+QEMU_PLUGIN_API
+bool qemu_plugin_mem_is_big_endian(qemu_plugin_meminfo_t info);
+/**
+ * qemu_plugin_mem_is_store() - was the access a store
+ * @info: opaque memory transaction handle
+ *
+ * Returns: true if it was, otherwise false
+ */
+QEMU_PLUGIN_API
+bool qemu_plugin_mem_is_store(qemu_plugin_meminfo_t info);
+
+/**
+ * qemu_plugin_get_hwaddr() - return handle for memory operation
+ * @info: opaque memory info structure
+ * @vaddr: the virtual address of the memory operation
+ *
+ * For system emulation returns a qemu_plugin_hwaddr handle to query
+ * details about the actual physical address backing the virtual
+ * address. For linux-user guests it just returns NULL.
+ *
+ * This handle is *only* valid for the duration of the callback. Any
+ * information about the handle should be recovered before the
+ * callback returns.
+ */
+QEMU_PLUGIN_API
+struct qemu_plugin_hwaddr *qemu_plugin_get_hwaddr(qemu_plugin_meminfo_t info,
+ uint64_t vaddr);
+
+/*
+ * The following additional queries can be run on the hwaddr structure to
+ * return information about it - namely whether it is for an IO access and the
+ * physical address associated with the access.
+ */
+
+/**
+ * qemu_plugin_hwaddr_is_io() - query whether memory operation is IO
+ * @haddr: address handle from qemu_plugin_get_hwaddr()
+ *
+ * Returns true if the handle's memory operation is to memory-mapped IO, or
+ * false if it is to RAM
+ */
+QEMU_PLUGIN_API
+bool qemu_plugin_hwaddr_is_io(const struct qemu_plugin_hwaddr *haddr);
+
+/**
+ * qemu_plugin_hwaddr_phys_addr() - query physical address for memory operation
+ * @haddr: address handle from qemu_plugin_get_hwaddr()
+ *
+ * Returns the physical address associated with the memory operation
+ *
+ * Note that the returned physical address may not be unique if you are dealing
+ * with multiple address spaces.
+ */
+QEMU_PLUGIN_API
+uint64_t qemu_plugin_hwaddr_phys_addr(const struct qemu_plugin_hwaddr *haddr);
+
+/*
+ * Returns a string representing the device. The string is valid for
+ * the lifetime of the plugin.
+ */
+QEMU_PLUGIN_API
+const char *qemu_plugin_hwaddr_device_name(const struct qemu_plugin_hwaddr *h);
+
+/**
+ * typedef qemu_plugin_vcpu_mem_cb_t - memory callback function type
+ * @vcpu_index: the executing vCPU
+ * @info: an opaque handle for further queries about the memory
+ * @vaddr: the virtual address of the transaction
+ * @userdata: any user data attached to the callback
+ */
+typedef void (*qemu_plugin_vcpu_mem_cb_t) (unsigned int vcpu_index,
+ qemu_plugin_meminfo_t info,
+ uint64_t vaddr,
+ void *userdata);
+
+/**
+ * qemu_plugin_register_vcpu_mem_cb() - register memory access callback
+ * @insn: handle for instruction to instrument
+ * @cb: callback of type qemu_plugin_vcpu_mem_cb_t
+ * @flags: (currently unused) callback flags
+ * @rw: monitor reads, writes or both
+ * @userdata: opaque pointer for userdata
+ *
+ * This registers a full callback for every memory access generated by
+ * an instruction. If the instruction doesn't access memory no
+ * callback will be made.
+ *
+ * The callback reports the vCPU the access took place on, the virtual
+ * address of the access and a handle for further queries. The user
+ * can attach some userdata to the callback for additional purposes.
+ *
+ * Other execution threads will continue to execute during the
+ * callback so the plugin is responsible for ensuring it doesn't get
+ * confused by making appropriate use of locking if required.
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_register_vcpu_mem_cb(struct qemu_plugin_insn *insn,
+ qemu_plugin_vcpu_mem_cb_t cb,
+ enum qemu_plugin_cb_flags flags,
+ enum qemu_plugin_mem_rw rw,
+ void *userdata);
+
+/**
+ * qemu_plugin_register_vcpu_mem_inline() - register an inline op to any memory access
+ * @insn: handle for instruction to instrument
+ * @rw: apply to reads, writes or both
+ * @op: the op, of type qemu_plugin_op
+ * @ptr: pointer memory for the op
+ * @imm: immediate data for @op
+ *
+ * This registers a inline op every memory access generated by the
+ * instruction. This provides for a lightweight but not thread-safe
+ * way of counting the number of operations done.
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_register_vcpu_mem_inline(struct qemu_plugin_insn *insn,
+ enum qemu_plugin_mem_rw rw,
+ enum qemu_plugin_op op, void *ptr,
+ uint64_t imm);
+
+
+
+typedef void
+(*qemu_plugin_vcpu_syscall_cb_t)(qemu_plugin_id_t id, unsigned int vcpu_index,
+ int64_t num, uint64_t a1, uint64_t a2,
+ uint64_t a3, uint64_t a4, uint64_t a5,
+ uint64_t a6, uint64_t a7, uint64_t a8);
+
+QEMU_PLUGIN_API
+void qemu_plugin_register_vcpu_syscall_cb(qemu_plugin_id_t id,
+ qemu_plugin_vcpu_syscall_cb_t cb);
+
+typedef void
+(*qemu_plugin_vcpu_syscall_ret_cb_t)(qemu_plugin_id_t id, unsigned int vcpu_idx,
+ int64_t num, int64_t ret);
+
+QEMU_PLUGIN_API
+void
+qemu_plugin_register_vcpu_syscall_ret_cb(qemu_plugin_id_t id,
+ qemu_plugin_vcpu_syscall_ret_cb_t cb);
+
+
+/**
+ * qemu_plugin_insn_disas() - return disassembly string for instruction
+ * @insn: instruction reference
+ *
+ * Returns an allocated string containing the disassembly
+ */
+
+QEMU_PLUGIN_API
+char *qemu_plugin_insn_disas(const struct qemu_plugin_insn *insn);
+
+/**
+ * qemu_plugin_insn_symbol() - best effort symbol lookup
+ * @insn: instruction reference
+ *
+ * Return a static string referring to the symbol. This is dependent
+ * on the binary QEMU is running having provided a symbol table.
+ */
+QEMU_PLUGIN_API
+const char *qemu_plugin_insn_symbol(const struct qemu_plugin_insn *insn);
+
+/**
+ * qemu_plugin_vcpu_for_each() - iterate over the existing vCPU
+ * @id: plugin ID
+ * @cb: callback function
+ *
+ * The @cb function is called once for each existing vCPU.
+ *
+ * See also: qemu_plugin_register_vcpu_init_cb()
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_vcpu_for_each(qemu_plugin_id_t id,
+ qemu_plugin_vcpu_simple_cb_t cb);
+
+QEMU_PLUGIN_API
+void qemu_plugin_register_flush_cb(qemu_plugin_id_t id,
+ qemu_plugin_simple_cb_t cb);
+
+/**
+ * qemu_plugin_register_atexit_cb() - register exit callback
+ * @id: plugin ID
+ * @cb: callback
+ * @userdata: user data for callback
+ *
+ * The @cb function is called once execution has finished. Plugins
+ * should be able to free all their resources at this point much like
+ * after a reset/uninstall callback is called.
+ *
+ * In user-mode it is possible a few un-instrumented instructions from
+ * child threads may run before the host kernel reaps the threads.
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_register_atexit_cb(qemu_plugin_id_t id,
+ qemu_plugin_udata_cb_t cb, void *userdata);
+
+/* returns -1 in user-mode */
+int qemu_plugin_n_vcpus(void);
+
+/* returns -1 in user-mode */
+int qemu_plugin_n_max_vcpus(void);
+
+/**
+ * qemu_plugin_outs() - output string via QEMU's logging system
+ * @string: a string
+ */
+QEMU_PLUGIN_API
+void qemu_plugin_outs(const char *string);
+
+/**
+ * qemu_plugin_bool_parse() - parses a boolean argument in the form of
+ * "<argname>=[on|yes|true|off|no|false]"
+ *
+ * @name: argument name, the part before the equals sign
+ * @val: argument value, what's after the equals sign
+ * @ret: output return value
+ *
+ * returns true if the combination @name=@val parses correctly to a boolean
+ * argument, and false otherwise
+ */
+QEMU_PLUGIN_API
+bool qemu_plugin_bool_parse(const char *name, const char *val, bool *ret);
+
+/**
+ * qemu_plugin_path_to_binary() - path to binary file being executed
+ *
+ * Return a string representing the path to the binary. For user-mode
+ * this is the main executable. For system emulation we currently
+ * return NULL. The user should g_free() the string once no longer
+ * needed.
+ */
+QEMU_PLUGIN_API
+const char *qemu_plugin_path_to_binary(void);
+
+/**
+ * qemu_plugin_start_code() - returns start of text segment
+ *
+ * Returns the nominal start address of the main text segment in
+ * user-mode. Currently returns 0 for system emulation.
+ */
+QEMU_PLUGIN_API
+uint64_t qemu_plugin_start_code(void);
+
+/**
+ * qemu_plugin_end_code() - returns end of text segment
+ *
+ * Returns the nominal end address of the main text segment in
+ * user-mode. Currently returns 0 for system emulation.
+ */
+QEMU_PLUGIN_API
+uint64_t qemu_plugin_end_code(void);
+
+/**
+ * qemu_plugin_entry_code() - returns start address for module
+ *
+ * Returns the nominal entry address of the main text segment in
+ * user-mode. Currently returns 0 for system emulation.
+ */
+QEMU_PLUGIN_API
+uint64_t qemu_plugin_entry_code(void);
+
+#endif /* QEMU_QEMU_PLUGIN_H */
diff --git a/debug/risc-v-gsp-rm-vm/tests/__init__.py b/debug/risc-v-gsp-rm-vm/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/__init__.py
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_core.py b/debug/risc-v-gsp-rm-vm/tests/test_core.py
new file mode 100644
index 0000000..ab27451
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_core.py
@@ -0,0 +1,131 @@
+"""Unit tests for ELF parsing, symbol resolution and structure decoding."""
+from __future__ import annotations
+
+import json
+import os
+import tempfile
+import unittest
+
+from greeneye.elf import EM_RISCV, parse_elf, load_elf
+from greeneye.structs import Field, Schema, decode, render, schema_from_json
+from greeneye.symbols import Symbol, SymbolDB
+
+DEMO_ELF = os.path.join(os.path.dirname(__file__), "..", "firmware", "uproc_demo", "build", "uproc_demo.elf")
+
+
+class ElfTests(unittest.TestCase):
+ def test_parse_demo_elf(self):
+ if not os.path.exists(DEMO_ELF):
+ self.skipTest("demo firmware not built")
+ image = load_elf(DEMO_ELF)
+ self.assertTrue(image.is_riscv)
+ self.assertEqual(image.e_machine, EM_RISCV)
+ self.assertEqual(image.entry, 0x80000000)
+ self.assertGreaterEqual(len(image.load_segments), 1)
+ names = {s.name for s in image.symbols}
+ self.assertIn("main", names)
+ self.assertIn("rpc_ping", names)
+ self.assertIn("gsp_ctrl", names)
+ ctrl = next(s for s in image.symbols if s.name == "gsp_ctrl")
+ self.assertEqual(ctrl.value, 0x80000840)
+ self.assertEqual(ctrl.size, 0x188)
+ plan = image.flat_load_plan()
+ self.assertTrue(plan and plan[0][0] == 0x80000000)
+
+ def test_not_elf(self):
+ with self.assertRaises(Exception):
+ parse_elf(b"not an elf")
+
+
+class SymbolTests(unittest.TestCase):
+ def setUp(self):
+ self.db = SymbolDB()
+ self.db.add(Symbol(addr=0x1000, name="alpha", size=0x100, source="test"))
+ self.db.add(Symbol(addr=0x1200, name="beta", size=0x20, source="test"))
+
+ def test_resolve(self):
+ self.assertEqual(self.db.resolve(0x1010).name, "alpha")
+ self.assertEqual(self.db.resolve(0x1210).name, "beta")
+ self.assertIsNone(self.db.resolve(0x5000))
+ self.assertEqual(self.db.annotate(0x1010), "0x1010 alpha+0x10")
+ self.assertEqual(self.db.lookup("beta"), 0x1200)
+
+ 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")
+ path = fh.name
+ try:
+ count = self.db.load_names_tsv(path, bias=0x100000)
+ self.assertEqual(count, 2)
+ self.assertEqual(self.db.lookup("_DT_INIT"), 0x279000 + 0x100000)
+ finally:
+ os.unlink(path)
+
+ def test_index_tsv(self):
+ with tempfile.NamedTemporaryFile("w", suffix=".tsv", delete=False) as fh:
+ fh.write("addr\tfile_addr\tsize\tname\n")
+ fh.write("0x00279000\t0x00179000\t24\t_DT_INIT\n")
+ path = fh.name
+ try:
+ count = self.db.load_index_tsv(path, bias=0)
+ self.assertEqual(count, 1)
+ sym = self.db.get("_DT_INIT")
+ self.assertEqual(sym.addr, 0x179000)
+ self.assertEqual(sym.size, 24)
+ self.assertEqual(sym.extras["program_addr"], 0x279000)
+ finally:
+ os.unlink(path)
+
+ def test_json_roundtrip(self):
+ with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as fh:
+ path = fh.name
+ try:
+ self.db.save_json(path, meta={"unit": "test"})
+ other = SymbolDB()
+ self.assertEqual(other.load_json(path), 2)
+ self.assertEqual(other.lookup("alpha"), 0x1000)
+ payload = json.load(open(path))
+ self.assertEqual(payload["format"], "greeneye-symbols/1")
+ finally:
+ os.unlink(path)
+
+
+class StructTests(unittest.TestCase):
+ SCHEMA = {
+ "name": "demo",
+ "fields": [
+ {"name": "magic", "offset": 0, "fmt": "u32"},
+ {"name": "state", "offset": 4, "fmt": "u32", "enum": {"0": "IDLE", "1": "RUN"}},
+ {"name": "count", "offset": 8, "fmt": "u64"},
+ {"name": "name", "offset": 16, "fmt": "cstr", "size": 16},
+ {"name": "buf", "offset": 32, "fmt": "bytes", "size": 8},
+ ],
+ }
+
+ def test_decode_render(self):
+ schema = schema_from_json(self.SCHEMA)
+ self.assertEqual(schema.size, 40)
+ data = bytearray(40)
+ data[0:4] = (0x47535031).to_bytes(4, "little")
+ data[4:8] = (1).to_bytes(4, "little")
+ data[8:16] = (42).to_bytes(8, "little")
+ data[16:22] = b"green\x00"
+ data[32:40] = bytes(range(8))
+ values = decode(schema, bytes(data), base=0x80000000)
+ self.assertEqual(values["magic"], 0x47535031)
+ self.assertEqual(values["state"], 1)
+ self.assertEqual(values["count"], 42)
+ self.assertEqual(values["name"], "green")
+ self.assertEqual(values["buf"], bytes(range(8)))
+ text = render(schema, values)
+ self.assertIn("magic", text)
+ self.assertIn("0x47535031", text)
+ self.assertIn("green", text)
+
+ def test_invalid_schema(self):
+ with self.assertRaises(Exception):
+ Field(name="x", offset=0, fmt="nope").nbytes
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_memory_record.py b/debug/risc-v-gsp-rm-vm/tests/test_memory_record.py
new file mode 100644
index 0000000..24b360d
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_memory_record.py
@@ -0,0 +1,101 @@
+"""Unit tests for memory helpers and session recording."""
+from __future__ import annotations
+
+import os
+import shutil
+import tempfile
+import unittest
+
+from greeneye.memory import Memory
+from greeneye.record import NullRecorder, Recorder, load_events, load_meta
+from greeneye.symbols import Symbol, SymbolDB
+
+
+class FakeClient:
+ def __init__(self, size=0x1000):
+ self.data = bytearray(size)
+ for i in range(size):
+ self.data[i] = i & 0xFF
+
+ def read_mem(self, addr, size, chunk=0):
+ return bytes(self.data[addr : addr + size])
+
+ def write_mem(self, addr, data, chunk=0):
+ self.data[addr : addr + len(data)] = data
+
+
+class MemoryTests(unittest.TestCase):
+ def setUp(self):
+ self.mem = Memory(FakeClient())
+
+ def test_read_write_hexdump(self):
+ self.assertEqual(self.mem.read(0x10, 4), bytes(range(0x10, 0x14)))
+ self.mem.write(0x20, b"AB")
+ self.assertEqual(self.mem.read(0x20, 2), b"AB")
+ text = self.mem.hexdump(0, 16)
+ self.assertIn("00000000", text)
+ self.assertIn("00 01 02", text)
+ self.assertIn("|", text)
+
+ def test_search(self):
+ self.assertEqual(self.mem.search(0, 0x40, b"\x10\x11"), [0x10])
+ self.assertEqual(self.mem.search_string(0, 0x40, "\x20\x21"), [0x20])
+ masked = self.mem.search(0, 0x100, b"\xa0", mask=b"\xf0")
+ self.assertEqual(masked[0], 0xA0)
+ self.mem.write(0x30, b"GREEN")
+ self.assertEqual(self.mem.search_string(0, 0x100, "GREEN"), [0x30])
+
+ def test_snapshot_and_diff(self):
+ before = self.mem.snapshot(0, 32, "before")
+ self.mem.write(0x10, b"\xff")
+ after = self.mem.snapshot(0, 32, "after")
+ diffs = Memory.diff(before, after, base=0)
+ self.assertEqual(len(diffs), 1)
+ self.assertEqual(diffs[0].addr, 0x10)
+ self.assertEqual(diffs[0].new, b"\xff")
+ text = Memory.render_diff(diffs)
+ self.assertIn("00000010", text)
+
+ def test_hexdump_annotated(self):
+ db = SymbolDB()
+ db.add(Symbol(addr=0, name="block", size=0x100, source="test"))
+ mem = Memory(FakeClient(), symbols=db)
+ text = mem.hexdump(0, 16, annotate=True)
+ self.assertIn("block", text)
+
+
+class RecorderTests(unittest.TestCase):
+ def setUp(self):
+ self.dir = tempfile.mkdtemp(prefix="greeneye-test-")
+ self.recorder = Recorder(self.dir, name="unit", meta={"note": "test"})
+ self.recorder.event("alpha", value=1)
+ self.recorder.event("alpha", value=2)
+ self.recorder.snapshot("regs", 0x80000000, b"\x00" * 16)
+
+ def tearDown(self):
+ shutil.rmtree(self.dir, ignore_errors=True)
+
+ def test_events_and_meta(self):
+ self.recorder.close()
+ events = load_events(self.dir)
+ kinds = [e["type"] for e in events]
+ self.assertEqual(kinds.count("alpha"), 2)
+ self.assertIn("snapshot_saved", kinds)
+ self.assertIn("session_end", kinds)
+ meta = load_meta(self.dir)
+ self.assertEqual(meta["name"], "unit")
+ self.assertEqual(meta["note"], "test")
+ self.assertTrue(os.path.exists(os.path.join(self.dir, "summary.md")))
+ summary = open(os.path.join(self.dir, "summary.md")).read()
+ self.assertIn("unit", summary)
+
+ def test_null_recorder(self):
+ null = NullRecorder()
+ null.event("x")
+ null.snapshot("y", 0, b"")
+ null.close()
+ self.assertIsNone(null.dir)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_probe.py b/debug/risc-v-gsp-rm-vm/tests/test_probe.py
new file mode 100644
index 0000000..f97a4bf
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_probe.py
@@ -0,0 +1,65 @@
+"""Unit tests for the ELF trampoline/flat-kernel path and QEMU fault parsing."""
+from __future__ import annotations
+
+import os
+import struct
+import tempfile
+import unittest
+
+from greeneye.analyze import describe_fault, parse_qemu_faults
+from greeneye.elf import _trampoline, load_elf, write_flat_kernel
+
+DEMO_ELF = os.path.join(os.path.dirname(__file__), "..", "firmware", "uproc_demo", "build", "uproc_demo.elf")
+
+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 0x0, size 2, region '(null)', reason: rejected
+riscv_cpu_do_interrupt: hart:0, async:0, cause:0000000000000001, epc:0x0000000000000000, tval:0x0000000000000000, desc=fault_fetch
+"""
+
+
+class TrampolineTests(unittest.TestCase):
+ def test_encoding(self):
+ stub = _trampoline(0x4009000)
+ auipc, jalr = struct.unpack("<II", stub)
+ self.assertEqual(auipc & 0x7F, 0x17) # AUIPC
+ self.assertEqual((auipc >> 7) & 0x1F, 5) # t0
+ self.assertEqual((auipc >> 12) & 0xFFFFF, 0x4009)
+ self.assertEqual(jalr & 0x7F, 0x67) # JALR
+ self.assertEqual((jalr >> 15) & 0x1F, 5)
+ self.assertEqual(jalr >> 20, 0)
+
+ def test_flat_demo(self):
+ if not os.path.exists(DEMO_ELF):
+ self.skipTest("demo firmware not built")
+ with tempfile.TemporaryDirectory() as tmp:
+ out = os.path.join(tmp, "flat.bin")
+ path, entry = write_flat_kernel(DEMO_ELF, out, load_addr=0x80000000)
+ self.assertEqual(entry, 0x80000000)
+ image = load_elf(DEMO_ELF)
+ self.assertEqual(os.path.getsize(path), image.max_vaddr - image.min_vaddr)
+ with open(path, "rb") as fh:
+ # entry at offset 0: the real first instruction must be present
+ first = fh.read(4)
+ self.assertNotEqual(first, b"\x00\x00\x00\x00")
+
+
+class FaultParseTests(unittest.TestCase):
+ def test_parse_and_describe(self):
+ faults = parse_qemu_faults(SAMPLE_LOG)
+ self.assertEqual(len(faults), 2)
+ first = faults[0]
+ self.assertEqual(first["cause"], 5)
+ self.assertEqual(first["epc"], 0x84009014)
+ self.assertEqual(first["access"]["access_addr"], 0x2000000000000A00)
+ self.assertEqual(first["access"]["size"], 4)
+ text = describe_fault(first, bias=0x80000000)
+ self.assertIn("0x4009014", text)
+ self.assertIn("load access fault", text)
+ self.assertIn("0x2000000000000a00", text)
+ self.assertEqual(faults[1]["desc"], "fault_fetch")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_rsp.py b/debug/risc-v-gsp-rm-vm/tests/test_rsp.py
new file mode 100644
index 0000000..70b9f45
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_rsp.py
@@ -0,0 +1,175 @@
+"""Unit tests for the RSP client using an in-process fake stub."""
+from __future__ import annotations
+
+import socket
+import threading
+import unittest
+
+from greeneye.rsp import (
+ RV64_FALLBACK_REGS,
+ RspClient,
+ checksum,
+ encode_packet,
+ parse_stop_reply,
+ _parse_target_xml,
+)
+
+TARGET_XML = """<?xml version="1.0"?>
+<target><architecture>riscv:rv64</architecture>
+<feature name="org.gnu.gdb.riscv.cpu">
+<reg name="x0" bitsize="64" regnum="0"/>
+<reg name="ra" bitsize="64" regnum="1"/>
+<reg name="pc" bitsize="64" regnum="32"/>
+</feature></target>"""
+
+
+class FakeStub(threading.Thread):
+ """Minimal RSP server for unit tests."""
+
+ def __init__(self, sock: socket.socket):
+ super().__init__(daemon=True)
+ self.sock = sock
+ self.no_ack = False
+ self.stopped = True
+ self.regs = bytearray(33 * 8)
+ self.regs[8:16] = (0xAA).to_bytes(8, "little") # ra (x1)
+ self.regs[16:24] = (0x80000194).to_bytes(8, "little") # pc (3-reg test layout)
+ self.memory = bytearray(0x10000)
+ for i in range(len(self.memory)):
+ self.memory[i] = i & 0xFF
+ self.bps: set[int] = set()
+
+ def run(self):
+ buf = b""
+ while True:
+ try:
+ chunk = self.sock.recv(4096)
+ except OSError:
+ return
+ if not chunk:
+ return
+ buf += chunk
+ while b"#" in buf and b"$" in buf:
+ start = buf.index(b"$")
+ end = buf.index(b"#", start)
+ if len(buf) < end + 3:
+ break
+ payload = buf[start + 1 : end]
+ buf = buf[end + 3 :]
+ self.handle(payload)
+
+ def send(self, text: str):
+ data = encode_packet(text.encode())
+ self.sock.sendall(data)
+
+ def handle(self, payload: bytes):
+ cmd = payload.decode("latin1")
+ if not self.no_ack:
+ self.sock.sendall(b"+")
+ if cmd.startswith("qSupported"):
+ self.send("PacketSize=1000;qXfer:features:read+;multiprocess+")
+ elif cmd == "QStartNoAckMode":
+ self.send("")
+ self.no_ack = True
+ elif cmd == "?":
+ self.send("T05thread:p01.01;")
+ elif cmd in ("Hg0", "Hc-1"):
+ self.send("OK")
+ elif cmd.startswith("qXfer:features:read:target.xml"):
+ self.send("l" + TARGET_XML)
+ elif cmd.startswith("qXfer"):
+ self.send("E01")
+ elif cmd == "g":
+ self.send(bytes(self.regs).hex())
+ elif cmd.startswith("p"):
+ regno = int(cmd[1:], 16)
+ if regno == 32:
+ self.send(self.regs[16:24].hex())
+ else:
+ self.send(self.regs[regno * 8 : regno * 8 + 8].hex())
+ elif cmd.startswith("m"):
+ addr_s, len_s = cmd[1:].split(",")
+ addr, length = int(addr_s, 16), int(len_s, 16)
+ self.send(bytes(self.memory[addr : addr + length]).hex())
+ elif cmd.startswith("M"):
+ head, data = cmd[1:].split(":", 1)
+ addr_s, len_s = head.split(",")
+ addr, length = int(addr_s, 16), int(len_s, 16)
+ blob = bytes.fromhex(data)
+ self.memory[addr : addr + length] = blob
+ self.send("OK")
+ elif cmd.startswith("Z0"):
+ self.bps.add(int(cmd.split(",")[1], 16))
+ self.send("OK")
+ elif cmd.startswith("z0"):
+ self.bps.discard(int(cmd.split(",")[1], 16))
+ self.send("OK")
+ elif cmd.startswith("Z2") or cmd.startswith("z2"):
+ self.send("OK")
+ elif cmd in ("c", "s"):
+ self.stopped = True
+ self.send("T05thread:p01.01;")
+ else:
+ self.send("")
+
+
+class RspTests(unittest.TestCase):
+ def setUp(self):
+ self.client_sock, self.server_sock = socket.socketpair()
+ self.stub = FakeStub(self.server_sock)
+ self.stub.start()
+ self.client = RspClient(timeout=2.0)
+ self.client.sock = self.client_sock
+ self.client.sync()
+
+ def tearDown(self):
+ self.client.close()
+ self.server_sock.close()
+
+ def test_checksum_and_encode(self):
+ self.assertEqual(checksum(b"OK"), (ord("O") + ord("K")) & 0xFF)
+ self.assertEqual(encode_packet(b"OK"), b"$OK#9a")
+
+ def test_parse_stop_reply(self):
+ stop = parse_stop_reply("T05watch:0000000080001000;thread:p01.01;")
+ self.assertEqual(stop.signal, 5)
+ self.assertEqual(stop.watch_addr, 0x80001000)
+ self.assertEqual(stop.watch_kind, "watch")
+ self.assertEqual(stop.thread, "p01.01")
+ self.assertTrue(stop.is_stop)
+ exit_stop = parse_stop_reply("W00")
+ self.assertTrue(exit_stop.is_exit)
+
+ def test_target_xml_parse(self):
+ regs = _parse_target_xml(TARGET_XML)
+ self.assertEqual([r.name for r in regs], ["x0", "ra", "pc"])
+ self.assertEqual([r.number for r in regs], [0, 1, 32])
+ by_name = {r.name: r for r in regs}
+ self.assertEqual(by_name["ra"].offset, 8)
+ self.assertEqual(by_name["pc"].offset, 16)
+ self.assertEqual(len(RV64_FALLBACK_REGS), 33)
+ self.assertEqual(RV64_FALLBACK_REGS[32].name, "pc")
+
+ def test_registers_and_memory(self):
+ regs = self.client.read_registers()
+ self.assertEqual(regs["ra"], 0xAA)
+ self.assertEqual(regs["pc"], 0x80000194)
+ self.assertEqual(self.client.read_reg("pc"), 0x80000194)
+ data = self.client.read_mem(0x10, 8)
+ self.assertEqual(data, bytes(range(0x10, 0x18)))
+ self.client.write_mem(0x20, b"\xde\xad\xbe\xef")
+ self.assertEqual(self.client.read_mem(0x20, 4), b"\xde\xad\xbe\xef")
+ self.assertEqual(self.client.read_u32(0x20), 0xEFBEADDE)
+
+ def test_breakpoints_watchpoints_continue(self):
+ self.client.set_sw_break(0x80000048)
+ self.client.clear_sw_break(0x80000048)
+ self.client.set_watch(0x80000850, size=8, kind="w")
+ stop = self.client.continue_()
+ self.assertEqual(stop.signal, 5)
+ stop = self.client.step()
+ self.assertEqual(stop.signal, 5)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_smoke_qemu.py b/debug/risc-v-gsp-rm-vm/tests/test_smoke_qemu.py
new file mode 100644
index 0000000..c0354d2
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_smoke_qemu.py
@@ -0,0 +1,78 @@
+"""Live integration smoke test: boots the demo uproc in QEMU.
+
+Skipped cleanly when qemu-system-riscv64 or the demo firmware is absent.
+"""
+from __future__ import annotations
+
+import os
+import shutil
+import unittest
+
+import greeneye as ge
+from greeneye.errors import GreenEyeError
+from greeneye.probes import compile_condition, install
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+DEMO_ELF = os.path.join(HERE, "..", "firmware", "uproc_demo", "build", "uproc_demo.elf")
+
+
+def qemu_available() -> bool:
+ return bool(os.environ.get("GREENEYE_QEMU") or shutil.which("qemu-system-riscv64"))
+
+
+@unittest.skipUnless(qemu_available(), "qemu-system-riscv64 not installed")
+@unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)")
+class QemuSmokeTests(unittest.TestCase):
+ def test_boot_hook_watch_snapshot_analyze(self):
+ session = ge.launch(kernel=DEMO_ELF, record=True, name="unittest-smoke")
+ hits = []
+ try:
+ ctrl = session.symbols.lookup("gsp_ctrl")
+ session.hook("rpc_sum", on_enter=lambda hit: hits.append(hit))
+ session.watch(ctrl + 16, size=8, kind="w")
+ 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")
+ 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()
+ if directory:
+ from greeneye.analyze import render_summary
+
+ report = render_summary(directory)
+ self.assertIn("rpc_sum", report)
+
+ def test_probe_spec_install(self):
+ session = ge.launch(kernel=DEMO_ELF, name="unittest-probes")
+ try:
+ spec = {
+ "probes": [
+ {
+ "name": "sum",
+ "symbol": "rpc_sum",
+ "when": "a1 > 0",
+ "on_enter": {"log": ["a0", "a1"], "note": "sum called"},
+ }
+ ]
+ }
+ handles = install(session, spec)
+ self.assertEqual(len(handles), 1)
+ session.run(duration=0.6)
+ self.assertEqual(handles[0].hits, 1) # self-test calls rpc_sum once
+ finally:
+ session.close()
+
+ def test_condition_guard(self):
+ self.assertIsNone(compile_condition(None))
+ check = compile_condition("a0 == 0x2a and a1 < 4")
+ self.assertTrue(check({"a0": 0x2A, "a1": 2}))
+ self.assertFalse(check({"a0": 0x2B, "a1": 2}))
+ with self.assertRaises(GreenEyeError):
+ compile_condition("__import__('os').system('true')")
+
+
+if __name__ == "__main__":
+ unittest.main()