From f909a5f822c5c4a4e9526b861d4171f8925f5113 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Rasmusson Date: Fri, 2 Oct 2026 10:58:21 -0400 Subject: debug: green-eye wave 3 - RM harness, local simulator, device map, MCP plugin, value-validation demos, CI pipeline, toolchain docs; add host RM tools rmtool/rmtop --- .github/workflows/greeneye.yml | 34 + debug/risc-v-gsp-rm-vm/README.md | 26 +- debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md | 25 +- debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md | 3 +- debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md | 6 +- debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md | 4 + debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md | 9 +- .../docs/14-ADVERSARIAL-INTEROP-GUIDE.md | 7 +- debug/risc-v-gsp-rm-vm/docs/16-CUSTOM-SIMULATOR.md | 156 +- debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md | 164 +++ .../risc-v-gsp-rm-vm/docs/18-HARNESS-AUTOMATION.md | 284 ++++ .../risc-v-gsp-rm-vm/docs/19-CONTROL-DISCOVERY.md | 445 ++++++ debug/risc-v-gsp-rm-vm/docs/20-MCP-SERVER.md | 277 ++++ .../docs/21-CI-RELEASE-PIPELINE.md | 320 +++++ .../docs/22-PORTING-CUDA-AND-DEVICES.md | 353 +++++ debug/risc-v-gsp-rm-vm/docs/23-RM610-READINESS.md | 259 ++++ debug/risc-v-gsp-rm-vm/docs/24-DEVICE-MAP.md | 207 +++ debug/risc-v-gsp-rm-vm/docs/25-VALUE-VALIDATION.md | 337 +++++ debug/risc-v-gsp-rm-vm/docs/26-ARCHITECTURE.md | 449 ++++++ debug/risc-v-gsp-rm-vm/docs/27-TOOL-AUDIT.md | 252 ++++ .../risc-v-gsp-rm-vm/examples/scripts/sim_bench.py | 75 + .../risc-v-gsp-rm-vm/examples/scripts/sim_diff.py | 104 ++ debug/risc-v-gsp-rm-vm/greeneye/__init__.py | 41 +- debug/risc-v-gsp-rm-vm/greeneye/analyze.py | 157 +- debug/risc-v-gsp-rm-vm/greeneye/cli.py | 126 +- debug/risc-v-gsp-rm-vm/greeneye/device_map.py | 555 +++++++ debug/risc-v-gsp-rm-vm/greeneye/ghidra.py | 2 + debug/risc-v-gsp-rm-vm/greeneye/session.py | 2 + debug/risc-v-gsp-rm-vm/greeneye/sim_backend.py | 390 +++++ debug/risc-v-gsp-rm-vm/greeneye/symbols.py | 17 +- debug/risc-v-gsp-rm-vm/harness/__init__.py | 11 + debug/risc-v-gsp-rm-vm/harness/ci_release.py | 444 ++++++ debug/risc-v-gsp-rm-vm/harness/common.py | 218 +++ .../risc-v-gsp-rm-vm/harness/control_discovery.py | 1055 ++++++++++++++ debug/risc-v-gsp-rm-vm/harness/mutation_harness.py | 586 ++++++++ .../risc-v-gsp-rm-vm/harness/portability_matrix.py | 779 ++++++++++ debug/risc-v-gsp-rm-vm/harness/reach.py | 946 ++++++++++++ .../risc-v-gsp-rm-vm/harness/value_demos/README.md | 83 ++ .../harness/value_demos/__init__.py | 1 + .../harness/value_demos/_common.py | 196 +++ .../harness/value_demos/run_all.py | 123 ++ .../harness/value_demos/s01_reply_consumer.py | 151 ++ .../harness/value_demos/s02_counter_publication.py | 126 ++ .../harness/value_demos/s03_unknown_command.py | 167 +++ .../harness/value_demos/s04_run_until_function.py | 143 ++ .../harness/value_demos/s05_snapshot_diff.py | 143 ++ .../harness/value_demos/s06_inject_service.py | 124 ++ .../harness/value_demos/s07_version_diff.py | 179 +++ .../harness/value_demos/s08_mutation_triage.py | 161 +++ .../value_demos/s09_grabbing_a_ghidra_address.py | 146 ++ .../value_demos/s10_full_session_evidence.py | 213 +++ debug/risc-v-gsp-rm-vm/harness/version_diff.py | 1509 ++++++++++++++++++++ debug/risc-v-gsp-rm-vm/plugins/README.md | 6 +- debug/risc-v-gsp-rm-vm/plugins/mcp_server.py | 1155 +++++++++++++++ debug/risc-v-gsp-rm-vm/plugins/rmharness_plugin.py | 120 ++ debug/risc-v-gsp-rm-vm/rmharness/README.md | 117 ++ debug/risc-v-gsp-rm-vm/rmharness/harness.sh | 147 ++ debug/risc-v-gsp-rm-vm/rmharness/rmq.sh | 21 + debug/risc-v-gsp-rm-vm/rmharness/rmtool.sh | 11 + debug/risc-v-gsp-rm-vm/rmharness/rmtop.sh | 11 + .../rmharness/tests/test_rmharness_plugin.py | 95 ++ debug/risc-v-gsp-rm-vm/sim/__init__.py | 43 + debug/risc-v-gsp-rm-vm/sim/decode.py | 399 ++++++ debug/risc-v-gsp-rm-vm/sim/machine.py | 825 +++++++++++ debug/risc-v-gsp-rm-vm/sim/trace.py | 145 ++ debug/risc-v-gsp-rm-vm/tests/test_bridge.py | 9 + debug/risc-v-gsp-rm-vm/tests/test_core.py | 42 +- debug/risc-v-gsp-rm-vm/tests/test_device_map.py | 325 +++++ debug/risc-v-gsp-rm-vm/tests/test_discovery.py | 214 +++ debug/risc-v-gsp-rm-vm/tests/test_harness.py | 144 ++ debug/risc-v-gsp-rm-vm/tests/test_mcp.py | 431 ++++++ debug/risc-v-gsp-rm-vm/tests/test_sim_backend.py | 244 ++++ debug/risc-v-gsp-rm-vm/tests/test_sim_trace.py | 224 +++ debug/risc-v-gsp-rm-vm/tests/test_value_demos.py | 107 ++ debug/risc-v-gsp-rm-vm/tests/test_version_diff.py | 333 +++++ .../02-development-and-analysis-environment.md | 363 +++++ tools/rmtool/Makefile | 35 + tools/rmtool/include/rm_api.h | 297 ++++ tools/rmtool/include/rmtool.h | 71 + tools/rmtool/rmtool | Bin 0 -> 57040 bytes tools/rmtool/src/engine.c | 252 ++++ tools/rmtool/src/gpu.c | 283 ++++ tools/rmtool/src/main.c | 95 ++ tools/rmtool/src/mem.c | 146 ++ tools/rmtool/src/nv2080.c | 398 ++++++ tools/rmtool/src/output.c | 251 ++++ tools/rmtool/src/query.c | 256 ++++ tools/rmtool/src/rm_api.c | 291 ++++ tools/rmtop/Makefile | 46 + tools/rmtop/include/rmtop.h | 49 + tools/rmtop/rmtop | Bin 0 -> 45408 bytes tools/rmtop/src/main.c | 323 +++++ tools/rmtop/src/panel.c | 232 +++ 93 files changed, 21098 insertions(+), 48 deletions(-) create mode 100644 .github/workflows/greeneye.yml create mode 100644 debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md create mode 100644 debug/risc-v-gsp-rm-vm/docs/18-HARNESS-AUTOMATION.md create mode 100644 debug/risc-v-gsp-rm-vm/docs/19-CONTROL-DISCOVERY.md create mode 100644 debug/risc-v-gsp-rm-vm/docs/20-MCP-SERVER.md create mode 100644 debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md create mode 100644 debug/risc-v-gsp-rm-vm/docs/22-PORTING-CUDA-AND-DEVICES.md create mode 100644 debug/risc-v-gsp-rm-vm/docs/23-RM610-READINESS.md create mode 100644 debug/risc-v-gsp-rm-vm/docs/24-DEVICE-MAP.md create mode 100644 debug/risc-v-gsp-rm-vm/docs/25-VALUE-VALIDATION.md create mode 100644 debug/risc-v-gsp-rm-vm/docs/26-ARCHITECTURE.md create mode 100644 debug/risc-v-gsp-rm-vm/docs/27-TOOL-AUDIT.md create mode 100644 debug/risc-v-gsp-rm-vm/examples/scripts/sim_bench.py create mode 100644 debug/risc-v-gsp-rm-vm/examples/scripts/sim_diff.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/device_map.py create mode 100644 debug/risc-v-gsp-rm-vm/greeneye/sim_backend.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/__init__.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/ci_release.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/common.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/control_discovery.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/mutation_harness.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/portability_matrix.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/reach.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/README.md create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/__init__.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/_common.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/run_all.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/s01_reply_consumer.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/s02_counter_publication.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/s03_unknown_command.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/s04_run_until_function.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/s05_snapshot_diff.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/s06_inject_service.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/s07_version_diff.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/s08_mutation_triage.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/s09_grabbing_a_ghidra_address.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/value_demos/s10_full_session_evidence.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/version_diff.py create mode 100644 debug/risc-v-gsp-rm-vm/plugins/mcp_server.py create mode 100644 debug/risc-v-gsp-rm-vm/plugins/rmharness_plugin.py create mode 100644 debug/risc-v-gsp-rm-vm/rmharness/README.md create mode 100755 debug/risc-v-gsp-rm-vm/rmharness/harness.sh create mode 100755 debug/risc-v-gsp-rm-vm/rmharness/rmq.sh create mode 100755 debug/risc-v-gsp-rm-vm/rmharness/rmtool.sh create mode 100755 debug/risc-v-gsp-rm-vm/rmharness/rmtop.sh create mode 100644 debug/risc-v-gsp-rm-vm/rmharness/tests/test_rmharness_plugin.py create mode 100644 debug/risc-v-gsp-rm-vm/sim/__init__.py create mode 100644 debug/risc-v-gsp-rm-vm/sim/decode.py create mode 100644 debug/risc-v-gsp-rm-vm/sim/machine.py create mode 100644 debug/risc-v-gsp-rm-vm/sim/trace.py create mode 100644 debug/risc-v-gsp-rm-vm/tests/test_device_map.py create mode 100644 debug/risc-v-gsp-rm-vm/tests/test_discovery.py create mode 100644 debug/risc-v-gsp-rm-vm/tests/test_harness.py create mode 100644 debug/risc-v-gsp-rm-vm/tests/test_mcp.py create mode 100644 debug/risc-v-gsp-rm-vm/tests/test_sim_backend.py create mode 100644 debug/risc-v-gsp-rm-vm/tests/test_sim_trace.py create mode 100644 debug/risc-v-gsp-rm-vm/tests/test_value_demos.py create mode 100644 debug/risc-v-gsp-rm-vm/tests/test_version_diff.py create mode 100644 docs/07-developers/02-development-and-analysis-environment.md create mode 100644 tools/rmtool/Makefile create mode 100644 tools/rmtool/include/rm_api.h create mode 100644 tools/rmtool/include/rmtool.h create mode 100755 tools/rmtool/rmtool create mode 100644 tools/rmtool/src/engine.c create mode 100644 tools/rmtool/src/gpu.c create mode 100644 tools/rmtool/src/main.c create mode 100644 tools/rmtool/src/mem.c create mode 100644 tools/rmtool/src/nv2080.c create mode 100644 tools/rmtool/src/output.c create mode 100644 tools/rmtool/src/query.c create mode 100644 tools/rmtool/src/rm_api.c create mode 100644 tools/rmtop/Makefile create mode 100644 tools/rmtop/include/rmtop.h create mode 100755 tools/rmtop/rmtop create mode 100644 tools/rmtop/src/main.c create mode 100644 tools/rmtop/src/panel.c diff --git a/.github/workflows/greeneye.yml b/.github/workflows/greeneye.yml new file mode 100644 index 0000000..9e67892 --- /dev/null +++ b/.github/workflows/greeneye.yml @@ -0,0 +1,34 @@ +# green-eye harness CI: build the SDK/demo and run the tool test suite. +# Local simulator only: no secrets, no hardware, no guest, no kernel module. +name: green-eye + +on: + push: + paths: + - 'debug/risc-v-gsp-rm-vm/**' + - 'tools/rm*/**' + pull_request: + paths: + - 'debug/risc-v-gsp-rm-vm/**' + - 'tools/rm*/**' + +jobs: + harness: + name: green-eye tests + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install toolchain + run: | + sudo apt-get update + sudo apt-get install -y qemu-system-misc clang lld-18 + + - name: Build SDK and demo + run: make -C debug/risc-v-gsp-rm-vm sdk demo + + - name: Run unit tests + working-directory: debug/risc-v-gsp-rm-vm + run: python3 -m unittest discover -s tests -t . diff --git a/debug/risc-v-gsp-rm-vm/README.md b/debug/risc-v-gsp-rm-vm/README.md index 4017cd4..eab691f 100644 --- a/debug/risc-v-gsp-rm-vm/README.md +++ b/debug/risc-v-gsp-rm-vm/README.md @@ -10,6 +10,10 @@ green-eye boots GSP/uproc firmware in `qemu-system-riscv64`, attaches over QEMU's gdbstub with a GDB-free Python client, and exposes Frida-like instrumentation: hooks, breakpoints, watchpoints, memory dumping, structure decoding, snapshot diffing, session recording, plugins and a Ghidra bridge. +An optional deterministic native RV64IMAC backend (M1) runs the same Session +in-process: `--backend native` or `ge.launch(..., backend="native")`, with an +in-process exec/access trace (M1.5/R4) written as a session blob and rendered +by `analyze` (`--trace`, `--trace-start/--trace-end`, `--trace-exec on|off`). Start with [`docs/00-OVERVIEW.md`](docs/00-OVERVIEW.md). @@ -20,6 +24,8 @@ cd open-iov/debug/risc-v-gsp-rm-vm make demo # build the bundled RV64 uproc demo (clang + lld) ./greeneye.sh demo # boot it, hook handlers, watch counters, record + analyze +./greeneye.sh demo --backend native # same run on the deterministic native sim +./greeneye.sh record --backend native --trace --duration 0.5 # exec/access trace ./greeneye.sh info # environment / tooling check ``` @@ -49,12 +55,17 @@ Everything else is Python stdlib. ``` greeneye/ Python package: RSP client, session, memory, symbols, structures, recording, analysis, probes, plugins, Ghidra bridge +sim/ deterministic native RV64IMAC simulator (M1: decode, RAM, + UART; M1.5: exec/access trace in sim/trace.py) firmware/uproc_demo/RV64 bare-metal demo "uproc" (mailbox RPC, state machine, trap) sdk/ injected-service SDK (position-independent RV64 blobs) qemu/ launch helper + optional TCG plugin for full memory tracing ghidra/ Ghidra scripts (export symbols, annotate hits, control bridge) -plugins/ example plugin + authoring guide +plugins/ example plugin, MCP server, RM-harness plugin + authoring guide examples/ Python instrumentation scripts and declarative probe specs +harness/ automation harness (mutation, portability, version diff, + control discovery/reachability, value-validation demos) +rmharness/ host RM oracle wrappers + read-only capture harness tests/ unit tests + QEMU integration smoke tests docs/ full documentation set (design, scripting, plugins, accuracy, portability playbooks, roadmap, service/injection guides) @@ -81,5 +92,16 @@ dumps/ recorded sessions (gitignored) | [13-GHIDRA-CONTROL](docs/13-GHIDRA-CONTROL.md) | driving green-eye from GhidraScripts | | [14-ADVERSARIAL-INTEROP-GUIDE](docs/14-ADVERSARIAL-INTEROP-GUIDE.md) | navigating unpredictable/adversarial firmware change | | [15-AGENTIC-GSP-IDE](docs/15-AGENTIC-GSP-IDE.md) | agentic workflows and runbooks | -| [16-CUSTOM-SIMULATOR](docs/16-CUSTOM-SIMULATOR.md) | non-QEMU simulator design (far later) | +| [16-CUSTOM-SIMULATOR](docs/16-CUSTOM-SIMULATOR.md) | non-QEMU simulator design; M1 native backend + M1.5 trace status/usage | +| [17-RM-HARNESS](docs/17-RM-HARNESS.md) | host RM oracle harness (`rmharness`, wrappers, capture) | +| [18-HARNESS-AUTOMATION](docs/18-HARNESS-AUTOMATION.md) | mutation/portability automation harness | +| [19-CONTROL-DISCOVERY](docs/19-CONTROL-DISCOVERY.md) | unknown-call discovery and reachability | +| [20-MCP-SERVER](docs/20-MCP-SERVER.md) | bundled MCP server plugin | +| [21-CI-RELEASE-PIPELINE](docs/21-CI-RELEASE-PIPELINE.md) | release triage pipeline (`harness/ci_release.py`) | +| [22-PORTING-CUDA-AND-DEVICES](docs/22-PORTING-CUDA-AND-DEVICES.md) | portability playbook for new CUDA/devices | +| [23-RM610-READINESS](docs/23-RM610-READINESS.md) | RM 610 readiness checklist | +| [24-DEVICE-MAP](docs/24-DEVICE-MAP.md) | versioned first-touch MMIO device map | +| [25-VALUE-VALIDATION](docs/25-VALUE-VALIDATION.md) | value-validation demos (s01–s10) | +| [26-ARCHITECTURE](docs/26-ARCHITECTURE.md) | full architecture/API surface map | +| [27-TOOL-AUDIT](docs/27-TOOL-AUDIT.md) | tool audit: purpose, tests, usage, status | | [GREEN-EYE-ROADMAP](GREEN-EYE-ROADMAP.md) | the long-term program (H1–H7) | diff --git a/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md b/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md index f1e1946..3b56512 100644 --- a/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md +++ b/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md @@ -42,11 +42,17 @@ whole stack scriptable, and QEMU is just the first execution backend. | Session recording + `analyze` report | working, live-verified | | Declarative probe specs | working, live-verified | | Plugin loader + example plugin | working | -| Test suite | 22 tests green (live QEMU smoke included) | +| Native RV64IMAC simulator (M1, `--backend native`) | working; QEMU-parity differential test | +| Native exec/access trace (M1.5, R4): `--trace`, analyzer coverage | working; trace blob + determinism test | +| Test suite | 155 tests green (live QEMU + native differential included) | | Ghidra bridge | scripts + docs | | Real GSP `.fwimage` probe (first-fault report) | working (honest: it does not boot without a TU104 model — see 06) | -| TCG full-memory tracing plugin | source + optional build | -| MCP server / custom simulator / full device model | roadmap only (08) | +| TCG full-memory tracing plugin | source + optional build (verified live) | +| Automation harness (mutation/portability/version diff) + value demos | working (18/19/21/25) | +| Host RM oracle harness (`rmharness`) | wrappers + capture working; device tables depend on driver permissions (17) | +| First-touch device map + lenient native MMIO | working (24) | +| MCP server (`plugins/mcp_server.py`) | working (20); stdlib-only, read-only by default | +| Native M2+ device model | roadmap only (08, 16) | ## Quick start @@ -84,7 +90,18 @@ Interactively: | [13-GHIDRA-CONTROL](13-GHIDRA-CONTROL.md) | driving green-eye from Ghidra | | [14-ADVERSARIAL-INTEROP-GUIDE](14-ADVERSARIAL-INTEROP-GUIDE.md) | adversarial firmware navigation | | [15-AGENTIC-GSP-IDE](15-AGENTIC-GSP-IDE.md) | agentic workflows and runbooks | -| [16-CUSTOM-SIMULATOR](16-CUSTOM-SIMULATOR.md) | non-QEMU simulator design (later) | +| [16-CUSTOM-SIMULATOR](16-CUSTOM-SIMULATOR.md) | native simulator (M1/M1.5 implemented; M2+ design) | +| [17-RM-HARNESS](17-RM-HARNESS.md) | host RM oracle harness (`rmharness`) | +| [18-HARNESS-AUTOMATION](18-HARNESS-AUTOMATION.md) | mutation/portability automation | +| [19-CONTROL-DISCOVERY](19-CONTROL-DISCOVERY.md) | unknown-call discovery and reachability | +| [20-MCP-SERVER](20-MCP-SERVER.md) | bundled MCP server plugin | +| [21-CI-RELEASE-PIPELINE](21-CI-RELEASE-PIPELINE.md) | release triage pipeline | +| [22-PORTING-CUDA-AND-DEVICES](22-PORTING-CUDA-AND-DEVICES.md) | porting playbook | +| [23-RM610-READINESS](23-RM610-READINESS.md) | RM 610 readiness checklist | +| [24-DEVICE-MAP](24-DEVICE-MAP.md) | first-touch MMIO device map | +| [25-VALUE-VALIDATION](25-VALUE-VALIDATION.md) | value-validation demos s01–s10 | +| [26-ARCHITECTURE](26-ARCHITECTURE.md) | full architecture and API surface map | +| [27-TOOL-AUDIT](27-TOOL-AUDIT.md) | tool audit: purpose, tests, usage, status | | [GREEN-EYE-ROADMAP](../GREEN-EYE-ROADMAP.md) | long-term program H1–H7 | ## Where this fits in the Open-IOV workflow diff --git a/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md b/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md index c677c8d..89c61c4 100644 --- a/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md +++ b/debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md @@ -4,7 +4,8 @@ green-eye scripts are ordinary Python programs run with `./greeneye.sh run ` (or `python3 -m greeneye run …`). The -names `ge`, `session`/`s`, `memory`, `symbols`, `recorder` are preloaded. +names `ge`, `session`/`s`, `memory`, `symbols`, `recorder` are preloaded, and +`__file__` points at the script path (use it to locate bundled assets). ## 1. Attach or launch diff --git a/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md b/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md index fa0fe0c..db9ee27 100644 --- a/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md +++ b/debug/risc-v-gsp-rm-vm/docs/04-PLUGINS.md @@ -82,9 +82,9 @@ changes behind plugin-level flags and document them in the session metadata (`s.recorder.meta`). **(d) AI/harness integration** — export sessions/reports to external tools, -generate probe specs, score runs. **MCP servers are planned as a plugin** -(`docs/08-ROADMAP.md`), not part of v0.1; do not add MCP dependencies to the -core package before that rung is scheduled. +generate probe specs, score runs. The **MCP server is implemented as a bundled +plugin** (`../plugins/mcp_server.py`, see [20-MCP-SERVER](20-MCP-SERVER.md)): +stdlib-only, read-only by default, no dependency added to the core package. ## 4. Example plugin diff --git a/debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md b/debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md index 857102e..f7f46c3 100644 --- a/debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md +++ b/debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md @@ -83,6 +83,10 @@ $GHIDRA/support/analyzeHeadless -process -noanaly `ExportForGreenEye.java` is the reverse helper: it walks the current program and writes a green-eye-compatible `addrnamesize` TSV. +`greeneye.ghidra.render_ghidra_command(proj_dir, project, program, hits_tsv)` +renders the `analyzeHeadless … MarkGreenEyeHits.java` command line above for +scripts (the helper is not wired into the CLI). + ## 5. Breakpoints from Ghidra Two practical routes today: diff --git a/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md b/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md index 10f4f5f..5d8a9b1 100644 --- a/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md +++ b/debug/risc-v-gsp-rm-vm/docs/08-ROADMAP.md @@ -68,6 +68,10 @@ only after the guest-bridge work (PLAN B) no longer depends on it. ## R3. MCP server plugin +**Status: implemented.** The bundled plugin is `plugins/mcp_server.py`; see +[20-MCP-SERVER](20-MCP-SERVER.md) for the live tool list, transports and +capability flags. The text below is the original design sketch. + **Why:** expose green-eye sessions to AI harnesses and other agents under the plugin model the user requested. @@ -78,8 +82,9 @@ session/analysis API as MCP tools: `list_sessions`, `open_session`, no network exposure by default. The plugin depends on an MCP library only at plugin load time; the core stays stdlib-only. -**Gate:** v0.1 must be stable (acceptance list in 00) and the session format -frozen. Explicitly deferred by the owner; do not add MCP code before then. +**Gate:** v0.1 stability and the session format. Implemented stdlib-only +(no MCP library), stdio/loopback only, read-only unless `--allow-write` / +`--allow-inject` are passed. ## R4. Full-memory trace (TCG plugin integration) diff --git a/debug/risc-v-gsp-rm-vm/docs/14-ADVERSARIAL-INTEROP-GUIDE.md b/debug/risc-v-gsp-rm-vm/docs/14-ADVERSARIAL-INTEROP-GUIDE.md index d310f22..fb78265 100644 --- a/debug/risc-v-gsp-rm-vm/docs/14-ADVERSARIAL-INTEROP-GUIDE.md +++ b/debug/risc-v-gsp-rm-vm/docs/14-ADVERSARIAL-INTEROP-GUIDE.md @@ -19,8 +19,11 @@ surface, plus items explicitly labelled roadmap; see also docs/06-ACCURACY.md. |---|---| | `probe`/`record`/`analyze`/`compare`/`symbols`/`inject`/`ghidra`, hooks, watchpoints, snapshots, sessions, `export_hits` | working (v0.1) | | Ghidra control bridge (`run_until`, breakpoints, hits) | working (v0.1) | -| Full TCG access trace (`qemu/greeneye_trace.c`) | source + optional build; not folded into sessions yet (R4) | -| Mutation harness (AFL++ class), device model, MCP | roadmap (R5, H2/R1, H7/R3) | +| Full TCG access trace (`qemu/greeneye_trace.c`) | source + optional build (verified live); native equivalent folds into sessions (`--trace`, R4); direct TCG JSONL import is still roadmap | +| Mutation harness, control discovery/reach, portability/version diff | working (docs/18, 19, 21, 22) | +| First-touch device map + lenient native MMIO (H2.1) | working (docs/24) | +| MCP server (`plugins/mcp_server.py`, H7/R3) | working (docs/20) | +| Full device model (H2.2) | roadmap (docs/16, 24) | ## 2. The adversary model (a moving target, not a malicious party) diff --git a/debug/risc-v-gsp-rm-vm/docs/16-CUSTOM-SIMULATOR.md b/debug/risc-v-gsp-rm-vm/docs/16-CUSTOM-SIMULATOR.md index 2b77ca3..2f99f7b 100644 --- a/debug/risc-v-gsp-rm-vm/docs/16-CUSTOM-SIMULATOR.md +++ b/debug/risc-v-gsp-rm-vm/docs/16-CUSTOM-SIMULATOR.md @@ -9,10 +9,116 @@ backend: a small deterministic RISC-V machine (`libgreenmachine`) implementing the same `Session` contract as today's QEMU backend. It expands `GREEN-EYE-ROADMAP.md` §H3 (H3.1–H3.4) and the §H6 soft-processor annex. -**Nothing here exists today.** The only supported backend is `QemuTarget` + -`RspClient` (`greeneye/target.py`, `greeneye/rsp.py`); native is gated behind -H3 entry — H2.2, the QEMU device model that makes the real GSP image reach its -message loop — and must not be started ad hoc. This document only scopes it. +**Beyond M1.5 (§1.1), nothing here exists yet.** The only production backend is +`QemuTarget` + `RspClient` (`greeneye/target.py`, `greeneye/rsp.py`); M2+ of +the native backend is gated behind H3 entry — H2.2, the QEMU device model that +makes the real GSP image reach its message loop — and must not be started ad +hoc. This document scopes it. + +### 1.1 M1 status (implemented in this tree) + +M1 was explicitly commissioned and is implemented behind `--backend native`; +the status marker above still applies to the remaining milestones (M2–M6) and +to any use on real GSP images. + +* **What exists.** A pure-Python, stdlib-only RV64IMAC interpreter under + `sim/` (`sim/decode.py` decoder, `sim/machine.py` 256 MiB RAM + NS16550A + UART + execution loop) with an `RspClient`-shaped adapter in + `greeneye/sim_backend.py` (`SimClient`, `SimTarget`, `launch_native`). + `greeneye.session.Session` runs unchanged on it: registers by name, memory, + SW/HW breakpoints, watchpoints, step/continue/interrupt, `run_until`/`call`. +* **Firmware parity.** The bundled `uproc_demo` boots unmodified; a scripted + hook/watch/leave scenario produces an event-for-event identical profile on + QEMU and native (`tests/test_sim_backend.py::DifferentialTests`), including + handler arguments and return values. Watchpoints stop *before* the accessing + instruction, which reproduces QEMU's `watch` stop and the Session + clear/step/re-arm dance exactly. +* **Deviations from this design.** M1 here is the *reference* interpreter, not + the Rust product core of §12; no MMU, FPU, GC, snapshots/moments, map DSL, + scheduler, event bus or injection ABI (those are M2–M5). There is no + self-modifying code: the per-PC decode cache is invalidated by debugger + writes only. +* **Time model.** Virtual time is instruction count: `continue_(timeout)` + executes at most `timeout * 1_000_000` instructions (SIM_IPS), then reports + a budget timeout. `Session.run(duration=...)` therefore bounds native runs + by virtual time, deterministically; QEMU's wall-clock execution naturally + reaches different heartbeat counts in the same duration (see §14). +* **Measured performance** (one host thread, CPython 3.12, this machine): + ~2.3 MIPS tight RAM loop, ~1.5 MIPS sustained demo workload + (`examples/scripts/sim_bench.py`), ~1.25 MIPS with a hook + watchpoint + active. The ≥10 MIPS M1 target in §14 is a compiled-core target and is not + met by the Python reference; the demo self-test completes in ~2k + instructions, so the parity suite still runs in seconds. +* **Stop semantics.** `ebreak` (the demo's `rpc_fault`) stops *at* the trap as + a SIGTRAP like the gdbstub, and `step` executes over it; `wfi`, `fence` and + `fence.i` are no-ops (single hart, no ordering model); unmapped accesses and + illegal instructions stop as SIGSEGV/SIGILL stops instead of raising. + +### 1.1a M1.5 status - native exec/access trace (implemented in this tree) + +The roadmap R4 full-trace path now exists natively, without the QEMU TCG +plugin and without leaving the process: + +* **Engine.** `sim/trace.py` (`Trace`, `make_trace`) collects compact JSONL + lines from the execution loop in `sim/machine.py`: + `{"t":"exec","vcpu":0,"pc":"0x...","vt":N}` and + `{"t":"read"|"write","vcpu":0,"addr":"0x...","size":S,"pc":"0x...","vt":N}`, + ending in the plugin's `{"t":"summary",...}` line. `vt` is the machine's + deterministic instruction count; nothing reads the host clock. +* **Knobs (same semantics as `qemu/greeneye_trace.c`).** `exec=on|off` + (default on in the CLI), `start=`/`end=` inclusive PC filters applied to the + executing instruction - so memory accesses are filtered by *instruction* PC + exactly as the plugin filters at translation time - and a ring budget + `max_events` after which lines are counted as `dropped` (default 1,000,000, + `0` = unlimited). +* **Delivery.** The trace is written at Session close as the session blob + `blobs/trace.jsonl` (referenced from `session.json` as `trace_blob`, with + `trace` config/counts in the metadata), so it never swamps `events.jsonl`; + folded `exec`/`access`/`read`/`write` events in `events.jsonl` are also + accepted by the analyzer. +* **Analyzer.** `greeneye analyze` renders an `## Execution trace` section: + exec/read/write totals, unique PCs grouped by symbol, and accessed 4 KiB + pages with read/write counts. `greeneye compare` adds a trace-stream totals + delta and includes trace writes in the written-page delta. +* **API.** `ge.launch(..., backend="native", trace=...)` and + `launch_native(trace=...)` take `True`, a dict + `{"exec", "start", "end", "max_events"}`, or a `sim.trace.Trace`. +* **Measured cost.** On the tight one-instruction loop microbenchmark the + always-present `if trace` guard costs ~7% versus an unguarded copy; on the + demo workload (best-of runs) untraced throughput is unchanged within noise + (1.53 MIPS before, 1.55 MIPS after). Tracing itself costs ~2.4x on the demo + workload and ~3x on the microbenchmark. `examples/scripts/sim_bench.py + --trace` reports both. + +### 1.2 Usage + +```bash +./greeneye.sh demo --backend native # the canned demo, native backend +./greeneye.sh run examples/scripts/trace_rpc.py --backend native +printf 'bp rpc_sum\nc 0.2\nquit\n' | ./greeneye.sh attach --backend native +./greeneye.sh record --backend native --trace --duration 0.5 +./greeneye.sh record --backend native --trace --trace-exec off \ + --trace-start 0x80000000 --trace-end 0x80010000 +./greeneye.sh analyze dumps/-record # renders the trace coverage section +python3 examples/scripts/sim_bench.py --trace # throughput + trace overhead +python3 examples/scripts/sim_diff.py # QEMU/native profile diff +``` + +```python +import greeneye as ge +s = ge.launch(kernel="firmware/uproc_demo/build/uproc_demo.elf", backend="native", + record="dumps/traced", trace={"exec": True, "start": 0x80000000}) +s.hook("rpc_sum", on_enter=lambda hit: print(hit.arg(0), hit.arg(1))) +s.watch(s.symbols.lookup("gsp_ctrl") + 0x10, size=8, kind="w") +s.run(duration=1.0) +s.close() # writes blobs/trace.jsonl +``` + +`--backend` is accepted by `demo`, `run`, `attach`, `record`, `inject` and +`ghidra`; the default stays `qemu` and the QEMU path is behaviourally +unchanged. `attach --port` stays QEMU-only (there is no server to attach to), +and `--trace` is native-only (QEMU uses the TCG plugin). Everything below +remains the design for M2+. ## 2. Why a custom simulator @@ -147,6 +253,13 @@ class Backend(Protocol): Compatibility decisions: +* **M1 implementation note.** Instead of the `Backend` protocol and typed + `Session(backend=...)` sketched below, M1 implements the narrower, already + exercised `RspClient` method surface (`greeneye/sim_backend.py`) and leaves + `Session` untouched. The `Backend` refactor is deferred until a second + non-RSP backend (e.g. Verilator, §16) actually needs it; §5's table is + still the M2+ target. + * `Session(client=...)` keeps working; `Session(backend=...)` is added and `client=` becomes an alias for `RspBackend(client)`. `Memory` is re-typed against `Backend`; its public methods do not change. @@ -291,16 +404,17 @@ single producer for stop-based and full-access events: |---|---|---| | `session_start`, `image_loaded`, `hook`, `watch`, `note`, `exit`, `session_end` | existing | unchanged | | `bp_hit`, `leave`, `watch_hit`, `stop`, `step`, `mem_read`, `mem_write`, `snapshot*`, `probe*`, `plugin*` | existing | unchanged; `mem_*` may now come from the bus, not only tooling | -| `exec` | new (M1, optional) | `pc`, `insn`, `vt`, `core`, `symbol` | -| `access` | new (M2) | `pc`, `addr`, `size`, `rw`, `region`, `vt`, `core` | +| `exec` | native M1.5 (blob) | `pc`, `vt` (+ `symbol` when folded into an event) | +| `access` | native M1.5 (`read`/`write` lines or folded events) | `pc`, `addr`, `size`, `vt`; `rw` only for the folded `access` form | | `device` | new (M2) | `device`, `name`, `rw`, `value`, `vt` | | `irq` / `schedule` | new (M3) | `line`, `state`, `core`, `vt`, `reason` | | `inject_*` | existing shape (docs/12) | `service_injected`, `call_enter`, `call_return` unchanged | `t` stays the recorder's monotonic seconds **filled from virtual time**, so the same run yields the same file. `exec`/`access` volume is controlled by -profile logging and a ring budget; the analyzer gains coverage/heatmap -rendering (roadmap R4). `greeneye compare` treats backend identity, `t` and +profile logging and a ring budget; for the native backend that is implemented +(M1.5, §1.1a): the stream is a session blob and the analyzer renders coverage +and accessed pages. `greeneye compare` treats backend identity, `t` and host paths as non-semantic, so cross-backend comparison is first-class. ## 11. Injection ABI @@ -352,12 +466,20 @@ familiar with the codebase; milestones are sequential. | # | Scope | Exit criteria | Effort | |---|---|---|---| | M1 | RV64IMAC interpreter, RAM, NS16550A-compatible UART, loader; `Session` adapter with `Backend` | bundled demo uproc boots unmodified; the canned instrumentation script (`03 §12`) produces the same hook/watch/stop timeline and memory snapshots as QEMU (canonical comparison); determinism test green | 4–6 wk | +| M1.5 | Native exec/access trace (R4 equivalent): `--trace`, filters, blob + analyzer coverage | trace determinism test green; analyzer renders unique PCs/pages; non-trace path unregressed (`sim_bench.py --trace`) | 1–2 wk | | M2 | Memory-map DSL + `falcon`/`csb`/`ptimer` devices + profile validation; native region snapshots; moments v1 | a re-hosted uproc build boots from a profile with no core changes; moment save/restore round-trips; restoring reproduces the event-stream hash | 4–6 wk | | M3 | Deterministic scheduler (budgets, timers, IRQ events) + event log/replay | multi-task test profile replays identically from the log on two hosts; tie-break and replay tests in CI | 3–4 wk | | M4 | Device-model parity with H2.2 + `dma`/`pmu_stub` + boot shims | real GSP image reaches the same stable state as the QEMU H2 exit under the same profile; differential suite passes for probe and boot fixtures; L3 parity documented, L2 still not claimed | 6–10 wk | | M5 | Injection ABI + service registry + `log`/`stub`/`forward` interception | an SDK v1 blob loads and serves a control end-to-end; each interception mode has a recorded acceptance session; fuzzing seed/restore demonstrated | 4–6 wk | | M6 | Performance/ergonomics: profiling, fast paths, `--backend native`, docs + flag-removal criteria | §14 targets met; `greeneye demo --backend native` in the differential CI job; user docs updated | 3–4 wk | +> **M1 status: done (pure-Python reference implementation).** Ported without +> the `Backend` refactor (see §5 note); differential parity and a determinism +> test are in `tests/test_sim_backend.py`; performance is §1.1. **M1.5 (native +> trace) is done** in §1.1a: `sim/trace.py`, `tests/test_sim_trace.py`, +> analyzer coverage rendering and `examples/scripts/sim_diff.py`. M2–M6 above +> are unchanged. + Total: roughly 24–36 engineering-weeks, consistent with the roadmap's "months; explicitly later" classification; none of it starts before the H3 entry gate, and if H2 slips this plan waits. @@ -389,6 +511,24 @@ harness, trended in CI): | MMIO access vs RAM access | ≤5× | M2 | | Event bus throughput | ≥1 M events/s buffered, lossless under budget | M3 | +> **M1 measured (pure-Python reference).** 2.3 MIPS tight RAM loop, 1.5 MIPS +> sustained demo workload, 1.25 MIPS with a hook + watchpoint active +> (`examples/scripts/sim_bench.py`, CPython 3.12). The ≥10 MIPS M1 row is a +> compiled-core gate and remains open for the Rust core; the Python reference +> exists to pin semantics and differentially test the firmware, which it does +> at parity. QEMU executes comparatively more instructions per wall second on +> this host, so equal wall-clock `run` durations produce different heartbeat +> counts across backends — the hook/watch/leave event profile is identical. +> +> **M1.5 trace overhead (same host).** The tracing guard is a single local +> boolean test per instruction plus one per memory access; A/B against an +> otherwise identical loop copy: ~2.4 MIPS vs ~2.6 MIPS on a pathological +> one-instruction loop (~7%), while the demo workload is unchanged within +> run-to-run noise (1.53 vs 1.55 MIPS best-of). With `exec=on` the demo +> workload runs at ~0.54 MIPS (~2.4x slower); an access-only trace is +> proportionally cheaper. Use `--trace-start/--trace-end` to bound the logged +> region, and the default 1M-event ring budget to bound memory. + ## 15. CI strategy * The Python package and QEMU path remain the default, always-on suite; no diff --git a/debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md b/debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md new file mode 100644 index 0000000..59db437 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md @@ -0,0 +1,164 @@ +# 17 — RM host oracle harness (`rmharness`) + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. +> The RM tools are read-only host diagnostics. They are development tooling: +> never shipped, never present in a guest, never a driver dependency. + +## 1. Purpose + +The GSP emulation can only be trusted against ground truth. Two of the +metrics in [06-ACCURACY](06-ACCURACY.md) need the real host RM as a reference: + +* **M1 — inventory coverage**: which controls/engines the real RM exposes; +* **M6 — memory side-effects / fixed points**: real VRAM/heap/BAR1 numbers + and engine inventory at a moment in time. + +`rmharness` packages the host-side oracle tools and wires their output into +green-eye sessions, so a simulator/emulation session and a host RM capture +can be recorded, diffed and cited together. It is the host half of the +accuracy loop (H4.5), not a boot path or a replacement for PLAN B traces. + +## 2. Tool inventory + +| tool | source | role in the loop | +|---|---|---| +| `rmtool` | `tools/rmtool/` (new) | CLI oracle: `gpu list/topology/info`, `engine list/info`, `mem stats`, `nv2080 class list`, allowlisted `nv2080 method call`, `sample timer`; `--format=table\|json\|csv` | +| `rmtop` | `tools/rmtop/` (new) | ncurses dashboard over the same query layer; `--once` renders table/json/csv for capture | +| `rmq` | `tools/rmq/` (pre-existing, unmodified) | FIFO/INTERNAL device info tables for TU104 static data | + +Both new tools share one ioctl layer (`tools/rmtool/src/rm_api.c`, +`query.c`): version check, `NV01_ROOT_CLIENT` -> `NV01_DEVICE_0` -> +`NV20_SUBDEVICE_0` alloc ladder, and `NV_ESC_RM_CONTROL` queries. The +device-open protocol follows what `nvidia-smi` does: open +`/dev/nvidia`, `NV_ESC_WAIT_OPEN_COMPLETE`, then allocate on +`/dev/nvidiactl`. The ABI structs match the running driver (the in-tree +userspace SDK snapshot is older and its 40-byte `NVOS64` is rejected). + +## 3. Build + +```bash +make -C tools/rmtool # builds rmtool + lib/librmapi.a +make -C tools/rmtop # builds rmtop (ncursesw) +make -C tools/rmq # unchanged, still builds +``` + +Requirements: gcc, ncursesw for rmtop. No external SDK, NVML or +libnvidia-ml linkage. `make clean` removes all artifacts. + +## 4. Running + +```bash +tools/rmtool/rmtool --help +tools/rmtool/rmtool gpu list --format=json +tools/rmtool/rmtool gpu info -d 0 +tools/rmtool/rmtool engine list +tools/rmtool/rmtool mem stats +tools/rmtool/rmtool sample timer --interval=100ms --count=20 +tools/rmtop/rmtop --once +tools/rmtop/rmtop -g 0 --interval=250 # interactive (needs a TTY) +``` + +`gpu list`/`gpu topology` need no device access (they read +`/proc/driver/nvidia/gpus` and PCI sysfs). Everything else opens a +short-lived RM client and issues read-only controls; this is the same +access model as `nvidia-smi` and is safe alongside a running CUDA job. + +`--device-spec`/`-d` accepts a minor (`0`) or a bus id (`0000:01:00.0`) and +maps to the RM `deviceId`; `--device PATH` overrides the `/dev/nvidiaN` +node. + +## 5. green-eye integration + +Wrappers `rmharness/rmtool.sh`, `rmharness/rmtop.sh`, `rmharness/rmq.sh` +locate and build the tools and pass arguments through. `rmharness/harness.sh` +adds capture: + +```bash +cd debug/risc-v-gsp-rm-vm +./rmharness/harness.sh --session dumps/real-rm-before --name before \ + -- rmtool engine list --format=json +./rmharness/harness.sh --session dumps/real-rm-before -- rmtop --once +``` + +The harness appends one `rm_harness_run` event per run and stores +stdout/stderr under `/blobs/` with sizes and SHA-256. Existing +sessions are appended to, never rewritten; a missing `session.json` is +created with the `greeneye-session/1` format. Because these runs are +external, the event `t` field is `0.0`; `start`/`end` (epoch seconds) carry +the wall clock. `greeneye analyze` tolerates the session; the run count shows +up in the event summary. + +`plugins/rmharness_plugin.py` exposes the same wrappers in the REPL +(`rmtool`, `rmtop`, `rmq`), recording through `ge.recorder.blob()` + +`ge.recorder.event()`. Write-looking arguments are refused and recorded as +`rm_harness_skip`. The plugin is stdlib-only and is exercised without QEMU +by `rmharness/tests/test_rmharness_plugin.py` (fake `ge`). + +## 6. Safety contract (absolute) + +* Host-side read-only queries only. The tools allocate only an RM client / + device / subdevice handle pair; they never create engine, channel or memory + objects. +* Never load/unload kernel modules, bind/unbind the driver, create/destroy + mdevs, claim the physical GPU, reboot, or touch `/sys/fs/pstore`. +* Write paths are enforced in code, not by convention: + * `rmtool` refuses `mem alloc/map/unmap/pin`, `engine allocate/start/stop/ + config`, and `channel/context/resserve/cudasw/resman/gsp` with exit 4/3; + * `nv2080 method call` only sends controls on a read-only allowlist + (`GPU_GET_INFO_V2`, `GPU_GET_NAME_STRING`, `GPU_GET_ENGINES_V2`, + `MC_GET_ARCH_INFO`, `FB_GET_INFO_V2`, `BUS_GET_PCI_INFO`, + `TIMER_GET_TIME`, `FIFO_GET_DEVICE_INFO_TABLE`, + `GPU_GET_CLASSLIST_V2`, ...) and refuses anything else before the ioctl; + * `harness.sh` only accepts `rmtool`, `rmtop`, `rmq`; + * the plugin blocks write-looking arguments. +* Another agent may be running a live CUDA/PLAN B job; the tools do not + touch its context and all smoke checks above run without a GPU claim. + +## 7. What was and was not run + +Smoke checks executed on the development host (driver 570.211.01, TU104 +RTX 2080 Super Max-Q, one GPU at `0000:01:00.0`): + +* `--help`/`--version` for both tools; `gpu list`, `gpu topology` (proc/sysfs + only); +* `gpu info`, `engine list`, `mem stats`, `sample timer`, `rmtop --once` + (read-only RM controls) — all returned live data (`TU100`, 10 engines GR + + 5 CE + NVDEC + NVENC + SW + SEC2, 8 GiB VRAM, BAR1 256 MiB); +* `harness.sh` session recording and the plugin test. + +Not run, and why: `rmq` device info tables and `nv2080 class list` return +`NV_ERR_INSUFFICIENT_PERMISSIONS` / `NV_ERR_NOT_SUPPORTED` for an +unprivileged client on this driver (reported, not escalated); interactive +`rmtop` was not driven (no TTY in the build shell); nothing here exercises +migrations, MIG or a second GPU. + +## 8. Roadmap tie-ins + +* **docs/06-ACCURACY** — recorded `rm_harness_run` blobs are the host + reference for M1/M6; keep the JSON captures in the session directories + cited by the metric table. +* **GREEN-EYE-ROADMAP H3** — the custom simulator's device map and RM-core + interception need real control semantics (IDs, sizes, status values) to + validate against; `rmtool` supplies that inventory before the H2.2 boot is + reached, and the shared `rm_api.c` can become the simulator's host-RM + client for differential tests. +* **GREEN-EYE-ROADMAP H4.5** — the canonical observe -> compare -> implement + loop can now include a host capture (`harness.sh --session`) beside the + simulator session, giving every emulation change an evidence pair. +* **R4 (full-memory trace)** — the session-event schema already carries + external runs; when the TCG trace lands, host `rm_harness_run` events and + firmware `access` events can be interleaved for a complete real-vs-sim + picture. +* **H1.3** — injected services that emulate RM controls should be diffed + field-by-field against the `rmtool nv2080 method call` reply for the same + control id. + +## 9. Extending safely + +1. Add new read-only controls to the allowlist **and** to `rmtool.h` + structs, with the exact kernel-side size (the userspace SDK snapshot is + not authoritative). +2. Never add an override that bypasses the allowlist/refusals. +3. Record any new query in the session and document the skip reason for + anything privilege-gated. +4. Keep `tools/rmq/` untouched; call it from the harness. diff --git a/debug/risc-v-gsp-rm-vm/docs/18-HARNESS-AUTOMATION.md b/debug/risc-v-gsp-rm-vm/docs/18-HARNESS-AUTOMATION.md new file mode 100644 index 0000000..ecd0614 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/18-HARNESS-AUTOMATION.md @@ -0,0 +1,284 @@ +# 18 — Harness automation: portability matrix and mutation harness + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. + +Two stdlib-only orchestration tools in `harness/` turn the manual playbooks +into repeatable runs: + +| Tool | Roadmap | What it does | +|---|---|---| +| `harness/portability_matrix.py` | **R6**, H4.1 | Runs probe + scripted demo + recorded sessions against several firmware ELFs and emits a cross-target delta report with the M1–M6 skeleton. | +| `harness/mutation_harness.py` | **R5**, H4.3 | Deterministically mutates the demo firmware's mailbox input surface, classifies abnormal outcomes, and records every attempt as a session plus a compact finding. | + +Both use the **QEMU backend only** (the same `probe`/`demo` machinery as the +CLI). They intentionally do not depend on `greeneye.sim_backend`, and they +never attach to an existing gdbstub, hardware, a guest or a kernel module. + +Requirements: `qemu-system-riscv64`, Python 3.10+, and (for the bundled demo) +`make demo`. All commands run from `open-iov/debug/risc-v-gsp-rm-vm/`. + +--- + +## 1. Portability matrix + +### 1.1 Usage + +Self-comparison of one build (a determinism check): + +```bash +python3 harness/portability_matrix.py \ + --target firmware/uproc_demo/build/uproc_demo.elf \ + --target firmware/uproc_demo/build/uproc_demo.elf \ + --out dumps/matrix-demo \ + --probe-instructions 200 --scenario-duration 0.2 +``` + +Old vs new firmware with the project's symbol DBs (one DB applies to every +target in the invocation; run separate invocations for per-version DBs): + +```bash +python3 harness/portability_matrix.py \ + --target firmware/57X-GSP/570.144/gsp-570.144.fwimage.bin \ + --target firmware/57X-GSP/570.211/gsp-570.211.fwimage.bin \ + --ghidra-index analysis/570.144/decompiled_index.tsv --ghidra-bias 0 \ + --symbols symbols-570.144.json \ + --out dumps/matrix-570 +``` + +Replacing the builtin scenario with a fixed green-eye script: + +```bash +python3 harness/portability_matrix.py --target \ + --scenario-script examples/scripts/trace_rpc.py --out dumps/matrix-rehosted +``` + +Flags: `--probe-instructions N` (0 = wall-clock `--probe-duration`), +`--scenario-duration`, `--max-stops`, `--max-rows`, `--symbols`, +`--ghidra-names/--ghidra-index/--ghidra-exports/--ghidra-callgraph` +(`--ghidra-bias`), and repeatable `--target` (positional targets also work). + +### 1.2 What each target does + +1. **Probe** — the ELF is flattened and relocated to `0x80000000` exactly as + `greeneye probe` does, then run for a fixed instruction budget (default + 2000) or a wall-clock budget. The first exception is parsed with + `greeneye.analyze.parse_qemu_faults`; the stopped PC is annotated from the + symbol DB. Raw `.fwimage` targets fault early here; that is the expected + first-touch data, not a harness failure. +2. **Symbols** — ELF `.symtab` plus optional Ghidra exports and/or an + existing JSON DB, merged and saved to `/symbols/.json` + (runtime addresses; the relocation `bias` is recorded in the DB meta). +3. **Scenario** — the target boots under QEMU with hooks on every `rpc_*` + handler that resolves and a write watchpoint on `gsp_ctrl.rpc_count`, runs + its self-test, then receives one deterministic host stimulus + (`mailbox = PING (1)`, `arg0 = 0x1234`). Everything is recorded as a + normal `greeneye-session/1` directory. A re-hosted ELF without `rpc_*`/ + `gsp_ctrl` symbols still runs; it simply produces an empty profile. + +### 1.3 Output format + +```text +/ + portability-report.json stable machine-readable report + portability-report.md rendered report + symbols/.json merged symbol DB per target + sessions/-probe/ recorded probe session + sessions/-scenario/ recorded scripted session + compare/--.md greeneye compare report per target pair +``` + +JSON shape (stable keys, targets in CLI order, tables sorted): + +```json +{ + "format": "greeneye-portability-matrix/1", + "tool": "harness/portability_matrix.py", + "backend": "qemu", + "targets": [{ + "name": "...", "path": "...", "sha256": "...", "entry": "0x...", + "status": "ok", + "probe": {"status": "...", "stopped_pc": 0, "stopped_symbol": "...", + "fault_count": 0, "first_fault": null}, + "symbols": {"count": 13, "sources": ["elf"], "path": "symbols/x.json"}, + "scenario": {"session": "sessions/x-scenario", "event_counts": {}, + "bp": {}, "watch": {}, "writes": {}, "ctrl": {}}, + "metrics": [{"id": "M1", "status": "not measured", "observed": {}}] + }], + "comparisons": [{"a": "...", "b": "...", "deltas": {"bp": [], "watch": [], "writes": []}, + "all_zero": true, "metrics": []}], + "skipped": [] +} +``` + +Determinism: fixed probe instructions, fixed scenario phases and the same +stimulus make hit/watch/write profiles reproducible, so an identical target +compared with itself reports **zero deltas**. The M6 control-block snapshot +hash is *not* a fixed point while the firmware heartbeats; compare snapshots +only between quiesced runs. Metrics are marked `"not measured"` unless a +reference capture exists (docs/06 §5); `observed` carries the local facts the +matrix could gather. + +Targets that are missing, not RISC-V ELF, or too large are reported with +`status: "skipped"` and a `skip_reason`; a failing step yields `"error"` for +that step and does not abort the matrix. + +--- + +## 2. Mutation harness + +### 2.1 Usage + +```bash +python3 harness/mutation_harness.py --out dumps/mutation-7 \ + --iterations 64 --seed 7 --timeout 0.25 --settle 0.2 +``` + +Targeting the command/args surface plus an optional RAM window: + +```bash +python3 harness/mutation_harness.py --out dumps/mutation-ram \ + --iterations 128 --seed 11 --memory-region 0x80000900:0x100 +``` + +Budgets: `--iterations` (1..10000), `--seed`, `--timeout` (0.01..10 s total +observation per mutation; a short first window plus one confirmation window +for slow handlers), `--settle` (per attempt, until `SELFTEST DONE` is seen), +and `--max-log-bytes` (QEMU log window per fault check; trap loops flood +`-d int`). `--kernel` defaults to the bundled demo; a non-demo ELF is refused +unless `--allow-foreign-elf` is given, and remains QEMU-only. + +### 2.2 Strategies + +| Strategy | Input surface | +|---|---| +| `cmd-invalid` | command IDs outside the demo's valid range (`0, 7..15, 24+, 0x100, 0xffff, 2^64-1`) | +| `arg-boundary` | one of arg0/arg1/arg2 set to `0, 1, 2, 0xff, 0x100, 0x7fffffff, 2^63, 2^64-1`, ... | +| `bit-flip` | one bit flipped in the command or an argument of a valid command | +| `buffer-length` | ECHO/FILL/SUM against `gsp_ctrl.buf` with lengths `0, 1, 31, 32, 33, 255, 256, 257, 4096, 2^32, ...` | +| `mem-region` | one byte bit-flip inside `--memory-region` (when supplied) | + +Each iteration uses a **fresh QEMU session**, settles the demo through its +self-test, applies exactly one mutation, runs bounded, then reads back the +control block. No hardware watchpoints are installed, so the only stops are +budget interrupts and real traps. + +### 2.3 Outcome classes + +| Class | Detection | +|---|---| +| `ok` | command dispatched (or `cmd=0`) and the demo oracle, when one exists, matched | +| `trap` | first new exception is a breakpoint (`ebreak`), or control transferred below `0x100` | +| `fault` | first new QEMU exception is a load/store/fetch/illegal fault (`parse_qemu_faults`) | +| `no-progress` | command written but `rpc_count` did not advance within both windows (no fault) | +| `unexpected-result` | result/last_rpc contradict the documented demo oracle for `cmd ∈ {0,1,3,5, invalid}` | +| `exit` / `settle-failed` / `error` | target exited; self-test never observed; iteration exception | + +The oracle is deliberately small: handlers that dereference `arg0` (ECHO, +SUM) and injected slots 0–5 have no oracle, so their successful executions +are `ok` with `expected: null`. + +### 2.4 Output format + +```text +/ + findings.jsonl one compact finding per iteration (deterministic order) + summary.md unique outcome classes, strategy coverage, examples, replay command + sessions/iter-0000/ recorded greeneye session per iteration +``` + +Finding shape: + +```json +{"iteration": 5, "strategy": "arg-boundary", + "mutation": {"cmd": 4, "arg0": 0, "arg1": 255, "arg2": 0, "region": null}, + "outcome": "fault", + "detail": {"observed": {"result": 48, "last_rpc": 5, "rpc_count_delta": 0}, + "fault": {"cause": 5, "desc": "fault_load", "epc": 2147483860, + "tval": 255, "access": {"rw": "read", "access_addr": 255, + "size": 1, "reason": "rejected"}}}, + "session": "sessions/iter-0005"} +``` + +Replay is re-running the same command: the per-iteration RNG is seeded from +`(seed, index)`, finding fields avoid wall-clock data, and `findings.jsonl` +is byte-identical for the same seed/budgets on the same host. Session +directories under `sessions/` contain the usual `events.jsonl`, `session.json` +and `summary.md`; they are the evidence behind each finding. + +--- + +## 3. Tests + +```bash +python3 -m unittest tests.test_harness -v +make test # includes the harness tests with the rest of the suite +``` + +The integration tests boot the demo twice for a self-comparison (must show +zero deltas) and run 12 seeded iterations twice (findings file, at least one +outcome class, byte-identical replay). They skip cleanly when QEMU or the +demo ELF is absent. + +--- + +## 4. Attaching reports to a portability change + +For the docs/07 P1/P2/P3 loop and the docs/14 §6 evidence pack: + +1. Run one matrix invocation per version/device/architecture family with the + same scenario options, and keep `/portability-report.json`, the + per-pair compare reports and the session directories (or their sha256 + list). +2. Record the firmware hash (`sha256` in each target entry), the symbol + source list and `bias` from `/symbols/*.json` meta. +3. Fill the M1–M6 table using `observed` from the matrix plus the real + reference capture; the matrix marks agreement metrics "not measured" on + purpose. Cite docs/06 L2/L3 limits in any claim. +4. For an input-surface change, run the mutation harness before and after the + change and diff the outcome-class tables; attach `findings.jsonl` as the + corpus entry list, with each finding's session as evidence. +5. Re-run the unchanged stimulus after the emulation change: profiles must + improve or stay equal (the docs/06 accuracy loop). + +--- + +## 5. Safety + +* Demo / re-hosted firmware under QEMU only. Never physical hardware, never + a guest, never a kernel module, never an existing gdbstub or the native + simulator. +* The mutation harness refuses non-demo ELFs unless `--allow-foreign-elf` is + passed explicitly, and still refuses non-ELF images. +* Memory-region mutation is bounds-checked against the QEMU RAM window; no + host memory is touched outside the emulated target. +* Every write is recorded in a session (`mem_write`, `note`, mutation fields); + budgets (`duration`/`max_stops`/`--timeout`) bound every run. +* Only temporary work directories and `/` are written on the host. + +--- + +## 6. Limits and fidelity + +* L2/L3 claims are out of scope: no timing, interrupt or device-model + conclusions (docs/06). The matrix observes RAM-resident behaviour and the + first unmapped access, nothing more. +* A QEMU log window is bounded; a fault not in the window is recorded as its + secondary signal (`trap_loop`/`no-progress`) rather than guessed. +* Mutation covers one mailbox write per iteration; multi-write races and + concurrency are future work (H2/H4.4). +* The demo oracle is small and documented; "unexpected-result" is only + meaningful for the listed commands. + +--- + +## 7. Roadmap mapping + +| Item | Where this doc/tool contributes | +|---|---| +| **R5 — fuzzing and mutation harness** | `mutation_harness.py`: bounded seeded mutation, outcome classes, recorded corpus (`findings.jsonl` + sessions) | +| **R6 — multi-version orchestration** | `portability_matrix.py`: probe + rehost/demo + compare + M1–M6 for a target set | +| **H4.1 — semantic diffing** | identical stimulus per target, `compare_sessions` plus hit/watch/write deltas in one report | +| **H4.3 — mutation/fuzzing** | error-path corpus with symbol-annotated sessions, feeding fail-closed rules | +| **H4.5 — reimplementation loop** | reports are the attachable evidence for the docs/07 checklist | +| **V1 — metrics/golden data** | deterministic profiles and seeded findings support golden comparisons | +| **H7 — agentic harness** | self-describing JSON/markdown artifacts designed for an agent to drive and review | diff --git a/debug/risc-v-gsp-rm-vm/docs/19-CONTROL-DISCOVERY.md b/debug/risc-v-gsp-rm-vm/docs/19-CONTROL-DISCOVERY.md new file mode 100644 index 0000000..0f9afbe --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/19-CONTROL-DISCOVERY.md @@ -0,0 +1,445 @@ +# 19 — Control discovery and hot-path reachability + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status and +> [`GREEN-EYE-ROADMAP.md`](../GREEN-EYE-ROADMAP.md) H4 for the program. + +Two harness tools close the loop between "a new firmware image does something +we do not model" and "here is the code path, its trigger and its effects": + +| Tool | What it answers | +|---|---| +| `harness/control_discovery.py` | Which command IDs exist, which code path each one takes, and what it changes. New/undiscovered controls fall out of the sweep instead of waiting for documentation. | +| `harness/reach.py` | Given one decompiled symbol/address: is it reachable, what triggers it, what arguments does it need, what happens. | + +Both are simulator-only, bounded, evidence-producing, and depend only on the +public `Session`/hook/watch primitives (stdlib-only Python). They are the +practical start of the "Sandsifter for RM" idea in +[`GREEN-EYE-ROADMAP.md`](../GREEN-EYE-ROADMAP.md) H4 and reuse the contracts +in [06-ACCURACY](06-ACCURACY.md) and [14-ADVERSARIAL-INTEROP-GUIDE](14-ADVERSARIAL-INTEROP-GUIDE.md). + +--- + +## 1. The problem + +The GSP's RM is a message/method system: services own objects and answer +controls dispatched by ID. NVIDIA ships new images on a hidden schedule, +stripped and signed, and new services, messages and controls appear between +versions. Static diffing (docs/18 portability matrix, docs/14 section 4.1) +tells us the code changed; it does not tell us which input activates the new +path, what the path needs, or what it changes. A sweep of the input surface +plus dynamic hooks answers exactly those questions, and it is safe to run in a +simulator where the firmware cannot be damaged. + +## 2. The discovery model + +### 2.1 Sweep spaces + +A "call surface" is a finite input space with an observable effect: + +| Surface (today / future) | Space | Probe action | +|---|---|---| +| demo mailbox (`--surface demo`) | command IDs `1..--max-id`, with argument patterns | write `gsp_ctrl.arg0..2`, then `gsp_ctrl.mailbox` | +| RM control/method entry (`--surface rm`, H1.3) | control IDs, classes and method opcodes | call the RM method entry with `(control, a0, a1, a2)` | +| future CSB/TRANSCOM message functions | message function IDs | write the message buffer and ring the doorbell | + +ID 0 is the demo's idle mailbox sentinel (the polling loop ignores it), so the +sweep starts at 1; surface classes with a meaningful 0 can start there instead. + +The same machinery applies to any of them because the sweep is described by a +:class:`Surface` subclass: a probe function, an effect observer, and an +entry-point table. + +### 2.2 Deterministic argument patterns + +Every ID is probed with `--patterns` (default all three): + +* `zero` — `(0, 0, 0)` (the "no arguments" baseline); +* `sentinel` — three distinct pseudo-random 64-bit values from a stream keyed + by `(seed, id)`, so an ID that misuses an argument is caught; +* `boundary` — `(2^64-1, 1, 2^63-1)`, the usual off-by-one/size boundaries. + +Patterns are deterministic: the same `--seed`/`--max-id` replays the same +findings byte-for-byte (verified by `tests/test_discovery.py`). + +### 2.3 Entry-point modelling + +Before probing, the tool reads the surface's declared entry points: + +* the dispatcher symbol (`rpc_dispatch` on the demo) is hooked for every + probe, capturing the command and the raw `arg0..arg2` at dispatch entry; +* every non-zero handler-table slot (`gsp_ctrl.handlers[0..7]`) is hooked so a + hit can be attributed to a function even when the command is not one of the + documented switch cases. + +On real RM the same role is played by the RM-core dispatch table / object +class table: hook the dispatcher, then resolve the handler the simulator +routes to. + +### 2.4 Effect capture + +Each probe observes: + +* control-block fields before/after (`state`, `rpc_count`, `last_rpc`, + `result`, `buf` hash) plus write watchpoints on the scalar fields, which + also record the *store PC* (`effects.watch`); +* the new QEMU exception window (`-d guest_errors,unimp,int`), parsed with + `greeneye.analyze.parse_qemu_faults`, so traps (`cause 3`) and faults are + distinguished from "no progress"; +* stop signals as a backend-independent fallback (a native backend has no + QEMU log: SIGTRAP maps to trap, SIGSEGV/SIGBUS/SIGILL to fault; budget + SIGINTs are deliberately excluded from findings). + +### 2.5 Classification + +One probe yields one outcome, in priority order: + +| Outcome | Meaning | +|---|---| +| `trap` | breakpoint exception (`ebreak`) or trap loop below `0x100` | +| `fault` | load/store/fetch/illegal exception | +| `method-return` | an RM-style call entry returned a value | +| `no-progress` | the mailbox was never consumed in the budget | +| `injected-slot` | command routed through the injected-service handler table | +| `known-handler` | a declared handler ran | +| `default-path` | the dispatcher catch-all ran (no dedicated handler) | +| `settle-failed` / `error` | the pre-probe state or the probe itself failed | + +`default-path`, `injected-slot` and `trap/fault` are the interesting classes: +they are where new behaviour, injection wiring and input bugs live. + +--- + +## 3. `control_discovery.py` — unknown-call discovery + +### 3.1 Usage + +Full demo mailbox sweep (189 probes: IDs 1..63 times three patterns): + +```bash +python3 harness/control_discovery.py --out dumps/discovery --seed 7 +``` + +A quick sweep for CI or bring-up (IDs 1..32, one pattern): + +```bash +python3 harness/control_discovery.py --surface demo --out dumps/discovery-min \ + --max-id 32 --patterns zero --seed 5 --timeout 0.25 +``` + +Other surfaces / budgets: + +```bash +# RM surface (documented future interface; see section 5) +python3 harness/control_discovery.py --surface rm --out dumps/discovery-rm + +# cap the probe count and keep every recorded session +python3 harness/control_discovery.py --out dumps/discovery-cap --iterations 40 --record-all +``` + +Flags: `--max-id` (1..4096), `--patterns zero,sentinel,boundary`, `--seed`, +`--iterations` (0 = all probes), `--timeout` (per-probe observation window), +`--settle` (self-test budget), `--backend qemu|native` (QEMU is the supported +default; native drops the QEMU fault log and uses signals), `--kernel` +(bundled demo by default; a foreign ELF needs `--allow-foreign-elf`), +`--record-classes` and `--record-all`, `--max-log-bytes`. + +Each probe uses a **fresh session**, settles through the self-test, then +applies one command. Fresh sessions are the safe strategy: a trap or fault can +leave the demo in a state that would corrupt the next probe. + +### 3.2 Output + +```text +/ + findings.jsonl one compact finding per probe (deterministic order) + discovery-report.json format + options + per-ID aggregate + findings + control-discovery.md rendered report grouped by class + sessions/-id-/ kept evidence for interesting classes +``` + +Finding shape (stable keys, no wall-clock data): + +```json +{"probe": 15, "cmd": 16, "pattern": "zero", "args": [0, 0, 0], + "outcome": "injected-slot", + "handler": "rpc_ping", "handler_entry": 2147483720, "documented_handler": null, + "dispatch_seen": true, "dispatch_pc": 2147483912, "dispatch_args": [16, 0, 0, 0], + "rpc_count_delta": 1, + "effects": {"changed": [{"field": "result", "before": 48, "after": 48879}], + "watch": [{"field": "result", "pc": 2147484066, "symbol": "rpc_dispatch"}]}, + "fault": null, "signals": [], "settle_ok": true, + "session": "sessions/0015-id016-zero", "reason": null} +``` + +### 3.3 Reading the report + +The markdown report opens with the outcome counts and an "Ids by class" line, +then sections that matter: + +* **Known handler ids** — the documented dispatch table (by the firmware's own + control block, not the docs); +* **Injected-slot dispatch ids** — commands whose path is the injected-service + table, with the handler each aliases; +* **Unknown / undiscovered ids** — the explicit list that answers "what is + not handled here yet"; +* **Traps and faults** — IDs that reach error paths under some input; +* **All findings by id** — the join key for `findings.jsonl`. + +### 3.4 What the demo sweep already shows + +Measured on 2026-10-02, demo ELF sha256 `c038a04a...`, 189 probes in ~36 s: + +* IDs 1..6 hit `rpc_ping`/`rpc_echo`/`rpc_fill`/`rpc_sum`/`rpc_state`/ + `rpc_fault`; the ID 6 path ends in the `ebreak` trap and never writes + `result` (the dispatcher's post-handler stores do not run); +* **discovered class:** IDs 16..23 fall through the switch into the + injected-service handler table. IDs 16..21 alias the *built-in* handlers + (`16 -> rpc_ping`, ..., `21 -> rpc_fault`); IDs 22..23 are NULL slots and + return `~id`. This is exactly the kind of wiring a new firmware can add + silently; +* IDs 7..15 and 24..63 take the catch-all and return `~id` with + `rpc_count + 1` — the unknown-ID space; +* argument patterns change the class where the handler uses the argument: + `rpc_echo` and `rpc_sum` fault on sentinel/boundary pointers, while + `rpc_ping`/`rpc_state`/`rpc_fill` stay on the handler path. + +--- + +## 4. `reach.py` — hot-path reachability + +### 4.1 Usage + +```bash +# Directly callable target, with an argument +python3 harness/reach.py --target rpc_ping --method call --args 0x41 \ + --out dumps/reach-ping + +# Natural boot/self-test path +python3 harness/reach.py --target rpc_sum --method run --out dumps/reach-sum + +# Only reachable through a mailbox command: discover the trigger from the +# handler table and apply it +python3 harness/reach.py --target rpc_fault --method trigger --out dumps/reach-fault + +# Replay a saved probe spec; emit one for CI +python3 harness/reach.py --probe dumps/reach-fault/probe.json --emit-probe /tmp/fault-probe.json +``` + +Flags: `--method auto|stimulus|trigger|run|call`, `--all-methods`, `--args`, +`--arg-count` with `--arg-fill zero|sentinel`, `--timeout`, `--settle`, +`--stimulus script.py`, `--probe spec.json`, `--emit-probe`, `--record [DIR]`, +`--max-prior`, `--kernel`, `--backend`, `--allow-foreign-elf`. + +### 4.2 Methods + +`auto` tries methods in this order and stops at the first hit (use +`--all-methods` to compare routes): + +1. `stimulus` — run a green-eye script (or a probe spec's stimulus) with the + target hook and watchpoints armed. The script drives `session.run` itself. +2. `trigger` — read the surface's handler table, find the command ID(s) that + resolve to the target, settle the firmware, apply that command and run + bounded. This is the trigger discovery for IDs 1..6 and 16..23 on the demo. +3. `run` — `Session.run_until(target, timeout)` from launch, which catches the + natural boot/self-test path. +4. `call` — `Session.call(target, args)`, the Frida-like direct call, for + unit-testing a function in isolation. + +A method reports a hit when the target-entry hook fires, even if execution +immediately traps (`rpc_fault`): reachability and success are different +questions, and the report distinguishes them. + +### 4.3 Output + +```text +/ + reach-report.json machine-readable report (stable keys) + reach-report.md rendered report + probe.json suggested repeatable probe spec (on a hit) + sessions// optional recorded session (--record) +``` + +The report records: + +* `reachable`, `method`, and every `methods_tried` with its miss reason; +* `entry`: the register state at the target (args `a0..a3`, `ra`, `sp`) and + `dispatch_args` — the authoritative command arguments when the target was + reached through the dispatcher; +* `prior_events`: the ordered hook events that led to the hit (bounded by + `--max-prior`); +* `required_args`: discovered arguments plus their provenance (`call-args` or + `firmware-dispatch`) and the caller `ra`; +* `effects`: field deltas, watchpoint stores, the return value, and whether a + trap/fault followed (`rpc_fault`: trap `ebreak` at `pc 0`); +* `trace_window`: hook counts and, when recorded, session event counts; +* `probe_spec`: the exact spec to replay the route. + +### 4.4 Demo results + +Measured on 2026-10-02 (each run ~0.5 s, QEMU): + +| Target | Method | Result | +|---|---|---| +| `rpc_ping` | `call` with `--args 0x41` | reachable; returns `0x41 ^ 0xbeef = 0xbeae` | +| `rpc_sum` | `run` | reachable in the self-test; entry args `(buf, 16, 32, ...)`; path passes through `rpc_ping` -> `rpc_echo` -> `rpc_fill` first | +| `rpc_fault` | `trigger` | reachable via mailbox command 6 (also aliased at 21); dispatch args `cmd=6, a0=0, a1=0`; immediate `ebreak` trap | + +The self-test path is a useful reminder that compile-time inlining can bypass +the dispatcher: the `run` route reaches `rpc_sum` from an inlined call site, +so there are no `rpc_dispatch` hits before it. The `trigger` route does pass +through the dispatcher and exposes the control arguments. + +--- + +## 5. Sandsifter for RM (the future surface) + +A Sandsifter-style tool for RM needs three more capabilities than the demo +has today, in this order. + +### 5.1 The `Surface` abstraction (already in place) + +`harness/control_discovery.Surface` separates: + +* `probe(session, id, args)` — how one call is applied (mailbox write today; + RM method call / message write later); +* `observer()` — the effect model (control-block fields today; RM reply + buffer and object state later); +* `handler_map()` / `command_for_addr()` — how IDs map to code, which drives + both discovery and reach's `trigger` method. + +Adding a real RM surface is a new subclass, not a rewrite of the sweep, +classification or reporting. + +### 5.2 RM sweep design + +1. **Control/method inventory.** Start from `rmtool`/`rmq` (docs/17) for the + host-visible control list and class IDs; import the Ghidra RM-core dispatch + table into the symbol DB. The sweep ranges are then control IDs by class + (`--rm-class`) and method opcodes within a control, mirroring the + `nv2080 method call` surface. +2. **Entry-point modelling.** Hook the RM dispatch routine, the object + constructor/method tables and the status/notifier writes. For each swept + ID, capture the handler entry, the object it selects and the status it + returns. +3. **Effect observation.** Point `--rm-reply` at the reply buffer for the + control and hash/snapshot it; add watchpoints on the object handle table + and status queue. Compare the reply field-by-field against the host oracle + (`rmtool` JSON) for M2 (docs/06). +4. **Branch exploration.** The native simulator's snapshots (H3) make it cheap + to fork at the dispatcher and sweep IDs without re-booting; the QEMU + surface uses fresh sessions exactly like today. +5. **Corpus and gates.** Findings become an inventory corpus (`findings.jsonl`) + that CI diffs between firmware versions; new IDs, new handlers, or changed + default-path results are the regression signal (docs/21). + +### 5.3 Rollout gates + +* Entry gate: H2.2/H1.3 — a simulator session reaches the RM message loop and + exposes the dispatch table (otherwise `--surface rm` exits cleanly with the + message below). +* Exit gate: sweep a real control space, reach one previously unknown control, + and replay its probe spec through `reach.py` with a recorded session. + +When unavailable the tool is explicit, not mysterious: + +```text +$ python3 harness/control_discovery.py --surface rm --out dumps/discovery-rm +[discovery] skipped: surface 'rm' unavailable: no RM control/method entry point is exposed +by the simulator or a live session yet (roadmap H1.3). Pass --rm-entry SYMBOL once the +RM method dispatcher exists; the demo surface works today with --surface demo. +``` + +--- + +## 6. Feeding the simulator/device model and CI + +* **Device model.** Each discovered class is a candidate row in the + simulator's dispatch/control model: the command ID, the entry symbol, the + argument pattern that reaches it, and the effect snapshot are exactly the + data the H2/H3 device map needs. Unknown IDs (`default-path`) document the + fail-closed/default behaviour the emulation must reproduce. +* **Accuracy metrics.** The `dispatch_pc`/`handler_entry`/effects fields feed + M1 (inventory coverage) and M4/M6 (state and memory side effects, + docs/06); compare two firmware versions by diffing `discovery-report.json` + (new handler IDs, moved handler entries, changed default results). +* **CI (docs/21).** The intended gates are: + 1. `control_discovery.py --max-id 32 --patterns zero` as a fast inventory + smoke test: known handlers must resolve, `rpc_fault` must trap, and the + default-path result must stay `~id`; + 2. `reach.py` on a pinned list of targets, asserting `reachable` and the + method (`call` for `rpc_ping`, `run` for `rpc_sum`, `trigger` for + `rpc_fault`) so a compiler/inlining or dispatch change is caught; + 3. diff `findings.jsonl`/`discovery-report.json` against a golden run. + Findings contain no wall-clock data, so the diff is stable for a fixed + seed/params (the same property the mutation harness documents in docs/18). +* **Probe specs are the interface.** `reach.py --emit-probe` and + `--probe` turn a discovered route into an artifact that any agent or CI job + can replay without re-deriving it. + +--- + +## 7. Tests + +```bash +python3 -m unittest tests.test_discovery -v +make test # includes the discovery tests with the rest of the suite +``` + +`tests/test_discovery.py` covers the pure functions (pattern determinism, +probe ordering/caps, classification priority), a 32-probe demo sweep that +must attribute IDs 1..6, find an unknown ID and an injected-slot alias and +write the report, byte-identical replay for a fixed seed, the clean RM skip, +and the three reach demonstrations (`rpc_ping` call, `rpc_sum` run, +`rpc_fault` trigger). It skips cleanly when QEMU or the demo ELF is absent. +The suite completes in well under a minute. + +--- + +## 8. Safety and ethics + +* **Simulator only.** The tools launch their own QEMU (or the native + simulator with `--backend native`) against the bundled demo or an + explicitly allowed re-hosted ELF. They never attach to hardware, a guest, a + kernel module or an existing gdbstub. Ctrl-C leaves no target running: each + session is closed in a `finally`. +* **Bounded probes.** Every probe has a settle budget and an observation + window; `--max-id`/`--iterations` cap the sweep; QEMU log reads are capped. +* **No hardware writes.** All memory writes go through the debug stub into + emulated RAM; the only host writes are under `--out`. +* **Evidence, not truth.** A `default-path` result is evidence about this + firmware at this fidelity (L0/L1, docs/06), not a statement about silicon. + Never use a simulator observation to bypass a hardware protection; escalate + anything that would change the product to a physical capture (docs/14 + sections 6-7). +* **No injected payloads here.** Discovery only *observes* injection slots; it + never loads a blob. Injection itself stays behind `greeneye inject` and the + docs/12 contract. + +--- + +## 9. Limits and fidelity + +* QEMU wall-clock slicing bounds when a handler is interrupted, so a very long + handler may be classified from its entry hit without its final result; the + finding says `known-handler` and leaves `effects` empty rather than guessing. +* The demo is not RM: there is no object model, no status queue and no + per-control reply. The RM design in section 5 is deliberately unbuilt until + the simulator exposes an RM dispatch table (H1.3/H2.2). +* `trap` vs `fault` uses the guest exception cause; QEMU and the native + simulator agree on the demo, but x86/RISC-V mapping differences are a + documented fidelity gap (docs/06 section 8). +* The sweep is single-threaded and deterministic; concurrent/multi-core + dispatch interleavings (H2.4) are future work. + +--- + +## 10. Roadmap mapping + +| Item | Contribution | +|---|---| +| **H4.adversarial navigation** | unknown-call discovery and trigger discovery over control surfaces | +| **H4.1 semantic diffing** | `discovery-report.json` is a version-diffable inventory (new/changed IDs, handlers, default paths) | +| **H4.2 capability inference** | `effects.watch` store PCs are access evidence for the capability table | +| **H4.5 reimplementation loop** | `reach.py` output plus probe spec is the observe -> reproduce artifact for a new behaviour | +| **H3 native simulator** | `--backend native` reuses the same sweep once parity lands; snapshots will remove the fresh-session cost | +| **V1 golden data / docs/21 CI** | stable findings and the fast 32-probe gate are the golden corpus | diff --git a/debug/risc-v-gsp-rm-vm/docs/20-MCP-SERVER.md b/debug/risc-v-gsp-rm-vm/docs/20-MCP-SERVER.md new file mode 100644 index 0000000..a3b6111 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/20-MCP-SERVER.md @@ -0,0 +1,277 @@ +# 20 — MCP server (green-eye agent interface) + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. + +## 1. What it is + +`plugins/mcp_server.py` is the green-eye **MCP (Model Context Protocol) +server** — roadmap [`R3`](08-ROADMAP.md#r3-mcp-server-plugin) and +[`H7`](../GREEN-EYE-ROADMAP.md). It exposes the *active* `Session` as MCP +tools so an AI agent (Claude Code, opencode, any MCP client) can inspect +symbols, set breakpoints/watchpoints, run the target, read memory, snapshot, +analyse/compare recordings, inject services, and drive the harnesses. + +* **Stdlib-only**: JSON-RPC 2.0 over newline-delimited stdin/stdout, no MCP + library, no network listener unless explicitly asked. +* **Plugin first**: `register(ge)` adds an `mcp-serve` REPL/CLI command that + serves the session already loaded by green-eye. +* **Standalone too**: the same file runs as a script that launches its own + session (`--kernel`, `--backend`, `--record`) and then serves MCP. +* **Read-only by default**: memory writes, remote calls and injection require + explicit startup flags. +* **Defensive capabilities**: tools owned by other components (mutation and + portability harnesses, native trace, injection) return a structured + `not_available` tool error when the module/file is absent instead of + crashing. The tool list always advertises every capability. + +The core green-eye package is untouched; everything lives in the plugin. + +## 2. Running it + +All commands run from `open-iov/debug/risc-v-gsp-rm-vm/`. + +### 2.1 Standalone (the usual MCP client mode) + +```bash +# bundled demo, QEMU, recorded session +python3 plugins/mcp_server.py --stdio --record + +# native simulator, recorded +python3 plugins/mcp_server.py --stdio --backend native --kernel firmware/uproc_demo/build/uproc_demo.elf --record + +# enable writes / injection (never on by default) +python3 plugins/mcp_server.py --stdio --allow-write --allow-inject + +# local loopback TCP instead of stdio (for clients that cannot spawn a process) +python3 plugins/mcp_server.py --tcp 127.0.0.1:8765 --record +``` + +Flags: `--stdio` (default), `--tcp HOST:PORT` (loopback only), +`--kernel ELF`, `--backend qemu|native`, `--record [PATH]`, `--name NAME`, +`--plugins PATH` (repeatable), `--allow-write`, `--allow-inject`. +Logs go to stderr; **stdout carries protocol frames only**. + +### 2.2 Plugin mode (serve a live session) + +```bash +./greeneye.sh attach --kernel firmware/uproc_demo/build/uproc_demo.elf \ + --plugin plugins/mcp_server.py +greeneye> mcp-serve # read/observe +greeneye> mcp-serve --allow-write # + memory writes / remote calls +greeneye> mcp-serve --allow-inject # + service injection +``` + +`mcp-serve` blocks until the MCP client disconnects or calls the `close` +tool, then returns to the REPL. In plugin mode `close` stops the server but +**does not** close the session (the REPL still owns it); in standalone mode +`close` also closes the launched session. + +Both `--stdio` and `--tcp` behave identically. The bundled plugin directory is +loaded by default, so `mcp_server.py` is auto-discovered; the command only +serves when invoked. + +## 3. Client configuration + +The MCP server must be started **before** the session it should serve, so +client configs use standalone mode. Use absolute paths. + +### 3.1 Claude Code / `.mcp.json` + +```json +{ + "mcpServers": { + "green-eye": { + "command": "python3", + "args": [ + "/home/rmcore/Git/integdev_gpu_drv/open-iov/debug/risc-v-gsp-rm-vm/plugins/mcp_server.py", + "--stdio", + "--backend", "native", + "--kernel", "/home/rmcore/Git/integdev_gpu_drv/open-iov/debug/risc-v-gsp-rm-vm/firmware/uproc_demo/build/uproc_demo.elf", + "--record" + ] + } + } +} +``` + +Or, one line: `claude mcp add green-eye -- python3 +/plugins/mcp_server.py --stdio --record`. + +### 3.2 opencode (`opencode.json`) + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "green-eye": { + "type": "local", + "command": [ + "python3", + "/home/rmcore/Git/integdev_gpu_drv/open-iov/debug/risc-v-gsp-rm-vm/plugins/mcp_server.py", + "--stdio", "--backend", "native", "--record" + ], + "enabled": true + } + } +} +``` + +For an agent that must write memory or inject, add `--allow-write` / +`--allow-inject` to `args` and keep the session recorded (the recording is +the audit trail). + +## 4. Protocol + +| Request | Behaviour | +|---|---| +| `initialize` | returns `protocolVersion` (echoes `2024-11-05`, `2025-03-26` or `2025-06-18`; otherwise the server default), `serverInfo`, `capabilities.tools` | +| `notifications/initialized` | accepted silently (notifications never get a response) | +| `ping` | `{}` | +| `tools/list` | the 24-tool catalogue with JSON input schemas | +| `tools/call` | `{"name": ..., "arguments": {...}}` → `{"content":[{"type":"text","text":""}]}`, or `isError: true` for tool errors | + +JSON-RPC errors: `-32700` parse error, `-32600` invalid request, `-32601` +unknown method, `-32602` unknown tool / bad params. Tool-level failures +(bad address, symbol not found, gating, capability absent) are returned as +`isError: true` results whose text is a JSON object with `error` and +`message` — the server never dies on a bad call. + +Tool result payloads are always a single JSON text block so agents can parse +them deterministically. + +## 5. Tool catalogue + +| Tool | Arguments | Class | Notes | +|---|---|---|---| +| `session_info` | — | read | backend, PC, symbols, recording, breakpoints/watches, enabled capabilities | +| `symbols_resolve` | `target` | read | symbol, `symbol+0xNN` or hex address | +| `bp_set` | `target`, `kind=sw\|hw` | observe | hits recorded as `bp_hit` | +| `bp_clear` | `target` | observe | removes every breakpoint at that address | +| `watch_set` | `target`, `size=8`, `kind=w\|r\|a` | observe | hits recorded as `watch_hit` | +| `run` | `duration=1.0`, `max_stops`, `timeout` | observe | bounded; services hooks/watchpoints | +| `run_until` | `target`, `timeout=5.0` | observe | temporary breakpoint | +| `step` | `count=1` (max 10000) | observe | dispatches hooks/watchpoints | +| `read_mem` | `addr`, `size=64` (max 1 MiB) | read | lowercase hex | +| `write_mem` | `addr`, `hex` | **write** | requires `--allow-write`; recorded as `mem_write` | +| `hexdump` | `addr`, `size=64` (max 1 MiB) | read | symbol-annotated text | +| `snapshot` | `addr`, `size` (max 16 MiB), `label=mcp` | read | zlib + sha256 into the session | +| `call_function` | `target`, `args=[]`, `timeout=5.0` | **write** | requires `--allow-write`; Frida-like remote call | +| `inject_service` | `service`, `slot=6`, `load_addr`, `cmd=1`, `arg0`, `arg1`, `path=remote\|firmware\|both`, `settle=0.25` | **inject** | requires `--allow-inject`; service ELF built with `make sdk` | +| `trace_start` | `exec=true`, `start`, `end`, `max_events` | trace | native backend + recording session only | +| `trace_stop` | — | trace | flushes `blobs/trace.jsonl`, returns counts | +| `analyze_current` | `max_rows=40` | read | `render_summary` for the active recording | +| `compare_sessions` | `a`, `b`, `symbols=true`, `max_rows=40` | read | A/B recorded sessions | +| `export_hits` | `session`, `out` | read | Ghidra `greeneye_hits.tsv` | +| `probe_firmware` | `elf`, `instructions=2000`, `duration=1.0`, `no_relocate`, `symbols`, `timeout=300` | harness | QEMU-only probe subprocess; first divergence | +| `mutation_run` | `kernel`, `out`, `iterations=10`, `seed=1`, `timeout=0.25`, `settle=0.2`, `memory_region`, `allow_foreign_elf` | harness | `harness/mutation_harness.py`, QEMU-only | +| `portability_matrix_run` | `targets[]`, `out`, `probe_instructions=2000`, `scenario_duration=0.4`, `symbols`, `max_rows` | harness | `harness/portability_matrix.py`, QEMU-only | +| `plugins_list` | — | read | bundled plugins, `GREENEYE_PLUGINS`, REPL commands | +| `close` | — | session | stop serving; standalone also closes the session | + +Capability classification follows the manifest in +[15-AGENTIC-GSP-IDE](15-AGENTIC-GSP-IDE.md) §5: read / observe are +unattended-safe; write and inject require the explicit startup flags and a +recorded session. `bp_set`, `watch_set`, `run` and `step` mutate target +*observation state* only and stay in the observe class. + +Harness and probe tools shell out to the existing CLIs with bounded budgets +and return their stdout/stderr; they never touch hardware, a guest or a +kernel module. + +## 6. Example agent sessions + +### 6.1 Monitor an automated firmware-release run + +```json +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"session_info","arguments":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"symbols_resolve","arguments":{"target":"rpc_ping"}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"bp_set","arguments":{"target":"rpc_ping"}}} +{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"run","arguments":{"duration":0.5,"max_stops":100}}} +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"analyze_current","arguments":{"max_rows":20}}} +``` + +The agent watches `bp_hit`/`watch_hit` counts in the report, snapshots the +control block, runs the release stimulus, then diffs the new recording +against the golden one: + +```json +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"snapshot","arguments":{"addr":"gsp_ctrl","size":392,"label":"release"}}} +{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"compare_sessions","arguments":{"a":"dumps/golden","b":"dumps/release","symbols":true}}} +``` + +### 6.2 Dump memory + +```json +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"hexdump","arguments":{"addr":"0x80000840","size":128}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_mem","arguments":{"addr":"0x80000840","size":16}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"snapshot","arguments":{"addr":"0x80000000","size":4096,"label":"ram-window"}}} +``` + +### 6.3 Inject a service + +Start the server with `--allow-write --allow-inject --record`, then: + +```json +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"inject_service","arguments":{"service":"sdk/build/injected_demo.elf","slot":6,"cmd":1,"arg0":65,"arg1":66,"path":"both"}}} +``` + +The result contains the load address/entry and both the direct (`remote`) and +firmware-mailbox (`firmware`) return values; every segment write and call is +recorded, and the session can be compared like any other run. + +## 7. Security rules + +1. **Local only.** Default transport is stdin/stdout. `--tcp` binds loopback + addresses only (`127.0.0.1`, `localhost`, `::1`); any other host is + refused at argument parse time. There is no auth, so never expose it. +2. **No guest, no hardware.** The server drives the simulator/QEMU session + only. It does not touch `/dev`, a kernel module, an mdev, a guest or a + physical GPU; the agent runs against the simulator by default + ([H7](../GREEN-EYE-ROADMAP.md), [15-AGENTIC-GSP-IDE](15-AGENTIC-GSP-IDE.md) §5). +3. **Read-only by default.** `--allow-write` enables `write_mem` and + `call_function`; `--allow-inject` enables `inject_service`. The agent's + client config decides which manifest class it gets; a read/observe agent is + configured without those flags. +4. **Sessions are the audit trail.** Writes, calls, injections and snapshots + land in `events.jsonl`; run with `--record` (the trace tools require it). + A finding without a session is not a finding. +5. **Budgets everywhere.** `run` defaults to 1 s, `step` is capped, reads and + snapshots have size caps, and harness subprocesses have timeouts. A wedged + target is left stopped and restarted clean, never poked. +6. **Protocol hygiene.** stdout is reserved for JSON-RPC; diagnostics go to + stderr. One bad tool call returns `isError` and the server keeps serving. +7. **No product changes.** MCP can propose evidence, not merge emulation + changes; human approval still gates `open-iov-vfio`. + +## 8. Absent capabilities + +`probe_firmware`, `mutation_run`, `portability_matrix_run`, `trace_*`, +`inject_service`, `analysis` and the Ghidra export are checked at call time. +If a module/file is missing the tool returns: + +```json +{"content":[{"type":"text","text":"{\"error\": \"not_available\", \"message\": \"mutation harness not present: ...\"}"}],"isError": true} +``` + +so an agent can plan around a capability instead of seeing the server crash. +The tool list is stable regardless of what is present. + +## 9. Testing + +```bash +python3 -m unittest tests.test_mcp -v # 34 tests, no external deps +``` + +The suite drives `initialize`/`tools/list`/`tools/call` over in-memory +streams (including unknown method/tool, parse errors, write/inject gating, +no-stdout-pollution and structured `not_available`), plus a standalone +subprocess test on the native backend that skips cleanly when the native +simulator is absent. + +## 10. Roadmap + +* **R3 (done)** — MCP plugin exposing sessions as agent tools; local stdio, + no network by default; capability gating. +* **H7 remaining** — named harness entry points and budgets, a session index + for agents, enforced capability manifest files, and the physical-escalation + gate. Those build on this server, not around it. diff --git a/debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md b/debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md new file mode 100644 index 0000000..d687cca --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md @@ -0,0 +1,320 @@ +# 21 — CI and release pipeline (version diff + evidence bundle) + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. + +## 1. Purpose + +This is the release-triage path for a new GSP firmware image (roadmap R6 / +H4.1, docs/23 C5/C7). Two stdlib-only tools turn the manual docs/07 P1/P2 +playbook into one repeatable run and one attachable bundle: + +| Tool | What it does | +|---|---| +| `harness/version_diff.py` | old vs new target, section by section: image/shape, symbol inventory, decompiled corpus hints, bounded QEMU probe, shared-scenario behavior, M1–M6 skeleton | +| `harness/ci_release.py` | runs version_diff → portability matrix → mutation smoke and hashes every artifact into `bundle-manifest.json` | + +`.github/workflows/greeneye.yml` runs the tool's test suite on pushes/PRs that +touch `debug/risc-v-gsp-rm-vm/**` or `tools/rm*/**`. The repository-level +`.github/workflows/ci.yml` is independent and unchanged. + +Everything here is local: the tools never download firmware, never attach to +hardware, a guest or a kernel module, and use their own QEMU processes. + +--- + +## 2. Version diff + +### 2.1 Usage + +Self-diff of the demo (a determinism/regression check; must be all-zero): + +```bash +python3 harness/version_diff.py \ + --old firmware/uproc_demo/build/uproc_demo.elf \ + --new firmware/uproc_demo/build/uproc_demo.elf \ + --out dumps/vdiff-selfcheck \ + --probe-instructions 200 --behavior-duration 0.2 +``` + +Old vs new firmware with per-version symbol DBs and Ghidra exports: + +```bash +python3 harness/version_diff.py \ + --old firmware/57X-GSP/570.144/gsp-570.144.fwimage.bin \ + --new firmware/57X-GSP/570.211/gsp-570.211.fwimage.bin \ + --old-label 570.144 --new-label 570.211 \ + --old-ghidra-exports analysis/570.144/symbols.tsv \ + --new-ghidra-exports analysis/570.211/symbols.tsv \ + --corpus-diff analysis/570.144/decompiled_index.tsv analysis/570.211/decompiled_index.tsv \ + --out dumps/vdiff-570 +``` + +Attaching a fixed stimulus and a pre-produced corpus report: + +```bash +python3 harness/version_diff.py --old --new \ + --stimulus examples/scripts/trace_rpc.py \ + --corpus-report run/corpus-delta-570.144-570.211.txt \ + --out dumps/vdiff-rehosted +``` + +Targets are RISC-V ELF files or raw images (`.fwimage`). A raw target is +relocated to `0x80000000` as one implicit load; its firmware-vaddr translation +is `runtime - 0x80000000`. Symbols may be absent for raw images; the symbol +section then reports `skipped` with a reason unless Ghidra/JSON sources are +supplied. + +Flags: `--old/--new` (required), `--out` (required), `--old-label/--new-label/ +--release`, `--old-symbols/--new-symbols` (green-eye JSON), `--old-ghidra-names`, +`--new-ghidra-names`, `--old-ghidra-index`, `--new-ghidra-index`, +`--old-ghidra-exports`, `--new-ghidra-exports` (`--old-ghidra-bias`/ +`--new-ghidra-bias`), `--corpus-diff OLD NEW`, `--corpus-report FILE`, +`--stimulus`, `--probe-instructions` (`0` = `--probe-duration` wall clock), +`--behavior-duration`, `--max-stops`, `--max-rows`, `--approval-token`. + +### 2.2 Sections + +Every section reports `status: ok | skipped | error`, and a skipped section +never aborts the others. A failing QEMU probe or scenario is recorded as +`error` for that section only. + +1. **Image / shape.** Entry, span, file size/hash, PT_LOAD layout (vaddr, + filesz/memsz, flags), and the ELF section table (name/type/addr/size). + Raw images have no section table; that is stated, not guessed. +2. **Symbol inventory.** ELF `.symtab` plus Ghidra `names.tsv`, + `decompiled_index.tsv`/bucket TSVs, 4-column `exports.tsv` and the + 3-column `ExportForGreenEye` form (`addrnamesize`), plus + green-eye `greeneye-symbols/1` JSON DBs. Matching follows + `userspace/cuda/tools/diff_corpus.py`: named functions match by name + (duplicates in address order), the address shift is the median of named + deltas, and `FUN_*` functions match at the shifted address. The report + lists added/removed/moved/resized (named and anonymous), the shift and its + dominance, match rates and the named-transfer rate. Merged per-side DBs are + saved under `/symbols/`. +3. **Decompiled-change hints.** `--corpus-diff A B` runs the same matching + discipline over two corpus indexes and writes `/corpus/corpus-diff.json`; + `--corpus-report FILE` attaches a previously produced report (hash + + preview). With neither flag the section is `skipped` with reason + `not supplied: ...`. +4. **Probe first-touch.** Both targets are flattened, relocated to + `0x80000000`, stepped for the bounded budget, and their QEMU logs parsed + with `greeneye.analyze.parse_qemu_faults`. The first fault is reported with + cause/desc, `epc` **and** `epc_firmware`, the access address **and** + `access_firmware` (translated only when the access address falls inside the + image). The diff says whether the stopped PC/first fault changed. +5. **Behavior.** The shared demo scenario from `portability_matrix` (hooks on + every resolvable `rpc_*`, a write watchpoint on `gsp_ctrl.rpc_count`, the + self-test, one deterministic PING) or a `--stimulus` script is run on both + targets and recorded as normal `greeneye-session/1` directories. The report + includes `analyze.compare_sessions` output under `/compare/`, explicit + hit/watch/written-page deltas, `all_zero`, and the docs/06 M1–M6 skeleton + with every agreement metric marked `not measured` (a reference capture is + required, docs/06 §5). + +### 2.3 Output + +```text +/ + version-diff.json stable machine-readable report + version-diff.md rendered report + symbols/.json merged symbol DB per side (when non-empty) + sessions/-probe/ recorded probe session + sessions/-behavior/ recorded scenario session + compare/--.md compare_sessions report + corpus/corpus-diff.json when --corpus-diff is used +``` + +Exit status: `0` for a normal report (skips included), `2` for a tool error, +`3` for an RM 610 gate refusal. + +--- + +## 3. RM 610 gate (hard, testable) + +The gate is enforced by `version_diff` and `ci_release` **before any target +file is opened** and before `--out` is created: + +* a target/release label matching `rm610` / `RM-610` / `rm_610` etc. (the + `--release`, `--old-label`, `--new-label` values and the target basenames) + is refused unless + * `--approval-token RM610-APPROVED` is passed, or + * `GREENEYE_RM610_APPROVED=1` is set; +* the refusal text points at [`docs/23-RM610-READINESS.md`](23-RM610-READINESS.md) + and states that nothing was downloaded and no target file was opened; +* the same check runs in `--dry-run` mode. + +Nothing in either tool performs network access, and `--dry-run` never touches +a target. Current-version rehearsal runs use `570.144`-labelled inputs; 610 +inputs require the §6 approval message in docs/23. + +--- + +## 4. Release pipeline + +### 4.1 Dry run first + +```bash +python3 harness/ci_release.py --old --new --out dumps/release-570 \ + --old-label 570.144 --new-label 570.211 --dry-run +``` + +`--dry-run` prints a deterministic JSON plan (the three step commands and the +artifact paths) and exits `0` without creating `--out` or opening a target. +This is the docs/23 §4 step 6 rehearsal. + +### 4.2 Real run + +```bash +python3 harness/ci_release.py --old --new --out dumps/release-570 \ + --old-label 570.144 --new-label 570.211 \ + --old-symbols symbols-570.144.json --new-symbols symbols-570.211.json \ + --stimulus examples/scripts/trace_rpc.py \ + --mutation-kernel firmware/uproc_demo/build/uproc_demo.elf \ + --mutation-iterations 8 --mutation-seed 7 +``` + +Steps, in order: + +1. **version_diff** — writes `/version-diff/` (section flags above); +2. **portability_matrix** — old and new as two targets, same budgets, writes + `/portability-matrix/portability-report.json|.md` and per-pair compare + reports; the self-comparison must show `all_zero`; +3. **mutation_smoke** — `mutation_harness` on the demo (or `--mutation-kernel`, + with `--allow-foreign-mutation` for a re-hosted ELF) with a small budget; + writes `/mutation-smoke/findings.jsonl|summary.md`. + +A step that raises is recorded as `status: error` and the later steps still +run; the process exits `1` when any step errored, `0` otherwise (`skipped` +steps do not fail the run, but their reasons are in the report). Exit `3` is +the RM 610 refusal. + +`ci_release` flags: the `version_diff` inputs above plus `--matrix-symbols` +(one JSON DB applied to both matrix targets), `--mutation-kernel` (default: +the bundled demo), `--mutation-iterations`/`--mutation-seed`/ +`--mutation-timeout`/`--mutation-settle`, and `--allow-foreign-mutation` for a +non-demo re-hosted ELF. + +### 4.3 Evidence bundle format + +```text +/ + release-report.json pipeline summary (steps, gate, labels) + bundle-manifest.json sha256 + byte size of every other file + version-diff/ docs/21 §2.3 tree + portability-matrix/ portability-report.json|.md, symbols/, sessions/, compare/ + mutation-smoke/ findings.jsonl, summary.md, sessions/ +``` + +`bundle-manifest.json`: + +```json +{ + "format": "greeneye-release-bundle/1", + "tool": "harness/ci_release.py", + "files": [{"path": "version-diff/version-diff.json", "bytes": 12345, "sha256": "..."}] +} +``` + +`release-report.json` shape: + +```json +{ + "format": "greeneye-ci-release/1", + "dry_run": false, + "rm610_gate": {"status": "passed", "labels": ["570.144", "570.211"]}, + "old": {"label": "570.144", "path": "gsp-570.144.fwimage.bin"}, + "new": {"label": "570.211", "path": "gsp-570.211.fwimage.bin"}, + "steps": [{"name": "version_diff", "status": "ok", + "sections": {"image": "ok", "symbols": "ok", "corpus": "ok", + "probe": "ok", "behavior": "ok"}}], + "errors": [], + "manifest": "bundle-manifest.json" +} +``` + +The manifest is written last, so its hashes cover the final release report. +Attach the directory (or its manifest) to the docs/07 evidence checklist and +the docs/14 §6 pack; the docs/23 §4 step 7 expected-artifact list maps 1:1. + +--- + +## 5. CI workflow + +`.github/workflows/greeneye.yml` (new; `ci.yml` is untouched): + +* triggers on push/PR paths `debug/risc-v-gsp-rm-vm/**` and `tools/rm*/**`; +* `ubuntu-latest`, no secrets, no hardware; +* installs `qemu-system-misc clang lld-18`; +* builds `make -C debug/risc-v-gsp-rm-vm sdk demo`; +* runs `python3 -m unittest discover -s tests -t .` from the tool root (QEMU + integration tests run; if QEMU were absent they skip cleanly). + +The intended release gates this workflow covers are the fast inventory smoke +from docs/19 §6 (`control_discovery --max-id 32 --patterns zero`), the harness +suites, and the docs/21 dry-run. A full `ci_release` run against real firmware +is driven manually/agentically because it needs the versioned inputs and +symbol DBs; CI-adjacent automation should call it with `--dry-run` first. + +--- + +## 6. MCP monitoring + +`plugins/mcp_server.py` (docs/20) exposes the same machinery to an agent: +`probe_firmware`, `portability_matrix_run`, `mutation_run`, `compare_sessions` +and `analyze_current`. The agent pattern for a release: + +1. `portability_matrix_run` with the old/new targets and fixed budgets; +2. `mutation_run` on the demo with the pinned seed and budget; +3. `compare_sessions` for the recorded evidence pair; +4. read `version-diff.json` / `release-report.json` / `bundle-manifest.json` + from the run directory (agent reads the bundle, it does not recompute it). + +The MCP server remains read/observe by default and simulator-only (docs/20 §7). + +--- + +## 7. Determinism, limits and safety + +* Fixed probe budgets, fixed scenario phases and the same stimulus make the + self-diff zero: an unchanged target reports zero symbol/probe/behavior + deltas (verified by `tests/test_version_diff.py`). +* Agreement metrics M1–M6 are **not measured** without a reference capture + (docs/06 §5); the skeleton carries local `observed` facts only. L2/L3 claims + (timing, interrupts, device model) are out of scope. +* Raw `.fwimage` targets execute as flat blobs; a firmware linked differently + may fault earlier than on silicon — that is first-touch data, not a boot + (docs/06 §3). +* Only temporary work directories and `/` are written; QEMU is always + launched and killed by the tools; log reads are bounded + (`--log-bytes`). +* No downloads, no `/dev`, no kernel module, no existing gdbstub, no physical + GPU. The 610 gate is part of both CLIs, not documentation-only. + +--- + +## 8. Tests + +```bash +python3 -m unittest tests.test_version_diff -v +make test # includes these with the rest of the suite +``` + +`tests/test_version_diff.py` covers the gate (default refusal, token/env +approval, gate-before-open), the `diff_corpus.py` matching discipline +(moved/resized/added/removed, anonymous shift), the 3-column export parser, +missing/incompatible/raw target handling, corpus hints, `ci_release --dry-run` +side-effect freedom, clean `--help`, and a demo self-diff asserting zero +inventory and behavior deltas. QEMU integration tests skip cleanly when QEMU +or the demo ELF is absent. + +--- + +## 9. Roadmap mapping + +| Item | Where this doc/tool contributes | +|---|---| +| **R6 — multi-version orchestration** | `version_diff` + `ci_release` prefix the matrix with a version-scoped diff | +| **H4.1 — semantic diffing** | symbol/corpus inventory deltas and behavior profiles for two firmware versions | +| **V1 — metrics/golden data** | deterministic self-diff and stable JSON reports support golden comparisons | +| **docs/23 C5/C7** | release pipeline implemented and exercisable; dry-run rehearsal path | +| **H7 — agentic harness** | self-describing bundle + docs/20 MCP tools for monitored runs | diff --git a/debug/risc-v-gsp-rm-vm/docs/22-PORTING-CUDA-AND-DEVICES.md b/debug/risc-v-gsp-rm-vm/docs/22-PORTING-CUDA-AND-DEVICES.md new file mode 100644 index 0000000..30f4720 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/22-PORTING-CUDA-AND-DEVICES.md @@ -0,0 +1,353 @@ +# 22 — Porting with green-eye: CUDA userspace, GSP firmware and new GPU devices + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00-OVERVIEW for status. +> This is the combined porting guide for the three churn axes the project +> faces. It joins the existing CUDA/GSP/firmware procedures with green-eye's +> runtime layer. Items marked "planned (docs/xx)" do not exist yet; do not +> invent commands for them. + +## 1. The three churn axes and the anchor discipline + +NVIDIA ships three things on independent schedules, and any one of them can +break `open-iov-vfio` while the other two stay still: + +| Axis | Examples | Primary evidence path | +|---|---|---| +| **CUDA userspace version** (`libcuda`) | 570.144 → 570.211.01; future 580/610 families | CUDA corpus tooling + guest smoke (`userspace/cuda/docs/07`) | +| **GSP firmware / RM version** | 570.144 → 570.211 GSP image; RM dispatcher and control-set changes | `greeneye probe` + `compare`, host oracle (`docs/05-operations/02` §4.1) | +| **GPU device / architecture** | TU104 → GB202 (RTX 5090), GB10 (Grace-Blackwell), aarch64 hosts | Device profile + pairing check + portability matrix (docs/07 P2/P3, docs/18) | + +They interact: + +* a userspace bump usually implies an emulation-anchor decision (guest driver + and emulated firmware move together) even when the firmware binary is + byte-identical; +* a device change can drag a different firmware family (TU10x vs GB100 vs + `gb10`) and a different partition/boot model (FMC/MP-SK; Init → ACR → RM for + confidential compute) with it; +* an architecture change (aarch64 host, newer NV-RISCV core) can invalidate + environment assumptions that the other two axes assume are stable + (PMU/SOE provisioning, coherent memory, ISA extensions). + +**Anchor discipline.** The lock-step that must stay coherent is the +**guest driver ↔ emulated firmware profile** (the GSP generation whose protocol +the host emulates). The host's distribution driver is **not** part of that +lock-step: it is the oracle used for A/B comparisons, and the optional OpenRM +host modules are only for native vGPU-management workflows. The current anchor +is **570.144**; a version bump moves the guest driver and the emulation, not +the host distro driver (see `docs/05-operations/02-debugging-and-maintenance-workflows.md` +§4.1). Porting rules that follow: + +1. One anchor per port; never evaluate a new `libcuda` against an old emulated + firmware profile and call a mismatch an emulation bug. +2. Every input is versioned and hashed: `firmware/-GSP//`, + `userspace/cuda//`, `config/versions/.yaml`. The `config/` + YAML files are **design targets, not live configuration** until the module + load path reads them (`docs/06-portability/hardware-software-matrix.md` §6). +3. New versions live in parallel directories; the previous anchor stays + untouched so rollback never destroys a working setup. +4. A port is a claim with evidence attached (M1–M6, §4), not a merged patch. + +## 2. Joint workflow A — CUDA userspace version port (`libcuda` bump) + +### 2.1 Decide what kind of port it is (minutes) + +Run the cheap triage before any decompilation or emulation work: + +```bash +cd userspace/cuda +tools/cudatool.py info /run/libcuda.so. +tools/cudatool.py delta /run/libcuda.so. /run/libcuda.so. > /run/delta.txt +``` + +| `delta` reading | Port class | What it implies | +|---|---|---| +| no export moves, small `.text` growth | patch | transfer the name map and spot-check touched subsystems | +| new/removed exports | feature | decode the new API surface before touching the emulation | +| many moves | reshuffle (toolchain/linker) | plan a full re-analysis; a fraction of names will not transfer | + +### 2.2 Static analysis vs dynamic confirmation + +| Step | Existing project tooling | green-eye addition | +|---|---|---| +| Export/interface diff | `tools/cudatool.py delta` | — | +| Function matching / name-transfer measurement | `tools/cudatool.py fdematch --names ` | — | +| Corpus-level diff (added/removed/resized functions) | `tools/diff_corpus.py ` | — | +| Name transfer + corpus annotation | `ApplyNameMap.java` (`--dry-run` first), `tools/annotate_corpus.py` | `./greeneye.sh symbols export` builds a runtime-address symbol DB from the same index/names | +| Driver-API smoke | `tools/test/cuda_drv_test.c` compiled as `/tmp/cdt` (prints `ALL OK`) | `./greeneye.sh symbols resolve --db ... ` spot-checks transferred addresses per call path | +| Runtime workload | `tools/cuda-ab/ab.sh all` (`vector_add.cu`; `RESULT`/`STATUS` must match host) | session-format evidence and `./greeneye.sh compare` when firmware-side sessions exist | +| Guest-side control observation | `tools/guest-diag/ioctl_trace.c`, host module trace (`gsp-resman-vfio:`) | annotation of the firmware-side RPC path with the per-version symbol DB | +| Firmware-side confirmation | — | `./greeneye.sh probe` / `record` / `compare` against the paired firmware | + +**Be honest about the execution boundary.** `cdt` and `vectorAdd` are x86_64 +guest binaries; the RISC-V simulator does not execute them. green-eye's +contribution to a userspace port is the **symbol/evidence layer**: a +runtime-address DB per `libcuda` version, per-address resolution checks for +transferred names, and a recorded comparison format for the GSP-side behaviour +the driver drives. The print-and-verify path (`cdt`, `ab.sh`, ioctl trace) +remains the dynamic oracle. + +### 2.3 Step-by-step runbook + +1. **Acquire and version.** Fetch the new `libcuda.so.` + (`570.144/scripts/fetch_from_guest.sh`, or the driver package with + `--extract-only`). Work in a new `userspace/cuda//` tree copied from + `570.144/`; never overwrite an analysed version. +2. **Cheap triage.** `cudatool.py info` + `delta`; classify the port (§2.1); + record `.text` growth, build-id and ABI note. +3. **Measure transfer before copying.** Run `cudatool.py fdematch` with the + old `names.tsv`. The known result from 570.144 → 570.211.01: all 659 + exports transfer exactly, verified internals 5/7, wrapper-callee internals + 21/29, but only **~1% of generated `wrapper_*` addresses** transfer. Never + move wrapper names by address arithmetic; re-resolve them with + `cudatool.py wrappers` and re-run `annotate_corpus.py`. +4. **Full analysis when needed.** For a feature/reshuffle port run + `ghidra_full_analysis.sh`, `ghidra_export_index.sh`, + `ghidra_batch_decompile.sh`, `ghidra_index_merge.sh` per + `userspace/cuda/docs/07-version-upgrade-playbook.md` §2, then + `diff_corpus.py` for the measured corpus delta. +5. **Transfer names and knowledge.** Apply the map with `ApplyNameMap.java` + `--dry-run` first, then for real; translate the subsystem index; re-validate + entries the delta marked as moved. +6. **Build the green-eye symbol DB** for the new version so runtime addresses + resolve: + + ```bash + cd debug/risc-v-gsp-rm-vm + ./greeneye.sh symbols export \ + --names ../../userspace/cuda//annotations/names.tsv \ + --index ../../userspace/cuda//run/analysis/decompiled/decompiled_index.tsv \ + --callgraph ../../userspace/cuda//run/analysis/elf-index/callgraph.tsv \ + --bias 0x100000 -o symbols-.json + ./greeneye.sh symbols resolve --db symbols-.json 0x + ``` + + `--bias 0x100000` is the Ghidra image base convention for `libcuda` + (docs/02 §6); use `0` for firmware linked at its load address. +7. **Dynamic confirmation on the emulated stack.** Compile and run `cdt` + (`tools/test/cuda_drv_test.c`), with the PTRACE ioctl tracer attached, on + the new userspace version against the **same** emulated firmware profile: + every `CHECK(...)` line and the final `ALL OK` is a per-entry-point + assertion (`cuInit`, `cuDeviceGetCount`, `cuDeviceGet`, `cuCtxCreate`, + `cuMemAlloc`, `cuMemcpyHtoD`, `cuMemcpyDtoH`, `cuMemFree`, `cuCtxDestroy`). + Then run the runtime workload end-to-end: + + ```bash + tools/cuda-ab/ab.sh all # host oracle + guest run + compare + ``` + + `DEVICE` lines are normalised; `RESULT`/`STATUS` must match exactly. Any + new or reordered control in the ioctl trace is the interesting delta. +8. **Compare across versions.** Diff `delta.txt`, the `fdematch` report, the + corpus diff, and the ioctl/control sequences old vs new. Where the firmware + side is recorded, use the session compare primitive: + + ```bash + ./greeneye.sh compare dumps/ dumps/ --symbols + ``` + + For multi-target orchestration use the portability matrix (docs/18); note + it targets firmware ELFs, not `libcuda`. +9. **Only then touch the emulation** — and only if the behaviour diff requires + it, with the docs/07 P4 escalation rule: any change that would change + emulation behaviour is reproduced against the physical/vfio-mdev reference + first. +10. **Update documents**: the per-version decompilation map, `names.tsv` / + `subsystems.tsv`, the version-upgrade playbook notes, and the interface + doc only if controls/capability bits changed. + +### 2.4 Acceptance evidence (CUDA userspace port) + +* [ ] `delta` report (exports added/removed/moved, `.text` growth, build-id); +* [ ] `fdematch` transfer table with counts per source class; +* [ ] wrapper re-resolution evidence; no wrapper transferred by address; +* [ ] `ApplyNameMap.java --dry-run` then real run; corpus diff report; +* [ ] `symbols-.json` with provenance/bias and spot-check resolutions; +* [ ] `cdt` output ending `ALL OK`, with the ioctl trace attached; +* [ ] `ab.sh` host/guest comparison with `RESULT`/`STATUS` matched; +* [ ] ioctl/control diff old vs new explained line by line; +* [ ] firmware anchor unchanged (or explicitly changed in the same PR); +* [ ] M1–M6 table when firmware-side behaviour moved (§4); +* [ ] docs updated; unexplained-delta list empty (empty is a claim). + +## 3. Joint workflow B — new GPU device (RTX 5090 / GB10-class) + +### 3.1 Device profile collection + +| Field | Where it comes from | Artifact | +|---|---|---| +| PCI IDs | `lspci`, vendor docs, `config/hardware/*.yaml` (design target) | device profile | +| BAR layout / sizes | guest `lspci -vvv`, host sysfs | device profile | +| FB geometry (VRAM, BAR1) | host oracle `rmtool mem stats --format=json` | capture in session | +| Engine inventory | host oracle `rmtool engine list --format=json`, guest `guest_engines.c` | inventory table | +| Interrupt topology | MSI-X tables, guest `lspci -vvv` | profile notes | +| ROM/VBIOS quirks | ROM read, laptop/Surface variants | profile overrides | +| Probe-time register values | guest driver init trace (`gsp-resman-vfio:`) | expected-value list | + +The host oracle (`rmharness`, docs/17) is read-only and safe alongside a live +CUDA job; capture it into a session so the numbers carry SHA-256 provenance: + +```bash +cd debug/risc-v-gsp-rm-vm +./rmharness/harness.sh --session dumps/real- --name before \ + -- rmtool engine list --format=json +./rmharness/harness.sh --session dumps/real- -- rmtool mem stats --format=json +``` + +### 3.2 Firmware pairing first + +Verify the image family (TU10x / GH100 / LS10 / `gb10`), the FMC partition +manifest and the version string **before** debugging anything. A mismatched +pairing presents as an emulation bug — the project learned this with 570.144 +vs 570.211. Record firmware hash, family, version string and pairing notes at +the top of the evidence pack. + +### 3.3 Probe first-touch diff and symbol inventory + +```bash +./greeneye.sh symbols export --index --bias 0 -o symbols-.json +./greeneye.sh probe --symbols symbols-.json --record +./greeneye.sh probe --symbols symbols-.json --record +``` + +The probe loads the segments, runs bounded and reports the first divergence +(faulting instruction, access address/size, stopped PC annotated from the +symbol DB). Raw `.fwimage` targets fault early by design — that first-touch +map is the data, not a failure. Changed first-touch addresses across devices +usually point at profile data (register windows, boot args), not logic +(docs/07 P2.2). The symbol inventory is the merged ELF/Ghidra DB per target; +provenance (export file, bias) is recorded in the DB meta. + +### 3.4 Device-model implications (H2/H5) + +What the probe reveals feeds the roadmap, not the product directly: + +* **H2 (platform device model)** — falcon/SEC2 windows, CSB/TRANSCOM mailboxes, + WPR2/FWSEC read-back state, PTIMER, PMU stub, DMA/IOMMU. A device that + touches a different first register set changes the modelled register map + before the image can reach its message loop. +* **H5 (adjacent microcontrollers)** — PMU/SEC2/ACR/SOE mailbox protocols and + boot ordering; a new device family may change identity/clock provisioning or + the Init → ACR → RM boot sequence. +* Entry gate for device-model work is the portability task itself (docs/08 R1); + until then use probe + rehosted uproc paths. + +### 3.5 Folding into the hardware-software matrix + +The project's support ladder is `docs/06-portability/hardware-software-matrix.md` +(Reference / Supported / Experimental / Target / Out of scope). A device is +"tested and documented", not "should work". The fold-in: + +1. Run `harness/portability_matrix.py` across the reference and candidate + device firmware with the same scenario options (docs/18 §1); keep + `/portability-report.json`, per-pair compare reports and sessions. +2. The planned version-diff/CI pipeline (docs/21-CI-RELEASE-PIPELINE.md, + planned) is where those reports become release-gated artifacts; until it + exists, attach them to the matrix process manually. +3. Update the device profile (documentation today; `config/hardware/*.yaml` is + not read by code — do not claim configuration-driven portability). +4. Validate on the physical device with the supervised harness before claiming + support; simulator-only evidence never justifies the claim (docs/06 §6). + +### 3.6 aarch64 host / GB10-class design notes + +* **Host tooling** — green-eye is Python + QEMU; on aarch64 install + `qemu-system-misc` (provides `qemu-system-riscv64`) and the same commands + run. Keep host-specific paths out of session metadata (docs/07 P3.5). + `GREENEYE_QEMU` is the existing escape hatch if the binary lives elsewhere. +* **Firmware family** — GB10-class devices pair a different firmware family + (`gb10`/GB100-era) than TU104's `57X-GSP`; treat the fwimage, FMC manifest + and PMU/SOE images as a new family, not a re-badged TU10x image. +* **Boot/partition model** — expect the newer FMC/manifest partition model and, + for confidential-compute paths, the Init → ACR → RM flow; the rehost shim + needs the matching entry/return convention (docs/09 §4, docs/07 P3). +* **PMU/SOE deltas** — identity and clock provisioning flow through PMU/SOE; + any change there invalidates prior environment assumptions and must be + recorded before probe results are interpreted. +* **ISA/core** — confirm the image is still RV64IMAC or has moved to RV64GC + (`file`/`readelf`); update rehost `-march` flags accordingly. QEMU supports + RV64GC even where the bundled demo uses IMAC. +* **Memory model** — Grace/GB10 coherence and page-size/cache-attribute + differences are memory-model risks (hardware-software-matrix §8); test on + aarch64 early rather than assuming x86 results transfer. + +## 4. Evidence and metrics contract for any port + +The metric contract is `docs/06-ACCURACY.md` §5; it is authoritative for every +port type: + +| # | Metric | How it is measured in a port | +|---|---|---| +| M1 | Inventory coverage | observed controls/RPCs vs emulated handled set; host oracle inventory | +| M2 | Reply agreement | field-by-field reply diff, canonical-size and consumed-field checks | +| M3 | Ordering agreement | sequence alignment of the control/event stream (LCS/edit distance) | +| M4 | State agreement | firmware state transitions vs the emulation state model | +| M5 | Error-path agreement | injected faults take the same status/notifier path | +| M6 | Memory side-effects | read/write map vs shadow state; snapshot hashes at fixed points | +| M7 | Timing | **not claimed**; order-of-events budgets only | + +Rules: at least an A/B pair per conclusion; every claim carries its fidelity +level (L0–L3) and provenance; a simulator observation is evidence about the +firmware, not silicon; any product change is confirmed against a physical +capture first. The evidence pack is docs/07's checklist plus docs/14 §6. + +**Checklist per port type** + +CUDA userspace (A): +* [ ] §2.4 items complete; firmware anchor documented. + +GSP firmware version (P1): +* [ ] firmware hash + version string + pairing notes; +* [ ] symbol DB hash and Ghidra export provenance; +* [ ] probe first-touch old vs new, every changed address explained; +* [ ] portability matrix report + per-pair `compare`; +* [ ] M1–M6 table with reference-capture method and L2/L3 limits; +* [ ] existing guest ladder has no new regression; +* [ ] every new/changed control has a version-keyed handler or explicit + "not used" note. + +Device (P2): +* [ ] profile fields (§3.1) recorded with host-oracle captures; +* [ ] pairing verified before any debugging; +* [ ] probe first-touch diff explained as profile vs logic; +* [ ] engine inventory complete and validated against the host oracle; +* [ ] matrix entry and evidence published; physical validation done before the + support claim. + +Architecture (P3): +* [ ] ISA/image shape confirmed (`file`/`readelf`), rehost flags updated; +* [ ] partition/boot model identified; shim convention matched; +* [ ] PMU/SOE deltas recorded; host portability verified on aarch64; +* [ ] all P2 items. + +## 5. Common failure modes and decided responses + +| Failure mode | Signature | Decided response | Source | +|---|---|---|---| +| Mismatched guest driver ↔ firmware pairing | failures indistinguishable from emulation bugs (e.g. 570.144 image vs 570.211 driver) | verify hash/version string/pairing before debugging; fix the pairing, not the emulation | docs/07 P2.2; docs/05-operations §4.1 | +| Runfile/metapackage version drift | a different major resolves than requested | check the candidate version; prefer the exact-version runfile | docs/05-operations §4.1 pitfalls | +| Silent control-shape change | message layout changes break the emulator with no error | M2 canonical-size + consumed-field checks; per-control probes; decode against the exact-version headers | docs/05-operations §4.1 table | +| Engine-inventory change blocking context creation | `cuCtxCreate` fails because an expected engine (e.g. `COPY14`) is absent | complete the inventory per device/driver profile, validate against `rmtool engine list`; treat inventory as profile data | hardware-software-matrix §6.2 | +| Wrapper re-layout on a patch bump | wrapper names resolve to wrong addresses | transfer exports/internals only; re-resolve wrappers with `cudatool.py wrappers`; never add a delta to old wrapper addresses | userspace/cuda/docs/07 §3 | +| Firmware address drift per build | stale Ghidra addresses produce nonsense | re-derive addresses per firmware build; never carry addresses into a new configuration | docs/05-operations §4.1 pitfalls | +| Host driver assumed to be in lock-step | A/B oracle "updated" and treated as the target | host distro driver is the oracle only; the anchor is guest driver ↔ emulated firmware | docs/05-operations §4.1 | +| Simulator-only divergence | `compare` delta that physical capture cannot reproduce | record as a fidelity gap; do not change the product from it | docs/06 §6; docs/07 P4.5 | +| QEMU missing on an aarch64 host | `qemu-system-riscv64 not found` | install `qemu-system-misc` or set `GREENEYE_QEMU` | docs/02 §9; docs/07 P3.5 | + +## 6. Roadmap hooks + +| Port activity | Roadmap item | What it adds | Gate | +|---|---|---|---| +| First-touch map and boot of real firmware | H2 / R1 | device model so ports boot beyond the first unmapped access | portability/accuracy task requires it | +| Semantic diff of dispatch/handler/control sets | H4.1 | automated portability delta report; per-version symbol corpora | H1+H2 | +| Capability inference across new devices | H4.2 | access map vs capability model; least-privilege findings | watchpoints (done); TCG-session fold (R4) | +| Mutation corpus for new input surfaces | H4.3 / R5 | fail-closed rules validated per version | R1/R2 | +| Differential execution and reimplementation loop | H4.5 | every port lands with A/B sessions and the M1–M6 table | reference-capture pipeline | +| Adjacent-controller work for GB10-class bring-up | H5 | PMU/SEC2/ACR/SOE models, cross-controller sessions | H2.2 | +| Multi-target orchestration | R6 / docs/18 | one report across firmware/device targets | v0.1 stable | +| Version diff and release gating | planned (docs/21) | CI pipeline that gates a port on the evidence bundle | pipeline implemented | +| Control discovery | planned (docs/19) | systematic control/class inventory across versions | document exists | + +Program-level success criterion served here: a new GSP version (or device) can +be triaged — inventory diff + behaviour diff + metric table — in **hours**, +with recorded evidence. diff --git a/debug/risc-v-gsp-rm-vm/docs/23-RM610-READINESS.md b/debug/risc-v-gsp-rm-vm/docs/23-RM610-READINESS.md new file mode 100644 index 0000000..18d93bd --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/23-RM610-READINESS.md @@ -0,0 +1,259 @@ +# 23 — RM 610 port readiness and approval gate + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00-OVERVIEW for status. +> This document defines the gate, not a claim: readiness is **not** met today +> (2026-10-02). It exists so no one starts the port early. + +**The RM 610 port begins only after explicit human approval.** That is a hard +gate, not a scheduling preference: until a named human approves it in writing, +nothing in this repository fetches, downloads, extracts or analyses a 610 GSP +firmware image, and no code or document treats 610 as an anchor or a supported +target. The gate is repeated in the sign-off checklist (§6) and is the first +thing the approval message must acknowledge. + +## 1. Hard gate + +| Rule | Meaning | +|---|---| +| No acquisition | No 610 runfile, GSP `.fwimage`, driver package or firmware blob is fetched, extracted, copied or mirrored before approval. | +| No analysis | No decompilation, symbol export, probe, corpus diff or pairing comparison against a 610 image before approval. | +| No emulation edits | No version-keyed handler, signature entry or profile edit justified by "610" before approval; changes require current-version evidence. | +| No physical-GPU work | No unsupervised physical-GPU cycle, mdev claim or driver bind/unbind for 610; the project rule is no unattended physical-GPU cycles (GREEN-EYE-ROADMAP H7). | +| No support claims | 610 is not entered in the hardware/software matrix or the version matrix until it passes the docs/22 port checklists with evidence. | + +Precision notes: + +* The pinned `nvidia-open-gpu-kernel-modules` checkout used as build glue + (recorded in `SBOM.md`) is **not** a GSP firmware image and is **not** a 610 + port; it neither authorises acquisition nor counts as analysis. +* This document, docs/22 and the current-version test runs are the only + 610-adjacent work permitted today. Everything else waits for approval. +* Approval is scoped and revocable: it covers named inputs and named + activities, on a named date, by a named approver. + +### 1.1 What approval does and does not cover + +| Approval covers | Approval does not cover | +|---|---| +| Acquiring the named 610 inputs through the named channel | Any other 610 artifact or channel not listed in the approval message | +| Analysis, probing, diffing and evidence capture on the 610 inputs | Merging an emulation change without the docs/22 A/B evidence | +| Running the docs/22 port workflow and the pipeline dry-run | Physical-GPU execution without a separate supervised plan | +| Publishing 610 evidence into the port log | Entering 610 as "Supported" in the matrix before validation | +| Re-asking for a scope extension | Retroactively blessing work done before approval | + +An approval with an open-ended scope is invalid; ask again with the specific +inputs and activities. A denied or expired approval leaves the repo in the +state described in §5. + +## 2. Readiness criteria (all must be true) + +Every row must pass on the **current anchor** (570.144) before approval is even +requested. "All must be true" is literal: a single open item keeps the gate +closed. + +| # | Criterion | How it is verified | Evidence | Status today | +|---|---|---|---|---| +| C1 | All tools implemented and tested on the current driver/firmware | full test suites green (documents/demo/probe/inject/Ghidra/harness) | test logs attached to the request | partial (MCP/CI missing) | +| C2 | Tests green | `make test` and the harness/rmharness suites pass | transcript with date and commit | green at v0.1 (55 tests) | +| C3 | Demo, probe, inject, Ghidra work | `./greeneye.sh demo`, `probe`, `inject`, `ghidra` all demonstrated | session dirs + transcript | working per docs/00 | +| C4 | MCP monitoring works | MCP plugin implemented and exercised (roadmap R3, docs/08; docs/15) | tool list + a monitored run | **not implemented (planned R3)** | +| C5 | CI pipeline works | release/CI pipeline implemented and exercised (planned docs/21) | pipeline run with artifacts | **not implemented (planned docs/21)** | +| C6 | Docs complete and accurate | doc review checklist (§3.2) signed | review record | to complete | +| C7 | Discovery/version-diff/CI exercised on the current version | control discovery (planned docs/19) + `harness/portability_matrix.py` + `compare` + pipeline dry-run on 570.144 | report + session hashes | partial (matrix/compare exist; docs/19 planned) | +| C8 | Harness produces a full evidence bundle | docs/07 checklist + docs/14 §6 pack produced end to end for a current-version port rehearsal | bundle directory with SHA-256 inventory | to complete | +| C9 | Human has approved | signed message (§6) | approval ID recorded in the request thread | pending | + +C4 and C5 are the long poles. Do not silently downgrade them: if the human +chooses to waive a criterion, the waiver is written into the approval message +with its risk and the affected scope. + +## 3. What "ready" looks like concretely + +### 3.1 Exact commands that must pass + +Run from `open-iov/debug/risc-v-gsp-rm-vm/` (docs/02 §8 has the make targets): + +```bash +make demo +make test +python3 -m unittest tests.test_harness -v +python3 rmharness/tests/test_rmharness_plugin.py +./greeneye.sh info +./greeneye.sh demo +./greeneye.sh demo --backend native +./greeneye.sh probe +./greeneye.sh inject +./greeneye.sh symbols export --index --bias 0 -o symbols-.json +./greeneye.sh analyze dumps/ --symbols symbols-.json +./greeneye.sh compare dumps/ dumps/ --symbols +``` + +Plus, on the current version: + +```bash +python3 harness/portability_matrix.py \ + --target firmware/uproc_demo/build/uproc_demo.elf \ + --target firmware/uproc_demo/build/uproc_demo.elf \ + --out dumps/matrix-selfcheck +python3 harness/mutation_harness.py --out dumps/mutation-selfcheck \ + --iterations 12 --seed 7 +python3 harness/mutation_harness.py --out dumps/mutation-replay \ + --iterations 12 --seed 7 +``` + +Required results: + +* every suite green, with installed QEMU; skips are explained, not ignored; +* `portability_matrix` self-comparison reports **zero deltas** (`all_zero`); +* mutation `findings.jsonl` is byte-identical between the two seeded runs; +* `greeneye compare` on two identical runs is empty; +* `analyze` renders a coherent session (hook/watch/breakpoint events, UART + banner liveness per docs/06 §7). + +### 3.2 Doc review checklist + +* [ ] 00–18 match the code on the current commit (commands, flags, status + tables, test counts); +* [ ] `docs/22` porting guide is accurate for the current tools; planned items + are labelled `planned (docs/xx)`; +* [ ] `docs/19-CONTROL-DISCOVERY.md` (planned) exists and has been run once on + the current version, or C7 is explicitly waived; +* [ ] `docs/21-CI-RELEASE-PIPELINE.md` (planned) exists and has run once on the + current version, or C5 is explicitly waived; +* [ ] `GREEN-EYE-ROADMAP.md` and `docs/08` status claims match reality; +* [ ] no local source paths, no invented command names, no unsupported + "supported/accurate" claims. + +### 3.3 Golden sessions and regression data in place + +* [ ] golden demo session(s) checked in or reproducible by a documented seed; +* [ ] matrix self-comparison zero-delta output archived; +* [ ] mutation replay corpus archived with its seed and budget; +* [ ] rmharness host-oracle captures for the current anchor archived + (`rmtool engine list --format=json`, `mem stats --format=json`); +* [ ] a full current-version evidence bundle (docs/07 checklist + docs/14 §6) + produced once, as the rehearsal for the 610 run. + +## 4. Dry-run plan for the day of approval (no execution today) + +Executed only after the approval in §6 exists. Steps 1–2 are acquisition and +must not happen beforehand. + +1. **Confirm the approval.** Record approval ID, approver, date, scope and any + waived criterion in the port log. +2. **Obtain the 610 inputs through the approved channel.** The 610 driver + runfile and matching GSP `.fwimage` (or the approved firmware source) are + fetched through the channel named in the approval. Record the runfile + version, firmware hash (MD5/SHA-256), version string and image family + immediately; extract the `.fwimage` per docs/05-operations §4.1 step 1. +3. **Lay down versioned directories.** `firmware/-GSP/610/`, + `userspace/cuda/610/` (copied skeleton), `config/versions/610.yaml` + (documentation until the load path reads it). Never overwrite a working + version; keep 570.144 untouched. +4. **Pair and probe.** Build the 610 symbol DB (`analyze_fwimage.py`, + `symbols export --bias 0`), probe the 610 image and the 570.144 reference + with the same bounded settings, and diff the first-touch maps. Pairing + mismatch stops the run here. +5. **Baseline vs candidate orchestration.** Run + `harness/portability_matrix.py` with the 610 and 570.144 targets and + identical scenario options; run the mutation harness once per version with + the same seed; archive both reports and every session. +6. **Pipeline dry-run, then real.** Run the release pipeline (planned + docs/21-CI-RELEASE-PIPELINE.md) in its dry-run mode first; inspect the + planned artifact list without publishing. Only after the dry-run is clean, + run the real pipeline on the 610 evidence bundle. +7. **Expected artifacts.** + * approval record and input hashes; + * 610 symbol DB + Ghidra export provenance; + * probe reports (610 and reference) and the first-touch diff; + * `portability-report.json`/`.md`, per-pair compare reports, sessions; + * mutation `findings.jsonl` + summary for both versions; + * M1–M6 table with reference-capture method and L2/L3 limits; + * doc updates per docs/22 §3.5/§4 and the matrix entry (if and only if the + support claim is earned). +8. **Decision points.** Stop and keep the current anchor if: pairing is + ambiguous; any unexplained probe delta remains; the engine/RPC inventory + diff is not fully explained; M1–M6 regress on the current workload; the + evidence bundle is incomplete. Forward only when every delta has a decided + response. +9. **Rollback / keep-old-version rules.** The 570.144 directories, + configuration and guest driver stay untouched; rollback = return to the old + anchor commit/tag and the old guest driver (docs/05-operations §4.1 step 6). + A forward attempt never destroys the working setup; do not delete the old + version tree and do not reuse 570.144-derived addresses for 610. + +## 5. What must NOT happen before approval + +| Prohibited action | Why it is prohibited | +|---|---| +| Download/fetch 610 blobs or runfiles | Version discipline and evidence integrity: an unapproved input hash cannot anchor a later claim; acquisition is the act the gate exists to control. | +| Probe or decompile a 610 image | Analysis before readiness contaminates the evidence chain and tempts ad-hoc emulation edits. | +| Edit emulation code "for 610" | No current-version evidence justifies it; version-keyed handlers must be driven by a measured inventory diff (docs/05-operations §4.1 step 4). | +| Touch the physical GPU for 610 | Project hard rule: no unattended physical-GPU cycles; physical validation is a supervised step after approval. | +| Try the 610 driver in a guest | The guest never contains Open-IOV code and the anchor is fixed; an unpaired driver failure looks like an emulation bug (docs/07 P2.2). | +| Record 610 in the support/version matrix | Support means tested and documented; an untested entry violates the matrix contract (hardware-software-matrix §1/§6). | +| Treat the pinned open-module tag as approval | Build glue is not a GSP image and not an authorised port input (SBOM.md). | + +Permitted before approval: reading this document and docs/22, running all +current-version tests, producing and archiving the current evidence bundle, +and preparing the approval request. + +## 6. Sign-off checklist and approval message + +### 6.1 Human sign-off (one page) + +* [ ] **The RM 610 port begins only after explicit human approval.** +* [ ] C1–C9 all true, or each waiver is listed with its scope and risk. +* [ ] Test transcript attached (date, commit, `make test` green). +* [ ] `portability_matrix` self-comparison zero deltas; mutation replay + byte-identical. +* [ ] Doc review checklist (§3.2) complete. +* [ ] Current-version evidence bundle complete with SHA-256 inventory. +* [ ] Golden sessions/regression data in place (§3.3). +* [ ] Approval message (§6.2) sent and answered with an approval ID. +* [ ] Named approver accepts the acquisition scope and the permitted channel. + +``` +Approver: ______________________ Role: ______________________ +Approval ID: ____________________ Date: ______________________ +Scope limits / waivers: ________________________________________ +Signature: ______________________ +``` + +### 6.2 Approval request message (exact format) + +Send exactly this shape; fill every `<...>` and leave "Waivers" as "none" if +empty: + +``` +RM 610 PORT APPROVAL REQUEST + +Anchor today: 570.144 (firmware ; guest driver ) +Target: RM 610 (requested input: ) +Requested scope: acquire the 610 driver + GSP firmware through ; lay down versioned dirs; analyse, probe, diff, and run the docs/22 +port workflow; no product change before A/B evidence. + +Readiness, docs/23 §2: + C1 tools tested .......... + C2 tests green ........... + C3 demo/probe/inject/Ghidra + C4 MCP monitoring ........ + C5 CI pipeline ........... + C6 docs complete ......... + C7 discovery/diff/CI ..... + C8 evidence bundle ....... + C9 human approval ........ requested by this message + +Waivers: +Evidence bundle: +Commit: ; transcripts: +Signed: , + +Decision (human): APPROVED / DENIED +Approval ID: ; Date: ; Scope limits: +``` + +After approval, the 610 port proceeds only through the docs/22 workflow and +the docs/05-operations §4.1 version discipline. Until that message exists, +this repository is a 610 no-fly zone. diff --git a/debug/risc-v-gsp-rm-vm/docs/24-DEVICE-MAP.md b/debug/risc-v-gsp-rm-vm/docs/24-DEVICE-MAP.md new file mode 100644 index 0000000..4430bf9 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/24-DEVICE-MAP.md @@ -0,0 +1,207 @@ +# 24 — Device map: first-touch MMIO and lenient native execution + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See 00 for status. +> This is the H2.1 data layer and the first H2.2 building block, not a device +> model. It carries the same fidelity rules as `06-ACCURACY.md`: L3 remains +> **absent**; nothing here is a hardware, timing or security claim. + +## 1. Purpose and status + +`greeneye-device-map/1` records which MMIO addresses a firmware image touched +first, at which width and direction, with observed values, hit counts and +provenance. It is JSON data, versioned with the firmware that produced it, and +consumed by the native simulator's lenient MMIO mode. It exists so the R1 +device work (H2.2) starts from measured addresses instead of guesses, and so +the real GSP image can be stepped past its first unmapped access to collect +the *next* ones. + +| Piece | Where | +|---|---| +| map model, harvest, query, CLI | `greeneye/device_map.py` | +| lenient / fault MMIO policy | `sim/machine.py` (`Machine(device_map=..., unknown_mmio=...)`) | +| backend plumbing | `greeneye/sim_backend.py` (`launch_native(..., device_map=..., unknown_mmio=..., learn_mmio=..., bias=...)`) | +| tests | `tests/test_device_map.py` | + +## 2. The model + +An entry is one `(address, width, direction)` observation: + +| field | meaning | +|---|---| +| `address` | the MMIO address as accessed (64-bit; 61-bit GSP addresses fit without allocating memory) | +| `width` | access size in bytes (1/2/4/8) | +| `kind` | `"r"` or `"w"` | +| `values` | observed values (first-seen order, deduplicated); empty for probe-harvested reads because the QEMU log records the fault, not the value | +| `hits` | number of times observed at this key | +| `first_epc` | the first observing PC as executed (relocated) | +| `first_epc_fw` | `first_epc - bias`: the firmware's own virtual address; a PC of 0 (trap-vector fetch) stays 0 | +| `source` | `probe` (harvested), `manual` (hand-written), `trace` (learned at runtime) | +| `notes` | fault/exception provenance, e.g. `fault_load reason=rejected` | + +The file carries `meta` with the schema version, harvest/learn source, the +relocation `bias`, `entry`, the firmware path, its `sha256` (when the file is +readable) and `gsp_version.txt` (sanitised), plus `qemu_fault_count` when +harvested. Entries are always sorted by `(address, width, kind)` and the file +is written with `sort_keys=True`, so harvesting the same log twice is +byte-identical; there are no timestamps. + +`DeviceMap.lookup(addr, size, write)` returns a behaviour spec: + +* `value:0x...` — a read observed to return this value; +* `zero` — the address is known (or the direction is unknown) and reads back 0; +* `unimplemented` — no evidence at this address. + +Exact `(address, width, kind)` wins; a different width at the same address is +still evidence for the register. A write to a read-observed address is +absorbed; a read of a write-only register reads back zero. + +## 3. Harvesting from `greeneye probe` + +`greeneye probe` (unchanged) records `blobs/qemu.log` and the parsed +`qemu_first_fault` in `session.json`. Harvest reads either: + +```bash +# 1) run a bounded probe against the real image (QEMU path, existing command) +./greeneye.sh probe # writes dumps/-probe-/ + +# 2) turn that session into a device map +python3 -m greeneye.device_map harvest dumps/-probe- \ + --out dumps/570.144-device-map.json + +# 3) inspect / merge maps later, e.g. across firmware versions +python3 -m greeneye.device_map show dumps/570.144-device-map.json +python3 -m greeneye.device_map merge a.json b.json --out merged.json +``` + +Harvesting the recorded TU104 570.144 probe session +(`dumps/20261002-091222-probe-gsp-570.144.fwimage`) gives two entries — the +first-touch MMIO read and the `mtvec=0` fetch loop QEMU falls into afterwards: + +```json +{"schema": "greeneye-device-map/1", "entries": [ + {"address": 0, "width": 2, "kind": "r", "hits": 367551, "first_epc": 0, + "first_epc_fw": 0, "source": "probe", "notes": "fault_fetch reason=rejected"}, + {"address": 2305843009213696512, "width": 4, "kind": "r", "hits": 1, + "first_epc": 2214629396, "first_epc_fw": 67145748, "source": "probe", + "notes": "fault_load reason=rejected"}]} +``` + +`2305843009213696512 == 0x2000000000000A00`, `67145748 == 0x4009014`; `meta` +also carries the firmware sha256 +(`2d1bb5c6…`), `firmware_version: 570.144W` and `qemu_fault_count: 367552`. +Programmatic forms: `harvest_probe_session(session_dir)` and +`harvest_from_text(log_text, bias=...)` for arbitrary logs; `DeviceMap.save`, +`DeviceMap.load`, `merge`, `lookup`. + +## 4. Lenient MMIO in the native simulator + +`sim.machine.Machine` gained three knobs (all default to today's behaviour): + +```python +Machine(memory_mb=256, ram_base=0x80000000, + device_map=None, unknown_mmio="fault", learn_mmio=False, bias=0) +``` + +* `unknown_mmio="fault"` (default) — **byte-for-byte M1 parity**: an access + that is neither RAM nor UART faults exactly as before, and nothing is logged + or counted. +* `device_map=...` — mapped entries are served in *either* mode; a mapped + address that is unknown to the map is still `unimplemented`. +* `unknown_mmio="lenient"` — an unmodelled access returns the mapped + behaviour, else zero; it is appended to `machine.mmio_log` as + `{pc, addr, size, rw}` and counted in `machine.mmio_unknown`. No logging + occurs when lenient mode is off. Debugger `read_mem`/`write_mem` stay + strict. +* `learn_mmio=True` — lenient observations are written back into the map + (`source: "trace"`, read value `0`, write value as written); with no map + given, one is created lazily. Values recorded with `pc` and `bias`. + +Through the backend: + +```python +session = ge.launch(kernel=..., backend="native", + device_map="dumps/570.144-device-map.json", + unknown_mmio="lenient") +session.target.mmio_log # [{pc, addr, size, rw}, ...] +session.target.mmio_unknown # int +``` + +`launch_native(..., device_map=..., unknown_mmio=..., learn_mmio=..., bias=...)` +takes the same options; `device_map` may be a `DeviceMap` or a saved path, and +`bias` relocates images linked for GPU physical addresses. The lenient path is +only entered on a miss, so RAM/UART/trace throughput is unchanged; recording +is a plain list append and the map lookup is a dict hit. + +**What lenient proves, honestly:** an unmodelled address can be served a +mapped/zero value and execution continues deterministically; a bounded run +lists every unmodelled access and their PCs. It does **not** prove what a real +device returns, does not model read-back, side effects, interrupts or timing, +and a zero that keeps the firmware running is a hypothesis to be checked +against the H2.2 device model (and eventually hardware), not a finding about +silicon. + +## 5. Real-image demonstration (bounded, measured 2026-10-02) + +```bash +python3 -m greeneye.device_map demo --instructions 200000 +``` + +```text +image: /home/rmcore/Git/integdev_gpu_drv/open-iov/firmware/57X-GSP/570.144/gsp-570.144.fwimage.bin +sha256: 2d1bb5c60b208cfd115f015b17dd04a6fece05fd2c0dcba3625d9d48cce3ceea +elf: entry=0x4009000 vaddr=[0x0,0x5bb9000) +bias: 0x80000000 relocated entry: 0x84009000 +budget: 200000 instructions +stopped: reason=budget pc=0x84009070 (fw 0x4009070) +unknown mmio accesses: 1 + first unknown: pc=0x84009014 (fw 0x4009014) addr=0x2000000000000a00 size=4 rw=r +[device-map] qemu probe first fault: fw vaddr 0x4009014 reading 0x2000000000000a00 (load access fault) +[device-map] verdict: first-touch address reproduced; lenient execution continued for 200000 instructions (199994 past the qemu trap) +``` + +Comparison against the QEMU probe (`docs/06 §3`, still current): QEMU executes +six instructions and traps at firmware vaddr `0x4009014` on the 4-byte read of +`0x2000000000000A00`, then loops on `mtvec=0` fetch faults. The lenient native +run reproduces the same first access at the same PC (the `pc` in `mmio_log` is +the accessing instruction, not the run start), returns 0, and continues +through the entire 200 000-instruction budget, ending at firmware vaddr +`0x4009070` with **exactly one** unmodelled access logged. It does not fault. + +**61-bit addresses.** `0x2000000000000A00` is a valid 64-bit value in the +interpreter's address computation; no memory is allocated for it. RAM is +bounds-checked by `addr - ram_base`, the UART window by range, and everything +else goes to the device map / lenient handler. (`1 << 61` is well inside the +RV64 address width; the only care needed is that no map or device code ever +treats MMIO addresses as host indices.) `--out map.json` saves the learned +map; `python3 -m greeneye.device_map show map.json` renders it. + +## 6. How this feeds H2.2 + +* The harvested entries are the first test vectors for the `falcon`/`CSB`/ + `pmu_stub` windows in the docs/16 profile DSL: group entries by page + (`address & ~0xFFF`), take `lookup()` behaviour as the initial read-back, + and replace `zero` with real semantics one window at a time. +* `learn_mmio=True` is the bring-up loop: run lenient, inspect the learned + map + `mmio_log`, promote hand-verified entries to `source: "manual"`, and + re-run strict to see the next unmapped wall. Each promotion should carry a + note and the firmware hash, so the map is a reviewable artifact. +* The map's `bias`, firmware sha256/version and schema make it profile data + keyed by `(device, firmware-family, profile-version)`, as required by + docs/16 §7. +* The same maps are the YAML/JSON seed for the QEMU `-device` model, keeping + the two backends on one source of truth. + +## 7. API and commands + +| Call | Purpose | +|---|---| +| `harvest_probe_session(dir)` | session dir -> `DeviceMap` | +| `harvest_from_text(text, bias=0)` | arbitrary QEMU log -> `DeviceMap` | +| `DeviceMap.lookup(addr, size, write)` | `zero` / `value:0x...` / `unimplemented` | +| `DeviceMap.record(...)`, `merge(...)`, `save(path)`, `load(path)` | authoring/persistence | +| `python3 -m greeneye.device_map harvest\|show\|merge\|demo` | CLI | +| `python3 -m pytest tests/test_device_map.py` | 20 unit/parity/real-image tests | + +Testing honesty: the unit tests are synthetic (no QEMU); the real-image test +is bounded to 200 000 instructions and skips when the fwimage is absent. +`tests/test_sim_backend.py` remains the M1 parity guard and passes unchanged. diff --git a/debug/risc-v-gsp-rm-vm/docs/25-VALUE-VALIDATION.md b/debug/risc-v-gsp-rm-vm/docs/25-VALUE-VALIDATION.md new file mode 100644 index 0000000..d47b8ea --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/25-VALUE-VALIDATION.md @@ -0,0 +1,337 @@ +# 25 — Value validation: measurable time-to-value evidence + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See +> [00-OVERVIEW](00-OVERVIEW.md) for status. Every number in this document is +> either **measured** on the bundled demo analog (recorded in +> `harness/value_demos/artifacts/`) or explicitly labelled a **projection** +> for the real case. No demo claims a real-GSP result the simulator cannot +> reach; device-model-blocked scenarios say so in their result. + +## 1. Why this document exists + +green-eye is only worth its maintenance if it turns real firmware questions +into answers faster than static analysis and supervised hardware cycles. The +project handoffs record those questions and their costs in wall-clock and +cycles: the 0x59 RM-init chase (PART 91, 2026-10-01), the 20 GR counters +(PARTs 46–64, 2026-09-24…29), the GPFIFO GET fault (PART 91 ADDENDUM 9), the +P6 service wiring (PART 92 ADDENDA 5–11, 2026-10-02), and the version pairing +question (570.144 vs 570.211). This document maps each of them to a runnable +demonstration under [`harness/value_demos/`](../harness/value_demos/), states +the baseline cost from the handoffs, and measures the green-eye path. + +## 2. Methodology + +### 2.1 What is measured, what is projected + +| Class | Meaning | Where it appears | +|---|---|---| +| **Measured (analog)** | Wall-clock of the green-eye path on the bundled `uproc_demo` firmware, native RV64 interpreter or QEMU; answers, counts, session bytes, trace slowdown | `result.json` per demo, `artifacts/results.json` | +| **Recorded (real baseline)** | Cost actually paid on the physical path, as recorded in the handoffs (dates, cycles, PARTs, wall spans) | `real_case.static_baseline` in each `result.json`, §5 below | +| **Projected** | What the same tool path would save on the real firmware once H2.2 executes it; labelled `projected` | `real_case.projection`; never presented as a result | +| **Blocked** | Real firmware access requires the H2.2 QEMU device model; `blocked_on_device_model: true` | every real `.fwimage` scenario here | + +Fidelity is exactly docs/06: L0 instruction semantics and L1 memory behaviour +are exercised; **L2 time/concurrency is not modelled** and **L3 device +environment is absent**. A green-eye observation is evidence about the +firmware, and anything that would change `open-iov-vfio` is escalated to a +physical capture before the product changes (docs/06 §6, docs/14 §7). + +### 2.2 Run contract + +* Each demo is a stdlib-only script run from the tool root; it prints the + question, commands, answer, evidence and elapsed time, then one + `RESULT_JSON: {...}` line. +* `run_all.py` executes each demo in a subprocess with a hard timeout, + aggregates `artifacts/results.json`, and enforces the suite budget + (per-demo ≤ 45 s, suite ≤ 180 s). +* Demos are deterministic: native sessions use virtual time (instruction + count), stimuli are fixed, and the version-diff patch site is verified + unique before use. Artifacts contain no host paths beyond the repo. + +### 2.3 Scenario selection and “why not edge cases” + +Every class below recurs across multiple handoff PARTs and sits on the +critical path to `vectorAdd`. The mapping is direct: + +| Demo | Recurs in | Why it is on the vectorAdd path | +|---|---|---| +| s01 reply consumer | PART 91 cycles 2–13; status-queue decode cycles 22–25 | RM init must complete (`0x1001`, `rpc_result=0`) before any RPC; a generic failure hides the first error | +| s02 counter publication | PARTs 46–64 | `cuCtxCreate` completes on per-channel counters; 4/20 blocked the client | +| s03 unknown commands | every new image; docs/19 | control inventory (M1) is the input surface the emulation must handle | +| s04 run-until | PART 91 ADDENDUM 9 (cycles 18–25) | each ladder rung (VAS, PDB, channel, token, GPFIFO, semaphore) is a reachability question | +| s05 snapshot diff | PART 91 ADDENDUM 6/7 (identity, static config) | static-config fields feed guest wiring; a zeroed field kept the device-id read at zero | +| s06 inject service | PART 92 ADDENDA 5–11 (P6a–P6c) | guest-originated work executing on the engine is the shape of the load | +| s07 version diff | PART 91 cycles 2–3; every GSP release | the emulation must track firmware changes without regressing | +| s08 mutation triage | docs/18 R5; docs/19 §3.4; PARTs 3–9 | fail-closed rules come from error paths, not happy paths | +| s09 Ghidra address | PART 91 ADDENDUM 9 cycle 24 | the GPFIFO GET fix was accepted only after the runtime fault PC existed | +| s10 evidence pack | docs/14 §6; every milestone | a finding without sessions/hashes/M1–M6 is not actionable | + +None of these is a bizarre edge case: they are the same observe → decode → +change loop the project runs on every firmware version, and each one is +cited more than once in the handoffs. + +## 3. Scenario → tool → baseline → measured + +Measured on the reference host (CPython 3.12); numbers regenerate with +`python3 harness/value_demos/run_all.py`. Times below are from the +`run_all` aggregate (~18.4 s total). + +| # | Real scenario (source, date) | Green-eye tool | Static/Ghidra-only baseline (recorded) | Analog (measured) | Real-case projection | +|---|---|---|---|---|---| +| s01 | 0x59 RM-init chase; fn72/fn73 (PART 91 + ADDENDA 2–7, 2026-10-01) | hook dispatcher, watch `result`, decode reply | cycles 2–13, ~11:40→16:52 ≈ 5 h 10 m; 10+ supervised boots (~5–6 min each); reply was generic `0x59` | **1.14 s**; 3/3 commands consumed and decoded; FAULT traps before the reply store | one hook + one watchpoint per command once H2.2 boots the image (blocked) | +| s02 | 20 GR per-channel counters (PARTs 46–64, 2026-09-24…29) | watch publication field, per-slot readback | ~5 days; PART 49 “only 4 of 20 counters ever moved”; ~2–3 s pin vs ~2 s client deadline | **1.34 s**; 20/20 slots published, 28 stores, store PCs captured | per-channel page watchpoints on real counter pages (blocked) | +| s03 | unknown control IDs / injected slots (docs/19, 2026-10-02) | sweep + dispatcher/handler hooks + classify | 189 real probes in ~36 s; static-only requires the indirect table per image | **5.96 s** for 24 IDs: 6 known, 8 injected-slot (aliases shown), 10 default | RM control/method sweep once H1.3/H2.2 exposes the table (blocked) | +| s04 | RM-client ladder reachability (PART 91 ADDENDUM 9, 2026-10-01) | `run_until`, trigger, direct `call` | cycles 18–25 = 19:12→20:53 ≈ 1 h 41 m, 8 supervised boots | **0.24 s**; natural hit / timeout / trigger / direct call | every ladder rung becomes a probe spec (blocked) | +| s05 | fn72 identity + 1656-B static config (PART 91 ADDENDUM 6/7, 2026-10-01) | before/after snapshot + schema diff | malformed fields suspected statically; acceptance required a live boot per iteration (10 NOCAT cycles) | **0.50 s**; 6 named fields changed, 7 byte runs fully attributed | same diff over real RM control/reply pages (blocked) | +| s06 | P6 service wiring / guest CE copies (PART 92 ADDENDA 5–11, 2026-10-02) | service injection SDK, both invocation routes | kernel rebuild + supervised cycle (~5–8 min) per rung; P6a→P6c spanned ~02:20–08:30 | **0.63 s**; blob loaded/registered; remote and mailbox results agree | RM service registry (M5) once H2.2 exists (blocked) | +| s07 | 570.144 vs 570.211 pairing (PART 91 cycles 2–3, 2026-10-01) | same stimulus on two builds, `compare_sessions` | one full boot (~5–6 min) per version plus manual log comparison | **1.14 s**; only the patched reply field moved; decision-table row = “prioritise” | real versions triaged on re-hosted firmware; symbols/sessions are the artifact (blocked for the raw image) | +| s08 | mutation / fail-closed corpus (R5; docs/18 §2; docs/19 §3.4) | seeded one-field mutations, signal classification | hand-derived asserts/NOCAT across PARTs 3–9; a 64-iteration QEMU run is the tracked harness baseline | **1.82 s**; 7 mutations → {ok:4, fault:2, no-progress:1}, all expectations matched | real controls land in the same harness (H1.3 surface, blocked) | +| s09 | GPFIFO GET fault `0x40000` vs `0x101000` (PART 91 ADDENDUM 9 cycle 24) | Ghidra TSV + bias + raw-address hook + `export_hits` | static hypothesis unproven; the decoded fault ended 8 supervised cycles (~1 h 41 m) | **0.72 s**; biased address resolves, hook fires at the exact PC, TSV annotated | names.tsv + bias loop once the image executes (blocked) | +| s10 | milestone evidence pack (docs/14 §6; cycles 11–13, 2026-10-01) | one recorded session → analyze/compare/hits/manifest/M1–M6 | hashes, sessions, logs and compare gathered by hand across boots | **4.92 s**; 12,276-byte session, A/B deterministic, all artifacts + overhead numbers | same pack against real sessions (blocked) | + +## 4. Exact commands (per demo) + +All from `open-iov/debug/risc-v-gsp-rm-vm`. `VALUE_DEMO_BACKEND` selects the +backend (`native` default, `qemu` where noted). + +```bash +# s01 — reply consumer (native; QEMU also supported) +python3 harness/value_demos/s01_reply_consumer.py +# hook("rpc_dispatch"); watch(gsp_ctrl+0x40, 8, 'w') +# mailbox cmd=1 arg0=0x1234 -> 0xacdb; cmd=7 -> ~7; cmd=6 -> trap, no result store + +# s02 — counter publication +python3 harness/value_demos/s02_counter_publication.py +# watch(gsp_ctrl+0x40, 8, 'w'); 20 x (mailbox cmd=5, run) -> 20/20 published + +# s03 — unknown command discovery +python3 harness/value_demos/s03_unknown_command.py +# fresh session per ID 1..24; hook dispatcher + 6 handlers; classify +# (findings.jsonl, control-discovery.md) + +# s04 — run-until / reachability +python3 harness/value_demos/s04_run_until_function.py +# run_until("rpc_sum"); run_until("rpc_fault", timeout=0.2) -> RspTimeout; +# mailbox cmd=6 + run_until; call("rpc_ping", (0x41,)) + +# s05 — snapshot diff +python3 harness/value_demos/s05_snapshot_diff.py +# snapshot before/after ECHO; Memory.diff; decode with gsp_ctrl.schema.json + +# s06 — service injection (needs `make sdk`) +python3 harness/value_demos/s06_inject_service.py +# load_service(...slot=6); invoke_remote; invoke_via_firmware + +# s07 — version diff +python3 harness/value_demos/s07_version_diff.py +# byte-patch the PING key (0xbeef -> 0xbef0) into version B; identical +# stimulus; decoded field diff + compare_sessions + +# s08 — mutation triage +python3 harness/value_demos/s08_mutation_triage.py +# 7 seeded one-field mutations; classify by stop signal + rpc_count + +# s09 — Ghidra address join (QEMU by default) +python3 harness/value_demos/s09_grabbing_a_ghidra_address.py +# names.tsv (program = runtime - 0x1000); load_names_tsv(bias=0x1000); +# hook(ghidra + bias); export_hits -> greeneye_hits.tsv + +# s10 — full evidence pack (QEMU by default) +python3 harness/value_demos/s10_full_session_evidence.py +# two identical recorded sessions; analyze + compare + hits + manifest + M1–M6 +# + startup/session/trace overhead + +# all of them +python3 harness/value_demos/run_all.py +python3 -m unittest tests.test_value_demos -v +``` + +## 5. Per-scenario detail + +### s01 — reply consumer (0x59 first-error visibility) + +The physical path sent `GSP_SET_SYSTEM_INFO` (fn72) and `SET_REGISTRY` +(fn73), and the only visible reply was `GSP_INIT_DONE rpc_result=0x59` +(`NV_ERR_OPERATING_SYSTEM`), wrapped generically by `RmStartup`; the RM log +ring was not mapped until the LOGRM id fix (`0x4c4f474d` “LOGM” vs +`0x4c4f47524d` “LOGRM”), so the failing collector step was invisible +(PART 91 ADDENDA 2–6). Ten-plus supervised boots later, the identity fix +(raw config-space dwords instead of unpacked fields) made init-done succeed +in 950 ms. The analog consumes each posted command, watches the result +field, and decodes the exact reply — including the case where the fault +handler traps before any reply store. **Success:** 3/3 dispatches, replies +match the ABI, FAULT has no result store. **Failure:** a command is not +consumed or a reply disagrees. + +### s02 — counter publication (20 slots) + +`cuCtxCreate` waits on 20 GR per-channel counters; the host saw the client’s +releases but could not translate the counter page, and for days only 4 of 20 +client-visible slots moved (PART 49, 2026-09-29). The page VA +(`0x20400f000`, slots at `+0xec0`, 16-byte stride) and the different GPA per +boot were only proven by a raw live page dump (PARTs 51, 64). The analog +publishes 20 slots and attributes each store to a PC. **Success:** 20/20 +published with one `rpc_count` increment per slot. **Failure:** any slot +missing or count delta disagreeing. + +### s03 — unknown command discovery + +New firmware can silently add or re-point dispatch entries; static reading of +an indirect table is per-image work. The 2026-10-02 sweep (189 probes, ~36 s) +found IDs 16–23 falling through to the injected-service handler table +(16–21 alias built-ins, 22–23 NULL returning `~id`) and 7–15/24–63 on the +default path (docs/19 §3.4). The analog sweeps 24 IDs with fresh sessions +and the same hook shape. **Success:** expected classes per ID. **Failure:** +a misclassification or an unreplayable probe. + +### s04 — run-until reachability + +The RM-client ladder asked per rung whether symbolized code executed under +the current stimulus; cycles 18–25 cost ~1 h 41 m of supervised boots +(PART 91 ADDENDUM 9). The analog shows three answers in one session: reached +on the natural path, timed out (not reached under this stimulus — docs/14 §5 +explicitly says this is not unreachability), reached via a trigger, and a +direct Frida-like call. **Success:** all four behaviors agree with the ABI. +**Failure:** a route reports reachable without a hit, or a direct call +disagrees. + +### s05 — snapshot diff + +The fn72 payload carried unpacked identity fields where the reference sends +raw config-space dwords; the server read the device id back as zero, and the +fix was confirmed by rejecting hypotheses field-by-field (PART 91 ADDENDUM 6, +cycle 12), then by decoding the full 1656-byte static-config reply (cycle +13). The analog diffs two snapshots and attributes every changed byte run to +a schema field. **Success:** `rpc_count`/`last_rpc`/`result`/`buf` change, +`result` equals the ECHO length, no unattributed run. **Failure:** an +unattributed changed region. + +### s06 — service injection + +P6 wired guest-originated copies through the real engine; every rung was a +kernel rebuild plus a supervised cycle with fail-safe release discipline +(PART 92 ADDENDA 5–11, 2026-10-02). The analog loads `injected_demo.elf`, +registers it in the dispatch table, and invokes it directly and through the +mailbox. **Success:** both routes agree with the SDK ABI. **Failure:** a +route disagrees, the slot is not registered, or the blob cannot load +(skipped with a reason when `make sdk` has not run). + +### s07 — version diff + +Cycles 2 (570.211) and 3 (570.144) both failed init with `0x59`, disproving +the image-pairing hypothesis at the cost of one full boot each, and the +project’s H4.1 decision table exists to separate “the code changed” from +“the behaviour moved” (docs/14 §4.1). The analog patches the PING reply +constant into a labelled version B and replays an identical stimulus; only +the patched field moves. **Success:** exactly the patched reply moves. +**Failure:** unrelated movement or an unreplayable version. + +### s08 — mutation triage + +The fail-closed rules come from error paths; the tracked harness is bounded +seeded mutation with outcome classes (docs/18 §2), and the discovery sweep +found the sentinel/boundary pointer faults (docs/19 §3.4). The analog runs 7 +one-field mutations in fresh sessions with `max_stops=1` (a faulted target +re-faults at the same PC, so the run stops at the first signal) and +classifies ok/trap/fault/no-progress. **Success:** every mutation matches +its case table. **Failure:** a contradicting class. + +### s09 — grabbing a Ghidra address + +The GPFIFO GET hypothesis (`0x101000`) was only settled when the decoded +status queue showed the fetch at `0x40000`; fix `6f9cbfa` followed +(PART 91 ADDENDUM 9, cycle 24). The analog exports a Ghidra-style +`names.tsv` with a deliberate base offset, loads it with the matching bias, +hooks the raw static address, triggers the path, and writes +`greeneye_hits.tsv` back for Ghidra. **Success:** biased address equals the +real function, hook records the hit, TSV annotates it. **Failure:** a hit at +a different address or an empty TSV. + +### s10 — full session evidence + +The evidence pack contract (docs/14 §6) was satisfied by hand for every +milestone (cycle evidence dirs, P6 dirs). The analog produces it in one run: +two deterministic sessions, analyze, compare, hits TSV, firmware/artifact +hashes, the M1–M6 skeleton (M2/M5 marked not measured without a physical +reference), and the measured overhead. **Success:** ABI reply, A/B +agreement, all artifacts, empty unexplained-delta list. **Failure:** +nondeterminism or a missing artifact. + +## 6. Overhead analysis (measured, with numbers) + +| Overhead | Measured on the analog | Real-path comparison | +|---|---|---| +| Session startup | native ≈ 0.001 s; QEMU ≈ 0.13 s per launch | one supervised hardware cycle ≈ 5–6 min (PART 91/92) | +| Session size | 12,276 B per recorded session (s10), including 2 snapshots and 16 event types | per-cycle evidence dirs hold multi-MB logs, rings and decodes | +| Trace slowdown | 3.1× run-only on the demo workload; 13.2× end-to-end including writing `blobs/trace.jsonl` (~200 k exec lines) | docs/16: ~2.4× exec trace on the demo workload (1.53 → 0.54 MIPS); guard ~7% | +| CI cost | `tests/test_value_demos.py`: 5 tests in ≈ 4.5 s; `run_all.py`: 10 demos in ≈ 18.4 s (budget 180 s) | a single physical cycle is 300–400× the entire demo suite | +| Host footprint | stdlib only; artifacts under `harness/value_demos/artifacts/`; no hardware, no guest, no kernel module | physical cycles hold D0/D3cold locks, mdev and a QEMU guest | + +**Why the overhead is dominated by the time saved.** The expensive baselines +are per-question hardware cycles and multi-hour manual campaigns (s01 ≈ 5 h, +s02 ≈ 5 days, s04/s09 ≈ 1 h 41 m each, s06 ≈ 6 h). The tool path for the same +question class costs 0.2–6.0 s on the analog plus ≤0.13 s of launch — under +7 s end to end for every demo, or ~0.02 % of one 5–6-minute cycle even +before the campaign time. Trace logging at 3–13× of a sub-second run is +still seconds; a 12 KB session replaces a multi-MB manual capture. The only +place the tools add real cost is the once-per-question scripting in +`harness/value_demos/` and the 4.5 s test, which run in CI and are reused by +every later version. + +## 7. OODA-loop compression + +| Stage | Before (real path, as recorded) | After (green-eye analog, measured) | +|---|---|---| +| **Observe** | one supervised boot + capture per hypothesis: 5–6 min, fail-safe discipline, one device-write experiment per cycle | launch 0.001–0.13 s, then hooks/watchpoints and bounded runs: 0.2–5.8 s per question | +| **Orient** | manual LOGINIT/LOGRM/NOCAT ring decode, static reports (REPORT3–10), hours per campaign; the ring was invisible until cycle 11 | `analyze`/`compare`/`Memory.diff` with named symbols: seconds, e.g. s05 attributes every changed byte to a schema field | +| **Decide** | one candidate fix per supervised cycle, accepted/rejected by another boot | version diff (s07), mutation matrix (s08) and per-slot publication (s02) test the whole candidate set in one deterministic run | +| **Act** | rebuild + supervised boot; escalate every conclusion to a physical capture | re-run the session (seconds); product change still gated on physical confirmation (docs/06 §6, docs/14 §7) | + +The compression is not “the simulator replaces the hardware”; it is the loop +around each physical experiment. green-eye reduces the number of physical +cycles needed to reach a confident hypothesis and produces the evidence pack +that makes the eventual physical confirmation cheap (s10). + +## 8. Success/failure criteria summary + +| Demo | Pass | Fail | +|---|---|---| +| s01 | 3/3 dispatches; PING/unknown replies match; FAULT traps before the store | command not consumed or reply disagrees | +| s02 | 20/20 slots published, `rpc_count` +20 | any slot missing / count mismatch | +| s03 | expected class per ID (6 known, 8 injected, 10 default) | misclassification or unreplayable probe | +| s04 | natural hit, timeout, trigger hit, direct call value | false reachability or wrong call value | +| s05 | required fields change, result = ECHO length, no unattributed run | unattributed changed region | +| s06 | remote and mailbox invocation agree with the SDK ABI | route disagreement or unloadable blob (skipped if SDK absent) | +| s07 | only the patched field moves | unrelated movement / unreplayable version | +| s08 | every mutation matches its expected class | contradicting class | +| s09 | biased address resolves; hook fires; TSV annotated | wrong address / empty TSV | +| s10 | ABI reply; A/B agreement; artifacts complete; no unexplained deltas | nondeterminism / missing artifact | + +## 9. Reproducing and extending + +```bash +cd open-iov/debug/risc-v-gsp-rm-vm +make demo && make sdk +python3 harness/value_demos/run_all.py # writes artifacts/results.json +python3 -m unittest tests.test_value_demos -v # fast gate +``` + +`artifacts/results.json` is the machine-readable source for the table in §3: +per-demo `elapsed_s`, `success`, `real_case`, answers, and artifact paths. +When the H2.2 device model lands, each demo's `projected` note becomes the +runbook for replacing its analog with the real image; the result format and +evidence contract do not change. + +## 10. Limits and fidelity statement + +* The analog firmware is not GSP-RM: no RM objects, status queue, object + model or GPU device model. Demos reproduce the *shape* of each scenario + (hook the dispatcher, watch the field, decode the reply, diff versions, + mutate, inject) and are labelled as such. +* L2 timing/concurrency is not modelled; no demo makes a timing claim. +* L3 device environment is absent; every real-firmware result is + `blocked_on_device_model`. +* Absence of a hit is a fact about that run at that fidelity, not proof of + dead code (docs/14 §2). +* Full suite status and per-demo times are regenerated by + `run_all.py`; do not quote a stale number without re-running it. diff --git a/debug/risc-v-gsp-rm-vm/docs/26-ARCHITECTURE.md b/debug/risc-v-gsp-rm-vm/docs/26-ARCHITECTURE.md new file mode 100644 index 0000000..326533b --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/26-ARCHITECTURE.md @@ -0,0 +1,449 @@ +# 26 — green-eye architecture: full picture, workflow and API surfaces + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** +> The architecture map for the green-eye suite: how the pieces fit, which +> surface to call, and which document specifies each part. Every claim here is +> implemented in the tree or explicitly marked as a gated roadmap design. +> Development-only: never shipped, never a driver dependency, never in a guest. + +## 1. What green-eye is (and is not) + +green-eye runs GSP/uproc firmware in a controllable machine, observes it with +Frida-like primitives, records a durable session, changes the machine's inputs +(including injected code), and compares runs as evidence for `open-iov-vfio` +emulation changes. + +| It is | It is not | +|---|---| +| A firmware runtime microscope (execution + memory, L0/L1) | A time, interrupt or device model (L2/L3 absent — [06](06-ACCURACY.md)) | +| A scriptable `Session` API with two backends | A GDB wrapper; there is no GDB dependency | +| A recorded-evidence system with a versioned format | A product component or a guest agent | +| A static/dynamic join with Ghidra | A decompiler or a CUDA-corpus replacement | +| An automation layer (harnesses, MCP, CI) | A fuzzing service against hardware; simulator/re-hosted code only | + +## 2. The suite as a whole + +```text + front doors control plane evidence + CLI/REPL/scripts ─┐ + MCP (stdio) ──────┼─► GreenEyeAPI ─► Session ─┬─► QEMU-RSP backend ──┐ + Ghidra bridge ────┤ (plugins) Memory │ qemu-system-riscv64│ + harness/ ─────────┤ SymbolDB └─► native backend ────┼─► session dir + rmharness ────────┘ Recorder sim/machine.py │ events.jsonl + └─► analyze / compare / + export_hits / bundles +``` + +```mermaid +flowchart LR + CLI["CLI/REPL/scripts"] --> CORE + MCP["MCP server"] --> CORE + GH["Ghidra bridges"] --> CORE + HAR["harness/*.py"] --> CORE + RMH["rmharness (read-only)"] --> CORE + CORE["GreenEyeAPI + Session
hooks, watchpoints, call, run_until
Memory, SymbolDB, Recorder"] + CORE --> QB["QEMU-RSP backend"] --> SESS + CORE --> NB["native backend (M1/M1.5)"] --> SESS + SESS["session dir, greeneye-session/1"] --> REP["analyze / compare / export_hits / MCP / bundles"] + RMH --> SESS +``` + +Status: transport, core, recording, both backends (native M1 trace included), +injection ABI v1, both Ghidra bridges, traces, device map, harnesses, MCP (R3) +and `rmharness` are implemented and exercised. The `Backend` protocol +refactor (16 §5), the H2.2 QEMU device model, RM-core injection (H1.3) and the +H3 M2–M6 milestones are design/roadmap only ([16](16-CUSTOM-SIMULATOR.md)). + +## 3. Session core: hooks, watchpoints and dispatch + +`Session` ([greeneye/session.py](../greeneye/session.py)) owns all stop-driven +state and is backend-agnostic: every stop reads registers, resolves the PC, +records an event, invokes matching hooks/watchpoints, then resumes. + +```mermaid +sequenceDiagram + S->>FW: set breakpoint / watchpoint + loop run(duration, max_stops) + FW-->>S: stop reply (T/S/W) + S->>S: resolve PC via SymbolDB + S->>R: bp_hit / watch_hit / stop / step + alt symbol entry + S->>H: on_enter(hit) with a0..a7 + S->>S: arm return breakpoint from ra (LIFO) + S->>FW: continue + else return breakpoint + S->>H: on_leave(hit), a0 as retval + else watchpoint (Z2/Z3/Z4) + S->>FW: clear watch, step over store, re-arm + S->>H: callback + store PC + end + S->>R: on_event(kind, hit) + end + S->>R: session_end on close +``` + +| Primitive | Semantics / limits | +|---|---| +| `breakpoint(addr, kind=sw\|hw, temporary=)` | sw is stepped over transparently; hw uses the backend's `Z1` | +| `hook(target, on_enter, on_leave)` | leave is a return breakpoint on `ra`; tail calls/interrupt returns are not tracked | +| `watch(target, size, kind=w\|r\|a, callback)` | QEMU stops *at* the access; Session clears/steps/re-arms; store PC is recorded | +| `run(duration, max_stops)` | services stops until a budget or exit; the deadline path interrupts once | +| `run_until(target, timeout)` | temporary breakpoint; raises `RspTimeout` when not reached | +| `call(target, args, timeout)` | Frida-like remote call; restores `RESTORE_REGS` around it | +| `step(n)`, `note(text)`, `on_event(kind, fn)` | single-step with dispatch; annotate and observe the stream | + +Instructions between stops are invisible unless the TCG plugin +(`qemu/greeneye_trace.c`) or the native trace (`sim/trace.py`) is on +(01 §11, 16 §1.1a). + +## 4. Backend abstraction: QEMU-RSP and native + +`Session` assumes a small RSP-shaped primitive set; `run_until` and `call` +live above it and work on every backend. M1 implements the same method +surface (`greeneye/sim_backend.py`) without the planned `Backend` protocol +refactor (16 §5); the table is the M2+ target. + +```mermaid +flowchart LR + S["Session (backend-independent)"] + S -->|"read/write reg+mem, step, continue, Z0-Z4"| RC + S -->|"same surface"| ST + subgraph QEMU["QEMU-RSP backend (default)"] + QT["QemuTarget
qemu-system-riscv64 virt"] --- RC["RspClient gdbstub packets"] + end + subgraph NAT["Native backend (--backend native)"] + ST["SimTarget / SimClient"] --- MC["sim.machine.Machine
RAM + UART + decode loop"] + MC --- TR["sim.trace.Trace exec/access"] + MC --- DM["DeviceMap lenient MMIO"] + end +``` + +| Session need | QEMU-RSP | Native | +|---|---|---| +| registers/memory | `g`/`p`/`P`, `m`/`M`/`X` chunked to `PacketSize` | direct register file and memory map | +| breakpoints/watchpoints | `Z0`–`Z4` | dispatch-loop tables; same stop semantics | +| snapshots | region copies over RSP | direct map copy; machine moments are M2+ | +| virtual time | host wall clock | deterministic instruction count (`SIM_IPS`) | +| full-access trace | TCG plugin (`-plugin`) | in-process `sim/trace.py`, `--trace` | +| device environment | QEMU `virt` only (no TU104) | device map + lenient MMIO; real devices H2.2 | +| injection | debugger writes + `Session.call` | same SDK ABI; RM interception is M5 | + +`--backend qemu|native` is accepted by `demo`, `run`, `attach`, `record`, +`inject` and `ghidra`; `attach --port` and QEMU `--trace` are QEMU-only; +`--trace` is native-only (16 §1.2). + +## 5. Recording and the evidence format + +A session directory is the durable artifact (`greeneye-session/1`, +[greeneye/record.py](../greeneye/record.py)). The event stream is the map; the +snapshot and blob files are the dump. + +```text +dumps/-/ + session.json # format, name, created, counts, snapshot inventory, meta + events.jsonl # append-only JSON lines: {"t": monotonic, "type": ...} + snapshots/*.bin.z # zlib-compressed region captures (sha256 recorded) + blobs/* # QEMU logs, trace.jsonl, rmharness stdout, plugin dumps + summary.md # rendered analysis +``` + +```mermaid +flowchart LR + E["Session stops, memory ops,
probe/plugin/injection events"] --> R["Recorder.event()"] + R --> J["events.jsonl (append-only)"] + M["Memory.snapshot()"] --> Z["snapshots/*.bin.z + sha256"] + B["Recorder.blob()"] --> BD["blobs/*: trace.jsonl, qemu.log"] + X["rmharness external runs"] --> J + J --> A["analyze: summary.md"] + J --> C["compare: A/B delta report"] + J --> G["export_hits: greeneye_hits.tsv"] + J --> S10["value_demos s10: manifest + M1-M6 skeleton"] +``` + +Event classes: core stop/memory events (`session_start`, `image_loaded`, +`hook`, `watch`, `bp_hit`, `leave`, `watch_hit`, `stop`, `step`, `mem_read`, +`mem_write`, `snapshot*`, `probe*`, `plugin*`, `note`, `exit`, `session_end` — +01 §6); injection (`service_injected`, `call_enter`, `call_return` — 12 §6); +traces (`exec`/`access`); M2/M3 native bus (`device`/`irq`/`schedule` — 16 +§10); `rmharness` (`rm_harness_run`, `t=0.0` with epoch `start`/`end` — 17 §5). + +Format invariants: append-only (partial sessions stay readable); additive +versioning with migration notes (V2); `compare` treats `t`, host paths and +backend identity as non-semantic; a claim without a session is not evidence +(06 §7, 25 §1). + +## 6. Injection SDK lifecycle + +The SDK (`sdk/greeneye_service.h`, `sdk/injected_demo.c`) builds +position-independent ELF64 `EM_RISCV` blobs; `greeneye/inject.py` loads, +registers and invokes them. ABI v1: `ge_u64 (*)(ge_u64 cmd, ge_u64 a0, +ge_u64 a1)`, magic `0x47455356`, entry `ge_demo_service_dispatch`. + +```mermaid +sequenceDiagram + Dev->>Inj: load_service(elf, slot=6, load_addr) + Inj->>FW: write PT_LOAD segments into RAM + Inj->>FW: patch gsp_ctrl.handlers[slot] entry + Inj->>Rec: service_injected + mem_write events + Dev->>Inj: invoke_remote(cmd, a0, a1) + Inj->>FW: Session.call(entry, args) + Rec-->>Rec: call_enter / call_return + Dev->>FW: mailbox = 16 + slot (arg0=cmd, arg1, arg2) + FW->>FW: dispatch handlers[slot] + FW-->>Rec: hook/watch events + result field +``` + +Demo registration: mailbox commands 16..23 select slots 0..7; slot 6 is the +SDK default; control-block offsets (`mailbox +0x20`, `handlers +0x150`) are +demo-specific and must come from `greeneye/inject.py` constants (12 §4). +Cleanup is not automatic in v1; record the injection so runs start from a +known state. Roadmap: RM-core shim, service registry and `log`/`stub`/ +`forward` interception at M5/H1.3 (12 §8, 16 §11). + +## 7. Ghidra bridges (two directions) + +The static/dynamic join uses three address spaces — file vaddr, Ghidra program +addr, runtime addr — with a bias per source (01 §5, 05 §1). + +```mermaid +sequenceDiagram + G->>B: {"id":n,"cmd":"run_until","args":{"target":"0x..."}} + B->>S: run_until(addr) (temporary breakpoint) + S->>T: continue; service intervening stops + T-->>S: stop at addr + S-->>B: {addr, symbol, kind} + B-->>G: JSON response + G->>B: read_mem / read_reg / call / snapshot / hits / export_hits + B->>R: hits and notes recorded in the session + R-->>G: greeneye_hits.tsv (via MarkGreenEyeHits.java) +``` + +| Tool | Purpose | Doc | +|---|---|---| +| `symbols export` + `SymbolDB` loaders | Ghidra TSV/JSON → runtime symbol DB | [05](05-GHIDRA-BRIDGE.md) | +| `greeneye.ghidra.export_hits` | session hits → `addrhitssymbolannotation` | 05 | +| `ExportForGreenEye.java` | Ghidra → `addrnamesize` TSV | 05 | +| `MarkGreenEyeHits.java` | annotate functions with runtime hits + bookmarks | 05 | +| `GreenEyeControl.java` + `greeneye.sh ghidra` | drive a live session from GhidraScripts over localhost JSON-lines | [13](13-GHIDRA-CONTROL.md) | + +Bridge commands: `ping`, `info`, `resolve`, `bp_set`, `bp_clear`, `bp_list`, +`watch_set`, `run_until`, `run`, `step`, `read_mem`, `write_mem`, `read_reg`, +`write_reg`, `call`, `snapshot`, `note`, `hits`, `export_hits`, `close` +(13 §3). Unauthenticated by design; localhost only. + +## 8. MCP server tool surface + +`plugins/mcp_server.py` serves the active `Session` over JSON-RPC 2.0 on +stdio (or loopback TCP) with no MCP library; read/observe by default, +`write_mem`/`call_function` require `--allow-write`, `inject_service` +requires `--allow-inject` ([20](20-MCP-SERVER.md)). + +```mermaid +flowchart LR + AG["AI agent (Claude Code, opencode)"] -->|"JSON-RPC 2.0 over stdio"| M["plugins/mcp_server.py"] + M --> READ["read: session_info, symbols_resolve,
read_mem, hexdump, snapshot, plugins_list"] + M --> OBS["observe: bp_set/clear, watch_set,
run, run_until, step (recorded)"] + M --> WR["write (gated): write_mem, call_function"] + M --> INJ["inject (gated): inject_service"] + M --> TR["trace: trace_start/stop"] + M --> AN["analysis: analyze_current, compare_sessions, export_hits"] + M --> HN["harness: probe_firmware, mutation_run,
portability_matrix_run"] + M --> CL["session: close"] + READ & OBS & WR & INJ & TR & AN & HN --> S["Session + Recorder"] +``` + +All results are one JSON text block; absent modules return a structured +`not_available` error; stdout carries protocol frames only (20 §4, §8). Full +argument schemas for all 24 tools are in 20 §5. + +## 9. Automation harness + +`harness/` turns the playbooks into repeatable, self-describing runs. Modules +bootstrap the tool root and use the QEMU backend by default (18, 19, 21); +`common.py` provides flat-image relocation, session helpers and bounded log +reads. + +```mermaid +flowchart TB + FW["firmware ELF / .fwimage"] --> PM & MU & CD & RE & VD & LV + SYM["Ghidra exports + symbol DBs"] --> PM & MU & CD & RE & VD & LV + STIM["stimulus / probe spec"] --> PM & MU & CD & RE & VD & LV + SEED["seed + budgets"] --> PM & MU & CD & RE & VD & LV + subgraph H["harness/"] + PM["portability_matrix.py R6/H4.1"] + MU["mutation_harness.py R5/H4.3"] + CD["control_discovery.py H4"] + RE["reach.py H4.1/H4.5"] + VD["version_diff.py H4.1"] + CR["ci_release.py"] + LV["value_demos/ s01..s10"] + end + VD --> CR + PM --> CR + MU --> CR + PM & MU & CD & RE & VD & LV & CR --> OUT["reports + sessions + findings.jsonl
+ compare/ + bundle-manifest.json"] +``` + +| Entry point | Purpose | Outputs | Doc | +|---|---|---|---| +| `portability_matrix.py` | probe + symbols + fixed scenario + compare across targets; M1–M6 skeleton | `portability-report.json/.md`, `symbols/`, `sessions/`, `compare/` | 18 §1 | +| `mutation_harness.py` | seeded one-input mutation, outcome classes, per-attempt session | `findings.jsonl`, `summary.md`, `sessions/` | 18 §2 | +| `control_discovery.py` | sweep command/call surfaces; classify handler/default/trap/fault | `findings.jsonl`, `discovery-report.json`, `control-discovery.md` | 19 §3 | +| `reach.py` | reachability, trigger discovery, required args and effects for one symbol | `reach-report.json/.md`, `probe.json`, sessions | 19 §4 | +| `version_diff.py` | old vs new image: shape, symbols, corpus hints, probe, behavior, M1–M6 | `version-diff.json/.md`, `corpus/`, `compare/` | 21 §2 | +| `ci_release.py` | version_diff → matrix → mutation smoke, then hash the bundle | `release-report.json`, `bundle-manifest.json` | 21 §4 | +| `value_demos/run_all.py` | ten fixed scenarios (reply, counters, discovery, reach, diff, inject, version, mutation, Ghidra, evidence) | `artifacts/results.json` + per-demo JSON | 25 | +| `common.py` | shared setup (relocate to `0x80000000`, open session, bounded logs) | library | 18 §1.2 | + +Harness limits are intentional: QEMU/re-hosted/demo code only; no hardware, +guest, kernel module or existing gdbstub; bounded budgets; deterministic +findings for a fixed seed (18 §5, 19 §8, 21 §7). + +## 10. Device map and lenient MMIO data flow + +`greeneye-device-map/1` is versioned JSON keyed by +`(address, width, direction)` with values, hit counts and firmware PCs +([24](24-DEVICE-MAP.md)). It records what the firmware touched first and lets +the native interpreter step past unmodelled registers to collect the next +ones; it feeds the H2.2 QEMU device model and the H3 profile DSL. + +```mermaid +flowchart LR + P["greeneye probe (raw .fwimage in QEMU)"] -->|"blobs/qemu.log + qemu_first_fault"| H["device_map.py harvest"] + H --> MAP["greeneye-device-map/1
entries + meta (bias, sha256)"] + MAP --> LOOK["DeviceMap.lookup
zero / value / unimplemented"] + LOOK --> LEN["Native Machine(unknown_mmio=lenient)"] + LEN --> LOG["machine.mmio_log / mmio_unknown"] + LEN --> LEARN["learn_mmio=True: source trace entries"] + LEARN --> MAP + MAP --> PROF["docs/16 profile DSL
falcon/csb/ptimer/pmu stubs"] + PROF --> H22["H2.2 QEMU device model"] + LOG --> NEXT["next unmapped wall; promote to manual"] + NEXT --> MAP +``` + +Honest scope: lenient execution proves an unmodelled access *can* be served +deterministically; it does not model read-back, side effects, interrupts or +timing, and a zero that keeps the firmware running is a hypothesis, not a +silicon fact (24 §4). + +## 11. Workflow architecture: scenario → evidence → emulation change + +The canonical loop (H4.5, 06 §5, 07 P4, 25 §7). Each stage names its gate. + +| Stage | Action / instrument | Success criterion | Failure signal | +|---|---|---|---| +| 1. Scenario | state the firmware question and the smallest stimulus (one command/control/boot phase) | question maps to a symbol or surface | no observable exists → add one (probe/plugin) | +| 2. Choose instrument | hooks/watchpoints for semantics; TCG/native trace for coverage; `run_until`/`call` for reachability; injection for new code | stop set answers the question without flooding | session too large/noisy → filter PCs, watch fields only | +| 3. Run | fresh bounded session; fixed stimulus, seed and budgets | run completes; UART/banner liveness holds | timeout, trap loop, wedged target → capture and retry clean | +| 4. Record | `greeneye-session/1` with snapshots and notes | events + snapshots + hashes written | missing `session.json`/empty analyzer → recording off | +| 5. Compare | `compare` against a reference (old firmware, hardware/VFIO capture, self-diff) | deltas explained symbolically; self-diff is zero | unexplained hit/watch/write delta → investigate first | +| 6. Decide | fill M1–M6 with the reference method (06 §5); mutation/triage for error paths | metric table improves or stays equal | metric claimed without a reference capture | +| 7. Implement | change `open-iov-vfio` (never the simulator for a product fix); keep the stimulus | physical confirmation for behaviour changes (06 §6, 14 §7) | product change from a simulator-only observation | +| 8. Regression | re-run step 3 with an unchanged stimulus; golden sessions/CI (`make test`, `greeneye.yml`) | profile improves or stays equal; CI green | regression → revert and re-derive | + +```mermaid +flowchart LR + Q["Question"] --> OBS["Observe
probe / hooks / watch / trace"] + OBS --> ORI["Orient
analyze + symbol decode + snapshots"] + ORI --> DEC["Decide
compare + M1-M6 + mutation"] + DEC --> ACT["Act
implement in open-iov-vfio"] + ACT --> REG["Regression
same stimulus, same budgets"] + REG -->|"improved / equal"| SHIP["Evidence pack
sessions + report + hashes"] + REG -->|"regressed"| Q + SHIP --> OBS +``` + +OODA compression evidence (analog, measured) is in 25 §7: hardware cycles are +minutes and campaigns hours/days; the green-eye path for the same question +class is sub-second to seconds plus scripting. + +## 12. API surfaces by tool + +### 12.1 CLI (`greeneye.sh`, `greeneye/cli.py`; usage in [02](02-USAGE.md)) + +| Command | Purpose | Not for | +|---|---|---| +| `info` | environment/tooling check | validating a firmware image | +| `demo` | canned boot + hooks + watch + record on the bundled uproc | booting real GSP (use `probe`) | +| `run ` | run a Python instrumentation session | shell pipelines (write Python) | +| `attach` / `launch` | interactive REPL / keep a target alive by port | injecting into a live product | +| `record` | boot and record with watches/snapshots/trace | post-processing an existing session | +| `probe ` | bounded run of a raw image; first divergence report | booting the image (no TU104 model) | +| `inject` | load/register/invoke an SDK service | shipping or hot-patching signed firmware | +| `ghidra` | serve a live session to GhidraScripts | network exposure (localhost only) | +| `analyze ` / `compare
` | markdown report / symbolic A/B delta | proving accuracy without a reference | +| `symbols export\|resolve` | build/inspect a symbol DB | recovering types/decompilation | +| `plugins` | list discoverable plugins | loading untrusted code into the core | +| `python3 -m greeneye.device_map harvest\|show\|merge\|demo` | MMIO first-touch map data | a device model or timing source | + +### 12.2 Python entry points (recipes in [03](03-SCRIPTING.md)) + +| Entry point | Purpose | Not for | +|---|---|---| +| `ge.launch/attach`, `Session.hook/watch/run/run_until/call/step/note` | session lifecycle and instrumentation | in-process C instrumentation | +| `Memory.read/write/hexdump/search_*/snapshot/diff/render_diff` | memory inspection and region diff | full-machine snapshots (M2+) | +| `SymbolDB.load_elf/*_tsv/*_json`, `resolve/annotate` | address-space-aware symbols | type recovery from Ghidra | +| `structs.schema_from_json/decode/render` | declarative struct decoding | automatic struct discovery | +| `probes.load_spec/install` | JSON probe specs (no arbitrary code) | complex control flow (write a script) | +| `inject.load_service/invoke_remote/invoke_via_firmware` | ABI v1 service load/invoke | product features or persistence | +| `ghidra.build_symbol_db/export_hits/corpus_location` | Ghidra TSV in/out and corpus location | replacing Ghidra analysis | +| `analyze.render_summary/compare_sessions/parse_qemu_faults/load_trace` | offline reports and QEMU fault parsing | live control | +| `record.Recorder/load_events/load_meta` | session writing/reading | a database (files are the interface) | +| `device_map.harvest_*/DeviceMap.*` | MMIO map authoring/merge | device semantics | +| `sim_backend.launch_native`, `sim.trace.Trace/make_trace` | native backend + trace knobs | timing/concurrency claims | +| `errors.*` (`GreenEyeError`, `RspTimeout`, ...) | typed failure surface | retrying a wedged target blindly | + +### 12.3 Plugin API (`register(ge)`, [04](04-PLUGINS.md)) + +`ge` exposes live objects (`session`, `memory`, `symbols`, `recorder`), +instrumentation (`hook`, `watch`, `on_event`, `resolve`), CLI extension +(`add_command(name, fn, help)`, `commands`) and audit logging (`log`). A +raising plugin is recorded as `plugin_error` and skipped. Not for: shipping +dependencies, assuming a specific firmware, or hiding unrecorded state. + +## 13. Extensibility and invariants + +Where extension lives: + +* **Plugins** (`plugins/`, `GREENEYE_PLUGINS`) — runtime RE tools, harnesses, + environment updates, agent integrations (04). +* **Harness modules** (`harness/`) — new sweeps/reports over the public + `Session`/analysis primitives; a new call surface is a `Surface` subclass + (19 §5.1). +* **SDK services** (`sdk/`) — PIC blobs behind ABI v1; v2 adds an RM-core call + table (12 §8). +* **Device maps/profiles** — versioned data (`greeneye-device-map/1`, the + `greenmachine-profile/1` DSL in 16 §7), never core special cases. +* **MCP tools** (`plugins/mcp_server.py`) — add tools in the plugin, keep the + core stdlib-only (20 §10). + +Invariants (enforced by tests or review): + +1. **stdlib-only core**: `greeneye/` and `sim/` import only the standard + library; optional deps live in plugins/harnesses. +2. **Session format**: `greeneye-session/1` is append-only and evolves + additively with migration notes (V2). +3. **Backend parity**: every backend satisfies the same `Session` primitives, + so scripts, probes, plugins, recordings and comparisons survive backend + changes (16 §4, R0). +4. **ABI**: blobs declare an ABI version and capability metadata; breaking + changes need a shim and a doc note (12 §8, V3). +5. **Markers**: the `TOOL IN DEVELOPMENT` banner stays until the acceptance + list in 00 passes; never shipped, never in a guest, never a driver + dependency. +6. **Evidence**: no accuracy, portability or security claim without a recorded + A/B session and an explicit L2/L3 fidelity statement (06 §5–6, 25 §10). +7. **Provenance**: tracked docs never name local source trees; public + descriptions first, local provenance in the local notes (V6). + +## 14. Diagram and document index + +Section diagrams and their backing docs: §2 suite (00, 01, README); §3 +dispatch (01, 03); §4 backends (01 §8, 16 §4–5); §5 recording (01 §6, 09, 12 +§6, 16 §10, 17 §5); §6 injection (11, 12); §7 Ghidra (05, 13); §8 MCP (20); +§9 harness (18, 19, 21); §10 device map (24, 16 §7); §11 OODA (06, 07 P4, 25 +§7). Roadmap keys: R3 = MCP (08), R5 = mutation (18), R6 = multi-version (18, +21), H1.3 = RM service injection (GREEN-EYE-ROADMAP), H2.2 = QEMU device +model, H3/M1–M6 = native simulator (16), H4 = adversarial navigation (14), +H7 = agentic platform (15, 20). diff --git a/debug/risc-v-gsp-rm-vm/docs/27-TOOL-AUDIT.md b/debug/risc-v-gsp-rm-vm/docs/27-TOOL-AUDIT.md new file mode 100644 index 0000000..e208253 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/docs/27-TOOL-AUDIT.md @@ -0,0 +1,252 @@ +# 27 — green-eye tool audit + +> **# TOOL IN DEVELOPMENT - NOT READY YET!** See [00-OVERVIEW](00-OVERVIEW.md) +> for status and [06-ACCURACY](06-ACCURACY.md) for fidelity limits. +> Development-only: never shipped, never a driver dependency, never in a guest. + +Audit date: 2026-10-02. Scope: every runnable surface in +`debug/risc-v-gsp-rm-vm/` (CLI, Python package, `sim/`, `harness/`, +`plugins/`, `qemu/`, `ghidra/`, `rmharness/`, `sdk/`, firmware demo, +`examples/`, `tests/`, docs). The goal is that every tool is either exercised +somewhere, documented well enough to be unmistakable, or explicitly marked +`not used yet`. + +## 0. Method (what was actually run) + +Environment: QEMU 8.2.2 (`qemu-system-riscv64`), Python 3.12, clang/lld. +Every safe smoke below was executed from the tool root; device/WPR paths were +not touched. + +* all 13 `./greeneye.sh --help` parsers, `--version`; +* `info`, `demo --backend native`, `record --backend native --trace`, + `analyze`, `compare`, `plugins`, `inject --backend native`; +* `probe --instructions 400` against the real 570.144 `.fwimage`; +* `launch` (bounded, QEMU killed afterwards) and the attach REPL path via + `tests/test_injection.py`; +* `symbols export` from a synthetic Ghidra index TSV, then `symbols resolve`; +* `run` of every `examples/scripts/*.py` as documented, plus the declarative + probe spec `examples/probes/demo_probes.json`; +* `make demo`, `make sdk`, `make -C qemu` (plugin already built) and a live + QEMU run with `qemu/greeneye-trace.so` (8,012,129 JSONL events in ~1 s); +* `harness/*.py --help`; `harness/value_demos/run_all.py --only s02,s05`; + `examples/scripts/sim_bench.py`, `sim_diff.py`; +* `plugin --help` / MCP suite; `rmharness/*.sh --help`, + `rmharness/harness.sh` capture with `rmtool gpu list` (read-only `/proc`); + `python3 rmharness/tests/test_rmharness_plugin.py` (3 tests); +* `python3 -m greeneye.device_map --help` and `demo` (bounded 50k + instructions, lenient MMIO); +* full suite: `python3 -m unittest discover -s tests -t .` → **155 tests, OK**; +* `python3 scripts/check-docs-links.py` from `open-iov/` → **OK, 55 files**; +* `git check-ignore -v` for the local-only ignore patterns. + +Status vocabulary: `verified useful` (exercised and/or wired into +tests/demos/docs), `not used yet` (no caller found; decision recorded), +`needs improvement` (concrete weakness; fixed where safe), `prune candidate` +(clearly dead; none found). + +## 1. Master table — `greeneye.sh` CLI + +| surface | purpose | API / entry points | tests | used by | status | notes/actions | +|---|---|---|---|---|---|---| +| `info` | environment/tooling check | `greeneye/cli.py:173`, `greeneye.sh info` | indirect (smoke) | README, 00, 02 | verified useful | ran; native sim + fwimage + CUDA names detection all correct | +| `demo` | canned demo boot + hooks/watch/diff/trap | `cli.py:209` | smoke via `test_smoke_qemu.py` | README, 00, 02; `make demo-run` | verified useful | ran on `--backend native`; 17 hook lines, diff + trap work | +| `run` | run a Python instrumentation script | `cli.py:272` | `test_injection.py` (REPL), `test_sim_trace.py` | 02, 03, examples scripts | needs improvement (fixed) | the exec namespace lacked `__file__`, so the documented `dump_diff.py` failed; namespace now sets `__file__`/`__name__`, doc 03 notes it | +| `attach` | interactive REPL | `cli.py:486`, `GreenEyeRepl` | `test_injection.py:68` | README, 02 | verified useful | REPL commands all back onto tested Session APIs | +| `launch` | start target, keep alive for attach | `cli.py:509`; `qemu/launch.sh` | smoke (bounded run) | README, 02 | verified useful | prints gdbstub endpoint; QEMU survives Ctrl-C by design | +| `probe` | run arbitrary/real firmware, report first divergence | `cli.py:551`; `test_probe.py` unit | `test_probe.py` (ELF/flat/fault parse) | README, 00, 06, 24 (`harvest` input) | verified useful | ran vs real fwimage: first fault `fault_fetch @0x0`, recorded session evidence | +| `record` | record a memory-activity session (+ native trace) | `cli.py:529` | `test_sim_trace.py` | README, 02, 03 | verified useful | ran with `--trace`; trace blob + watch events rendered by `analyze` | +| `analyze` | session → symbol-annotated report | `cli.py:690`, `greeneye/analyze.py` | `test_sim_trace.py`, `test_smoke_qemu.py` | 02, 03, 05, 13, 14, 25 | verified useful | ran on recorded session | +| `compare` | A/B recorded sessions | `cli.py:704`, `analyze.compare_sessions` | `test_sim_backend.py`, `test_value_demos.py` | 02, 03, 14, 25 | verified useful | ran demo-vs-record; correct profile deltas | +| `symbols export\|resolve` | build/inspect symbol DB | `cli.py:711` | `test_core.py` (loaders) | 05, README | needs improvement (fixed) | bad `--db` JSON produced a traceback; `SymbolDB.load_json` now raises a clean `GreenEyeError` (exit 2) | +| `plugins` | list discoverable plugins | `cli.py:733` | smoke | README, 04 | verified useful | lists all three bundled plugins | +| `inject` | load + call an injected RV64 service | `cli.py:630`; `greeneye/inject.py` | `test_injection.py` | 11, 12, 14 | verified useful | ran native: direct call and firmware mailbox both `0xbeae` | +| `ghidra` | serve session to GhidraScripts | `cli.py:660`; `greeneye/gdbridge.py` | `test_bridge.py` (full protocol) | 05, 13, 14 | verified useful | bridge protocol covered live over a socket; Java side needs Ghidra | + +## 2. Master table — Python API (`greeneye/*.py`) + +| surface | purpose | API / entry points | tests | used by | status | notes/actions | +|---|---|---|---|---|---|---| +| `__init__.py` | public API: `launch`, `attach`, exports | `ge.launch(backend=, trace=, ...)` | all integration tests | README, 02, 03 | verified useful | stable-ish v0.1 surface, documented | +| `errors.py` | exception taxonomy | `GreenEyeError` + subclasses | everywhere | 03 §13 | verified useful | small and meaningful | +| `rsp.py` | GDB-free RSP client | `RspClient`, `StopReply` | `test_rsp.py`, `test_mcp.py` | 01, 03 | verified useful | fake-stub unit tests | +| `elf.py` | ELF64/32 reader, flat kernel writer | `load_elf`, `write_flat_kernel`, `write_relocated_elf`, `FirmwareImage` | `test_core.py` (`write_relocated_elf` test added), `test_probe.py`, `test_device_map.py` | 06, 12 | verified useful | `write_relocated_elf` had no caller; a relocation test now locks its entry/PT_LOAD shift | +| `symbols.py` | symbol DB, address translation, TSV/JSON loaders | `SymbolDB.get/lookup/resolve/annotate/by_subsystem`, `Symbol` | `test_core.py` (`by_subsystem` + list/bad JSON tests added), `test_mcp.py`, `test_memory_record.py` | 03, 05 | verified useful | `load_json` list payloads worked by accident? no — they crashed; fixed and tested; parse errors now `GreenEyeError` | +| `memory.py` | read/write/hexdump/search/snapshot/diff | `Memory`, `Diff` | `test_memory_record.py` | 03 §5/§7 | verified useful | full docs in 03 | +| `structs.py` | schema-driven struct decode/render | `schema_from_json`, `Schema`, `render` | `test_core.py` | 03 §6 | verified useful | docs example matches demo schema | +| `record.py` | durable session: events.jsonl/snapshots/summary | `Recorder`, `NullRecorder`, `load_events` | `test_memory_record.py` | 01, 02, 03 | verified useful | `rmharness/harness.sh` also appends compatible events | +| `session.py` | hooks/bp/watchpoints/events/step/call | `Session`, `hook`, `watch`, `run`, `call` | `test_injection.py`, `test_mcp.py`, `test_bridge.py` | 01, 03, everywhere | verified useful | `Session.symbol_at` is a thin alias of `symbols.resolve` with no caller yet; docstring added, kept for API symmetry | +| `target.py` | QEMU process lifecycle + UART/dlog | `QemuTarget`, `find_qemu`, `free_port` | `test_smoke_qemu.py`, `test_injection.py` | 01, 02 | verified useful | `launch`/`probe` both exercise it | +| `sim_backend.py` | in-process native backend (M1) | `launch_native`, `SimClient`, `SimTarget` | `test_sim_backend.py`, `test_sim_trace.py`, `test_device_map.py`, `test_mcp.py` | README, 16, 24 | verified useful | QEMU-parity differential test | +| `analyze.py` | report renderer, fault parser, A/B compare | `render_summary`, `compare_sessions`, `describe_fault` | `test_sim_trace.py`, `test_probe.py`, `test_smoke_qemu.py` | 02, 03, 06, 25 | verified useful | consumed by CLI and value demos | +| `device_map.py` | versioned first-touch MMIO map + lenient policy | `python3 -m greeneye.device_map {harvest,show,merge,demo}` | `test_device_map.py` | 24 | verified useful | `demo` reproduced the documented first GSP access (lenient run 50k insns) | +| `gdbridge.py` | JSON-lines Ghidra control server | `GhidraBridge`, `cmd_*` | `test_bridge.py` | 05, 13 | verified useful | 25 commands; protocol tested live | +| `ghidra.py` | symbol import / hit export | `build_symbol_db`, `export_hits`, `corpus_location`, `render_ghidra_command` | `test_core.py` (TSV loaders), `test_value_demos.py` s09 (`export_hits`), `test_bridge.py` (`render_ghidra_command` test added) | 05, 13, 25 | verified useful | `render_ghidra_command` now has a test and is cited from 05 §4 | +| `inject.py` | position-independent service load/call | `load_service`, `invoke_remote`, `invoke_via_firmware` | `test_injection.py` | 11, 12 | verified useful | both call paths run in tests and in the CLI smoke | +| `plugins.py` | plugin discovery/API | `PluginManager`, `GreenEyeAPI` | `test_injection.py`; plugin smoke | 04, plugins/README | verified useful | bundled + `GREENEYE_PLUGINS` paths | +| `probes.py` | declarative JSON probes | `load_spec`, `install`, `compile_condition` | `test_smoke_qemu.py`; `demo_probes.json` smoke | 03 §9, 14 | verified useful | 3 probes installed, fired as expected; no standalone runner needed (documented snippet) | +| `cli.py` | argparse entry points (section 1) | `main`, `build_parser` | CLI smokes + integration tests | everywhere | needs improvement (fixed) | docstring now lists `launch`/`inject`/`ghidra`; `run` sets `__file__` | + +## 3. Master table — simulator, harness, plugins, host tooling + +| surface | purpose | API / entry points | tests | used by | status | notes/actions | +|---|---|---|---|---|---|---| +| `sim/decode.py` | RV64IMAC decoder | `decode32`, `decode16` | `test_sim_backend.py` | 16 | verified useful | compressed canonicalisation tested | +| `sim/machine.py` | deterministic machine, UART, lenient MMIO | `Machine`, `Uart16550` | `test_sim_backend.py`, `test_sim_trace.py`, `test_device_map.py` | 16, 24 | verified useful | no wall-clock claims (06) | +| `sim/trace.py` | native exec/access trace (R4) | `Trace` | `test_sim_trace.py` | 16, README | verified useful | trace blob rendered by `analyze` | +| `harness/common.py` | shared harness helpers | `load_target`, `write_json`, `read_faults_window`, … | `test_harness.py`, `test_discovery.py`, `test_version_diff.py` | 18–23 | verified useful | internal, well factored | +| `harness/ci_release.py` | release triage orchestration | `python3 harness/ci_release.py --old … --new …` | `test_version_diff.py` | 21 | verified useful | dry-run supported; manifest/mini-gate | +| `harness/control_discovery.py` | unknown-call discovery ("Sandsifter for RM") | CLI, `--surface demo\|rm` | `test_discovery.py` | 19, 21 | verified useful | deterministic probe generation + effect observer | +| `harness/mutation_harness.py` | bounded deterministic mutation | CLI `--iterations/--seed` | `test_harness.py` (integration, seed replay byte-identical) | 18, 20, 23 | verified useful | replayed identically in test | +| `harness/portability_matrix.py` | multi-target portability runs | CLI `--target` (repeatable) | `test_harness.py` (self-comparison all-zero deltas) | 18, 22 | verified useful | includes probe/scenario/compare sections | +| `harness/reach.py` | reachability answers for a target | CLI `--method auto\|stimulus\|trigger\|run\|call` | `test_discovery.py` | 19 | verified useful | emits suggested probe spec | +| `harness/version_diff.py` | old-vs-new firmware diff | CLI `--old/--new` | `test_version_diff.py` | 21, 25 | verified useful | composes ci_release | +| `harness/value_demos/*` | s01–s10 value validation + `run_all.py` | `python3 harness/value_demos/run_all.py [--only]` | `test_value_demos.py` | 25, MCP tools | verified useful | ran s02/s05 from the CLI; artifacts under `value_demos/artifacts/` | +| `plugins/example_plugin.py` | plugin authoring reference | `register(ge)` | plugin smoke (`--plugin`) | 04, plugins/README | verified useful | loaded via `greeneye run --plugin` | +| `plugins/mcp_server.py` | MCP server (stdio/loopback) | `--stdio`, `--tcp`, `--allow-write`, `--allow-inject` | `test_mcp.py` (431 lines) | 08/R3, 15, 20 | verified useful | read-only default; structured `not_available` fallbacks | +| `plugins/rmharness_plugin.py` | REPL commands for host RM oracle | `register(ge)`; commands `rmtool/rmtop/rmq` | `rmharness/tests/test_rmharness_plugin.py` (3 tests) | 17, 23 | verified useful | write-named args refused (`rm_harness_skip`) | +| `qemu/greeneye_trace.c` | full TCG exec/access JSONL trace | `make -C qemu`; `-plugin greeneye-trace.so,path=…` | none automated; **live-verified in this audit** | 10, 16, 14 | verified useful | 8M exec events in 1 s on the demo; vendored `qemu-plugin.h` builds out of the box | +| `qemu/launch.sh` | one-line `greeneye launch` wrapper | `qemu/launch.sh --kernel ` | smoke | qemu/README | verified useful | thin `exec` of `greeneye.sh launch` | +| `ghidra/*.java` (Export/Mark/Control) | static/dynamic join inside Ghidra | `analyzeHeadless -postScript …` | protocol counterpart covered by `test_bridge.py`; no Ghidra installed here | 05, 13 | verified useful (static) | README documents exact command lines; Java not executed in this environment | +| `rmharness/rmtool.sh` | RM oracle wrapper | passthrough to `tools/rmtool` | `harness.sh` capture smoke (`gpu list`) | 17, 23 | verified useful | builds tool on first use; read-only | +| `rmharness/rmtop.sh` | dashboard wrapper | passthrough to `tools/rmtop` | `--help` | 17 | verified useful | `--once` for captures | +| `rmharness/rmq.sh` | pre-existing rmq wrapper | passthrough to `tools/rmq` | `--help` | 17 | needs improvement (fixed) | `--help` used to fall through and open the RM; wrapper now answers help itself | +| `rmharness/harness.sh` | capture tool run into a green-eye session | `--session DIR -- TOOL ARGS` | capture smoke (verified event + blobs) | 17, 22 | verified useful | additive recording; read-only allowlist enforced | +| `rmharness/tests/` | plugin registration check | `python3 rmharness/tests/test_rmharness_plugin.py` | 3 tests, run and green | 17 | needs improvement (open) | not part of `tests/` discovery nor CI; kept separate by design, documented in 17/README — consider CI step when the workflow is next edited | +| `sdk/injected_demo.c` | position-independent service SDK | `make sdk` | `test_injection.py`; `inject` smoke | 11, 12 | verified useful | direct + firmware call paths | +| `firmware/uproc_demo/` | bundled RV64 uproc (mailbox RPC/state/trap) | `make demo` | many integration tests | README, 00, 02 | verified useful | self-test + scripted stimulus work | +| `examples/scripts/dump_diff.py` | schema decode + snapshot diff demo | `./greeneye.sh run examples/scripts/dump_diff.py` | verified after `__file__` fix | 02 | needs improvement (fixed) | was broken by the `run` namespace gap; now prints decoded ctrl + changed bytes | +| `examples/scripts/trace_rpc.py` | hook every RPC handler | `./greeneye.sh run …` | ran | 02, 16, 18 | verified useful | 17 hook events from the demo | +| `examples/scripts/sim_bench.py` | native throughput benchmark | standalone CLI | ran (0.92 MIPS untraced, pure Python) | 16 | verified useful | self-bootstraps `sys.path`, works from anywhere | +| `examples/scripts/sim_diff.py` | QEMU-vs-native differential | standalone CLI | ran: `PARITY: OK` | 16 | verified useful | exit 1 on divergence, 0 on skip | +| `examples/probes/demo_probes.json` | declarative probe spec sample | loaded by `greeneye.probes.install` | installed + fired via `greeneye run` | 03 §9 | verified useful | 3 probes, hits `[1, 3, 0]` on the demo | +| `tests/` (15 files) | unit + live QEMU integration | `python3 -m unittest discover -s tests -t .` | **155 tests OK** | CI (`greeneye.yml`) | verified useful | includes live QEMU, native parity, MCP, bridge, harness, value demos | + +## 4. Scripting API purpose review + +* **Entry points and lifetime.** `ge.launch()` (QEMU or `backend="native"`) + and `ge.attach()` return a `Session`; `close()` clears breakpoints and kills + owned QEMU unless `keep_alive=True`. Documented in 03 §1 and the + `__init__` docstring. +* **`greeneye run` namespace.** Preloads `ge`, `session`/`s`, `memory`, + `symbols`, `recorder`, plus `__file__`/`__name__` (fixed here so scripts can + locate bundled assets; `examples/scripts/dump_diff.py` depends on it). + `docs/03-SCRIPTING.md` §intro now states this. +* **Static/dynamic join.** `SymbolDB` understands ELF, Ghidra name/index/ + export/callgraph TSVs and native JSON; `resolve()/annotate()` power every + report. `by_subsystem` now has a unit test; `Session.symbol_at` is an + untested-but-documented alias of `symbols.resolve`. +* **Safety.** Probe conditions are whitelisted (`compile_condition`), and the + RSP/MCP surfaces never eval user text except through that whitelist. +* **Errors.** All library errors derive from `GreenEyeError`; `load_json` now + reports file/parse failures as `GreenEyeError` instead of leaking + `JSONDecodeError` through the CLI. +* **Gaps (deliberate).** No session rewind/replay API; TCG JSONL import into + sessions is still roadmap (R4). + +## 5. Plugin API purpose review + +* **Contract.** `register(ge)`; `ge` exposes `session`, `memory`, `symbols`, + `recorder`, `hook`, `watch`, `on_event`, `add_command`, `log` + (`greeneye/plugins.py:36`, `plugins/README.md`). A raising plugin is + recorded as `plugin_error` and cannot break the session. +* **Discovery order.** `--plugin PATH` → `GREENEYE_PLUGINS` → bundled + `plugins/`. Documented in `plugins/README.md` and 04. +* **Bundled plugins.** + * `example_plugin.py` — authoring reference; loaded in this audit. + * `mcp_server.py` — the AI/agent surface; tested by `tests/test_mcp.py`, + documented in 20. The old "MCP is planned/do not add it" notes in + `plugins/README.md`, 04 and 08 are now corrected to point at 20. + * `rmharness_plugin.py` — host oracle REPL commands; tested separately. +* **Gaps.** Plugins cannot yet declare tool capabilities for MCP discovery + beyond `add_command`; `run_command` is REPL-only. Noted, not blocking. + +## 6. Unused tools and decisions + +No `prune candidate` was found: every unreferenced item was a small, coherent +public helper. Three were cheaply wired into unit tests; one remains +`not used yet` with a docstring. Nothing was deleted. + +| item | decision | rationale | +|---|---|---| +| `greeneye.symbols.SymbolDB.by_subsystem` | **wired**: docstring + `test_core.test_by_subsystem` | natural read-only query alongside `names()` | +| `greeneye.elf.write_relocated_elf` | **wired**: `test_core.test_write_relocated_elf` verifies entry/PT_LOAD shift | complete ELF utility next to `write_flat_kernel` | +| `greeneye.ghidra.render_ghidra_command` | **wired**: `test_bridge.GhidraCommandTests` + cited in 05 §4 | renders the `MarkGreenEyeHits.java` command for scripts | +| `greeneye.session.Session.symbol_at` | `not used yet`: docstring only; kept | thin alias of `symbols.resolve`; external scripts may prefer the Session namespace | + +Nothing was deleted. + +## 7. Portability / extensibility / discoverability checks + +* **Portability.** Stdlib-only Python; `greeneye.sh` pins `PYTHONPATH` to the + tool root. Harness tools and `run_all.py` bootstrap their own `sys.path`. + QEMU is optional (integration tests skip cleanly); the native backend makes + the suite largely hardware-free. `qemu-plugin.h` is vendored so the TCG + plugin builds without a QEMU source tree (verified). +* **Extensibility.** Three documented extension axes with stable contracts: + Python scripts (`run`, doc 03), JSON probe specs (`probes`, doc 03 §9), and + plugins (`register(ge)`, docs 04/20). Device behaviour is data, not code + (`greeneye-device-map/1`, doc 24). +* **Discoverability.** `README.md` + `docs/00-OVERVIEW.md` doc maps were + completed for docs 17–27 (previously they stopped at 16); `plugins/` and + `harness/` are now listed in the README layout. `greeneye.sh --help` and + `plugins/mcp_server.py --help` are self-describing. +* **CI.** `.github/workflows/greeneye.yml` builds SDK+demo and runs the main + suite on Linux; `rmharness/tests` is the only suite the workflow misses. + +## 8. How to tell if a tool is misbehaving (symptom → check → fix) + +| tool | symptom | check | fix | +|---|---|---|---| +| `greeneye.sh` / CLI | `greeneye error: …` on a valid path | `./greeneye.sh info` shows resolved paths; `--help` for arguments | correct the path/arg; check `GREENEYE_QEMU`/`GREENEYE_DUMPS`; bad symbol DBs now fail with a clear message | +| `run` scripts | `NameError: __file__` (pre-fix) or names missing | `./greeneye.sh run` preloads `ge/session/s/memory/symbols/recorder/__file__`; run with `--backend native` to remove QEMU | keep scripts inside the tool tree and invoke through `greeneye.sh run`, not bare `python3` | +| hooks/watchpoints | callback never fires | hook an obviously-hit symbol (`rpc_sum`), `record` + `analyze`; check the session event counts | re-run demo to confirm firmware self-test; ensure the target is resumed (`run`) and the symbol exists (`symbols.get`) | +| `probe` | 0 faults reported | check `dumps//blobs/qemu.log` and `recorder.meta["qemu_fault_count"]` | expected for images that stay in mapped memory; use `--instructions` for a bounded step instead of `--duration` | +| native backend / trace | QEMU-vs-native diff fails | `python3 examples/scripts/sim_diff.py`; `analyze` on the trace blob | divergence means a real backend bug: capture both sessions and diff `exec`/`access` counts (see 16) | +| `inject` | service not found or call returns garbage | `make sdk`; check slot (`--slot 0..7`) and load address; hook `rpc_dispatch` | rebuild the SDK, restore the default slot, verify `dispatch` ABI (12 §) | +| harness mutation/portability | findings not reproducible | `--seed` must be fixed; compare `findings.jsonl` byte-for-byte | if a seed diverges, the target or environment changed: capture a session and diff the baseline | +| discovery / reach | everything "unreachable" | run `--surface demo` on the built demo first; check `--timeout/--settle` | raise settle time; confirm the stimulus hook matches the firmware's mailbox | +| version diff / CI release | huge spurious diff | check symbol biases/TSVs (`--*-symbols`, `--bias`) and labels | fix the bias first; use `--dry-run` to inspect the plan before writing artifacts | +| MCP server | client sees no tools / writes refused | `python3 plugins/mcp_server.py --help`; stdout must carry only protocol frames (logs go to stderr) | remove stdout prints from plugins; pass `--allow-write`/`--allow-inject` only when intended | +| `rmharness` wrappers | `--help` opens the device (pre-fix) or permission errors | `--help` is now device-free; `rmtool gpu list/topology` read `/proc`/sysfs only | run `rmtool.sh --help` to check the build; device-table queries need the driver's permission model (17 §Not implemented) | +| `qemu/greeneye-trace.so` | empty trace file | run `make -C qemu`; QEMU must reach the `start/end` window | widen `start/end`, set `exec=on`, and resume the target (plugin records only executed code) | +| device map | unexpected `unimplemented` read | `greeneye.device_map show `; check `bias` and schema version | harvest from a `probe` session with the same bias; use `unknown_mmio="lenient"` for first-touch exploration | + +## 9. Provenance discipline + +Tracked tool-tree docs describe components and behaviour; no non-public +paths, filenames or source locations appear in them. The audit re-ran the +marker scan across this tree and found none. No further action. + +## 10. Summary + +| status | count | items | +|---|---:|---| +| `verified useful` | 57 | all 13 CLI commands (2 with a fixed gap); all 19 package modules; all `sim/`; all `harness/` incl. value demos; 3 plugins; QEMU plugin + launch helper; Ghidra scripts (static); all `rmharness/` (1 fixed gap); SDK, demo firmware; 5 example assets; tests | +| `not used yet` | 1 | `session.symbol_at` — docstring added, kept (section 6) | +| `needs improvement` | 5 | `run` `__file__` namespace (**fixed**), `symbols resolve --db` traceback (**fixed**), `rmq.sh --help` device touch (**fixed**), stale doc maps/MCP/status text in README/00/04/08/14 + `plugins/README` (**fixed**), `rmharness/tests` outside main suite/CI (**open**, documented) | +| `prune candidate` | 0 | nothing deleted | + +Files changed by this audit: + +* `greeneye/cli.py` — command docstring completed; `run` namespace sets + `__file__`/`__name__`. +* `greeneye/symbols.py` — `load_json` raises `GreenEyeError` and now handles + list payloads; docstrings for `by_subsystem`, `nearest`. +* `greeneye/session.py` — docstring for `symbol_at`. +* `greeneye/ghidra.py` — docstring for `render_ghidra_command`. +* `tests/test_core.py` — tests for list/bad JSON payloads, `by_subsystem`, + `write_relocated_elf`. +* `tests/test_bridge.py` — test for `render_ghidra_command`. +* `rmharness/rmq.sh` — device-free `--help`. +* `README.md`, `docs/00-OVERVIEW.md` — doc maps completed (17–27), layout + rows for `harness/`/`rmharness/`, status-table corrections. +* `docs/03-SCRIPTING.md` — documents `__file__`. +* `docs/04-PLUGINS.md`, `docs/08-ROADMAP.md`, `plugins/README.md` — MCP is + implemented, not planned; point at 20. +* `docs/05-GHIDRA-BRIDGE.md` — documents `render_ghidra_command`. +* `docs/14-ADVERSARIAL-INTEROP-GUIDE.md` — capability table updated to + implemented harness/device-map/MCP status. +* `docs/27-TOOL-AUDIT.md` — this document. + +Verification after the changes: 155/155 unit tests OK; docs-link check OK +(55 files); every smoke in section 0 re-run green. diff --git a/debug/risc-v-gsp-rm-vm/examples/scripts/sim_bench.py b/debug/risc-v-gsp-rm-vm/examples/scripts/sim_bench.py new file mode 100644 index 0000000..fa805ad --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/examples/scripts/sim_bench.py @@ -0,0 +1,75 @@ +"""Measure native (M1) simulator throughput on the bundled demo firmware. + +Run standalone (from anywhere): + + python3 examples/scripts/sim_bench.py # untraced baseline + python3 examples/scripts/sim_bench.py --trace # baseline + R4 trace overhead + +Reports MIPS for a fixed instruction budget -- the budget +``SimClient.continue_(timeout=1.0)`` derives from SIM_IPS = 1e6. With +``--trace`` a second run enables the exec/access trace and reports the +per-instruction slowdown, so the non-trace path can be checked for regressions. +""" +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import greeneye as ge +from greeneye.errors import RspTimeout +from sim.trace import Trace + +DEMO = ROOT / "firmware" / "uproc_demo" / "build" / "uproc_demo.elf" + + +def measure(budget: int, *, trace: bool = False) -> tuple[int, float]: + """Run a fixed instruction budget; return (instructions, seconds).""" + session = ge.launch(kernel=str(DEMO), backend="native", name="sim-bench") + machine = session.client.machine + if trace: + session.client.trace = Trace() # recorder stays null: nothing is written + try: + machine.insns = 0 + started = time.perf_counter() + try: + session.client.continue_(timeout=budget / 1_000_000.0) + except RspTimeout: + pass # budget exhausted: the benchmark workload completed + elapsed = time.perf_counter() - started + banner = session.target.read_uart().splitlines() + print(f"uart: {banner[0] if banner else ''!r}") + return machine.insns, elapsed + finally: + session.close() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--budget", type=int, default=1_000_000) + parser.add_argument("--trace", action="store_true", help="also measure traced throughput") + parser.add_argument("--trace-budget", type=int, default=200_000) + args = parser.parse_args(argv) + + insns, elapsed = measure(args.budget) + mips = insns / elapsed / 1e6 if elapsed else float("inf") + print(f"native M1: {insns} instructions in {elapsed:.3f}s = {mips:.2f} MIPS (untraced)") + + if args.trace: + t_insns, t_elapsed = measure(args.trace_budget, trace=True) + t_mips = t_insns / t_elapsed / 1e6 if t_elapsed else float("inf") + overhead = (mips / t_mips - 1.0) if t_mips else float("inf") + print( + f"native M1 trace: {t_insns} instructions in {t_elapsed:.3f}s = {t_mips:.2f} MIPS " + f"({overhead * 100:+.1f}% per-instruction vs untraced)" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/examples/scripts/sim_diff.py b/debug/risc-v-gsp-rm-vm/examples/scripts/sim_diff.py new file mode 100644 index 0000000..f79b822 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/examples/scripts/sim_diff.py @@ -0,0 +1,104 @@ +"""Differential harness: run one demo scenario on QEMU and native, then diff. + + python3 examples/scripts/sim_diff.py [--out DIR] [--keep] + +Both backends execute the same scripted profile: enter/leave hooks on every +uproc RPC handler, a write watchpoint on ``gsp_ctrl->rpc_count``, and a fixed +0.3 s serviced run. The scenario is deterministic on both backends (the demo +self-test finishes and call counts are fixed), so the hook/watch/breakpoint +profiles must match exactly. Sessions are recorded under ``--out`` (or a +temporary directory) and ``analyze.compare_sessions`` renders the delta report. + +Exit status: 0 when the profiles match (or QEMU is unavailable: skip), +1 when they diverge. +""" +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import greeneye as ge +from greeneye import analyze + +DEMO = ROOT / "firmware" / "uproc_demo" / "build" / "uproc_demo.elf" +RPC_HANDLERS = ["rpc_ping", "rpc_echo", "rpc_fill", "rpc_sum", "rpc_state"] +DURATION = 0.3 + + +def run_scenario(backend: str, session_dir: str) -> dict: + """Record one scripted run and return its canonical profile.""" + session = ge.launch(kernel=str(DEMO), backend=backend, record=session_dir, name=f"diff-{backend}") + events: list = [] + watch_hits: list = [] + try: + for name in RPC_HANDLERS: + session.hook( + name, + on_enter=lambda hit, n=name: events.append(["enter", n, hit.arg(0), hit.arg(1)]), + on_leave=lambda hit, n=name: events.append(["leave", n, hit.retval]), + ) + ctrl = session.symbols.lookup("gsp_ctrl") + session.watch(ctrl + 16, size=8, kind="w", callback=lambda hit: watch_hits.append(hit.addr)) + session.run(duration=DURATION) + uart = session.target.read_uart() + bp_counts = [handle.hits for handle in session._bps.values()] + finally: + session.close() + return { + "hook_events": events, + "watch_hits": watch_hits, + "bp_counts": bp_counts, + "uart_first_line": uart.splitlines()[0] if uart else "", + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--out", help="directory for the two recorded sessions") + parser.add_argument("--keep", action="store_true", help="keep a temporary --out directory") + args = parser.parse_args(argv) + + if not DEMO.exists(): + print(f"skip: demo firmware not built ({DEMO}); run `make demo`") + return 0 + if not shutil.which(os.environ.get("GREENEYE_QEMU", "qemu-system-riscv64")): + print("skip: qemu-system-riscv64 not found; native profile not compared") + return 0 + + out = args.out or tempfile.mkdtemp(prefix="greeneye-simdiff-") + os.makedirs(out, exist_ok=True) + qemu = run_scenario("qemu", os.path.join(out, "qemu")) + native = run_scenario("native", os.path.join(out, "native")) + report = analyze.compare_sessions( + os.path.join(out, "qemu"), os.path.join(out, "native") + ) + + if not args.out and not args.keep: + shutil.rmtree(out, ignore_errors=True) + location = "" + else: + location = out + + print(f"# QEMU vs native differential profile ({location})") + print(json.dumps(qemu, sort_keys=True)) + print(json.dumps(native, sort_keys=True)) + print(report) + + if qemu != native: + print("PARITY: FAIL") + return 1 + print("PARITY: OK (hook/watch/bp profiles identical)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/greeneye/__init__.py b/debug/risc-v-gsp-rm-vm/greeneye/__init__.py index 8b1c1e5..717160c 100644 --- a/debug/risc-v-gsp-rm-vm/greeneye/__init__.py +++ b/debug/risc-v-gsp-rm-vm/greeneye/__init__.py @@ -85,13 +85,46 @@ def launch( name: str = "session", load_symbols: bool = True, keep_alive: bool = False, + backend: str = "qemu", + trace=None, ) -> Session: - """Launch QEMU (paused), attach, and return a ready Session. + """Launch a target (paused), attach, and return a ready Session. - The target starts paused at its reset vector; use Session.load_image() to - load an ELF (which also sets PC), or QemuTarget.run_kernel() style helper - launch with kernel= for the fast `-kernel` path. + ``backend="qemu"`` (default) starts ``qemu-system-riscv64`` exactly as + before. ``backend="native"`` uses the in-process deterministic RV64IMAC + simulator (M1) via ``greeneye.sim_backend``; the Session contract is the + same. ``trace`` (native only) enables the R4 exec/access trace: ``True``, + a dict of ``{"exec", "start", "end", "max_events"}``, or a + ``sim.trace.Trace``; it is saved to the session as ``blobs/trace.jsonl``. + The target starts paused at its reset vector; use + Session.load_image() to load an ELF (which also sets PC), or launch with + kernel= for the fast `-kernel` path. """ + if backend in ("native", "sim"): + from .sim_backend import launch_native + + return launch_native( + kernel=kernel, + memory_mb=memory_mb, + record=record, + symbols=symbols, + name=name, + load_symbols=load_symbols, + keep_alive=keep_alive, + trace=trace, + ) + if trace is not None: + from .errors import TargetError + + raise TargetError( + "trace= is native-only; for QEMU build qemu/greeneye-trace.so and " + "pass -plugin (see qemu/README.md)" + ) + if backend != "qemu": + from .errors import TargetError + + raise TargetError(f"unknown backend {backend!r} (use 'qemu' or 'native')") + target = QemuTarget( machine=machine, memory_mb=memory_mb, diff --git a/debug/risc-v-gsp-rm-vm/greeneye/analyze.py b/debug/risc-v-gsp-rm-vm/greeneye/analyze.py index 66429e9..1c7e930 100644 --- a/debug/risc-v-gsp-rm-vm/greeneye/analyze.py +++ b/debug/risc-v-gsp-rm-vm/greeneye/analyze.py @@ -6,12 +6,14 @@ offline comparison between runs (bare-metal vs VFIO, old firmware vs new). """ from __future__ import annotations +import json import os import re from collections import Counter, defaultdict from .record import load_events, load_meta +_TRACE_KINDS = ("exec", "access", "read", "write") _ACCESS_RE = re.compile( r"Invalid (read|write) at addr (0x[0-9a-fA-F]+), size (\d+), region '([^']*)', reason: (\w+)" ) @@ -90,13 +92,56 @@ def describe_fault(fault: dict, bias: int = 0) -> str: return "\n".join(lines) +def _trace_kind(record: dict) -> str | None: + """Native/TCG trace records use ``t``; folded session events use ``type``.""" + return record.get("type") or record.get("t") + + +def _number(value, default: int = 0) -> int: + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value, 0) + except ValueError: + return default + return default + + +def load_trace(session_dir: str, meta: dict | None = None) -> list[dict]: + """Load the trace blob referenced by ``meta['trace_blob']`` (R4).""" + meta = meta if meta is not None else load_meta(session_dir) + rel = meta.get("trace_blob") + if not rel: + return [] + path = rel if os.path.isabs(rel) else os.path.join(session_dir, rel) + records: list[dict] = [] + try: + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + record = json.loads(line) + if _trace_kind(record) != "summary": + records.append(record) + except (OSError, ValueError): + return [] + return records + + def render_summary(session_dir: str, symbols=None, max_rows: int = 40) -> str: meta = load_meta(session_dir) events = load_events(session_dir) - return render_summary_from(meta, events, symbols=symbols, max_rows=max_rows) + return render_summary_from( + meta, events, symbols=symbols, max_rows=max_rows, trace=load_trace(session_dir, meta) + ) -def render_summary_from(meta: dict, events: list[dict], symbols=None, max_rows: int = 40) -> str: +def render_summary_from(meta: dict, events: list[dict], symbols=None, max_rows: int = 40, + trace: list[dict] | None = None) -> str: + if trace is None: + trace = [e for e in events if _trace_kind(e) in _TRACE_KINDS] lines: list[str] = [] lines.append(f"# green-eye session summary: {meta.get('name', '?')}") lines.append("") @@ -194,6 +239,79 @@ def render_summary_from(meta: dict, events: list[dict], symbols=None, max_rows: lines.append(f"| `{page:#x}` | {count} | `{sym.name if sym else ''}` |") lines.append("") + # Execution/access trace coverage (native R4 or folded TCG trace) + if trace: + def access_kind(record): + kind = _trace_kind(record) + if kind == "access": + return "write" if str(record.get("rw", "")).startswith("w") else "read" + return kind + + execs = [r for r in trace if access_kind(r) == "exec"] + reads = [r for r in trace if access_kind(r) == "read"] + writes = [r for r in trace if access_kind(r) == "write"] + lines.append("## Execution trace") + lines.append("") + blob = meta.get("trace_blob") + if blob: + lines.append(f"* trace blob: `{blob}`") + pcs = Counter() + for record in execs: + pcs[_number(record.get("pc"))] += 1 + suffix = f", unique PCs: {len(pcs)}" if execs else " (exec=off)" + lines.append(f"* exec events: {len(execs)}{suffix}") + read_pages = Counter() + for record in reads: + read_pages[(_number(record.get("addr")) >> 12) << 12] += 1 + write_pages = Counter() + for record in writes: + write_pages[(_number(record.get("addr")) >> 12) << 12] += 1 + lines.append( + f"* accesses: {len(reads)} reads ({len(read_pages)} pages), " + f"{len(writes)} writes ({len(write_pages)} pages)" + ) + config = meta.get("trace") or {} + if config: + bits = [f"exec={'on' if config.get('exec') else 'off'}"] + for key in ("start", "end"): + if config.get(key) is not None: + bits.append(f"{key}={config[key]:#x}" if isinstance(config[key], int) else f"{key}={config[key]}") + if config.get("max_events"): + bits.append(f"max_events={config['max_events']}") + if config.get("dropped"): + bits.append(f"dropped={config['dropped']}") + lines.append("* trace config: " + ", ".join(bits)) + lines.append("") + + if execs: + by_sym = defaultdict(lambda: [0, 0]) # unique PCs, exec count + for pc, count in pcs.items(): + sym = symbols.resolve(pc) if symbols else None + entry = by_sym[sym.name if sym else f"{pc:#x}"] + entry[0] += 1 + entry[1] += count + lines.append("### Unique PCs by symbol") + lines.append("") + lines.append("| unique PCs | execs | symbol |") + lines.append("|---:|---:|---|") + for name, (unique, count) in sorted(by_sym.items(), key=lambda kv: -kv[1][1])[:max_rows]: + lines.append(f"| {unique} | {count} | `{name}` |") + lines.append("") + + if read_pages or write_pages: + lines.append("### Accessed pages") + lines.append("") + lines.append("| page | reads | writes | symbol |") + lines.append("|---|---:|---:|---|") + for page in sorted(set(read_pages) | set(write_pages), + key=lambda p: -(read_pages.get(p, 0) + write_pages.get(p, 0)))[:max_rows]: + sym = symbols.resolve(page) if symbols else None + lines.append( + f"| `{page:#x}` | {read_pages.get(page, 0)} | {write_pages.get(page, 0)} " + f"| `{sym.name if sym else ''}` |" + ) + lines.append("") + # Coverage steps = [e for e in events if e.get("type") == "step"] if steps: @@ -277,9 +395,16 @@ def compare_sessions(a_dir: str, b_dir: str, symbols=None, max_rows: int = 40) - """Diff two recorded sessions at the symbolic event level (A/B tooling).""" a_meta, b_meta = load_meta(a_dir), load_meta(b_dir) a_events, b_events = load_events(a_dir), load_events(b_dir) + a_trace, b_trace = load_trace(a_dir, a_meta), load_trace(b_dir, b_meta) lines = [f"# green-eye session comparison", "", f"* A: {a_meta.get('name')} ({a_dir})", f"* B: {b_meta.get('name')} ({b_dir})", ""] - def profile(events): + def trace_kind(record: dict) -> str: + kind = _trace_kind(record) + if kind == "access": + return "write" if str(record.get("rw", "")).startswith("w") else "read" + return kind + + def profile(events, trace): bp = Counter() watch = Counter() writes = Counter() @@ -290,10 +415,13 @@ def compare_sessions(a_dir: str, b_dir: str, symbols=None, max_rows: int = 40) - watch[(event.get("addr"), event.get("kind"))] += 1 elif event.get("type") == "mem_write": writes[(event.get("addr", 0) >> 12) << 12] += 1 + for record in trace: + if trace_kind(record) == "write": + writes[(_number(record.get("addr")) >> 12) << 12] += 1 return bp, watch, writes - a_bp, a_watch, a_writes = profile(a_events) - b_bp, b_watch, b_writes = profile(b_events) + a_bp, a_watch, a_writes = profile(a_events, a_trace) + b_bp, b_watch, b_writes = profile(b_events, b_trace) lines.append("## Breakpoint profile delta (A vs B)") lines.append("") @@ -318,4 +446,23 @@ def compare_sessions(a_dir: str, b_dir: str, symbols=None, max_rows: int = 40) - for page in sorted(set(a_writes) | set(b_writes), key=lambda k: -(a_writes.get(k, 0) + b_writes.get(k, 0)))[:max_rows]: lines.append(f"| `{page:#x}` | {a_writes.get(page, 0)} | {b_writes.get(page, 0)} |") lines.append("") + + def trace_totals(trace): + totals = Counter() + for record in trace: + kind = trace_kind(record) + if kind in ("exec", "read", "write"): + totals[kind] += 1 + return totals + + a_totals, b_totals = trace_totals(a_trace), trace_totals(b_trace) + if a_trace or b_trace: + lines.append("## Trace stream totals (A vs B)") + lines.append("") + lines.append("| stream | A | B | delta |") + lines.append("|---|---:|---:|---:|") + for kind in ("exec", "read", "write"): + a_count, b_count = a_totals.get(kind, 0), b_totals.get(kind, 0) + lines.append(f"| {kind} | {a_count} | {b_count} | {b_count - a_count:+d} |") + lines.append("") return "\n".join(lines) + "\n" diff --git a/debug/risc-v-gsp-rm-vm/greeneye/cli.py b/debug/risc-v-gsp-rm-vm/greeneye/cli.py index 94102a4..e7023ec 100644 --- a/debug/risc-v-gsp-rm-vm/greeneye/cli.py +++ b/debug/risc-v-gsp-rm-vm/greeneye/cli.py @@ -1,13 +1,17 @@ """green-eye command line interface. Commands: - info environment/tooling check + info environment/tooling check (incl. native simulator availability) demo boot the bundled uproc demo and run a canned instrumentation pass run run a Python instrumentation script against a target - attach interactive REPL against a QEMU gdbstub + attach interactive REPL over a gdbstub or the native simulator + launch start a target and keep it alive for a later attach probe run an arbitrary firmware (default: the real GSP fwimage) for N instructions and report the first divergence record boot a firmware and record a memory-activity session + (native: --trace collects an exec/access trace blob) + inject load and invoke an injected RV64 service (Frida-like) + ghidra serve a live session to GhidraScripts over the JSON-lines bridge analyze render a recorded session to a symbol-annotated report compare A/B two recorded sessions at the symbolic level symbols build/inspect a green-eye symbol database from Ghidra exports @@ -82,14 +86,20 @@ def _start_session( record, name: str, plugins: list[str] | None = None, + backend: str = "qemu", + trace=None, ): if port: + if backend != "qemu": + raise GreenEyeError("--backend native cannot attach to an existing gdbstub port") + if trace is not None: + raise GreenEyeError("--trace is native-only; use the qemu/greeneye-trace.so plugin for QEMU") db = SymbolDB() if kernel: db.load_elf(kernel) session = ge.attach(port=port, record=record, symbols=db, name=name) else: - session = ge.launch(kernel=kernel, record=record, name=name) + session = ge.launch(kernel=kernel, record=record, name=name, backend=backend, trace=trace) manager = PluginManager(session) manager.load_all(plugins) return session, manager @@ -101,6 +111,63 @@ def _print_session_dir(session) -> None: print(f"[green-eye] session: {directory}") +def _add_backend_arg(parser) -> None: + """Select the execution backend; qemu stays the default reference path.""" + parser.add_argument( + "--backend", + choices=("qemu", "native"), + default="qemu", + help="execution backend: qemu (default) or the native RV64IMAC simulator", + ) + + +def _add_trace_args(parser) -> None: + """Native exec/access tracing (R4), mirroring qemu/greeneye_trace.c.""" + parser.add_argument( + "--trace", + action="store_true", + help="native backend: record an exec/access trace into the session", + ) + parser.add_argument( + "--trace-exec", + choices=("on", "off"), + default="on", + help="trace instruction exec events (default on; 'off' records accesses only)", + ) + parser.add_argument( + "--trace-start", + type=lambda v: int(v, 0), + default=None, + help="only trace PCs >= this address (inclusive)", + ) + parser.add_argument( + "--trace-end", + type=lambda v: int(v, 0), + default=None, + help="only trace PCs <= this address (inclusive)", + ) + parser.add_argument( + "--trace-max", + type=int, + default=None, + help="cap buffered trace events (0 = unlimited)", + ) + + +def _trace_spec(args): + """Build a launch_native trace spec from parsed args (None when disabled).""" + if not getattr(args, "trace", False): + return None + spec = {"exec": args.trace_exec == "on"} + if args.trace_start is not None: + spec["start"] = args.trace_start + if args.trace_end is not None: + spec["end"] = args.trace_end + if args.trace_max is not None: + spec["max_events"] = args.trace_max + return spec + + # --------------------------------------------------------------------------- # commands # --------------------------------------------------------------------------- @@ -121,6 +188,13 @@ def cmd_info(args) -> int: version = subprocess.run([resolved, "--version"], capture_output=True, text=True).stdout.strip() print(f"qemu version: {version}") + try: + from .sim_backend import SIM_IPS # noqa: F401 + + print("native simulator: available (deterministic RV64IMAC, pure-Python reference)") + print("native trace: available (--trace exec/access, --trace-start/--trace-end filters)") + except Exception as exc: # pragma: no cover - only on a broken install + print(f"native simulator: unavailable ({exc})") for label, path in [ ("demo firmware", DEMO_ELF), ("demo schema", DEMO_SCHEMA), @@ -142,7 +216,10 @@ def cmd_demo(args) -> int: raise GreenEyeError(f"demo firmware not built: {elf}") schema = _load_schema(DEMO_SCHEMA) record = True if args.record is None else args.record - session, _manager = _start_session(kernel=str(elf), port=None, record=record, name="demo") + session, _manager = _start_session( + kernel=str(elf), port=None, record=record, name="demo", backend=args.backend, + trace=_trace_spec(args), + ) hits: list[str] = [] try: ctrl = session.symbols.lookup("gsp_ctrl") @@ -204,7 +281,13 @@ def cmd_run(args) -> int: if not kernel and not args.port: kernel = str(_build_demo()) session, manager = _start_session( - kernel=kernel, port=args.port, record=args.record or True, name=script.stem, plugins=args.plugin + kernel=kernel, + port=args.port, + record=args.record or True, + name=script.stem, + plugins=args.plugin, + backend=args.backend, + trace=_trace_spec(args), ) namespace = { "ge": ge, @@ -214,6 +297,8 @@ def cmd_run(args) -> int: "symbols": session.symbols, "recorder": session.recorder, "greeneye": ge, + "__file__": str(script), + "__name__": "__main__", } try: source = script.read_text(encoding="utf-8") @@ -411,7 +496,13 @@ def cmd_attach(args) -> int: elif not args.port: kernel = str(_build_demo()) session, manager = _start_session( - kernel=kernel, port=args.port, record=args.record, name="repl", plugins=args.plugin + kernel=kernel, + port=args.port, + record=args.record, + name="repl", + plugins=args.plugin, + backend=args.backend, + trace=_trace_spec(args), ) try: GreenEyeRepl(session, manager).cmdloop() @@ -443,7 +534,10 @@ def cmd_launch(args) -> int: def cmd_record(args) -> int: _ensure_dumps_env() elf = args.kernel or str(_build_demo()) - session, _manager = _start_session(kernel=str(elf), port=None, record=args.record or True, name=args.name) + session, _manager = _start_session( + kernel=str(elf), port=None, record=args.record or True, name=args.name, + backend=args.backend, trace=_trace_spec(args), + ) try: for spec in args.watch or []: addr_or_sym, size = (spec.split(":", 1) + ["8"])[:2] @@ -550,7 +644,8 @@ def cmd_inject(args) -> int: ) kernel = args.kernel or str(_build_demo()) session, _manager = _start_session( - kernel=str(kernel), port=args.port, record=args.record or True, name="inject", plugins=args.plugin + kernel=str(kernel), port=args.port, record=args.record or True, name="inject", + plugins=args.plugin, backend=args.backend, trace=_trace_spec(args), ) try: service = load_service(session, str(service_path), load_addr=args.load_addr, slot=args.slot) @@ -578,7 +673,8 @@ def cmd_ghidra(args) -> int: elif not args.target_port: kernel = str(_build_demo()) session, _manager = _start_session( - kernel=kernel, port=args.target_port, record=args.record or True, name="ghidra", plugins=args.plugin + kernel=kernel, port=args.target_port, record=args.record or True, name="ghidra", + plugins=args.plugin, backend=args.backend, trace=_trace_spec(args), ) bridge = GhidraBridge(session, port=args.bridge_port) port = bridge.start() @@ -669,6 +765,8 @@ def build_parser() -> argparse.ArgumentParser: demo.add_argument("--no-build", action="store_true", help="do not rebuild the demo") demo.add_argument("--record", nargs="?", const=True, default=None, help="record to a path (or default dumps/)") demo.add_argument("--duration", type=float, default=1.0) + _add_backend_arg(demo) + _add_trace_args(demo) demo.set_defaults(fn=cmd_demo) run = sub.add_parser("run", help="run a Python instrumentation script") @@ -677,6 +775,8 @@ def build_parser() -> argparse.ArgumentParser: run.add_argument("--port", type=int) run.add_argument("--record", nargs="?", const=True, default=None) run.add_argument("--plugin", action="append", default=[]) + _add_backend_arg(run) + _add_trace_args(run) run.set_defaults(fn=cmd_run) attach = sub.add_parser("attach", help="interactive REPL over a gdbstub (launches if no --port)") @@ -684,6 +784,8 @@ def build_parser() -> argparse.ArgumentParser: attach.add_argument("--kernel", help="firmware ELF (default: built demo)") attach.add_argument("--record", nargs="?", const=True, default=None) attach.add_argument("--plugin", action="append", default=[]) + _add_backend_arg(attach) + _add_trace_args(attach) attach.set_defaults(fn=cmd_attach) launch = sub.add_parser("launch", help="start a target and keep it alive for attach") @@ -713,6 +815,8 @@ def build_parser() -> argparse.ArgumentParser: inject.add_argument("--no-firmware", action="store_true", help="only make the direct debugger-side call") inject.add_argument("--record", nargs="?", const=True, default=None) inject.add_argument("--plugin", action="append", default=[]) + _add_backend_arg(inject) + _add_trace_args(inject) inject.set_defaults(fn=cmd_inject) record = sub.add_parser("record", help="boot and record a memory-activity session") @@ -722,6 +826,8 @@ def build_parser() -> argparse.ArgumentParser: record.add_argument("--watch", action="append", help="addr|symbol[:size]") record.add_argument("--snapshot", action="append", help="addr|symbol:len") record.add_argument("--record", nargs="?", const=True, default=None) + _add_backend_arg(record) + _add_trace_args(record) record.set_defaults(fn=cmd_record) ghidra = sub.add_parser("ghidra", help="serve a live session to GhidraScripts (JSON-lines bridge)") @@ -730,6 +836,8 @@ def build_parser() -> argparse.ArgumentParser: ghidra.add_argument("--bridge-port", type=int, default=7000) ghidra.add_argument("--record", nargs="?", const=True, default=None) ghidra.add_argument("--plugin", action="append", default=[]) + _add_backend_arg(ghidra) + _add_trace_args(ghidra) ghidra.set_defaults(fn=cmd_ghidra) analyze = sub.add_parser("analyze", help="render a recorded session report") diff --git a/debug/risc-v-gsp-rm-vm/greeneye/device_map.py b/debug/risc-v-gsp-rm-vm/greeneye/device_map.py new file mode 100644 index 0000000..11ac656 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/device_map.py @@ -0,0 +1,555 @@ +"""Versioned first-touch device map (roadmap H2.1 -> H2.2 start). + +A device map is JSON data, not code: it records which MMIO addresses a +firmware image touched first, at which width and direction, with observed +values, hit counts and provenance. ``harvest_*`` builds one from the QEMU +logs a ``greeneye probe`` session records; the native simulator +(:class:`sim.machine.Machine`) consults it so unmodelled accesses return a +mapped behaviour instead of faulting (``unknown_mmio="lenient"``). + +Schema ``greeneye-device-map/1``:: + + { + "schema": "greeneye-device-map/1", + "meta": {"bias": 2147483648, "firmware": "...", "firmware_sha256": "..."}, + "entries": [ + {"address": 2305843009213696512, "width": 4, "kind": "r", + "values": [], "hits": 1, "first_epc": 2214629396, + "first_epc_fw": 67145716, "source": "probe", "notes": "fault_load"}, + ... + ] + } + +Entries are emitted in a deterministic order (address, width, kind) and the +file contains no timestamps or host-specific paths in semantic fields, so two +harvests of the same log are byte-identical. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +from dataclasses import dataclass + +from .errors import GreenEyeError + +SCHEMA = "greeneye-device-map/1" +KINDS = ("r", "w") +SOURCES = ("probe", "manual", "trace") +_RAM_BASE = 0x80000000 +#: QEMU probe first fault for the TU104 570.144 image (docs/06-ACCURACY.md §3). +QEMU_REFERENCE = { + "firmware_vaddr": 0x4009014, + "access_addr": 0x2000000000000A00, + "size": 4, + "rw": "read", + "cause": 5, + "desc": "load access fault", +} + +_BEHAVIOUR_ZERO = "zero" +_BEHAVIOUR_VALUE = "value:" + + +def _fw_pc(pc: int, bias: int) -> int: + """Translate a relocated PC back to the firmware's own vaddr. + + A PC of 0 is a trap-vector fetch (not relocated code) and stays 0; PCs + below the relocation bias are not attributable and stay untranslated. + """ + if not bias or pc == 0 or pc < bias: + return pc + return pc - bias + + +@dataclass(frozen=True) +class DeviceEntry: + """One observed (address, width, direction) MMIO touch.""" + + address: int + width: int + kind: str # "r" | "w" + values: tuple[int, ...] = () + hits: int = 1 + first_epc: int = 0 # PC as observed in the run (relocated) + first_epc_fw: int = 0 # PC - bias: the firmware's own virtual address + source: str = "manual" # probe | manual | trace + notes: str = "" + + def key(self) -> tuple[int, int, str]: + return (self.address, self.width, self.kind) + + def to_dict(self) -> dict: + return { + "address": self.address, + "width": self.width, + "kind": self.kind, + "values": list(self.values), + "hits": self.hits, + "first_epc": self.first_epc, + "first_epc_fw": self.first_epc_fw, + "source": self.source, + "notes": self.notes, + } + + @classmethod + def from_dict(cls, data: dict) -> "DeviceEntry": + return cls( + address=int(data["address"]), + width=int(data["width"]), + kind=str(data["kind"]), + values=tuple(int(v) for v in data.get("values", ())), + hits=int(data.get("hits", 1)), + first_epc=int(data.get("first_epc", 0)), + first_epc_fw=int(data.get("first_epc_fw", 0)), + source=str(data.get("source", "manual")), + notes=str(data.get("notes", "")), + ) + + +class DeviceMap: + """A versioned collection of :class:`DeviceEntry` records.""" + + def __init__(self, *, meta: dict | None = None): + self.meta: dict = dict(meta or {}) + self._entries: dict[tuple[int, int, str], DeviceEntry] = {} + + # -- construction ------------------------------------------------------ + + def record( + self, + addr: int, + size: int, + kind: str, + value: int | None = None, + *, + pc: int = 0, + bias: int = 0, + source: str = "manual", + notes: str = "", + ) -> DeviceEntry: + """Add or merge one observation; returns the resulting entry.""" + if kind not in KINDS: + raise GreenEyeError(f"device map: access kind must be r or w, got {kind!r}") + if size <= 0: + raise GreenEyeError(f"device map: width must be positive, got {size}") + key = (addr, size, kind) + entry = self._entries.get(key) + if entry is None: + entry = DeviceEntry( + address=addr, + width=size, + kind=kind, + values=(value,) if value is not None else (), + hits=1, + first_epc=pc, + first_epc_fw=_fw_pc(pc, bias), + source=source, + notes=notes, + ) + else: + values = entry.values + if value is not None and value not in values: + values = values + (value,) + entry = DeviceEntry( + address=entry.address, + width=entry.width, + kind=entry.kind, + values=values, + hits=entry.hits + 1, + first_epc=entry.first_epc or pc, + first_epc_fw=entry.first_epc_fw or _fw_pc(pc, bias), + source=entry.source, + notes=entry.notes or notes, + ) + self._entries[key] = entry + return entry + + def add(self, entry: DeviceEntry) -> DeviceEntry: + """Merge a whole entry (used by :meth:`merge`).""" + key = entry.key() + current = self._entries.get(key) + if current is None: + self._entries[key] = entry + return entry + values = current.values + for value in entry.values: + if value not in values: + values = values + (value,) + merged = DeviceEntry( + address=current.address, + width=current.width, + kind=current.kind, + values=values, + hits=current.hits + entry.hits, + first_epc=current.first_epc or entry.first_epc, + first_epc_fw=current.first_epc_fw or entry.first_epc_fw, + source=current.source, + notes=current.notes or entry.notes, + ) + self._entries[key] = merged + return merged + + def merge(self, other: "DeviceMap") -> "DeviceMap": + """Merge ``other`` into this map (deterministic given call order).""" + for key in sorted(other._entries): + self.add(other._entries[key]) + for key, value in other.meta.items(): + self.meta.setdefault(key, value) + return self + + # -- queries ----------------------------------------------------------- + + def entries(self) -> list[DeviceEntry]: + """All entries, sorted by ``(address, width, kind)``.""" + return [self._entries[key] for key in sorted(self._entries)] + + def __len__(self) -> int: + return len(self._entries) + + def _find(self, addr: int, size: int, kind: str) -> DeviceEntry | None: + exact = self._entries.get((addr, size, kind)) + if exact is not None: + return exact + for key in sorted(self._entries): # width fallback: smallest width first + k_addr, _k_size, k_kind = key + if k_addr == addr and k_kind == kind: + return self._entries[key] + return None + + def lookup(self, addr: int, size: int, write: bool = False) -> str: + """Behaviour spec for an access: ``zero``, ``value:0x...`` or ``unimplemented``. + + Exact (address, width, direction) wins; a different width at the same + address is still evidence for the register. A write to an address + known only for reads is absorbed (``zero``); a read of a write-only + register reads back zero. An address with no entry at all is + ``unimplemented``. + """ + kind = "w" if write else "r" + entry = self._find(addr, size, kind) + if entry is None: + if self._find(addr, size, "w" if not write else "r") is not None: + return _BEHAVIOUR_ZERO # known address, opposite direction + return "unimplemented" + if not write and entry.values: + return f"{_BEHAVIOUR_VALUE}0x{entry.values[-1]:x}" + return _BEHAVIOUR_ZERO + + # -- persistence ------------------------------------------------------- + + def to_dict(self) -> dict: + return { + "schema": SCHEMA, + "meta": dict(self.meta), + "entries": [entry.to_dict() for entry in self.entries()], + } + + def to_json(self) -> str: + return json.dumps(self.to_dict(), indent=1, sort_keys=True) + "\n" + + @classmethod + def from_dict(cls, data: dict) -> "DeviceMap": + schema = data.get("schema") + if schema != SCHEMA: + raise GreenEyeError(f"device map: unsupported schema {schema!r} (want {SCHEMA!r})") + dm = cls(meta=data.get("meta") or {}) + for raw in data.get("entries", ()): + dm.add(DeviceEntry.from_dict(raw)) + return dm + + @classmethod + def from_json(cls, text: str) -> "DeviceMap": + try: + data = json.loads(text) + except ValueError as exc: + raise GreenEyeError(f"device map: invalid JSON ({exc})") from exc + if not isinstance(data, dict): + raise GreenEyeError("device map: top level must be an object") + return cls.from_dict(data) + + def save(self, path: str) -> str: + with open(path, "w", encoding="utf-8") as fh: + fh.write(self.to_json()) + return path + + @classmethod + def load(cls, path: str) -> "DeviceMap": + try: + with open(path, "r", encoding="utf-8") as fh: + text = fh.read() + except OSError as exc: + raise GreenEyeError(f"device map: cannot read {path}: {exc}") from exc + return cls.from_json(text) + + +def firmware_sha256(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +# -- harvest --------------------------------------------------------------- + + +def harvest_from_text(log_text: str, bias: int = 0, source: str = "probe") -> DeviceMap: + """Build a map from an arbitrary QEMU ``guest_errors,unimp,int`` log. + + Only records that pair an invalid access with its exception contribute an + entry, using the faulting PC (translated by ``bias``) and the access + address/width/direction. Fetch-loop faults (no access) are skipped. + """ + from .analyze import parse_qemu_faults + + dm = DeviceMap(meta={"bias": bias, "source": source}) + for fault in parse_qemu_faults(log_text): + access = fault.get("access") + if not access: + continue + kind = "r" if access.get("rw") == "read" else "w" + desc = fault.get("desc") or "" + reason = access.get("reason") or "" + dm.record( + int(access["access_addr"]), + int(access["size"]), + kind, + pc=int(fault.get("epc", 0)), + bias=bias, + source=source, + notes=f"{desc} reason={reason}".strip(), + ) + return dm + + +def _fault_to_map(fault: dict, bias: int, source: str = "probe") -> DeviceMap: + dm = DeviceMap(meta={"bias": bias, "source": source}) + access = fault.get("access") + if access: + kind = "r" if access.get("rw") == "read" else "w" + dm.record( + int(access["access_addr"]), + int(access["size"]), + kind, + pc=int(fault.get("epc", 0)), + bias=bias, + source=source, + notes=str(fault.get("desc") or ""), + ) + return dm + + +def harvest_probe_session(session_dir: str, bias: int | None = None) -> DeviceMap: + """Harvest a ``greeneye probe`` session directory into a device map. + + Reads ``session.json`` for the relocation bias and firmware identity, then + the ``qemu_log_blob`` (falling back to ``blobs/qemu.log``, ``qemu.log`` or + the stored ``qemu_first_fault`` record when no blob was kept). ``bias`` + overrides the session's recorded relocation bias when given. + """ + session_file = os.path.join(session_dir, "session.json") + try: + with open(session_file, "r", encoding="utf-8") as fh: + session = json.load(fh) + except FileNotFoundError as exc: + raise GreenEyeError(f"not a greeneye session directory: {session_dir}") from exc + except ValueError as exc: + raise GreenEyeError(f"{session_file}: invalid JSON ({exc})") from exc + + if bias is None: + bias = int(session.get("bias", 0) or 0) + log_text: str | None = None + blob = session.get("qemu_log_blob") + candidates = [blob] if blob else [] + candidates += ["blobs/qemu.log", "qemu.log"] + log_path: str | None = None + for candidate in candidates: + if candidate and os.path.exists(os.path.join(session_dir, candidate)): + log_path = os.path.join(session_dir, candidate) + break + if log_path is not None: + with open(log_path, "r", encoding="utf-8", errors="replace") as fh: + log_text = fh.read() + + first = session.get("qemu_first_fault") + if log_text is not None: + dm = harvest_from_text(log_text, bias=bias) + elif isinstance(first, dict): + dm = _fault_to_map(first, bias) + else: + dm = DeviceMap(meta={"bias": bias}) + + meta = dm.meta + meta["session"] = os.path.basename(os.path.abspath(session_dir)) + if session.get("firmware"): + meta["firmware"] = session["firmware"] + if session.get("entry") is not None: + meta["entry"] = int(session["entry"]) + if session.get("qemu_fault_count") is not None: + meta["qemu_fault_count"] = int(session["qemu_fault_count"]) + fw = session.get("firmware") + if fw and os.path.isfile(fw): + meta["firmware_sha256"] = firmware_sha256(fw) + version_file = os.path.join(os.path.dirname(fw), "gsp_version.txt") + if os.path.isfile(version_file): + with open(version_file, "r", encoding="utf-8", errors="replace") as fh: + meta["firmware_version"] = "".join( + ch for ch in fh.read() if ch.isprintable() + ).strip() + return dm + + +# -- CLI ------------------------------------------------------------------- + + +def _default_image() -> str: + tool_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return os.path.join( + os.path.dirname(os.path.dirname(tool_root)), + "firmware", "57X-GSP", "570.144", "gsp-570.144.fwimage.bin", + ) + + +def _cmd_harvest(args) -> int: + dm = harvest_probe_session(args.session_dir, bias=args.bias) + print(f"[device-map] harvested {len(dm)} entries from {args.session_dir}", file=sys.stderr) + if args.out: + dm.save(args.out) + print(f"[device-map] wrote {args.out}", file=sys.stderr) + else: + sys.stdout.write(dm.to_json()) + return 0 + + +def _cmd_show(args) -> int: + dm = DeviceMap.load(args.map) + if args.json: + sys.stdout.write(dm.to_json()) + return 0 + print(f"map: {args.map}") + for key in sorted(dm.meta): + print(f"meta {key}: {dm.meta[key]}") + print(f"{'address':>20} {'w':>2} {'rw':>3} {'hits':>7} {'first_epc_fw':>14} {'values':>10} source") + for entry in dm.entries(): + values = ",".join(f"{v:#x}" for v in entry.values) or "-" + print( + f"{entry.address:#20x} {entry.width:>2} {entry.kind:>3} {entry.hits:>7} " + f"{entry.first_epc_fw:#14x} {values:>10} {entry.source}" + ) + return 0 + + +def _cmd_merge(args) -> int: + merged = DeviceMap() + for path in args.maps: + merged.merge(DeviceMap.load(path)) + merged.save(args.out) + print(f"[device-map] merged {len(args.maps)} maps -> {args.out} ({len(merged)} entries)") + return 0 + + +def _run_real_image(args) -> tuple: + """Load the fwimage into a lenient native Machine and run a fixed budget.""" + from .elf import load_elf + from sim.machine import Machine + + image = load_elf(args.image) + bias = args.bias + end = image.max_vaddr + bias + need = end - _RAM_BASE + (1 << 20) + memory_mb = max(256, (need + (1 << 20) - 1) // (1 << 20)) + dm = DeviceMap(meta={"source": "learn", "image": os.path.basename(args.image), "bias": bias}) + machine = Machine( + memory_mb=memory_mb, device_map=dm, unknown_mmio="lenient", learn_mmio=True, bias=bias + ) + machine.load_segments(image.flat_load_plan(bias=bias), image.relocated_entry(bias=bias)) + reason, pc, a, b = machine.run(args.instructions) + return machine, image, dm, reason, pc, a, b + + +_REASONS = { + 0: "budget", 1: "breakpoint", 2: "watch", 3: "ebreak", 4: "fault", 5: "step", 6: "ecall", +} + + +def _cmd_demo(args) -> int: + if not os.path.isfile(args.image): + print(f"[device-map] image not found: {args.image}", file=sys.stderr) + return 2 + machine, image, dm, reason, pc, a, b = _run_real_image(args) + digest = firmware_sha256(args.image) + print(f"image: {args.image}") + print(f"sha256: {digest}") + print(f"elf: entry={image.entry:#x} vaddr=[{image.min_vaddr:#x},{image.max_vaddr:#x})") + print(f"bias: {args.bias:#x} relocated entry: {image.relocated_entry(args.bias):#x}") + print(f"budget: {args.instructions} instructions") + print(f"stopped: reason={_REASONS.get(reason, reason)} pc={pc:#x} (fw {pc - args.bias:#x})") + print(f"unknown mmio accesses: {machine.mmio_unknown}") + for line in machine.mmio_log[: args.first]: + print( + f" first unknown: pc={line['pc']:#x} (fw {line['pc'] - args.bias:#x}) " + f"addr={line['addr']:#x} size={line['size']} rw={line['rw']}" + ) + if machine.mmio_log and machine.mmio_log[0]["addr"] == QEMU_REFERENCE["access_addr"]: + delta = machine.insns - 6 # QEMU traps on the sixth instruction + print( + "[device-map] qemu probe first fault: " + f"fw vaddr {QEMU_REFERENCE['firmware_vaddr']:#x} reading " + f"{QEMU_REFERENCE['access_addr']:#x} ({QEMU_REFERENCE['desc']})" + ) + print( + "[device-map] verdict: first-touch address reproduced; lenient execution " + f"continued for {machine.insns} instructions ({delta} past the qemu trap)" + ) + else: + print("[device-map] verdict: no unknown access at the documented first-touch address") + if args.out: + dm.save(args.out) + print(f"[device-map] learned map ({len(dm)} entries) -> {args.out}") + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python3 -m greeneye.device_map", + description="Harvest, inspect and merge green-eye first-touch device maps.", + ) + sub = parser.add_subparsers(dest="cmd", required=True) + + harvest = sub.add_parser("harvest", help="harvest a greeneye probe session directory") + harvest.add_argument("session_dir") + harvest.add_argument("--out") + harvest.add_argument("--bias", type=lambda s: int(s, 0), default=None) + harvest.set_defaults(func=_cmd_harvest) + + show = sub.add_parser("show", help="print a device map") + show.add_argument("map") + show.add_argument("--json", action="store_true", help="emit the raw JSON") + show.set_defaults(func=_cmd_show) + + merge = sub.add_parser("merge", help="merge maps into one (self, then others)") + merge.add_argument("maps", nargs="+") + merge.add_argument("--out", required=True) + merge.set_defaults(func=_cmd_merge) + + demo = sub.add_parser("demo", help="bounded lenient run of a real fwimage") + demo.add_argument("--image", default=_default_image()) + demo.add_argument("--bias", type=lambda s: int(s, 0), default=_RAM_BASE) + demo.add_argument("--instructions", type=int, default=200_000) + demo.add_argument("--first", type=int, default=8) + demo.add_argument("--out", help="save the learned device map here") + demo.set_defaults(func=_cmd_demo) + + args = parser.parse_args(argv) + try: + return args.func(args) + except GreenEyeError as exc: + print(f"[device-map] error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py b/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py index 6986d74..399a355 100644 --- a/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py +++ b/debug/risc-v-gsp-rm-vm/greeneye/ghidra.py @@ -73,6 +73,8 @@ def corpus_location(symbol, corpus_root: str) -> str | None: def render_ghidra_command(project_dir: str, project_name: str, program: str, hits_tsv: str) -> str: + """Render the ``analyzeHeadless`` command that runs MarkGreenEyeHits.java + against ``hits_tsv`` (the file written by :func:`export_hits`).""" return ( "analyzeHeadless {proj} {name} -process {program} -noanalysis " "-scriptPath /debug/risc-v-gsp-rm-vm/ghidra " diff --git a/debug/risc-v-gsp-rm-vm/greeneye/session.py b/debug/risc-v-gsp-rm-vm/greeneye/session.py index fa54f77..fffc60e 100644 --- a/debug/risc-v-gsp-rm-vm/greeneye/session.py +++ b/debug/risc-v-gsp-rm-vm/greeneye/session.py @@ -227,6 +227,8 @@ class Session: raise GreenEyeError(f"cannot resolve target {target!r}") def symbol_at(self, addr: int) -> Symbol | None: + """Return the symbol containing ``addr``, or the nearest one (alias of + ``symbols.resolve``); ``None`` when the database is empty.""" return self.symbols.resolve(addr) # -- breakpoints / hooks ---------------------------------------------- diff --git a/debug/risc-v-gsp-rm-vm/greeneye/sim_backend.py b/debug/risc-v-gsp-rm-vm/greeneye/sim_backend.py new file mode 100644 index 0000000..d17076f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/greeneye/sim_backend.py @@ -0,0 +1,390 @@ +"""Native green-eye execution backend (M1): deterministic RV64IMAC in Python. + +``ge.launch(kernel=..., backend="native")`` builds a :class:`SimTarget` around +:class:`sim.machine.Machine` and exposes a :class:`SimClient` whose method +surface matches :class:`greeneye.rsp.RspClient`, so ``greeneye.session.Session`` +runs unchanged against it. ``trace=`` enables the M1.5 exec/access trace +(:mod:`sim.trace`), written to the session as ``blobs/trace.jsonl``. +``device_map=`` and ``unknown_mmio="lenient"`` (H2.1/H2.2 start) serve +unmodelled MMIO accesses from a harvested first-touch map instead of faulting; +lenient accesses are logged to ``target.mmio_log`` and counted in +``target.mmio_unknown``. + +Virtual time is instruction count: ``continue_(timeout)`` executes at most +``timeout * SIM_IPS`` instructions (SIM_IPS = 1 MIPS), deterministically. The +wall-clock budgets ``Session.run`` derives from its deadline therefore bound +the run; the stop/watch/hook sequence within a fixed instruction budget is +reproducible on every host. +""" +from __future__ import annotations + +import os +import sys + +from .elf import load_elf +from .errors import GreenEyeError, RspProtocolError, RspTimeout +from .rsp import Register, StopReply +from .session import Session +from .symbols import SymbolDB + +_TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _TOOL_ROOT not in sys.path: # `sim/` lives beside the greeneye package + sys.path.insert(0, _TOOL_ROOT) + +from sim.machine import ( # noqa: E402 + R_BUDGET, + R_FAULT, + R_WATCH, + Machine, +) +from sim.trace import make_trace # noqa: E402 +#: Virtual instructions per second used to convert wall timeouts to budgets. +SIM_IPS = 1_000_000 +#: Safety valve for ``continue_(timeout=None)``: never run unbounded. +MAX_UNBOUNDED_BUDGET = 20_000_000 + +#: Canonical register names, as exposed by QEMU's target.xml for riscv:rv64. +ABI_NAMES = ( + ["zero", "ra", "sp", "gp", "tp", "t0", "t1", "t2", "s0", "s1"] + + [f"a{i}" for i in range(8)] + + [f"s{i}" for i in range(2, 12)] + + [f"t{i}" for i in range(3, 7)] +) +#: xN -> ABI-name aliases (x0 == zero, x1 == ra, ...). +_X_TO_ABI = {f"x{i}": ABI_NAMES[i] for i in range(32)} +_X_TO_ABI["fp"] = "s0" +_X_TO_ABI["return"] = "ra" +_ABI_TO_INDEX = {name: i for i, name in enumerate(ABI_NAMES)} + + +class SimClient: + """Drop-in replacement for :class:`RspClient` over a native Machine.""" + + def __init__(self, machine: Machine, *, sim_ips: int = SIM_IPS, trace=None): + self.machine = machine + self.sim_ips = max(1, int(sim_ips)) + self.timeout = 10.0 + self.packet_size = 0x1000 + self.trace = trace # sim.trace.Trace | None + self._recorder = None + self.registers = [ + Register(number=i, name=name, bitsize=64, offset=i * 8) + for i, name in enumerate(ABI_NAMES) + ] + [Register(number=32, name="pc", bitsize=64, offset=32 * 8)] + self._by_name = {r.name: r for r in self.registers} + self._by_name.update(_X_TO_ABI) + self._by_number = {r.number: r for r in self.registers} + self.features = "PacketSize=1000;swbreak+;hwbreak+;greeneye-native+" + self.connected = True + self._bps: set[int] = set() + self._hw_bps: set[int] = set() + self._watches: dict[tuple[int, int, str], None] = {} + + # -- lifecycle --------------------------------------------------------- + + def connect(self, retries: int = 1, retry_delay: float = 0.1) -> None: + self.connected = True + + def sync(self) -> None: + pass # no server to hand-shake with + + def close(self) -> None: + self.connected = False + if self.trace is not None: + self.trace.flush(self._recorder) + + def __enter__(self) -> "SimClient": + return self + + def __exit__(self, *exc) -> None: + self.close() + + # -- registers --------------------------------------------------------- + + def read_registers(self) -> dict[str, int]: + x = self.machine.x + out = {name: x[i] for i, name in enumerate(ABI_NAMES)} + out["fp"] = out["s0"] # QEMU's target.xml names x8 "fp" + out["pc"] = self.machine.pc + return out + + def _index(self, name_or_number: str | int) -> int: + if isinstance(name_or_number, int): + if 0 <= name_or_number < 32: + return name_or_number + if name_or_number == 32: + return 32 + raise RspProtocolError(f"unknown register number {name_or_number}") + key = _X_TO_ABI.get(name_or_number, name_or_number) + if key == "pc": + return 32 + index = _ABI_TO_INDEX.get(key) + if index is None: + raise RspProtocolError(f"unknown register {name_or_number!r}") + return index + + def read_reg(self, name_or_number: str | int) -> int: + index = self._index(name_or_number) + if index == 32: + return self.machine.pc + return self.machine.reg(index) + + def write_reg(self, name_or_number: str | int, value: int) -> None: + index = self._index(name_or_number) + if index == 32: + self.machine.pc = value & ((1 << 64) - 1) + else: + self.machine.set_reg(index, value) + + @property + def pc(self) -> int: + return self.machine.pc + + # -- memory ------------------------------------------------------------ + + def read_mem(self, addr: int, size: int, chunk: int = 0) -> bytes: + try: + return self.machine.read_mem(addr, size) + except Exception as exc: + raise RspProtocolError(str(exc)) from exc + + def write_mem(self, addr: int, data: bytes, chunk: int = 0) -> None: + try: + self.machine.write_mem(addr, data) + except Exception as exc: + raise RspProtocolError(str(exc)) from exc + + def read_cstring(self, addr: int, max_len: int = 256) -> str: + raw = self.read_mem(addr, max_len) + return raw.split(b"\x00", 1)[0].decode("utf-8", "replace") + + # -- breakpoints / watchpoints ---------------------------------------- + + def set_sw_break(self, addr: int, kind: int = 4) -> None: + self._bps.add(addr) + + def clear_sw_break(self, addr: int, kind: int = 4) -> None: + self._bps.discard(addr) + + def set_hw_break(self, addr: int, kind: int = 4) -> None: + self._hw_bps.add(addr) + + def clear_hw_break(self, addr: int, kind: int = 4) -> None: + self._hw_bps.discard(addr) + + _WATCH_KINDS = {"w": 2, "r": 3, "a": 4} + _WATCH_REVERSE = {"w": "watch", "r": "rwatch", "a": "awatch"} + + def set_watch(self, addr: int, size: int = 8, kind: str = "w") -> None: + if kind not in self._WATCH_KINDS: + raise RspProtocolError(f"unknown watchpoint kind {kind!r}") + self._watches[(addr, size, kind)] = None + + def clear_watch(self, addr: int, size: int = 8, kind: str = "w") -> None: + self._watches.pop((addr, size, kind), None) + + def _watcher_list(self): + out = [] + for (addr, size, kind) in self._watches: + mask = (2 if kind in ("r", "a") else 0) | (1 if kind in ("w", "a") else 0) + out.append((addr, addr + size, kind, mask)) + return tuple(out) + + # -- run control ------------------------------------------------------- + + def _budget(self, timeout: float | None) -> int | None: + if timeout is None: + return None + return max(1, int(timeout * self.sim_ips)) + + def _to_stop(self, result) -> StopReply: + reason, pc, a, b = result + if reason == R_WATCH: + kind = self._WATCH_REVERSE.get(b, "watch") + return StopReply( + raw=f"T05watch:{a:x};", + signal=5, + watch_addr=a, + watch_kind=kind, + ) + if reason == R_FAULT: + signal = 4 if a == 2 else 11 + return StopReply(raw=f"T0{signal:x}", signal=signal) + # bp, ebreak, ecall and step all report SIGTRAP, like the gdbstub. + return StopReply(raw="T05", signal=5) + + def continue_(self, timeout: float | None = None) -> StopReply: + budget = self._budget(timeout) + if budget is None: + budget = MAX_UNBOUNDED_BUDGET + result = self.machine.run( + budget, + breakpoints=frozenset(self._bps | self._hw_bps), + watchers=self._watcher_list(), + trace=self.trace, + ) + if result[0] == R_BUDGET: + raise RspTimeout(f"native budget exhausted after {budget} instructions") + return self._to_stop(result) + + def step(self, timeout: float | None = None) -> StopReply: + result = self.machine.step(watchers=self._watcher_list(), trace=self.trace) + return self._to_stop(result) + + def interrupt(self, timeout: float | None = None) -> StopReply: + return StopReply(raw="T02", signal=2) + + # -- monitor ----------------------------------------------------------- + + def monitor(self, cmd: str) -> str: + return "" + + +class SimTarget: + """QemuTarget-shaped handle for a native Machine (no process, no socket).""" + + def __init__( + self, + machine: Machine, + *, + memory_mb: int = 256, + ram_base: int = 0x80000000, + sim_ips: int = SIM_IPS, + keep_alive: bool = False, + trace=None, + ): + self.machine = machine + self.memory_mb = memory_mb + self.ram_base = ram_base + self.keep_alive = keep_alive + self.client = SimClient(machine, sim_ips=sim_ips, trace=trace) + self.kernel: str | None = None + self.workdir: str | None = None + self._alive = True + + # QemuTarget lifecycle surface ---------------------------------------- + + def start(self, paused: bool = True, timeout: float = 0.0) -> "SimTarget": + return self + + def wait_for_port(self, timeout: float = 0.0) -> None: + return None + + def alive(self) -> bool: + return self._alive + + def kill(self, timeout: float = 0.0) -> None: + self._alive = False + self.client.close() + + def read_uart(self) -> str: + return self.machine.uart.text() + + @property + def mmio_log(self) -> list[dict]: + """Lenient-mode log (empty unless ``unknown_mmio="lenient"``).""" + return self.machine.mmio_log + + @property + def mmio_unknown(self) -> int: + """Count of unmodelled MMIO accesses served leniently.""" + return self.machine.mmio_unknown + + def wait_for_uart(self, marker: str, timeout: float = 5.0, interval: float = 0.05) -> bool: + # Execution is synchronous: by the time the caller looks, the marker + # either exists or the budget was too small. No polling needed. + return marker in self.read_uart() + + def read_qemu_log(self) -> str: + return "" + + @property + def command_line(self) -> list[str]: + return [f"native-sim:{self.kernel or ''}"] + + def shell_command(self) -> str: + return " ".join(self.command_line) + + def __repr__(self) -> str: # pragma: no cover - cosmetic + return f"SimTarget(pc={self.machine.pc:#x}, alive={self._alive})" + + +def launch_native( + *, + kernel: str | None = None, + memory_mb: int = 256, + record=None, + symbols: SymbolDB | None = None, + name: str = "session", + load_symbols: bool = True, + keep_alive: bool = False, + sim_ips: int = SIM_IPS, + trace=None, + device_map=None, + unknown_mmio: str = "fault", + learn_mmio: bool = False, + bias: int = 0, +) -> Session: + """Boot the native simulator and return a ready Session. + + ``trace`` enables the R4 execution/access trace: ``True`` for defaults, a + dict of ``{"exec": bool, "start": int, "end": int, "max_events": int}``, + or a pre-built :class:`sim.trace.Trace`. The trace is written to the + session as ``blobs/trace.jsonl`` when the Session closes; tracing implies + recording, so a session directory is created when none was given. + + ``device_map`` is a :class:`greeneye.device_map.DeviceMap` (or a path to a + saved map) describing modelled MMIO; ``unknown_mmio="lenient"`` serves + unmodelled accesses from it (or zero) instead of faulting, logging each in + ``machine.mmio_log`` and counting ``machine.mmio_unknown``. + ``learn_mmio=True`` writes the observed values back into the map. + ``bias`` relocates PT_LOAD segments and the entry for images linked for + GPU physical addresses (e.g. ``0x80000000`` for the real GSP fwimage). + """ + try: + tracer = make_trace(trace) + except ValueError as exc: + raise GreenEyeError(f"invalid trace spec: {exc}") from exc + if tracer is not None and not record: + record = True # the trace artifact lives in the session + if isinstance(device_map, (str, os.PathLike)): + from .device_map import DeviceMap + + device_map = DeviceMap.load(os.fspath(device_map)) + machine = Machine( + memory_mb=memory_mb, + device_map=device_map, + unknown_mmio=unknown_mmio, + learn_mmio=learn_mmio, + bias=bias, + ) + target = SimTarget( + machine, + memory_mb=memory_mb, + sim_ips=sim_ips, + keep_alive=keep_alive, + trace=tracer, + ) + target.kernel = kernel + symdb = symbols or SymbolDB() + meta = {"backend": "native", "sim_ips": sim_ips, "firmware": kernel} + if unknown_mmio != "fault" or device_map is not None: + meta["unknown_mmio"] = unknown_mmio + session = Session( + client=target.client, + target=target, + symbols=symdb, + record=record, + name=name, + meta=meta, + ) + if tracer is not None: + target.client._recorder = session.recorder + session.recorder.meta["trace"] = {**tracer.config()} + if kernel: + image = load_elf(kernel) + if not image.is_riscv: + raise GreenEyeError(f"{kernel}: not a RISC-V ELF image") + machine.load_segments(image.flat_load_plan(bias=bias), image.relocated_entry(bias=bias)) + session.load_image(image, bias=bias, load_symbols=load_symbols) + return session diff --git a/debug/risc-v-gsp-rm-vm/greeneye/symbols.py b/debug/risc-v-gsp-rm-vm/greeneye/symbols.py index 7b2e9dd..cb11f7f 100644 --- a/debug/risc-v-gsp-rm-vm/greeneye/symbols.py +++ b/debug/risc-v-gsp-rm-vm/greeneye/symbols.py @@ -25,7 +25,7 @@ import json import os from dataclasses import dataclass, field as _field -from .errors import SymbolNotFound +from .errors import GreenEyeError, SymbolNotFound @dataclass @@ -193,9 +193,15 @@ class SymbolDB: return count def load_json(self, path: str) -> int: - with open(path, "r", encoding="utf-8") as fh: - payload = json.load(fh) - symbols = payload.get("symbols", payload if isinstance(payload, list) else []) + """Load a green-eye JSON symbol DB; raise GreenEyeError on bad input.""" + try: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + except (OSError, json.JSONDecodeError) as exc: + raise GreenEyeError(f"cannot load symbol DB {path!r}: {exc}") from exc + if not isinstance(payload, (dict, list)): + raise GreenEyeError(f"symbol DB {path!r} must be a JSON object or list") + symbols = payload.get("symbols", []) if isinstance(payload, dict) else payload count = 0 for item in symbols: try: @@ -276,12 +282,15 @@ class SymbolDB: return f"{addr:#x} {sym.label(addr)}" def nearest(self, addr: int, max_delta: int = 0x10000) -> Symbol | None: + """Alias of :meth:`resolve` kept for call sites that read better as + "nearest symbol" (same containing/preceding/max-delta rules).""" return self.resolve(addr, max_delta=max_delta) def names(self) -> list[str]: return sorted(self._by_name) def by_subsystem(self, subsystem: str) -> list[Symbol]: + """Return all symbols tagged with ``subsystem`` (loaders set it when known).""" return [s for s in self._symbols if s.subsystem == subsystem] diff --git a/debug/risc-v-gsp-rm-vm/harness/__init__.py b/debug/risc-v-gsp-rm-vm/harness/__init__.py new file mode 100644 index 0000000..d99d547 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/__init__.py @@ -0,0 +1,11 @@ +"""green-eye automation harness. + +Two stdlib-only orchestration tools: + +* :mod:`harness.portability_matrix` - multi-version/multi-target probe + demo + orchestration with a cross-target delta report (roadmap R6, H4.1); +* :mod:`harness.mutation_harness` - bounded, seeded mutation of the demo + firmware's mailbox input surface (roadmap R5, H4.3). + +Both are development tools; see docs/18-HARNESS-AUTOMATION.md. +""" diff --git a/debug/risc-v-gsp-rm-vm/harness/ci_release.py b/debug/risc-v-gsp-rm-vm/harness/ci_release.py new file mode 100644 index 0000000..dbc1f6f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/ci_release.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +"""Release triage orchestration for green-eye (planned docs/21). + +Given an old and a new firmware target it runs, in order: + +1. ``harness/version_diff.py`` -- image/symbol/corpus/probe/behavior diff; +2. ``harness/portability_matrix.py`` -- probe + scenario + cross-target deltas; +3. ``harness/mutation_harness.py`` -- a small deterministic mutation smoke. + +Every step writes under ``--out`` and the whole directory is hashed into +``bundle-manifest.json`` (the evidence bundle). A failing step is recorded and +the remaining steps still run; the process exits non-zero only when a step +reports an error. + +``--dry-run`` prints the plan and exits 0 without opening targets, and the +RM 610 gate (docs/23) is enforced before any target file is read. Nothing is +ever downloaded. + +Run from the tool root: + + python3 harness/ci_release.py --old old.elf --new new.elf --out dumps/release + python3 harness/ci_release.py --old old.elf --new new.elf --dry-run +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from dataclasses import dataclass +from pathlib import Path + +try: + from .common import DEMO_ELF, ensure_dir, qemu_available, sha256_file, write_json, write_text + from .mutation_harness import HarnessOptions, run_harness + from .portability_matrix import MatrixOptions, TargetSpec, render_markdown, run_matrix + from .version_diff import ( + RM610_TOKEN, + Rm610GateError, + VersionDiffOptions, + check_rm610_gate, + run_version_diff, + ) +except ImportError: # direct script execution (python3 harness/ci_release.py) + from common import ( # type: ignore[no-redef] + DEMO_ELF, + ensure_dir, + qemu_available, + sha256_file, + write_json, + write_text, + ) + from mutation_harness import HarnessOptions, run_harness # type: ignore[no-redef] + from portability_matrix import MatrixOptions, TargetSpec, render_markdown, run_matrix # type: ignore[no-redef] + from version_diff import ( # type: ignore[no-redef] + RM610_TOKEN, + Rm610GateError, + VersionDiffOptions, + check_rm610_gate, + run_version_diff, + ) + +FORMAT = "greeneye-ci-release/1" +PLAN_FORMAT = "greeneye-ci-release-plan/1" +BUNDLE_FORMAT = "greeneye-release-bundle/1" + + +@dataclass +class ReleaseOptions: + old: str + new: str + out: str | None = None + old_label: str | None = None + new_label: str | None = None + release: str | None = None + approval_token: str | None = None + old_symbols: str | None = None + new_symbols: str | None = None + matrix_symbols: str | None = None + old_ghidra_names: str | None = None + new_ghidra_names: str | None = None + old_ghidra_index: str | None = None + new_ghidra_index: str | None = None + old_ghidra_exports: str | None = None + new_ghidra_exports: str | None = None + old_ghidra_bias: int = 0 + new_ghidra_bias: int = 0 + corpus_diff: tuple[str, str] | None = None + corpus_report: str | None = None + stimulus: str | None = None + probe_instructions: int = 2000 + behavior_duration: float = 0.4 + max_rows: int = 40 + mutation_kernel: str = DEMO_ELF + mutation_iterations: int = 8 + mutation_seed: int = 7 + mutation_timeout: float = 0.25 + mutation_settle: float = 0.1 + allow_foreign_mutation: bool = False + dry_run: bool = False + + def labels(self) -> list[str]: + return [ + value + for value in ( + self.release, + self.old_label, + self.new_label, + os.path.basename(self.old), + os.path.basename(self.new), + os.path.basename(self.mutation_kernel) if self.mutation_kernel else None, + ) + if value + ] + + +def _version_diff_options(options: ReleaseOptions, out_dir: str) -> VersionDiffOptions: + return VersionDiffOptions( + old=options.old, + new=options.new, + out=out_dir, + old_label=options.old_label, + new_label=options.new_label, + release=options.release, + old_symbols=options.old_symbols, + new_symbols=options.new_symbols, + old_ghidra_names=options.old_ghidra_names, + new_ghidra_names=options.new_ghidra_names, + old_ghidra_index=options.old_ghidra_index, + new_ghidra_index=options.new_ghidra_index, + old_ghidra_exports=options.old_ghidra_exports, + new_ghidra_exports=options.new_ghidra_exports, + old_ghidra_bias=options.old_ghidra_bias, + new_ghidra_bias=options.new_ghidra_bias, + corpus_diff=options.corpus_diff, + corpus_report=options.corpus_report, + stimulus=options.stimulus, + probe_instructions=options.probe_instructions, + behavior_duration=options.behavior_duration, + max_rows=options.max_rows, + approval_token=options.approval_token, + ) + + +# --------------------------------------------------------------------------- +# plan (dry run) +# --------------------------------------------------------------------------- + + +def build_plan(options: ReleaseOptions) -> dict: + out = os.path.abspath(options.out) if options.out else "" + vd_out = os.path.join(out, "version-diff") + matrix_out = os.path.join(out, "portability-matrix") + mutation_out = os.path.join(out, "mutation-smoke") + vd_argv = [sys.executable, "harness/version_diff.py", "--old", options.old, "--new", options.new, + "--out", vd_out, "--probe-instructions", str(options.probe_instructions), + "--behavior-duration", str(options.behavior_duration), "--max-rows", str(options.max_rows)] + if options.old_label: + vd_argv += ["--old-label", options.old_label] + if options.new_label: + vd_argv += ["--new-label", options.new_label] + if options.release: + vd_argv += ["--release", options.release] + if options.old_symbols: + vd_argv += ["--old-symbols", options.old_symbols] + if options.new_symbols: + vd_argv += ["--new-symbols", options.new_symbols] + if options.stimulus: + vd_argv += ["--stimulus", options.stimulus] + matrix_argv = [sys.executable, "harness/portability_matrix.py", "--target", options.old, + "--target", options.new, "--out", matrix_out, + "--probe-instructions", str(options.probe_instructions), + "--scenario-duration", str(options.behavior_duration), + "--max-rows", str(options.max_rows)] + mutation_argv = [sys.executable, "harness/mutation_harness.py", "--kernel", options.mutation_kernel, + "--out", mutation_out, "--iterations", str(options.mutation_iterations), + "--seed", str(options.mutation_seed), "--timeout", str(options.mutation_timeout), + "--settle", str(options.mutation_settle)] + if options.allow_foreign_mutation: + mutation_argv.append("--allow-foreign-elf") + return { + "format": PLAN_FORMAT, + "tool": "harness/ci_release.py", + "dry_run": True, + "status": "planned", + "rm610_gate": {"status": "passed", "labels": sorted(set(options.labels()))}, + "old": {"label": options.old_label or Path(options.old).stem, "path": options.old}, + "new": {"label": options.new_label or Path(options.new).stem, "path": options.new}, + "out": out, + "steps": [ + {"name": "version_diff", "out": vd_out, "argv": vd_argv}, + {"name": "portability_matrix", "out": matrix_out, "argv": matrix_argv}, + {"name": "mutation_smoke", "out": mutation_out, "argv": mutation_argv}, + ], + "manifest": os.path.join(out, "bundle-manifest.json"), + "release_report": os.path.join(out, "release-report.json"), + } + + +# --------------------------------------------------------------------------- +# steps +# --------------------------------------------------------------------------- + + +def step_version_diff(options: ReleaseOptions, out_dir: str) -> dict: + report = run_version_diff(_version_diff_options(options, out_dir)) + sections = {name: section["status"] for name, section in report["sections"].items()} + status = "error" if "error" in sections.values() else "ok" + return { + "status": status, + "out": os.path.basename(out_dir), + "report": "version-diff.json", + "sections": sections, + "summary": report["summary"], + } + + +def step_portability_matrix(options: ReleaseOptions, out_dir: str) -> dict: + matrix = run_matrix( + [TargetSpec(path=os.path.abspath(options.old), name=Path(options.old).stem), + TargetSpec(path=os.path.abspath(options.new), name=Path(options.new).stem)], + MatrixOptions( + out=out_dir, + probe_instructions=options.probe_instructions, + scenario_duration=options.behavior_duration, + max_rows=options.max_rows, + symbols_json=options.matrix_symbols, + ), + ) + ensure_dir(out_dir) + write_json(os.path.join(out_dir, "portability-report.json"), matrix) + write_text( + os.path.join(out_dir, "portability-report.md"), + render_markdown(matrix, MatrixOptions(out=out_dir, max_rows=options.max_rows)), + ) + statuses = [target["status"] for target in matrix["targets"]] + status = "error" if "error" in statuses else "ok" + return { + "status": status, + "report": "portability-report.json", + "targets": {target["name"]: target["status"] for target in matrix["targets"]}, + "comparisons": [ + {"a": comp["a"], "b": comp["b"], "status": comp["status"], "all_zero": comp["all_zero"]} + for comp in matrix["comparisons"] + ], + } + + +def step_mutation_smoke(options: ReleaseOptions, out_dir: str) -> dict: + if not qemu_available(): + return {"status": "skipped", "reason": "qemu-system-riscv64 not found"} + if not os.path.isfile(options.mutation_kernel): + return {"status": "skipped", "reason": f"mutation kernel not found: {options.mutation_kernel}"} + harness = HarnessOptions( + kernel=os.path.abspath(options.mutation_kernel), + out=out_dir, + iterations=options.mutation_iterations, + seed=options.mutation_seed, + timeout=options.mutation_timeout, + settle=options.mutation_settle, + allow_foreign_elf=options.allow_foreign_mutation, + ) + findings = run_harness(harness) + outcomes: dict[str, int] = {} + for finding in findings: + outcomes[finding["outcome"]] = outcomes.get(finding["outcome"], 0) + 1 + return { + "status": "ok", + "report": "findings.jsonl", + "iterations": len(findings), + "outcomes": dict(sorted(outcomes.items())), + } + + +def _run_step(name: str, fn, *args) -> dict: + try: + detail = fn(*args) + except Exception as exc: # a failed step must not hide the others + return {"name": name, "status": "error", "reason": f"{type(exc).__name__}: {exc}"} + entry = {"name": name} + entry.update(detail) + return entry + + +# --------------------------------------------------------------------------- +# bundle +# --------------------------------------------------------------------------- + + +def build_manifest(out_dir: str) -> dict: + files: list[dict] = [] + for root, dirs, names in os.walk(out_dir): + dirs.sort() + for name in sorted(names): + path = os.path.join(root, name) + rel = os.path.relpath(path, out_dir) + if rel == "bundle-manifest.json": + continue + files.append({"path": rel, "bytes": os.path.getsize(path), "sha256": sha256_file(path)}) + files.sort(key=lambda row: row["path"]) + return {"format": BUNDLE_FORMAT, "tool": "harness/ci_release.py", "files": files} + + +def run_release(options: ReleaseOptions) -> dict: + check_rm610_gate(options.labels(), approval_token=options.approval_token) + if not options.out: + raise ValueError("--out is required unless --dry-run is used") + out = os.path.abspath(options.out) + ensure_dir(out) + steps = [ + _run_step("version_diff", step_version_diff, options, os.path.join(out, "version-diff")), + _run_step("portability_matrix", step_portability_matrix, options, + os.path.join(out, "portability-matrix")), + _run_step("mutation_smoke", step_mutation_smoke, options, os.path.join(out, "mutation-smoke")), + ] + report = { + "format": FORMAT, + "tool": "harness/ci_release.py", + "dry_run": False, + "rm610_gate": {"status": "passed", "labels": sorted(set(options.labels()))}, + "old": {"label": options.old_label or Path(options.old).stem, "path": os.path.basename(options.old)}, + "new": {"label": options.new_label or Path(options.new).stem, "path": os.path.basename(options.new)}, + "steps": steps, + "errors": [step["name"] for step in steps if step["status"] == "error"], + "manifest": "bundle-manifest.json", + } + write_json(os.path.join(out, "release-report.json"), report) + manifest = build_manifest(out) + write_json(os.path.join(out, "bundle-manifest.json"), manifest) + return report + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="ci_release", description=__doc__.splitlines()[0]) + parser.add_argument("--old", required=True, help="old target (RISC-V ELF or raw .fwimage)") + parser.add_argument("--new", required=True, help="new target (RISC-V ELF or raw .fwimage)") + parser.add_argument("--out", help="evidence bundle directory (required unless --dry-run)") + parser.add_argument("--dry-run", action="store_true", help="print the plan and exit without touching targets") + parser.add_argument("--old-label", help="report label for the old target") + parser.add_argument("--new-label", help="report label for the new target") + parser.add_argument("--release", help="release label; an rm610 release is gated by docs/23") + parser.add_argument("--approval-token", help=f"explicit RM610 token ({RM610_TOKEN})") + parser.add_argument("--old-symbols", help="green-eye symbol DB JSON for the old target") + parser.add_argument("--new-symbols", help="green-eye symbol DB JSON for the new target") + parser.add_argument("--matrix-symbols", help="one symbol DB JSON applied to both matrix targets") + parser.add_argument("--old-ghidra-names", help="Ghidra names.tsv for the old target") + parser.add_argument("--new-ghidra-names", help="Ghidra names.tsv for the new target") + parser.add_argument("--old-ghidra-index", help="Ghidra decompiled_index.tsv for the old target") + parser.add_argument("--new-ghidra-index", help="Ghidra decompiled_index.tsv for the new target") + parser.add_argument("--old-ghidra-exports", help="Ghidra exports.tsv / 3-column export for the old target") + parser.add_argument("--new-ghidra-exports", help="Ghidra exports.tsv / 3-column export for the new target") + parser.add_argument("--old-ghidra-bias", type=lambda value: int(value, 0), default=0) + parser.add_argument("--new-ghidra-bias", type=lambda value: int(value, 0), default=0) + parser.add_argument("--corpus-diff", nargs=2, metavar=("OLD_TSV", "NEW_TSV"), + help="diff two decompiled_index.tsv corpus exports") + parser.add_argument("--corpus-report", help="attach a previously produced corpus diff report") + parser.add_argument("--stimulus", help="green-eye script replacing the builtin demo scenario") + parser.add_argument("--probe-instructions", type=int, default=2000) + parser.add_argument("--behavior-duration", type=float, default=0.4) + parser.add_argument("--max-rows", type=int, default=40) + parser.add_argument("--mutation-kernel", default=DEMO_ELF, help="kernel for the mutation smoke (default: demo)") + parser.add_argument("--mutation-iterations", type=int, default=8) + parser.add_argument("--mutation-seed", type=int, default=7) + parser.add_argument("--mutation-timeout", type=float, default=0.25) + parser.add_argument("--mutation-settle", type=float, default=0.1) + parser.add_argument("--allow-foreign-mutation", action="store_true", + help="permit a non-demo mutation kernel (QEMU only)") + return parser + + +def options_from_args(args: argparse.Namespace) -> ReleaseOptions: + return ReleaseOptions( + old=args.old, + new=args.new, + out=args.out, + old_label=args.old_label, + new_label=args.new_label, + release=args.release, + approval_token=args.approval_token, + old_symbols=args.old_symbols, + new_symbols=args.new_symbols, + matrix_symbols=args.matrix_symbols, + old_ghidra_names=args.old_ghidra_names, + new_ghidra_names=args.new_ghidra_names, + old_ghidra_index=args.old_ghidra_index, + new_ghidra_index=args.new_ghidra_index, + old_ghidra_exports=args.old_ghidra_exports, + new_ghidra_exports=args.new_ghidra_exports, + old_ghidra_bias=args.old_ghidra_bias, + new_ghidra_bias=args.new_ghidra_bias, + corpus_diff=tuple(args.corpus_diff) if args.corpus_diff else None, # type: ignore[arg-type] + corpus_report=args.corpus_report, + stimulus=args.stimulus, + probe_instructions=args.probe_instructions, + behavior_duration=args.behavior_duration, + max_rows=args.max_rows, + mutation_kernel=args.mutation_kernel, + mutation_iterations=args.mutation_iterations, + mutation_seed=args.mutation_seed, + mutation_timeout=args.mutation_timeout, + mutation_settle=args.mutation_settle, + allow_foreign_mutation=args.allow_foreign_mutation, + dry_run=args.dry_run, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + options = options_from_args(args) + try: + check_rm610_gate(options.labels(), approval_token=options.approval_token) + except Rm610GateError as exc: + print(f"[release] RM610 gate refused: {exc}", file=sys.stderr) + return 3 + if options.dry_run: + plan = build_plan(options) + print(json.dumps(plan, indent=2, sort_keys=True)) + print(f"[release] dry-run: {len(plan['steps'])} steps planned, no targets touched") + return 0 + if not options.out: + parser.error("--out is required unless --dry-run is used") + try: + report = run_release(options) + except ValueError as exc: + parser.error(str(exc)) + except Exception as exc: + print(f"[release] error: {type(exc).__name__}: {exc}", file=sys.stderr) + return 2 + for step in report["steps"]: + reason = f" reason={step['reason']}" if step.get("reason") else "" + print(f"[release] {step['name']}: status={step['status']}{reason}") + print(f"[release] wrote {os.path.join(os.path.abspath(options.out), 'release-report.json')}") + print(f"[release] wrote {os.path.join(os.path.abspath(options.out), 'bundle-manifest.json')}") + return 1 if report["errors"] else 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/common.py b/debug/risc-v-gsp-rm-vm/harness/common.py new file mode 100644 index 0000000..74dabe0 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/common.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Shared helpers for the green-eye automation harness. + +The harness scripts run from the tool root without installation, so this +module bootstraps the tool root onto ``sys.path`` before importing greeneye. +The QEMU backend is the only execution backend the harness claims: the matrix +must stay comparable to the CLI ``probe``/``demo`` paths and must not depend +on the in-development native simulator. +""" +from __future__ import annotations + +import contextlib +import hashlib +import json +import os +import shutil +import sys +import tempfile +from dataclasses import dataclass, field + +TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from greeneye.analyze import parse_qemu_faults # noqa: E402 +from greeneye.elf import FirmwareImage, load_elf, write_flat_kernel # noqa: E402 +from greeneye.errors import GreenEyeError, TargetError # noqa: E402 +from greeneye.rsp import RspClient # noqa: E402 +from greeneye.session import Session # noqa: E402 +from greeneye.symbols import SymbolDB # noqa: E402 +from greeneye.target import QemuTarget, find_qemu # noqa: E402 + +DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf") +DEMO_SCHEMA = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "gsp_ctrl.schema.json") + +#: Raw images are relocated to the top of QEMU's virt RAM, matching `greeneye probe`. +LOAD_ADDR = 0x80000000 +#: Refuse images whose flat form would be unreasonably large. +MAX_FLAT_BYTES = 256 * 1024 * 1024 +#: Cap the QEMU memory size the harness will request. +MAX_MEMORY_MB = 2048 +#: Default bound when reading a QEMU log window (trap loops flood `-d int`). +MAX_LOG_BYTES = 1 << 20 + + +def sha256_file(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +def qemu_available() -> bool: + try: + find_qemu() + return True + except TargetError: + return False + + +def qemu_path() -> str: + return find_qemu() + + +def load_target(path: str) -> FirmwareImage: + if not os.path.isfile(path): + raise GreenEyeError(f"target not found: {path}") + image = load_elf(path) + if not image.is_riscv: + raise GreenEyeError(f"{path}: not a RISC-V ELF image") + span = image.max_vaddr - image.min_vaddr + if span > MAX_FLAT_BYTES: + raise GreenEyeError(f"{path}: image span {span:#x} exceeds the {MAX_FLAT_BYTES:#x} harness cap") + return image + + +def memory_mb_for(image: FirmwareImage) -> int: + span = image.max_vaddr - image.min_vaddr + return min(MAX_MEMORY_MB, max(256, span // (1024 * 1024) + 64)) + + +def ensure_dir(path: str) -> str: + os.makedirs(path, exist_ok=True) + return path + + +def write_json(path: str, payload: dict) -> str: + ensure_dir(os.path.dirname(path)) + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.write("\n") + return path + + +def write_text(path: str, text: str) -> str: + ensure_dir(os.path.dirname(path)) + with open(path, "w", encoding="utf-8") as fh: + fh.write(text) + return path + + +def relpath(path: str, root: str) -> str: + return os.path.relpath(path, root) + + +def counter_delta(a: dict, b: dict) -> list[dict]: + """Stable union delta of two count maps (B minus A), sorted by key.""" + rows = [] + for key in sorted(set(a) | set(b)): + a_count = int(a.get(key, 0)) + b_count = int(b.get(key, 0)) + rows.append({"key": key, "a": a_count, "b": b_count, "delta": b_count - a_count}) + return rows + + +def read_log_window(path: str | None, offset: int = 0, max_bytes: int = MAX_LOG_BYTES) -> tuple[str, bool]: + """Read a bounded QEMU log window; returns ``(text, capped)``. + + Trap loops emit a ``-d int`` line per faulting instruction, so an + unbounded read can be hundreds of megabytes. Faults of interest (the + first exception) are at the start of the window. + """ + if not path: + return "", False + try: + with open(path, "rb") as fh: + fh.seek(offset) + data = fh.read(max_bytes) + except (FileNotFoundError, OSError): + return "", False + capped = len(data) == max_bytes + text = data.decode("utf-8", "replace") + if capped: + text = text.rsplit("\n", 1)[0] + return text, capped + + +def read_faults_window(path: str | None, offset: int = 0, max_bytes: int = MAX_LOG_BYTES) -> tuple[list[dict], bool]: + text, capped = read_log_window(path, offset=offset, max_bytes=max_bytes) + return parse_qemu_faults(text), capped + + +@dataclass +class FlatSession: + """A live QEMU session around a relocated flat kernel.""" + + image: FirmwareImage + bias: int + session: Session + target: QemuTarget + workdir: str + flat_kernel: str + owns_workdir: bool = field(default=False) + + +@contextlib.contextmanager +def open_flat_session( + image: FirmwareImage, + target_path: str, + *, + record: str | bool | None, + name: str, + symbols: SymbolDB | None = None, + log_faults: bool = True, + workdir: str | None = None, + meta: dict | None = None, +): + """Start QEMU on the target's flat relocated image and yield a session. + + Relocation (``LOAD_ADDR - image.min_vaddr``) mirrors ``greeneye probe``: + firmware linked for GPU physical addresses has to be placed into virt RAM + before QEMU can execute it. Symbols are loaded at the relocated addresses. + """ + owns_workdir = workdir is None + if workdir is None: + workdir = tempfile.mkdtemp(prefix="greeneye-harness-") + flat_kernel = os.path.join(workdir, "flat.bin") + write_flat_kernel(target_path, flat_kernel, load_addr=LOAD_ADDR) + bias = LOAD_ADDR - image.min_vaddr + target = QemuTarget( + kernel=flat_kernel, + memory_mb=memory_mb_for(image), + dlog="guest_errors,unimp,int" if log_faults else None, + workdir=workdir, + ) + target.start(paused=True) + client = RspClient(target.host, target.port) + client.connect() + client.sync() + db = symbols if symbols is not None else SymbolDB() + session_meta = {"backend": "qemu", "bias": bias, "firmware": target_path, "harness": name} + session_meta.update(meta or {}) + session = Session( + client=client, + target=target, + symbols=db, + record=record, + name=name, + meta=session_meta, + ) + session.load_image(image, bias=bias, load_symbols=symbols is None) + try: + yield FlatSession( + image=image, + bias=bias, + session=session, + target=target, + workdir=workdir, + flat_kernel=flat_kernel, + owns_workdir=owns_workdir, + ) + finally: + try: + session.close() + finally: + if owns_workdir: + shutil.rmtree(workdir, ignore_errors=True) diff --git a/debug/risc-v-gsp-rm-vm/harness/control_discovery.py b/debug/risc-v-gsp-rm-vm/harness/control_discovery.py new file mode 100644 index 0000000..eecf082 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/control_discovery.py @@ -0,0 +1,1055 @@ +#!/usr/bin/env python3 +"""Unknown-call discovery over firmware control surfaces (H4, "Sandsifter for RM"). + +The problem this tool attacks: NVIDIA ships new GSP images on a hidden +schedule and new RM messages/controls/services appear without documentation. +Instead of waiting for a new control to be noticed, sweep the input space and +ask the firmware itself which calls exist, which code path each one takes, and +what it changes. + +Each probe uses a fresh QEMU session, settles the firmware through its +self-test, hooks the command dispatcher (and every handler the control block +declares), applies exactly one command with a deterministic argument pattern, +runs a bounded window and classifies the outcome: + + known-handler the documented/id-table handler for this id ran + injected-slot the id routed through the injected-service handler table + default-path the dispatcher's catch-all ran (undiscovered id) + trap the id reached an ``ebreak`` (debugger trap) + fault the id produced a load/store/fetch/illegal exception + no-progress the mailbox was never consumed within the budget + +Findings are written to ``findings.jsonl`` and a markdown report groups ids by +class, listing unknown/undiscovered ids explicitly. + +Surfaces (``--surface``): + +* ``demo`` -- the bundled uproc mailbox command space (works today on QEMU); +* ``rm`` -- an RM control/method entry point for the future simulator or a + live session. It is implemented behind the same :class:`Surface` + abstraction (a probe function + an effect observer), so a real RM surface is + a new class, not a rewrite. When no entry point is exposed the tool prints + a documented message and exits cleanly. + +Safety: the bundled demo (or an explicitly allowed foreign ELF) under QEMU, +fresh session per probe, bounded budgets, host writes limited to ``--out``. +Never hardware, a guest, an existing gdbstub or the native simulator by +default. See docs/19-CONTROL-DISCOVERY.md. + +Run from the tool root: + + python3 harness/control_discovery.py --surface demo --out dumps/discovery +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import random +import shutil +import struct +import sys +import time +from collections import Counter +from dataclasses import dataclass, field + +try: + from .common import ( + DEMO_ELF, + MAX_LOG_BYTES, + ensure_dir, + load_target, + qemu_available, + read_faults_window, + sha256_file, + ) +except ImportError: # direct script execution (python3 harness/control_discovery.py) + from common import ( # type: ignore[no-redef] + DEMO_ELF, + MAX_LOG_BYTES, + ensure_dir, + load_target, + qemu_available, + read_faults_window, + sha256_file, + ) + +from greeneye import launch # noqa: E402 +from greeneye.errors import GreenEyeError, RspTimeout # noqa: E402 + +FORMAT = "greeneye-control-discovery/1" +MASK64 = (1 << 64) - 1 + +#: gsp_ctrl field offsets (firmware/uproc_demo/gsp_ctrl.schema.json). +CTRL_MAGIC = 0 +CTRL_STATE = 8 +CTRL_RPC_COUNT = 16 +CTRL_LAST_RPC = 24 +CTRL_MAILBOX = 32 +CTRL_ARG0 = 40 +CTRL_ARG1 = 48 +CTRL_ARG2 = 56 +CTRL_RESULT = 64 +CTRL_BUF = 72 +CTRL_BUF_SIZE = 256 +CTRL_HANDLER_COUNT = 328 +CTRL_HANDLERS = 336 +CTRL_SIZE = 400 + +#: The demo dispatcher's documented switch cases. +DEMO_CASE_HANDLERS = { + 1: "rpc_ping", + 2: "rpc_echo", + 3: "rpc_fill", + 4: "rpc_sum", + 5: "rpc_state", + 6: "rpc_fault", +} +#: Mailbox commands 16..23 select handler-table slots 0..7 (docs/12 section 4). +INJECT_CMD_BASE = 16 +INJECT_CMD_END = 24 + +SETTLE_MARKER = "SELFTEST DONE" +SETTLE_SLICE = 0.02 +RUN_SLICE = 0.02 +TRAP_GRACE = 0.05 +#: gdb signals that carry a hazard (illegal, trap, bus, segv); SIGINT from the +#: budget interrupt is wall-clock dependent and must never enter a finding. +HAZARD_SIGNALS = (4, 5, 7, 11) + +PATTERNS = ("zero", "sentinel", "boundary") +OUTCOMES = ( + "known-handler", + "injected-slot", + "method-return", + "default-path", + "trap", + "fault", + "no-progress", + "settle-failed", + "error", +) +DEFAULT_RECORD_CLASSES = ("injected-slot", "method-return", "trap", "fault", "default-path", "settle-failed", "error") + + +# --------------------------------------------------------------------------- +# deterministic probe generation +# --------------------------------------------------------------------------- + + +@dataclass +class Probe: + index: int + cmd: int + pattern: str + args: tuple[int, int, int] + + @property + def name(self) -> str: + return f"{self.index:04d}-id{self.cmd:03d}-{self.pattern}" + + +def pattern_args(pattern: str, seed: int, cmd: int) -> tuple[int, int, int]: + """Deterministic argument vector per (pattern, seed, cmd). + + The per-command RNG stream keeps sentinels distinct across ids while a + fixed seed makes the whole sweep replayable. + """ + if pattern == "zero": + return (0, 0, 0) + rng = random.Random(seed * 1_000_003 + cmd * 97 + 0x5EED) + if pattern == "sentinel": + return tuple(rng.getrandbits(64) for _ in range(3)) # type: ignore[return-value] + if pattern == "boundary": + return (MASK64, 1, 0x7FFF_FFFF_FFFF_FFFF) + raise GreenEyeError(f"unknown pattern {pattern!r} (expected one of {', '.join(PATTERNS)})") + + +def generate_probes(max_id: int, patterns: list[str], seed: int, iterations: int = 0) -> list[Probe]: + probes: list[Probe] = [] + for cmd in range(1, max_id + 1): + for pattern in patterns: + probes.append(Probe(index=len(probes), cmd=cmd, pattern=pattern, args=pattern_args(pattern, seed, cmd))) + if iterations > 0: + probes = probes[:iterations] + return probes + + +# --------------------------------------------------------------------------- +# effect observer +# --------------------------------------------------------------------------- + + +@dataclass +class WatchedField: + name: str + offset: int + size: int + fmt: str # u8 | u32 | u64 | bytes + + +@dataclass +class EffectObserver: + """Captures the effect half of a surface: field snapshots + store PCs.""" + + watched: tuple[WatchedField, ...] = () + hits: list[dict] = field(default_factory=list) + + def reset(self) -> None: + self.hits.clear() + + def arm(self, session, base: int) -> None: + """Install write watchpoints for every watched field.""" + for watched in self.watched: + if watched.fmt == "bytes": + continue # byte-range watchpoints are not portable; hash at snapshot time + session.watch(base + watched.offset, size=watched.size, kind="w", callback=self._make_callback(watched)) + + def _make_callback(self, watched: WatchedField): + def callback(hit) -> None: + self.hits.append( + { + "field": watched.name, + "pc": hit.pc, + "symbol": hit.symbol.name if hit.symbol else None, + "addr": hit.addr, + } + ) + + return callback + + def snapshot(self, session, base: int) -> dict: + values: dict = {} + for watched in self.watched: + raw = session.memory.read(base + watched.offset, watched.size) + if watched.fmt == "bytes": + values[watched.name] = { + "sha256": hashlib.sha256(raw).hexdigest()[:16], + "len": len(raw), + "preview": raw[:16].hex(), + } + elif watched.fmt == "u8": + values[watched.name] = raw[0] + elif watched.fmt == "u32": + values[watched.name] = struct.unpack_from(" dict: + changed = [ + {"field": name, "before": before.get(name), "after": after.get(name)} + for name in before + if before.get(name) != after.get(name) + ] + return {"changed": changed, "watch": list(self.hits)} + + +# --------------------------------------------------------------------------- +# surfaces +# --------------------------------------------------------------------------- + + +class Surface: + """A sweepable input surface: command space + probe action + effects. + + A future surface is a subclass; the sweep, classification and reporting + machinery is shared. ``probe`` applies one command, ``observer`` returns + the effect model, and ``handler_map``/``command_for_addr`` model the + entry points so ids can be attributed to code. + """ + + name = "abstract" + + def describe(self) -> str: + return self.name + + def available(self) -> tuple[bool, str | None]: + return True, None + + def documented_handlers(self, session) -> dict[int, str]: + return {} + + def handler_map(self, session) -> dict[int, tuple[int, str | None]]: + """Map command id -> (entry address, symbol name) for declared handlers.""" + return {} + + def command_hook(self, session) -> str | None: + return None + + def is_injected(self, cmd: int) -> bool: + return False + + def probe(self, session, cmd: int, args: tuple[int, int, int], *, timeout: float) -> int | None: + """Apply one command. Returns a direct return value when the surface is + a call entry point, else None (effects carry the reply).""" + raise NotImplementedError + + def observer(self) -> EffectObserver: + return EffectObserver() + + def command_for_addr(self, session, addr: int) -> list[dict]: + """Ids whose declared handler resolves to ``addr`` (trigger discovery).""" + out: list[dict] = [] + for cmd, (entry, name) in sorted(self.handler_map(session).items()): + if entry == addr: + out.append({"id": cmd, "handler": name}) + return out + + +class DemoSurface(Surface): + """The uproc demo mailbox surface (gsp_ctrl.handlers + 1..6 switch).""" + + name = "demo" + + def documented_handlers(self, session) -> dict[int, str]: + return { + cmd: name for cmd, name in DEMO_CASE_HANDLERS.items() if session.symbols.get(name) is not None + } + + def handler_map(self, session) -> dict[int, tuple[int, str | None]]: + mapping: dict[int, tuple[int, str | None]] = {} + for cmd, name in self.documented_handlers(session).items(): + mapping[cmd] = (session.symbols.lookup(name), name) + ctrl = session.symbols.get("gsp_ctrl") + if ctrl is not None: + for slot in range(8): + entry = session.memory.read_u64(ctrl.addr + CTRL_HANDLERS + slot * 8) + if entry: + symbol = session.symbols.resolve(entry) + mapping[INJECT_CMD_BASE + slot] = (entry, symbol.name if symbol else None) + return mapping + + def command_hook(self, session) -> str | None: + return "rpc_dispatch" if session.symbols.get("rpc_dispatch") is not None else None + + def is_injected(self, cmd: int) -> bool: + return INJECT_CMD_BASE <= cmd < INJECT_CMD_END + + def probe(self, session, cmd: int, args: tuple[int, int, int], *, timeout: float) -> int | None: + ctrl = session.symbols.lookup("gsp_ctrl") + # Args first: the firmware polls the mailbox last and consumes all four. + session.memory.write_u64(ctrl + CTRL_ARG0, args[0]) + session.memory.write_u64(ctrl + CTRL_ARG1, args[1]) + session.memory.write_u64(ctrl + CTRL_ARG2, args[2]) + session.memory.write_u64(ctrl + CTRL_MAILBOX, cmd) + return None + + def observer(self) -> EffectObserver: + return EffectObserver( + watched=( + WatchedField("state", CTRL_STATE, 4, "u32"), + WatchedField("rpc_count", CTRL_RPC_COUNT, 8, "u64"), + WatchedField("last_rpc", CTRL_LAST_RPC, 8, "u64"), + WatchedField("result", CTRL_RESULT, 8, "u64"), + WatchedField("buf", CTRL_BUF, CTRL_BUF_SIZE, "bytes"), + ) + ) + + def snapshot_extra(self, session, base: int) -> dict: + raw = session.memory.read(base, CTRL_SIZE) + return { + "magic": struct.unpack_from(" str: + bits = [f"entry={self.entry or ''}"] + bits.append(f"reply={self.reply_addr:#x}/{self.reply_size:#x}" if self.reply_addr else "reply=") + return "rm(" + ", ".join(bits) + ")" + + def available(self) -> tuple[bool, str | None]: + if not self.entry: + return False, ( + "surface 'rm' unavailable: no RM control/method entry point is exposed by the " + "simulator or a live session yet (roadmap H1.3). Pass --rm-entry SYMBOL once the " + "RM method dispatcher exists; the demo surface works today with --surface demo." + ) + return True, None + + def is_injected(self, cmd: int) -> bool: + return False + + def probe(self, session, cmd: int, args: tuple[int, int, int], *, timeout: float) -> int | None: + return session.call(self.entry, (cmd, args[0], args[1], args[2]), timeout=timeout) + + def observer(self) -> EffectObserver: + if not self.reply_addr: + return EffectObserver() + return EffectObserver( + watched=(WatchedField("reply", self.reply_addr, self.reply_size, "bytes"),) + ) + + def snapshot_extra(self, session, base: int) -> dict: + return {} + + +def make_surface(args) -> Surface: + if args.surface == "demo": + return DemoSurface() + if args.surface == "rm": + return RMSurface(entry=args.rm_entry, reply_addr=args.rm_reply, reply_size=args.rm_reply_size) + raise GreenEyeError(f"unknown surface {args.surface!r}") + + +# --------------------------------------------------------------------------- +# one probe +# --------------------------------------------------------------------------- + + +@dataclass +class Finding: + probe: int + cmd: int + pattern: str + args: list[int] + outcome: str = "error" + handler: str | None = None + handler_entry: int | None = None + documented_handler: str | None = None + dispatch_seen: bool = False + dispatch_pc: int | None = None + dispatch_args: list[int] | None = None + call_retval: int | None = None + rpc_count_delta: int = 0 + effects: dict = field(default_factory=dict) + fault: dict | None = None + signals: list[dict] = field(default_factory=list) + settle_ok: bool = True + session: str | None = None + reason: str | None = None + + def to_json(self) -> dict: + return { + "probe": self.probe, + "cmd": self.cmd, + "pattern": self.pattern, + "args": self.args, + "outcome": self.outcome, + "handler": self.handler, + "handler_entry": self.handler_entry, + "documented_handler": self.documented_handler, + "dispatch_seen": self.dispatch_seen, + "dispatch_pc": self.dispatch_pc, + "dispatch_args": self.dispatch_args, + "call_retval": self.call_retval, + "rpc_count_delta": self.rpc_count_delta, + "effects": self.effects, + "fault": self.fault, + "signals": self.signals, + "settle_ok": self.settle_ok, + "session": self.session, + "reason": self.reason, + } + + +def _compact_fault(fault: dict) -> dict: + return { + "cause": fault.get("cause"), + "desc": fault.get("desc"), + "epc": fault.get("epc"), + "tval": fault.get("tval"), + "access": fault.get("access"), + } + + +def classify_outcome( + *, + cmd: int, + is_injected: bool, + handler: str | None, + dispatch_seen: bool, + call_retval: int | None, + faults: list[dict], + signals: list[dict], + settle_ok: bool, +) -> str: + """Priority-ordered classification of one probe result (pure function).""" + if not settle_ok: + return "settle-failed" + causes = [int(fault.get("cause", 0)) for fault in faults] + if any(cause == 3 for cause in causes) or any(sig.get("signal") == 5 for sig in signals): + return "trap" + if causes or any(sig.get("signal") in (4, 7, 11) for sig in signals): + return "fault" + if call_retval is not None: + return "method-return" + if not dispatch_seen: + return "no-progress" + if is_injected and handler: + return "injected-slot" + if handler: + return "known-handler" + return "default-path" + + +class ProbeRunner: + """Runs one probe in a fresh session and builds the finding.""" + + def __init__(self, surface: Surface, options: "DiscoveryOptions", image, kernel_sha: str): + self.surface = surface + self.options = options + self.image = image + self.kernel_sha = kernel_sha + + def run(self, probe: Probe) -> Finding: + finding = Finding(probe=probe.index, cmd=probe.cmd, pattern=probe.pattern, args=list(probe.args)) + staging = os.path.join(self.options.out, ".staging", probe.name) + ensure_dir(staging) + qemu_log = os.path.join(staging, "qemu-faults.log") + # Record into staging unconditionally: the classification decides whether + # the session is evidence worth keeping, and re-running a probe to + # record it later would double the sweep cost. + record_path = staging + session = None + try: + extra_args = None + if self.options.backend == "qemu": + extra_args = ["-d", "guest_errors,unimp,int", "-D", qemu_log] + session = launch( + kernel=self.options.kernel, + record=record_path, + name=f"discovery-{probe.name}", + backend=self.options.backend, + extra_args=extra_args, + ) + finding.settle_ok = self._settle(session, self.options.settle) + if not finding.settle_ok: + finding.reason = f"{SETTLE_MARKER!r} not observed within the settle budget" + return self._finish(session, probe, finding) + + observer = self.surface.observer() + before = observer.snapshot(session, self._base(session)) + before.update(self._extra(self.surface, session)) + observer.reset() + observer.arm(session, self._base(session)) + + dispatches: list[dict] = [] + handlers: list[dict] = [] + signals: list[dict] = [] + self._install_hooks(session, dispatches, handlers, signals) + observer.reset() + + log_offset = os.path.getsize(qemu_log) if os.path.exists(qemu_log) else 0 + call_retval: int | None = None + try: + call_retval = self.surface.probe(session, probe.cmd, probe.args, timeout=self.options.timeout) + except RspTimeout as exc: + finding.reason = f"probe call timed out: {exc}" + self._run_window(session, dispatches, handlers, signals, observer) + + faults: list[dict] = [] + if self.options.backend == "qemu": + faults, _capped = read_faults_window(qemu_log, log_offset, self.options.max_log_bytes) + + after = observer.snapshot(session, self._base(session)) + after.update(self._extra(self.surface, session)) + effects = observer.delta(before, after) + effects["before"] = before + effects["after"] = after + + dispatch = dispatches[0] if dispatches else None + handler = handlers[0] if handlers else None + mapped = self.surface.handler_map(session) + finding.documented_handler = self.surface.documented_handlers(session).get(probe.cmd) + finding.dispatch_seen = dispatch is not None + finding.dispatch_pc = dispatch.get("addr") if dispatch else None + finding.dispatch_args = dispatch.get("args") if dispatch else None + finding.handler = handler.get("symbol") if handler else None + finding.handler_entry = handler.get("addr") if handler else None + if handler is None and probe.cmd in mapped: + entry, name = mapped[probe.cmd] + finding.handler = name + finding.handler_entry = entry + finding.call_retval = call_retval + finding.rpc_count_delta = int(effects.get("after", {}).get("rpc_count", 0)) - int( + effects.get("before", {}).get("rpc_count", 0) + ) + finding.effects = effects + finding.fault = _compact_fault(faults[0]) if faults else None + finding.signals = signals + finding.outcome = classify_outcome( + cmd=probe.cmd, + is_injected=self.surface.is_injected(probe.cmd), + handler=finding.handler, + dispatch_seen=dispatch is not None, + call_retval=call_retval, + faults=faults, + signals=signals, + settle_ok=True, + ) + return self._finish(session, probe, finding) + except Exception as exc: # one bad probe must not end the sweep + finding.outcome = "error" + finding.reason = f"{type(exc).__name__}: {exc}" + return self._finish(session, probe, finding) + + # -- helpers ----------------------------------------------------------- + + def _base(self, session) -> int: + return session.symbols.lookup("gsp_ctrl") if session.symbols.get("gsp_ctrl") else 0 + + def _extra(self, surface: Surface, session) -> dict: + extra = getattr(surface, "snapshot_extra", None) + if extra is None: + return {} + return extra(session, self._base(session)) + + def _install_hooks(self, session, dispatches: list[dict], handlers: list[dict], signals: list[dict]) -> None: + def on_signal(hit) -> None: + stop = hit.stop + if stop is not None and stop.signal in HAZARD_SIGNALS: + signals.append({"signal": stop.signal, "pc": hit.pc}) + + session.on_event("stop", on_signal) + hook_name = self.surface.command_hook(session) + if hook_name: + session.hook( + hook_name, + on_enter=lambda hit: dispatches.append( + { + "addr": hit.addr, + "symbol": hook_name, + "args": [hit.arg(0), hit.arg(1), hit.arg(2), hit.arg(3)], + "caller": hit.regs.get("ra", 0), + } + ), + listener=f"discovery:{hook_name}", + ) + for cmd, (entry, name) in sorted(self.surface.handler_map(session).items()): + session.hook( + entry, + on_enter=lambda hit, name=name: handlers.append( + { + "addr": hit.addr, + "symbol": hit.symbol.name if hit.symbol else name, + "args": [hit.arg(0), hit.arg(1), hit.arg(2)], + "caller": hit.regs.get("ra", 0), + } + ), + listener=f"discovery:handler-{entry:#x}", + ) + + def _settle(self, session, budget: float) -> bool: + deadline = time.monotonic() + budget + while time.monotonic() < deadline: + if SETTLE_MARKER in session.target.read_uart(): + return True + session.run(duration=SETTLE_SLICE, max_stops=1) + return SETTLE_MARKER in session.target.read_uart() + + def _run_window(self, session, dispatches, handlers, signals, observer) -> None: + """Two-phase bounded window: wait for dispatch, then for completion. + + Completion is a ``result`` store (the dispatcher writes it after every + handler) or a trap/grace for ids that never return to the dispatcher. + """ + deadline = time.monotonic() + self.options.timeout + handler_seen_at: float | None = None + while time.monotonic() < deadline: + if not session.target.alive(): + return + remaining = max(0.005, min(RUN_SLICE, deadline - time.monotonic())) + session.run(duration=remaining, max_stops=64) + if handlers and handler_seen_at is None: + handler_seen_at = time.monotonic() + result_written = any(hit.get("field") == "result" for hit in observer.hits) + trapped = any(sig.get("signal") == 5 for sig in signals) or session.pc < 0x100 + if dispatches and (result_written or trapped): + return + if handler_seen_at is not None and time.monotonic() - handler_seen_at >= TRAP_GRACE: + # Slow/hung handler: one grace window after the entry, then stop. + return + + def _keep(self, outcome: str) -> bool: + return self.options.record_any or outcome in self.options.record_classes + + def _finish(self, session, probe: Probe, finding: Finding) -> Finding: + if session is not None: + try: + session.close() + except Exception: + pass + staging = os.path.join(self.options.out, ".staging", probe.name) + if self._keep(finding.outcome) and os.path.isdir(staging): + final = os.path.join(self.options.out, "sessions", probe.name) + ensure_dir(os.path.dirname(final)) + if os.path.isdir(final): + shutil.rmtree(final, ignore_errors=True) + os.replace(staging, final) + finding.session = os.path.join("sessions", probe.name) + else: + shutil.rmtree(staging, ignore_errors=True) + return finding + + +# --------------------------------------------------------------------------- +# report +# --------------------------------------------------------------------------- + + +def aggregate(findings: list[Finding]) -> dict: + per_id: dict[int, dict] = {} + for finding in findings: + row = per_id.setdefault( + finding.cmd, + { + "id": finding.cmd, + "patterns": [], + "outcomes": [], + "handlers": [], + "documented_handler": finding.documented_handler, + "results": [], + "rpc_count_delta": 0, + "sessions": [], + "faults": [], + }, + ) + row["patterns"].append(finding.pattern) + row["outcomes"].append(finding.outcome) + if finding.handler: + row["handlers"].append(finding.handler) + result = (finding.effects or {}).get("after", {}).get("result") + if result is not None and finding.dispatch_seen: + row["results"].append(result) + row["rpc_count_delta"] += finding.rpc_count_delta + if finding.session: + row["sessions"].append(finding.session) + if finding.fault: + row["faults"].append(finding.fault) + for row in per_id.values(): + row["outcomes"] = sorted(set(row["outcomes"])) + row["handlers"] = sorted(set(row["handlers"])) + row["results"] = sorted(set(row["results"])) + return dict(sorted(per_id.items())) + + +def primary_class(row: dict) -> str: + """One label per id for grouping: handle, injected, trap, fault, unknown.""" + outcomes = set(row["outcomes"]) + if "trap" in outcomes: + return "trap/fault" + if "fault" in outcomes: + return "trap/fault" + if "known-handler" in outcomes and row["documented_handler"]: + return "known-handler" + if "injected-slot" in outcomes: + return "injected-slot" + if "method-return" in outcomes: + return "method-return" + if outcomes & {"default-path", "no-progress"}: + return "unknown/undiscovered" + return "unknown/undiscovered" + + +def render_markdown(report: dict, options: "DiscoveryOptions") -> str: + per_id = report["ids"] + outcome_counts = Counter(finding["outcome"] for finding in report["findings"]) + grouped: dict[str, list[int]] = {} + for row in per_id.values(): + grouped.setdefault(primary_class(row), []).append(row["id"]) + lines = [ + "# green-eye control discovery report", + "", + "> TOOL IN DEVELOPMENT - NOT READY YET! Generated by `harness/control_discovery.py`.", + "", + f"* format: `{FORMAT}`", + f"* surface: `{report['surface']}`", + f"* kernel: `{report['kernel']}`", + f"* kernel sha256: `{report['kernel_sha256']}`", + f"* backend: `{report['backend']}`", + f"* ids swept: 1..{report['max_id']} ({report['probes_run']} probes), " + f"seed={report['seed']}, patterns={','.join(report['patterns'])}", + f"* per-probe timeout: {report['timeout']}s; settle budget: {report['settle']}s", + "", + "## Outcome counts", + "", + ] + lines += _table( + ["outcome", "count"], + [[outcome, outcome_counts[outcome]] for outcome in sorted(outcome_counts)], + ) + lines += ["", "## Ids by class", ""] + for klass in ("known-handler", "injected-slot", "method-return", "trap/fault", "unknown/undiscovered"): + ids = grouped.get(klass, []) + lines += [f"* **{klass}**: " + (", ".join(str(i) for i in ids) if ids else "_none_")] + lines += ["", "## Known handler ids", ""] + known = [row for row in per_id.values() if row["documented_handler"]] + lines += _render_rows(known) + lines += ["", "## Injected-slot dispatch ids", ""] + injected = [row for row in per_id.values() if "injected-slot" in row["outcomes"]] + lines += _render_rows(injected) + lines += ["", "## Unknown / undiscovered ids", ""] + unknown = [ + row + for row in per_id.values() + if not row["documented_handler"] and not (set(row["outcomes"]) & {"trap", "fault"}) + ] + if unknown: + lines += _render_rows(unknown) + lines += [ + "", + "These ids reached the dispatcher's catch-all (result ``~id``) and no handler " + "table entry; they are the candidate space for new/unmapped controls.", + ] + else: + lines += ["_none_"] + lines += ["", "## Traps and faults", ""] + hazards = [row for row in per_id.values() if set(row["outcomes"]) & {"trap", "fault"}] + lines += _render_rows(hazards) + lines += ["", "## All findings by id", ""] + lines += _render_rows(list(per_id.values())) + lines += ["", "## Reproduce", "", "```bash", report["reproduce"], "```", ""] + return "\n".join(lines) + "\n" + + +def _render_rows(rows: list[dict]) -> list[str]: + if not rows: + return ["_none_"] + return _table( + ["id", "documented handler", "observed handler", "outcomes", "results", "rpc delta", "sessions"], + [ + [ + row["id"], + row["documented_handler"] or "-", + ", ".join(row["handlers"]) or "-", + ", ".join(row["outcomes"]), + ", ".join(f"{value:#x}" for value in row["results"][:4]) or "-", + row["rpc_count_delta"], + ", ".join(f"`{path}`" for path in row["sessions"]) or "-", + ] + for row in rows + ], + ) + + +def _table(headers: list[str], rows: list[list]) -> list[str]: + lines = ["| " + " | ".join(headers) + " |", "|" + "|".join("---" for _ in headers) + "|"] + for row in rows: + lines.append("| " + " | ".join(str(cell) for cell in row) + " |") + return lines + + +# --------------------------------------------------------------------------- +# orchestration +# --------------------------------------------------------------------------- + + +@dataclass +class DiscoveryOptions: + surface: str + out: str + kernel: str = DEMO_ELF + max_id: int = 63 + patterns: list[str] = field(default_factory=lambda: list(PATTERNS)) + seed: int = 1 + iterations: int = 0 + timeout: float = 0.25 + settle: float = 2.0 + record_all: bool = False + record_classes: tuple[str, ...] = DEFAULT_RECORD_CLASSES + backend: str = "qemu" + allow_foreign_elf: bool = False + max_log_bytes: int = MAX_LOG_BYTES + rm_entry: str | None = None + rm_reply: int | None = None + rm_reply_size: int = 0x100 + + @property + def record_any(self) -> bool: + return self.record_all + + +def run_discovery(options: DiscoveryOptions) -> dict: + if options.backend == "qemu" and not qemu_available(): + raise GreenEyeError("qemu-system-riscv64 not found; install qemu-system-misc or set GREENEYE_QEMU") + if not os.path.isfile(options.kernel): + raise GreenEyeError(f"kernel not found: {options.kernel}") + if not options.allow_foreign_elf and os.path.abspath(options.kernel) != os.path.abspath(DEMO_ELF): + raise GreenEyeError( + "control discovery is restricted to the bundled demo firmware; " + "pass --allow-foreign-elf to sweep another ELF under QEMU" + ) + image = load_target(options.kernel) + kernel_sha = sha256_file(options.kernel) + surface = make_surface(options) + ensure_dir(options.out) + probes = generate_probes(options.max_id, options.patterns, options.seed, options.iterations) + runner = ProbeRunner(surface, options, image, kernel_sha) + findings: list[Finding] = [] + findings_path = os.path.join(options.out, "findings.jsonl") + with open(findings_path, "w", encoding="utf-8") as fh: + for probe in probes: + finding = runner.run(probe) + findings.append(finding) + fh.write(json.dumps(finding.to_json(), sort_keys=True) + "\n") + fh.flush() + print( + f"[discovery] {finding.probe + 1}/{len(probes)} id={finding.cmd} {finding.pattern} " + f"-> {finding.outcome}" + (f" ({finding.handler})" if finding.handler else ""), + file=sys.stderr, + ) + shutil.rmtree(os.path.join(options.out, ".staging"), ignore_errors=True) + report = { + "format": FORMAT, + "tool": "harness/control_discovery.py", + "surface": surface.describe(), + "kernel": options.kernel, + "kernel_sha256": kernel_sha, + "backend": options.backend, + "max_id": options.max_id, + "patterns": options.patterns, + "seed": options.seed, + "timeout": options.timeout, + "settle": options.settle, + "probes_run": len(findings), + "ids": aggregate(findings), + "findings": [finding.to_json() for finding in findings], + "reproduce": reproduce_command(options), + } + write_json(os.path.join(options.out, "discovery-report.json"), report) + with open(os.path.join(options.out, "control-discovery.md"), "w", encoding="utf-8") as fh: + fh.write(render_markdown(report, options)) + return report + + +def reproduce_command(options: DiscoveryOptions) -> str: + foreign = " --allow-foreign-elf" if options.allow_foreign_elf else "" + record = " --record-all" if options.record_all else "" + rm = "" + if options.surface == "rm": + rm = f" --rm-entry {options.rm_entry}" if options.rm_entry else "" + if options.rm_reply: + rm += f" --rm-reply {options.rm_reply:#x}" + return ( + f"python3 harness/control_discovery.py --surface {options.surface} --kernel {options.kernel} " + f"--out {options.out} --max-id {options.max_id} --patterns {','.join(options.patterns)} " + f"--seed {options.seed} --timeout {options.timeout} --settle {options.settle}" + f"{record}{foreign}{rm}" + ) + + +def write_json(path: str, payload: dict) -> str: + ensure_dir(os.path.dirname(path)) + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.write("\n") + return path + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def parse_patterns(text: str) -> list[str]: + patterns = [part.strip() for part in text.split(",") if part.strip()] + unknown = [pattern for pattern in patterns if pattern not in PATTERNS] + if unknown: + raise argparse.ArgumentTypeError(f"unknown pattern(s) {unknown}; expected {', '.join(PATTERNS)}") + return patterns + + +def parse_record_classes(text: str) -> tuple[str, ...]: + classes = tuple(part.strip() for part in text.split(",") if part.strip()) + unknown = [klass for klass in classes if klass not in OUTCOMES] + if unknown: + raise argparse.ArgumentTypeError(f"unknown outcome class(es) {unknown}; expected {', '.join(OUTCOMES)}") + return classes + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="control_discovery", description=__doc__.splitlines()[0]) + parser.add_argument("--surface", choices=("demo", "rm"), default="demo", help="input surface to sweep") + parser.add_argument("--kernel", default=DEMO_ELF, help="demo/re-hosted ELF (default: bundled demo)") + parser.add_argument("--out", required=True, help="output directory for findings and reports") + parser.add_argument("--max-id", type=int, default=63, help="highest command id to sweep (0..4096)") + parser.add_argument("--patterns", type=parse_patterns, default=list(PATTERNS), help="comma list: zero,sentinel,boundary") + parser.add_argument("--seed", type=int, default=1, help="deterministic pattern seed") + parser.add_argument("--iterations", type=int, default=0, help="cap total probes (0 = all)") + parser.add_argument("--timeout", type=float, default=0.25, help="bounded observation window per probe (seconds)") + parser.add_argument("--settle", type=float, default=2.0, help="budget to observe the self-test before probing") + parser.add_argument("--record-all", action="store_true", help="record every probe (default: interesting classes only)") + parser.add_argument( + "--record-classes", + type=parse_record_classes, + default=DEFAULT_RECORD_CLASSES, + help="outcome classes whose sessions are kept", + ) + parser.add_argument("--backend", choices=("qemu", "native"), default="qemu", help="execution backend (qemu is the supported default)") + parser.add_argument("--allow-foreign-elf", action="store_true", help="permit a non-demo ELF under QEMU") + parser.add_argument("--max-log-bytes", type=int, default=MAX_LOG_BYTES, help="QEMU fault-log window per probe") + parser.add_argument("--rm-entry", help="RM surface: method/control entry symbol (future, H1.3)") + parser.add_argument("--rm-reply", type=lambda value: int(value, 0), help="RM surface: reply buffer address") + parser.add_argument("--rm-reply-size", type=lambda value: int(value, 0), default=0x100, help="RM surface: reply buffer size") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if not 1 <= args.max_id <= 4096: + parser.error("--max-id must be in 1..4096") + if args.iterations < 0: + parser.error("--iterations must be >= 0") + if not 0.01 <= args.timeout <= 30.0: + parser.error("--timeout must be in 0.01..30.0") + options = DiscoveryOptions( + surface=args.surface, + out=os.path.abspath(args.out), + kernel=os.path.abspath(args.kernel), + max_id=args.max_id, + patterns=args.patterns, + seed=args.seed, + iterations=args.iterations, + timeout=args.timeout, + settle=args.settle, + record_all=args.record_all, + record_classes=args.record_classes, + backend=args.backend, + allow_foreign_elf=args.allow_foreign_elf, + max_log_bytes=args.max_log_bytes, + rm_entry=args.rm_entry, + rm_reply=args.rm_reply, + rm_reply_size=args.rm_reply_size, + ) + surface = make_surface(args) + available, reason = surface.available() + if not available: + print(f"[discovery] skipped: {reason}") + return 0 + try: + report = run_discovery(options) + except GreenEyeError as exc: + print(f"control discovery error: {exc}", file=sys.stderr) + return 2 + counts = Counter(finding["outcome"] for finding in report["findings"]) + print(f"[discovery] {report['probes_run']} probe(s) on surface={report['surface']}, out={options.out}") + for outcome in sorted(counts): + print(f"[discovery] {outcome}: {counts[outcome]}") + print(f"[discovery] wrote {os.path.join(options.out, 'findings.jsonl')}") + print(f"[discovery] wrote {os.path.join(options.out, 'control-discovery.md')}") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/mutation_harness.py b/debug/risc-v-gsp-rm-vm/harness/mutation_harness.py new file mode 100644 index 0000000..7b558bd --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/mutation_harness.py @@ -0,0 +1,586 @@ +#!/usr/bin/env python3 +"""Bounded deterministic mutation of the demo firmware input surface (R5, H4.3). + +Each iteration starts a fresh QEMU session, settles the demo firmware to the +end of its self-test, mutates the mailbox command/args (and optionally a RAM +region), runs it for a bounded budget, and classifies the outcome: normal, +trap (ebreak / control transfer to an invalid PC), fault (first new QEMU +exception, parsed from the guest_errors/int log), no-progress, or +unexpected-result against a small demo oracle. Every attempt is recorded as a +green-eye session and a compact finding. + +Strategies: invalid command IDs, boundary values, single bit flips of valid +inputs, and buffer length boundaries for the ECHO/FILL/SUM commands. + +Safety: the harness launches its own QEMU on the bundled (or explicitly +allowed) demo/re-hosted ELF only. It never attaches to hardware, a guest or a +kernel module, and it never uses the native simulator or an existing gdbstub. + +Run from the tool root: + + python3 harness/mutation_harness.py --out dumps/mutation --iterations 64 --seed 7 +""" +from __future__ import annotations + +import argparse +import json +import os +import random +import shutil +import struct +import sys +import tempfile +from collections import Counter +from dataclasses import dataclass + +try: + from .common import ( + DEMO_ELF, + LOAD_ADDR, + MAX_LOG_BYTES, + ensure_dir, + load_target, + memory_mb_for, + open_flat_session, + qemu_available, + read_faults_window, + sha256_file, + ) +except ImportError: # direct script execution (python3 harness/mutation_harness.py) + from common import ( # type: ignore[no-redef] + DEMO_ELF, + LOAD_ADDR, + MAX_LOG_BYTES, + ensure_dir, + load_target, + memory_mb_for, + open_flat_session, + qemu_available, + read_faults_window, + sha256_file, + ) + +from greeneye.errors import GreenEyeError # noqa: E402 + +FORMAT = "greeneye-mutation-findings/1" +MASK64 = (1 << 64) - 1 +VALID_CMDS = (1, 2, 3, 4, 5, 6) +INVALID_CMDS = (0, 7, 8, 15, 24, 25, 31, 32, 63, 0x100, 0xFFFF, 0xFFFFFFFF, MASK64) +BOUNDARY_VALUES = ( + 0, + 1, + 2, + 3, + 4, + 7, + 8, + 16, + 32, + 63, + 64, + 65, + 128, + 0xFF, + 0x100, + 0x101, + 1024, + 0x7FFFFFFF, + 0x80000000, + 0x40000000, + 0xFFFFFFFF, + 0x8000000000000000, + MASK64, +) +BUFFER_LENGTHS = (0, 1, 2, 31, 32, 33, 255, 256, 257, 512, 4096, 0x10000, 0x10000000, MASK64) +STRATEGIES = ("cmd-invalid", "arg-boundary", "bit-flip", "buffer-length") + +CTRL_MAGIC = 0 +CTRL_VERSION = 4 +CTRL_STATE = 8 +CTRL_RPC_COUNT = 16 +CTRL_LAST_RPC = 24 +CTRL_MAILBOX = 32 +CTRL_ARG0 = 40 +CTRL_ARG1 = 48 +CTRL_ARG2 = 56 +CTRL_RESULT = 64 +CTRL_HANDLER_COUNT = 328 +CTRL_BUF_OFFSET = 72 # char buf[256] +CTRL_SIZE = 400 +SETTLE_MARKER = "SELFTEST DONE" +SETTLE_RETRIES = 3 + + +@dataclass +class Mutation: + iteration: int + strategy: str + cmd: int = 0 + arg0: int | str = 0 + arg1: int = 0 + arg2: int = 0 + region: dict | None = None + + def to_json(self) -> dict: + return { + "cmd": self.cmd, + "arg0": self.arg0, + "arg1": self.arg1, + "arg2": self.arg2, + "region": self.region, + } + + +@dataclass +class HarnessOptions: + kernel: str + out: str + iterations: int = 50 + seed: int = 1 + timeout: float = 0.25 + settle: float = 0.2 + phase_a: float = 0.05 + memory_region: tuple[int, int] | None = None + allow_foreign_elf: bool = False + max_log_bytes: int = MAX_LOG_BYTES + kernel_sha256: str = "" + is_demo: bool = False + + +# --------------------------------------------------------------------------- +# mutation generation +# --------------------------------------------------------------------------- + + +def generate_mutations(iterations: int, seed: int, region: tuple[int, int] | None = None) -> list[Mutation]: + """Deterministic strategy rotation; per-iteration RNG seeded by (seed, index).""" + strategies = STRATEGIES + (("mem-region",) if region else ()) + mutations: list[Mutation] = [] + for index in range(iterations): + # Integer seed: deterministic across Python versions/processes. + rng = random.Random(seed * 1_000_003 + index) + strategy = strategies[index % len(strategies)] + mutation = Mutation(iteration=index, strategy=strategy) + if strategy == "cmd-invalid": + mutation.cmd = rng.choice(INVALID_CMDS) + elif strategy == "arg-boundary": + mutation.cmd = rng.choice(VALID_CMDS) + args = [0, 0, 0] + args[rng.randrange(3)] = rng.choice(BOUNDARY_VALUES) + mutation.arg0, mutation.arg1, mutation.arg2 = args + elif strategy == "bit-flip": + values = { + "cmd": rng.choice(VALID_CMDS), + "arg0": rng.choice((0, 1, 0x41, 0xFF)), + "arg1": rng.choice((0, 1, 32)), + "arg2": 0, + } + target = rng.choice(("cmd", "arg0", "arg1", "arg2")) + values[target] = (values[target] ^ (1 << rng.randrange(64))) & MASK64 + mutation.cmd = values["cmd"] + mutation.arg0, mutation.arg1, mutation.arg2 = values["arg0"], values["arg1"], values["arg2"] + elif strategy == "buffer-length": + mutation.cmd = rng.choice((2, 3, 4)) + mutation.arg0 = "ctrl_buf" if mutation.cmd in (2, 4) else rng.choice((0, 0x41)) + mutation.arg1 = rng.choice(BUFFER_LENGTHS) + elif strategy == "mem-region": + addr, size = region # type: ignore[misc] + mutation.region = { + "addr": addr, + "size": size, + "offset": rng.randrange(size), + "xor": 1 << rng.randrange(8), + "old": None, + "new": None, + } + mutations.append(mutation) + return mutations + + +def resolve_arg0(mutation: Mutation, ctrl: int) -> int: + if mutation.arg0 == "ctrl_buf": + return ctrl + CTRL_BUF_OFFSET + return int(mutation.arg0) + + +# --------------------------------------------------------------------------- +# demo oracle (documented subset only; everything else is not judged) +# --------------------------------------------------------------------------- + + +def expected_for(mutation: Mutation, before: dict, ctrl: int) -> dict: + cmd = mutation.cmd + if cmd == 0: + return {"mode": "same"} + if cmd == 1: + return {"mode": "value", "value": (resolve_arg0(mutation, ctrl) ^ 0xBEEF) & MASK64} + if cmd == 3: + return {"mode": "value", "value": min(mutation.arg1, 256)} + if cmd == 5: + return {"mode": "one_of", "values": [0x10, 0x20, 0x30, 0x40]} + if 16 <= cmd <= 21: + return {"mode": None, "reason": "injected handler slot result is service-dependent"} + if cmd in (2, 4, 6): + return {"mode": None, "reason": "memory/trap-dependent handler"} + return {"mode": "value", "value": (~cmd) & MASK64} + + +def expected_ok(expectation: dict, mutation: Mutation, before: dict, after: dict) -> bool | None: + mode = expectation.get("mode") + if mode is None: + return None + if mode == "same": + return after["result"] == before["result"] and after["last_rpc"] == before["last_rpc"] + if mode == "value": + return after["result"] == expectation["value"] and after["last_rpc"] == mutation.cmd + if mode == "one_of": + return after["result"] in expectation["values"] and after["last_rpc"] == mutation.cmd + return None + + +# --------------------------------------------------------------------------- +# iteration execution +# --------------------------------------------------------------------------- + + +def read_ctrl_state(session, ctrl: int) -> dict: + raw = session.memory.read(ctrl, CTRL_SIZE) + u32 = lambda offset: struct.unpack_from(" None: + if mutation.region: + region = mutation.region + data = bytearray(session.memory.read(region["addr"], region["size"])) + region["old"] = data[region["offset"]] + data[region["offset"]] ^= region["xor"] + region["new"] = data[region["offset"]] + session.memory.write(region["addr"], bytes(data)) + session.memory.write_u64(ctrl + CTRL_MAILBOX, mutation.cmd) + session.memory.write_u64(ctrl + CTRL_ARG0, resolve_arg0(mutation, ctrl)) + session.memory.write_u64(ctrl + CTRL_ARG1, mutation.arg1) + session.memory.write_u64(ctrl + CTRL_ARG2, mutation.arg2) + + +def _compact_fault(fault: dict) -> dict: + return { + "cause": fault.get("cause"), + "desc": fault.get("desc"), + "epc": fault.get("epc"), + "tval": fault.get("tval"), + "access": fault.get("access"), + } + + +def classify( + mutation: Mutation, + before: dict, + after: dict, + faults: list[dict], + trap_loop: bool, + exit_seen: bool, + settle_ok: bool, + ctrl: int, +) -> tuple[str, dict]: + delta = after["rpc_count"] - before["rpc_count"] + detail = { + "observed": { + "result": after["result"], + "last_rpc": after["last_rpc"], + "rpc_count_delta": delta, + }, + "expected": None, + "expected_ok": None, + } + if not settle_ok: + detail["reason"] = f"{SETTLE_MARKER!r} not observed within the settle budget" + return "settle-failed", detail + if exit_seen: + return "exit", detail + if faults: + fault = faults[0] + detail["fault"] = _compact_fault(fault) + return ("trap" if fault.get("cause") == 3 else "fault"), detail + if trap_loop: + return "trap", detail + if mutation.cmd != 0 and delta <= 0: + return "no-progress", detail + expectation = expected_for(mutation, before, ctrl) + detail["expected"] = expectation + verdict = expected_ok(expectation, mutation, before, after) + detail["expected_ok"] = verdict + if verdict is False: + return "unexpected-result", detail + return "ok", detail + + +def _settle(session, fs, options: HarnessOptions) -> bool: + for _ in range(SETTLE_RETRIES): + session.run(duration=options.settle, max_stops=1) + if SETTLE_MARKER in fs.target.read_uart(): + return True + return False + + +def run_iteration(mutation: Mutation, options: HarnessOptions, image) -> dict: + session_rel = os.path.join("sessions", f"iter-{mutation.iteration:04d}") + finding = { + "iteration": mutation.iteration, + "strategy": mutation.strategy, + "mutation": mutation.to_json(), + "outcome": "error", + "detail": {}, + "session": session_rel, + } + workdir = tempfile.mkdtemp(prefix="greeneye-mut-") + try: + with open_flat_session( + image, + options.kernel, + record=os.path.join(options.out, session_rel), + name=f"mut-{mutation.iteration:04d}", + symbols=None, + log_faults=True, + workdir=workdir, + ) as fs: + session = fs.session + ctrl_symbol = session.symbols.get("gsp_ctrl") + if ctrl_symbol is None: + raise GreenEyeError("gsp_ctrl symbol not found: the mutation harness targets the demo control block") + ctrl = ctrl_symbol.addr + settle_ok = _settle(session, fs, options) + before = read_ctrl_state(session, ctrl) + faults: list[dict] = [] + trap_loop = False + exit_seen = False + after = before + if settle_ok: + log_offset = os.path.getsize(fs.target.qemu_log) if os.path.exists(fs.target.qemu_log) else 0 + apply_mutation(session, ctrl, mutation) + phase_a = min(options.phase_a, options.timeout) + session.run(duration=phase_a, max_stops=1) + faults, _capped = read_faults_window(fs.target.qemu_log, log_offset, options.max_log_bytes) + trap_loop = session.pc < 0x100 + after = read_ctrl_state(session, ctrl) + # Slow/hung handlers get one confirmation window before we call + # it no-progress; faults are still picked up if they appear. + if not faults and not trap_loop and mutation.cmd != 0 and after["rpc_count"] == before["rpc_count"]: + session.run(duration=max(0.01, options.timeout - phase_a), max_stops=1) + more, _capped = read_faults_window(fs.target.qemu_log, log_offset, options.max_log_bytes) + faults = more or faults + trap_loop = session.pc < 0x100 + after = read_ctrl_state(session, ctrl) + exit_seen = not fs.target.alive() + outcome, detail = classify(mutation, before, after, faults, trap_loop, exit_seen, settle_ok, ctrl) + finding["outcome"] = outcome + finding["detail"] = detail + except Exception as exc: # one bad iteration must not end the run + finding["outcome"] = "error" + finding["detail"] = {"reason": f"{type(exc).__name__}: {exc}"} + finally: + shutil.rmtree(workdir, ignore_errors=True) + return finding + + +# --------------------------------------------------------------------------- +# run + report +# --------------------------------------------------------------------------- + + +def run_harness(options: HarnessOptions) -> list[dict]: + if not qemu_available(): + raise GreenEyeError("qemu-system-riscv64 not found; install qemu-system-misc or set GREENEYE_QEMU") + if not os.path.isfile(options.kernel): + raise GreenEyeError(f"kernel not found: {options.kernel}") + image = load_target(options.kernel) + options.kernel_sha256 = sha256_file(options.kernel) + demo_sha = sha256_file(DEMO_ELF) if os.path.isfile(DEMO_ELF) else None + options.is_demo = options.kernel_sha256 == demo_sha or os.path.abspath(options.kernel) == os.path.abspath(DEMO_ELF) + if not options.is_demo and not options.allow_foreign_elf: + raise GreenEyeError( + "mutation is restricted to the bundled demo firmware; " + "pass --allow-foreign-elf to mutate another re-hosted ELF under QEMU" + ) + if options.memory_region: + addr, size = options.memory_region + limit = LOAD_ADDR + memory_mb_for(image) * 1024 * 1024 + if size <= 0 or addr < LOAD_ADDR or addr + size > limit: + raise GreenEyeError( + f"--memory-region {addr:#x}:{size:#x} is outside QEMU RAM " + f"[{LOAD_ADDR:#x}, {limit:#x})" + ) + ensure_dir(options.out) + findings: list[dict] = [] + mutations = generate_mutations(options.iterations, options.seed, options.memory_region) + findings_path = os.path.join(options.out, "findings.jsonl") + with open(findings_path, "w", encoding="utf-8") as fh: + for mutation in mutations: + finding = run_iteration(mutation, options, image) + findings.append(finding) + fh.write(json.dumps(finding, sort_keys=True) + "\n") + fh.flush() + write_summary(options, findings) + return findings + + +def write_summary(options: HarnessOptions, findings: list[dict]) -> str: + classes = Counter(finding["outcome"] for finding in findings) + strategies = Counter(finding["strategy"] for finding in findings) + lines = [ + "# green-eye mutation findings", + "", + "> TOOL IN DEVELOPMENT - NOT READY YET! Generated by `harness/mutation_harness.py`.", + "", + f"* format: `{FORMAT}`", + f"* kernel: `{options.kernel}`", + f"* kernel sha256: `{options.kernel_sha256}`", + f"* demo restriction: {'demo' if options.is_demo else 'foreign ELF (explicitly allowed)'}", + f"* seed: {options.seed}, iterations: {options.iterations}", + f"* timeout: {options.timeout}s, settle: {options.settle}s", + f"* memory region: {_fmt_region(options.memory_region)}", + "", + "## Outcome classes", + "", + ] + lines += _table( + ["outcome", "count", "first iteration"], + [ + [outcome, classes[outcome], next(f["iteration"] for f in findings if f["outcome"] == outcome)] + for outcome in sorted(classes) + ], + ) + lines += ["", "## Strategy coverage", ""] + lines += _table( + ["strategy", "iterations", "outcomes"], + [ + [ + strategy, + strategies[strategy], + ", ".join( + f"{outcome}={count}" + for outcome, count in sorted(Counter(f["outcome"] for f in findings if f["strategy"] == strategy).items()) + ), + ] + for strategy in sorted(strategies) + ], + ) + notable = [finding for finding in findings if finding["outcome"] != "ok"][:20] + lines += ["", "## Notable findings (first 20 non-ok)", ""] + if not notable: + lines += ["_all iterations completed normally_", ""] + for finding in notable: + lines += [ + f"* iteration {finding['iteration']} `{finding['strategy']}` " + f"cmd={finding['mutation']['cmd']:#x} -> **{finding['outcome']}** " + f"{_short_json(finding['detail'])}", + ] + lines += ["", "## Reproduce", "", "```bash", _reproduce_command(options), "```", ""] + path = os.path.join(options.out, "summary.md") + with open(path, "w", encoding="utf-8") as fh: + fh.write("\n".join(lines)) + return path + + +def _reproduce_command(options: HarnessOptions) -> str: + region = f" --memory-region {_fmt_region(options.memory_region)}" if options.memory_region else "" + foreign = " --allow-foreign-elf" if not options.is_demo else "" + return ( + f"python3 harness/mutation_harness.py --kernel {options.kernel} --out {options.out} " + f"--iterations {options.iterations} --seed {options.seed} --timeout {options.timeout} " + f"--settle {options.settle}{region}{foreign}" + ) + + +def _fmt_region(region: tuple[int, int] | None) -> str: + return "-" if not region else f"{region[0]:#x}:{region[1]:#x}" + + +def _short_json(value, limit: int = 120) -> str: + text = json.dumps(value, sort_keys=True) + return f"`{text}`" if len(text) <= limit else f"`{text[:limit - 1]}...`" + + +def _table(headers: list[str], rows: list[list]) -> list[str]: + lines = ["| " + " | ".join(headers) + " |", "|" + "|".join("---" for _ in headers) + "|"] + for row in rows: + lines.append("| " + " | ".join(str(cell) for cell in row) + " |") + return lines + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def parse_region(text: str) -> tuple[int, int]: + try: + addr_text, size_text = text.split(":", 1) + addr = int(addr_text, 0) + size = int(size_text, 0) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"expected ADDR:SIZE, got {text!r}") from exc + return addr, size + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="mutation_harness", description=__doc__.splitlines()[0]) + parser.add_argument("--kernel", default=DEMO_ELF, help="demo/re-hosted ELF to mutate (default: bundled demo)") + parser.add_argument("--out", required=True, help="output directory for findings and recorded sessions") + parser.add_argument("--iterations", type=int, default=50, help="mutation count (1..10000)") + parser.add_argument("--seed", type=int, default=1, help="deterministic RNG seed") + parser.add_argument("--timeout", type=float, default=0.25, help="total observation budget per mutation (seconds)") + parser.add_argument("--settle", type=float, default=0.2, help="settle budget per attempt before mutating (seconds)") + parser.add_argument("--phase-a", type=float, default=0.05, help="first observation window before confirmation") + parser.add_argument("--memory-region", type=parse_region, help="optional RAM region to mutate, ADDR:SIZE") + parser.add_argument("--allow-foreign-elf", action="store_true", help="permit a non-demo ELF (QEMU only)") + parser.add_argument("--max-log-bytes", type=int, default=MAX_LOG_BYTES, help="QEMU log window per fault check") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if not 1 <= args.iterations <= 10000: + parser.error("--iterations must be in 1..10000") + if not 0.01 <= args.timeout <= 10.0: + parser.error("--timeout must be in 0.01..10.0") + if not 0.0 <= args.settle <= 10.0: + parser.error("--settle must be in 0..10.0") + options = HarnessOptions( + kernel=os.path.abspath(args.kernel), + out=os.path.abspath(args.out), + iterations=args.iterations, + seed=args.seed, + timeout=args.timeout, + settle=args.settle, + phase_a=args.phase_a, + memory_region=args.memory_region, + allow_foreign_elf=args.allow_foreign_elf, + max_log_bytes=args.max_log_bytes, + ) + try: + findings = run_harness(options) + except GreenEyeError as exc: + print(f"mutation harness error: {exc}", file=sys.stderr) + return 2 + classes = Counter(finding["outcome"] for finding in findings) + print(f"[mutation] {len(findings)} iteration(s), out={options.out}") + for outcome in sorted(classes): + print(f"[mutation] {outcome}: {classes[outcome]}") + print(f"[mutation] wrote {os.path.join(options.out, 'findings.jsonl')}") + print(f"[mutation] wrote {os.path.join(options.out, 'summary.md')}") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/portability_matrix.py b/debug/risc-v-gsp-rm-vm/harness/portability_matrix.py new file mode 100644 index 0000000..82af458 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/portability_matrix.py @@ -0,0 +1,779 @@ +#!/usr/bin/env python3 +"""Multi-version portability orchestration (roadmap R6, H4.1). + +Given one or more firmware ELF targets (a demo or re-hosted build, a raw +``.fwimage``, a future version), run the same deterministic scenario against +each on the QEMU backend: + +1. bounded probe: relocate into virt RAM and run/step a fixed budget, + capturing the first exception via ``parse_qemu_faults``; +2. symbol DB build: ELF symbols plus optional Ghidra exports / an existing + green-eye JSON DB, saved per target; +3. scripted demo run with handler hooks and a control-block watchpoint, + recorded as a normal green-eye session; +4. cross-target comparison with ``greeneye.analyze.compare_sessions`` plus + explicit hit/watch/write profile deltas and the docs/06 M1-M6 skeleton + (metrics without a reference capture are marked "not measured"). + +Outputs ``portability-report.json`` and ``portability-report.md`` under +``--out DIR`` with stable ordering. Unsupported targets are skipped with a +reason instead of aborting the matrix. + +Run from the tool root: + + python3 harness/portability_matrix.py --target firmware/uproc_demo/build/uproc_demo.elf --out dumps/matrix +""" +from __future__ import annotations + +import argparse +import hashlib +import os +import struct +from collections import Counter +from dataclasses import dataclass +from pathlib import Path + +try: + from .common import ( + LOAD_ADDR, + MAX_LOG_BYTES, + SymbolDB, + counter_delta, + ensure_dir, + load_target, + open_flat_session, + qemu_available, + read_log_window, + sha256_file, + write_json, + write_text, + ) +except ImportError: # direct script execution (python3 harness/portability_matrix.py) + from common import ( # type: ignore[no-redef] + LOAD_ADDR, + MAX_LOG_BYTES, + SymbolDB, + counter_delta, + ensure_dir, + load_target, + open_flat_session, + qemu_available, + read_log_window, + sha256_file, + write_json, + write_text, + ) + +from greeneye.analyze import compare_sessions, parse_qemu_faults # noqa: E402 +from greeneye.record import load_events # noqa: E402 + +FORMAT = "greeneye-portability-matrix/1" +RPC_HANDLERS = ("rpc_ping", "rpc_echo", "rpc_fill", "rpc_sum", "rpc_state", "rpc_fault") +CTRL_SIZE = 400 +CTRL_RPC_COUNT = 16 +CTRL_STATE = 8 +CTRL_HANDLER_COUNT = 328 + +METRIC_DEFS = ( + ("M1", "Inventory coverage"), + ("M2", "Reply agreement"), + ("M3", "Ordering agreement"), + ("M4", "State agreement"), + ("M5", "Error-path agreement"), + ("M6", "Memory side-effects"), +) +NO_REFERENCE = "agreement is not measured: needs a reference capture (docs/06 5)" + + +@dataclass +class TargetSpec: + path: str + name: str + + +@dataclass +class MatrixOptions: + out: str + probe_instructions: int = 2000 + probe_duration: float = 0.5 + scenario_duration: float = 0.4 + max_stops: int = 5000 + max_rows: int = 40 + symbols_json: str | None = None + ghidra_names: str | None = None + ghidra_index: str | None = None + ghidra_exports: str | None = None + ghidra_callgraph: str | None = None + ghidra_bias: int = 0 + scenario_script: str | None = None + log_bytes: int = MAX_LOG_BYTES + + +def _skipped_probe(reason: str | None) -> dict: + return { + "status": "skipped", + "reason": reason, + "session": None, + "instructions": None, + "stopped_pc": None, + "stopped_symbol": None, + "fault_count": 0, + "faults_capped": False, + "first_fault": None, + } + + +def _skipped_scenario(reason: str | None) -> dict: + return { + "status": "skipped", + "reason": reason, + "session": None, + "hooks": [], + "uart_banner": None, + "ctrl": None, + "snapshot": None, + "event_counts": {}, + "bp": {}, + "watch": {}, + "writes": {}, + "fault_count": 0, + "faults_capped": False, + "first_fault": None, + } + + +# --------------------------------------------------------------------------- +# per-target steps +# --------------------------------------------------------------------------- + + +def build_symbols(spec: TargetSpec, bias: int, out_dir: str, options: MatrixOptions): + """ELF symbols + optional Ghidra exports / JSON DB, saved per target.""" + db = SymbolDB() + db.load_elf(spec.path, bias=bias, source="elf") + sources = ["elf"] + if options.symbols_json: + if not os.path.isfile(options.symbols_json): + raise FileNotFoundError(f"symbol DB not found: {options.symbols_json}") + db.load_json(options.symbols_json) + sources.append(os.path.basename(options.symbols_json)) + ghidra_inputs = any( + ( + options.ghidra_names, + options.ghidra_index, + options.ghidra_exports, + options.ghidra_callgraph, + ) + ) + if ghidra_inputs: + from greeneye.ghidra import build_symbol_db + + gdb = build_symbol_db( + names_tsv=options.ghidra_names, + index_tsv=options.ghidra_index, + exports_tsv=options.ghidra_exports, + callgraph_tsv=options.ghidra_callgraph, + bias=options.ghidra_bias, + ) + for symbol in gdb: + db.add(symbol) + sources.append("ghidra") + rel = os.path.join("symbols", f"{spec.name}.json") + out_path = os.path.join(out_dir, rel) + ensure_dir(os.path.dirname(out_path)) + db.save_json( + out_path, + meta={"bias": bias, "sources": sources, "firmware": os.path.basename(spec.path)}, + ) + return db, sources, rel + + +def run_probe(spec: TargetSpec, image, db: SymbolDB, out_dir: str, options: MatrixOptions, qemu_ok: bool) -> dict: + if not qemu_ok: + return _skipped_probe("qemu-system-riscv64 not found") + result = _skipped_probe(None) + result["instructions"] = options.probe_instructions + session_rel = os.path.join("sessions", f"{spec.name}-probe") + try: + with open_flat_session( + image, + spec.path, + record=os.path.join(out_dir, session_rel), + name=f"{spec.name}-probe", + symbols=db, + log_faults=True, + ) as fs: + session = fs.session + if options.probe_instructions > 0: + session.step(options.probe_instructions) + else: + session.run(duration=options.probe_duration) + result["stopped_pc"] = session.pc + symbol = session.symbols.resolve(session.pc) + result["stopped_symbol"] = symbol.name if symbol else None + text, capped = read_log_window(fs.target.qemu_log, 0, options.log_bytes) + faults = parse_qemu_faults(text) + result["fault_count"] = len(faults) + result["faults_capped"] = capped + result["first_fault"] = faults[0] if faults else None + result["session"] = session_rel + if text: + fs.session.recorder.meta["qemu_log_blob"] = fs.session.recorder.blob("qemu.log", text.encode("utf-8")) + fs.session.recorder.meta["faults_capped"] = capped + result["status"] = "ok" + except Exception as exc: # a failed step must not abort the matrix + result["status"] = "error" + result["reason"] = f"{type(exc).__name__}: {exc}" + return result + + +def install_scenario(session) -> list[str]: + hooks: list[str] = [] + for name in RPC_HANDLERS: + symbol = session.symbols.get(name) + if symbol is None: + continue + session.hook(symbol.addr, on_enter=lambda hit: None, listener=f"matrix:{name}") + hooks.append(name) + return hooks + + +def read_ctrl(session, symbol) -> dict | None: + if symbol is None: + return None + raw = session.memory.read(symbol.addr, CTRL_SIZE) + u32 = lambda offset: struct.unpack_from(" dict: + counts: Counter = Counter() + bp: Counter = Counter() + watch: Counter = Counter() + writes: Counter = Counter() + for event in events: + kind = event.get("type", "?") + counts[kind] += 1 + if kind == "bp_hit": + bp[event.get("symbol") or f"{event.get('pc', 0):#x}"] += 1 + elif kind == "watch_hit": + watch[f"{event.get('addr', 0):#x}:{event.get('kind') or 'w'}"] += 1 + elif kind == "mem_write": + writes[f"{(event.get('addr', 0) >> 12) << 12:#x}"] += 1 + return { + "event_counts": dict(sorted(counts.items())), + "bp": {key: bp[key] for key in sorted(bp)}, + "watch": {key: watch[key] for key in sorted(watch)}, + "writes": {key: writes[key] for key in sorted(writes)}, + } + + +def run_scenario(spec: TargetSpec, image, db: SymbolDB, out_dir: str, options: MatrixOptions, qemu_ok: bool) -> dict: + if not qemu_ok: + return _skipped_scenario("qemu-system-riscv64 not found") + result = _skipped_scenario(None) + session_rel = os.path.join("sessions", f"{spec.name}-scenario") + try: + with open_flat_session( + image, + spec.path, + record=os.path.join(out_dir, session_rel), + name=f"{spec.name}-scenario", + symbols=db, + log_faults=True, + ) as fs: + session = fs.session + result["hooks"] = install_scenario(session) + ctrl_symbol = session.symbols.get("gsp_ctrl") + ctrl_addr = ctrl_symbol.addr if ctrl_symbol else None + if ctrl_addr is not None: + session.watch(ctrl_addr + CTRL_RPC_COUNT, size=8, kind="w", listener="matrix:rpc_count") + if options.scenario_script: + import greeneye as ge + + script_path = Path(options.scenario_script) + namespace = { + "ge": ge, + "greeneye": ge, + "session": session, + "s": session, + "memory": session.memory, + "symbols": session.symbols, + "recorder": session.recorder, + } + exec(compile(script_path.read_text(encoding="utf-8"), str(script_path), "exec"), namespace) + else: + session.run(duration=options.scenario_duration, max_stops=options.max_stops) + if ctrl_addr is not None: + session.memory.write_u64(ctrl_addr + 32, 1) # PING + session.memory.write_u64(ctrl_addr + 40, 0x1234) + session.run(duration=options.scenario_duration, max_stops=options.max_stops) + result["ctrl"] = read_ctrl(session, ctrl_symbol) + if ctrl_addr is not None: + data = session.snapshot(ctrl_addr, CTRL_SIZE, name="ctrl") + result["snapshot"] = {"label": "ctrl", "size": len(data), "sha256": hashlib.sha256(data).hexdigest()} + uart = fs.target.read_uart() + result["uart_banner"] = uart.strip().splitlines()[0] if uart.strip() else None + text, capped = read_log_window(fs.target.qemu_log, 0, options.log_bytes) + faults = parse_qemu_faults(text) + result["fault_count"] = len(faults) + result["faults_capped"] = capped + result["first_fault"] = faults[0] if faults else None + result.update(profile_from_events(load_events(os.path.join(out_dir, session_rel)))) + result["session"] = session_rel + result["status"] = "ok" + except Exception as exc: # a failed step must not abort the matrix + result["status"] = "error" + result["reason"] = f"{type(exc).__name__}: {exc}" + return result + + +# --------------------------------------------------------------------------- +# metrics + comparisons +# --------------------------------------------------------------------------- + + +def metric_rows(probe: dict, scenario: dict) -> list[dict]: + """docs/06 M1-M6 skeleton; observed data is local, agreement stays unmeasured.""" + bp = scenario.get("bp") or {} + ctrl = scenario.get("ctrl") or {} + snapshot = scenario.get("snapshot") or {} + probe_fault = (probe.get("first_fault") or {}).get("desc") + scenario_fault = (scenario.get("first_fault") or {}).get("desc") + rows = [ + { + "id": "M1", + "name": "Inventory coverage", + "status": "not measured", + "reason": NO_REFERENCE, + "observed": { + "rpc_handlers_hit": sorted(k for k in bp if k in RPC_HANDLERS), + "other_hit_sites": sorted(k for k in bp if k not in RPC_HANDLERS), + "declared_handlers": ctrl.get("handler_count"), + }, + }, + { + "id": "M2", + "name": "Reply agreement", + "status": "not measured", + "reason": NO_REFERENCE, + "observed": {"note": "reply fields are not decoded by the builtin scenario", "result": ctrl.get("result")}, + }, + { + "id": "M3", + "name": "Ordering agreement", + "status": "not measured", + "reason": NO_REFERENCE, + "observed": {"events": sum((scenario.get("event_counts") or {}).values())}, + }, + { + "id": "M4", + "name": "State agreement", + "status": "not measured", + "reason": NO_REFERENCE, + "observed": {"final_state": ctrl.get("state"), "rpc_state_hits": bp.get("rpc_state", 0)}, + }, + { + "id": "M5", + "name": "Error-path agreement", + "status": "not measured", + "reason": NO_REFERENCE, + "observed": { + "probe_first_fault": probe_fault, + "scenario_first_fault": scenario_fault, + "scenario_fault_count": scenario.get("fault_count", 0), + }, + }, + { + "id": "M6", + "name": "Memory side-effects", + "status": "not measured", + "reason": NO_REFERENCE, + "observed": { + "written_pages": len(scenario.get("writes") or {}), + "ctrl_snapshot_sha256": snapshot.get("sha256"), + }, + }, + ] + return rows + + +def compare_targets(a: dict, b: dict, out_dir: str, options: MatrixOptions) -> dict: + entry = { + "a": a["name"], + "b": b["name"], + "status": "ok", + "reason": None, + "compare_report": None, + "deltas": {"bp": [], "watch": [], "writes": []}, + "all_zero": True, + "metrics": [], + } + a_scenario = a.get("scenario") or {} + b_scenario = b.get("scenario") or {} + if a_scenario.get("status") != "ok" or b_scenario.get("status") != "ok": + entry["status"] = "skipped" + entry["reason"] = "a scenario session is not available on both targets" + return entry + a_dir = os.path.join(out_dir, a_scenario.get("session") or "") + b_dir = os.path.join(out_dir, b_scenario.get("session") or "") + try: + report = compare_sessions(a_dir, b_dir, max_rows=options.max_rows) + rel = os.path.join("compare", f"{a['name']}--{b['name']}.md") + write_text(os.path.join(out_dir, rel), report) + entry["compare_report"] = rel + except Exception as exc: + entry["reason"] = f"{type(exc).__name__}: {exc}" + deltas = {"bp": [], "watch": [], "writes": []} + for name in ("bp", "watch", "writes"): + deltas[name] = counter_delta(a_scenario.get(name) or {}, b_scenario.get(name) or {}) + entry["deltas"] = deltas + entry["all_zero"] = all(row["delta"] == 0 for rows in deltas.values() for row in rows) + a_metrics = {row["id"]: row for row in a.get("metrics") or []} + b_metrics = {row["id"]: row for row in b.get("metrics") or []} + entry["metrics"] = [ + { + "id": metric_id, + "name": name, + "status": "not measured", + "reason": NO_REFERENCE, + "a_observed": (a_metrics.get(metric_id) or {}).get("observed"), + "b_observed": (b_metrics.get(metric_id) or {}).get("observed"), + } + for metric_id, name in METRIC_DEFS + ] + return entry + + +# --------------------------------------------------------------------------- +# report assembly + rendering +# --------------------------------------------------------------------------- + + +def analyze_target(spec: TargetSpec, options: MatrixOptions, qemu_ok: bool) -> dict: + target = { + "name": spec.name, + "path": spec.path, + "sha256": "", + "kind": "elf", + "entry": None, + "status": "skipped", + "skip_reason": None, + "probe": _skipped_probe(None), + "symbols": {"count": 0, "sources": [], "path": None}, + "scenario": _skipped_scenario(None), + "metrics": metric_rows({}, {}), + } + if not os.path.isfile(spec.path): + target["skip_reason"] = "file not found" + return target + target["sha256"] = sha256_file(spec.path) + try: + image = load_target(spec.path) + except Exception as exc: + target["kind"] = "unknown" + target["skip_reason"] = f"{type(exc).__name__}: {exc}" + return target + target["entry"] = f"{image.entry:#x}" + bias = LOAD_ADDR - image.min_vaddr + db = SymbolDB() + try: + db, sources, sym_rel = build_symbols(spec, bias, options.out, options) + target["symbols"] = {"count": len(db), "sources": sources, "path": sym_rel} + except Exception as exc: + target["symbols"] = {"count": 0, "sources": [], "path": None, "reason": f"{type(exc).__name__}: {exc}"} + db.load_elf(spec.path, bias=bias, source="elf") + target["symbols"]["count"] = len(db) + target["symbols"]["sources"] = ["elf"] + target["probe"] = run_probe(spec, image, db, options.out, options, qemu_ok) + target["scenario"] = run_scenario(spec, image, db, options.out, options, qemu_ok) + target["metrics"] = metric_rows(target["probe"], target["scenario"]) + statuses = {target["probe"]["status"], target["scenario"]["status"]} + if statuses == {"ok"}: + target["status"] = "ok" + elif "ok" in statuses: + target["status"] = "partial" + elif statuses == {"skipped"}: + target["status"] = "skipped" + target["skip_reason"] = target["probe"].get("reason") or target["scenario"].get("reason") + else: + target["status"] = "error" + target["skip_reason"] = target["probe"].get("reason") or target["scenario"].get("reason") + return target + + +def run_matrix(specs: list[TargetSpec], options: MatrixOptions) -> dict: + ensure_dir(options.out) + qemu_ok = qemu_available() + targets = [analyze_target(spec, options, qemu_ok) for spec in specs] + comparisons: list[dict] = [] + for i in range(len(targets)): + for j in range(i + 1, len(targets)): + comparisons.append(compare_targets(targets[i], targets[j], options.out, options)) + report = { + "format": FORMAT, + "tool": "harness/portability_matrix.py", + "backend": "qemu", + "qemu_available": qemu_ok, + "options": { + "probe_instructions": options.probe_instructions, + "probe_duration": options.probe_duration, + "scenario_duration": options.scenario_duration, + "scenario_script": options.scenario_script, + "max_rows": options.max_rows, + "symbols_json": options.symbols_json, + "ghidra_bias": options.ghidra_bias, + }, + "targets": targets, + "comparisons": comparisons, + "skipped": [ + {"name": target["name"], "reason": target["skip_reason"]} + for target in targets + if target["status"] == "skipped" + ], + } + return report + + +def _table(headers: list[str], rows: list[list]) -> list[str]: + lines = ["| " + " | ".join(headers) + " |", "|" + "|".join("---" for _ in headers) + "|"] + for row in rows: + lines.append("| " + " | ".join(str(cell) for cell in row) + " |") + return lines + + +def _profile_tables(title: str, profile: dict, max_rows: int) -> list[str]: + lines = [f"**{title}**", ""] + if not profile: + return lines + ["_none observed_", ""] + rows = [[f"`{key}`", count] for key, count in sorted(profile.items(), key=lambda kv: (-int(kv[1]), str(kv[0])))[:max_rows]] + lines += _table(["key", "hits"], rows) + [""] + return lines + + +def render_markdown(report: dict, options: MatrixOptions) -> str: + lines = [ + "# green-eye portability delta report", + "", + "> TOOL IN DEVELOPMENT - NOT READY YET! Generated by `harness/portability_matrix.py`.", + "", + f"* format: `{report['format']}` (green-eye {_greeneye_version()})", + f"* backend: `{report['backend']}` (qemu available: {report['qemu_available']})", + f"* targets: {len(report['targets'])}", + "", + "## Targets", + "", + ] + lines += _table( + ["target", "status", "sha256", "probe", "symbols", "scenario events"], + [ + [ + f"`{target['name']}`", + target["status"], + f"`{(target['sha256'] or '')[:16]}`", + f"{target['probe']['status']} ({target['probe']['fault_count']} faults)", + target["symbols"]["count"], + sum((target["scenario"].get("event_counts") or {}).values()), + ] + for target in report["targets"] + ], + ) + lines += [""] + + for target in report["targets"]: + lines += [f"### {target['name']}", ""] + lines += [f"* path: `{target['path']}`", f"* entry: `{target['entry']}`", f"* sha256: `{target['sha256']}`"] + if target["skip_reason"]: + lines += [f"* note: {target['skip_reason']}"] + lines += [""] + + probe = target["probe"] + lines += ["#### Probe", ""] + fault = probe.get("first_fault") + stopped_pc = probe.get("stopped_pc") + probe_rows = [ + ["status", probe["status"]], + ["instructions", probe.get("instructions")], + ["stopped pc", f"`{stopped_pc:#x}`" if stopped_pc is not None else None], + ["stopped symbol", probe.get("stopped_symbol")], + ["fault count", probe.get("fault_count")], + ["faults capped", probe.get("faults_capped")], + ] + if fault: + probe_rows.append( + ["first fault", f"cause {fault.get('cause')} {fault.get('desc')} @ epc `{fault.get('epc', 0):#x}`"] + ) + if probe.get("reason"): + probe_rows.append(["reason", probe["reason"]]) + lines += _table(["field", "value"], probe_rows) + [""] + + lines += ["#### Symbols", ""] + lines += _table( + ["count", "sources", "path"], + [[target["symbols"]["count"], ", ".join(target["symbols"]["sources"]), target["symbols"]["path"]]], + ) + [""] + + scenario = target["scenario"] + lines += ["#### Scenario", ""] + lines += _table( + ["status", "session", "hooks", "uart banner", "faults"], + [[ + scenario["status"], + f"`{scenario.get('session')}`", + ", ".join(scenario.get("hooks") or []), + scenario.get("uart_banner"), + scenario.get("fault_count"), + ]], + ) + [""] + lines += _profile_tables("Breakpoint hits", scenario.get("bp") or {}, options.max_rows) + lines += _profile_tables("Watchpoint hits", scenario.get("watch") or {}, options.max_rows) + lines += _profile_tables("Written pages", scenario.get("writes") or {}, options.max_rows) + + lines += ["#### M1-M6", ""] + lines += [f"> {NO_REFERENCE}", ""] + lines += _table( + ["#", "metric", "status", "observed"], + [ + [row["id"], row["name"], row["status"], _short_json(row["observed"])] + for row in target["metrics"] + ], + ) + [""] + + lines += ["## Cross-target comparisons", ""] + if not report["comparisons"]: + lines += ["_single target: no comparison_", ""] + for comp in report["comparisons"]: + lines += [f"### {comp['a']} vs {comp['b']}", ""] + lines += [f"* status: {comp['status']}", f"* zero deltas: {'yes' if comp['all_zero'] else 'no'}"] + if comp.get("compare_report"): + lines += [f"* compare report: `{comp['compare_report']}`"] + if comp.get("reason"): + lines += [f"* note: {comp['reason']}"] + lines += [""] + for name in ("bp", "watch", "writes"): + rows = [row for row in comp["deltas"][name] if row["delta"] != 0] + rows.sort(key=lambda row: (-abs(row["delta"]), row["key"])) + lines += [f"**{name} delta (nonzero, first {options.max_rows})**", ""] + if rows: + lines += _table( + ["key", comp["a"], comp["b"], "delta"], + [[f"`{row['key']}`", row["a"], row["b"], row["delta"]] for row in rows[: options.max_rows]], + ) + [""] + else: + lines += ["_no deltas_", ""] + lines += ["**M1-M6 (not measured: reference capture required)**", ""] + lines += _table( + ["#", "metric", "A observed", "B observed"], + [ + [row["id"], row["name"], _short_json(row["a_observed"]), _short_json(row["b_observed"])] + for row in comp["metrics"] + ], + ) + [""] + + if report["skipped"]: + lines += ["## Skipped targets", ""] + lines += _table(["target", "reason"], [[f"`{item['name']}`", item["reason"]] for item in report["skipped"]]) + lines += [""] + return "\n".join(lines) + + +def _short_json(value, limit: int = 72) -> str: + import json + + if value is None: + return "-" + text = json.dumps(value, sort_keys=True) + return f"`{text}`" if len(text) <= limit else f"`{text[:limit - 1]}...`" + + +def _greeneye_version() -> str: + try: + import greeneye + + return greeneye.__version__ + except Exception: # pragma: no cover + return "unknown" + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="portability_matrix", description=__doc__.splitlines()[0]) + parser.add_argument("targets", nargs="*", help="firmware ELF paths") + parser.add_argument("--target", action="append", default=[], help="firmware ELF path (repeatable)") + parser.add_argument("--out", required=True, help="output directory for the report, sessions and symbols") + parser.add_argument("--symbols", dest="symbols_json", help="existing greeneye symbol DB JSON") + parser.add_argument("--ghidra-names", help="Ghidra names.tsv export") + parser.add_argument("--ghidra-index", help="Ghidra decompiled_index.tsv export") + parser.add_argument("--ghidra-exports", help="Ghidra exports.tsv") + parser.add_argument("--ghidra-callgraph", help="Ghidra callgraph.tsv") + parser.add_argument("--ghidra-bias", type=lambda value: int(value, 0), default=0) + parser.add_argument("--probe-instructions", type=int, default=2000, help="probe step budget (0 = wall-clock run)") + parser.add_argument("--probe-duration", type=float, default=0.5, help="probe wall budget when --probe-instructions=0") + parser.add_argument("--scenario-duration", type=float, default=0.4, help="budget for each scenario phase") + parser.add_argument("--max-stops", type=int, default=5000, help="safety bound on serviced stops per scenario phase") + parser.add_argument("--max-rows", type=int, default=40, help="table row cap in reports") + parser.add_argument("--scenario-script", help="replace the builtin scenario with a green-eye script") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + targets = list(args.targets) + list(args.target) + if not targets: + parser.error("at least one target is required (positional or --target)") + options = MatrixOptions( + out=os.path.abspath(args.out), + probe_instructions=args.probe_instructions, + probe_duration=args.probe_duration, + scenario_duration=args.scenario_duration, + max_stops=args.max_stops, + max_rows=args.max_rows, + symbols_json=args.symbols_json, + ghidra_names=args.ghidra_names, + ghidra_index=args.ghidra_index, + ghidra_exports=args.ghidra_exports, + ghidra_callgraph=args.ghidra_callgraph, + ghidra_bias=args.ghidra_bias, + scenario_script=args.scenario_script, + ) + specs = _name_specs(targets) + report = run_matrix(specs, options) + json_path = write_json(os.path.join(options.out, "portability-report.json"), report) + md_path = write_text(os.path.join(options.out, "portability-report.md"), render_markdown(report, options)) + print(f"[matrix] {len(specs)} target(s), out={options.out}") + for target in report["targets"]: + print(f"[matrix] {target['name']}: status={target['status']} probe={target['probe']['status']} " + f"scenario={target['scenario']['status']}") + print(f"[matrix] wrote {json_path}") + print(f"[matrix] wrote {md_path}") + return 0 + + +def _name_specs(paths: list[str]) -> list[TargetSpec]: + seen: dict[str, int] = {} + specs: list[TargetSpec] = [] + for path in paths: + base = Path(path).stem + seen[base] = seen.get(base, 0) + 1 + name = base if seen[base] == 1 else f"{base}-{seen[base]}" + specs.append(TargetSpec(path=os.path.abspath(path), name=name)) + return specs + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/reach.py b/debug/risc-v-gsp-rm-vm/harness/reach.py new file mode 100644 index 0000000..9df0c4c --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/reach.py @@ -0,0 +1,946 @@ +#!/usr/bin/env python3 +"""Reachability answers for a decompiled firmware target (H4.1/H4.5). + +Given a symbol name or address taken from Ghidra or the symbol DB, answer the +practical questions a reimplementation needs: *is this code reachable, how do +I trigger it, what arguments does it need, and what happens when it runs?* + +Methods attempted (``--method auto`` tries them in this order and stops at the +first hit unless ``--all-methods`` is given): + +* ``stimulus`` -- run a supplied green-eye script (``--stimulus script.py``) + or probe spec (``--probe probe.json``) under instrumentation; +* ``trigger`` -- discover the dispatch entry that routes to the target (the + demo's handler table), apply that command, and run bounded; +* ``run`` -- ``Session.run_until(target)`` from launch, which catches the + natural boot/self-test path; +* ``call`` -- ``Session.call(target, args)`` with ``--args`` and + ``--arg-count`` auto-fill (zeros or sentinels). + +On a hit the report records the entry register state, the prior hook events +that led there, the caller, and the effects after resume (field deltas and +watchpoint stores, optional recorded-session trace summary). A repeatable +probe spec is written to ``--emit-probe`` (default ``/probe.json``) so a +later run (or docs/21 CI) can replay the exact route. + +Demonstrated on the demo firmware: ``rpc_ping`` via ``call``, ``rpc_sum`` via +``run`` (self-test), ``rpc_fault`` via ``trigger`` (mailbox 6). See +docs/19-CONTROL-DISCOVERY.md. + +Run from the tool root: + + python3 harness/reach.py --target rpc_fault --method trigger --out dumps/reach-fault +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import sys +import time +from collections import Counter +from dataclasses import dataclass, field +from pathlib import Path + +try: + from .common import DEMO_ELF, ensure_dir, qemu_available, read_faults_window, sha256_file + from .control_discovery import DemoSurface, SETTLE_MARKER, SETTLE_SLICE +except ImportError: # direct script execution (python3 harness/reach.py) + from common import DEMO_ELF, ensure_dir, qemu_available, read_faults_window, sha256_file # type: ignore[no-redef] + from control_discovery import DemoSurface, SETTLE_MARKER, SETTLE_SLICE # type: ignore[no-redef] + +from greeneye import launch # noqa: E402 +from greeneye.errors import GreenEyeError, RspTimeout # noqa: E402 +from greeneye.record import load_events # noqa: E402 +from greeneye.symbols import SymbolDB # noqa: E402 + +FORMAT = "greeneye-reach-report/1" +PROBE_FORMAT = "greeneye-reach-probe/1" +SENTINEL_BASE = 0xA5A5_0000_0000_0000 +RUN_SLICE = 0.02 +GRACE = 0.10 +AUTO_ORDER = ("stimulus", "trigger", "run", "call") +TRAP_SIGNALS = (5,) +FAULT_SIGNALS = (4, 7, 11) + + +@dataclass +class ReachOptions: + kernel: str = DEMO_ELF + out: str = "" + target: str = "" + method: str = "auto" + args: list[int] = field(default_factory=list) + arg_count: int = 0 + arg_fill: str = "zero" + timeout: float = 0.5 + settle: float = 2.0 + stimulus: str | None = None + probe: str | None = None + emit_probe: str | None = None + record: str | None = None + backend: str = "qemu" + max_prior: int = 12 + all_methods: bool = False + allow_foreign_elf: bool = False + watch: list[dict] = field(default_factory=list) + + +@dataclass +class Attempt: + method: str + ok: bool = False + reason: str | None = None + entry: dict | None = None + retval: int | None = None + dispatch: list[dict] = field(default_factory=list) + prior: list[dict] = field(default_factory=list) + effects: dict = field(default_factory=dict) + trap: bool = False + fault: bool = False + trigger: dict | None = None + session: str | None = None + hit_count: int = 0 + + def summary(self) -> dict: + return { + "method": self.method, + "ok": self.ok, + "reason": self.reason, + "entry": self.entry, + "retval": self.retval, + "dispatch": self.dispatch[:4], + "prior_events": self.prior, + "effects": self.effects, + "trap": self.trap, + "fault": self.fault, + "trigger": self.trigger, + "session": self.session, + "hit_count": self.hit_count, + } + + +def build_args(options: ReachOptions) -> list[int]: + """Explicit --args plus deterministic --arg-count auto-fill.""" + args = list(options.args) + count = max(options.arg_count, len(args)) + if count > 8: + raise GreenEyeError(f"--arg-count {count}: the RISC-V ABI passes at most 8 integer args") + while len(args) < count: + if options.arg_fill == "sentinel": + args.append(SENTINEL_BASE | len(args)) + else: + args.append(0) + return args + + +class Collector: + """Ordered hook/watch evidence for one reachability attempt.""" + + def __init__( + self, + session, + surface: DemoSurface | None, + target_addr: int, + target_name: str, + options: ReachOptions, + fault_log: str | None = None, + ): + self.session = session + self.surface = surface + self.target_addr = target_addr + self.target_name = target_name + self.options = options + self.fault_log = fault_log + self.fault_offset = 0 + self.faults: list[dict] = [] + self.hits: list[dict] = [] + self.entry: dict | None = None + self.prior: list[dict] = [] + self.dispatch: list[dict] = [] + self.signals: list[dict] = [] + self.watch: list[dict] = [] + self.observer = surface.observer() if surface is not None else None + self.before: dict = {} + self.base = 0 + + # -- installation ------------------------------------------------------ + + def install(self) -> None: + self.session.on_event("stop", self._on_stop) + if self.fault_log and os.path.exists(self.fault_log): + self.fault_offset = os.path.getsize(self.fault_log) + if self.surface is not None and self.session.symbols.get("gsp_ctrl"): + self.base = self.session.symbols.lookup("gsp_ctrl") + if self.observer is not None: + self.observer.reset() + self.observer.arm(self.session, self.base) + for spec in self.options.watch: + self._install_watch(spec) + hooked: set[int] = set() + self._hook_addr(self.target_addr, self.target_name) + hooked.add(self.target_addr) + if self.surface is not None: + command_hook = self.surface.command_hook(self.session) + if command_hook: + addr = self.session.symbols.lookup(command_hook) + self._hook_addr(addr, command_hook, stage="dispatch") + hooked.add(addr) + for cmd, (entry, name) in sorted(self.surface.handler_map(self.session).items()): + if entry in hooked: + continue + self._hook_addr(entry, name or f"handler-{entry:#x}", stage="handler", cmd=cmd) + hooked.add(entry) + + def _install_watch(self, spec: dict) -> None: + target = spec.get("symbol", spec.get("addr")) + if target is None: + return + if isinstance(target, str) and not target.lower().startswith("0x"): + base = self.session.symbols.lookup(target) + else: + base = int(target, 0) if isinstance(target, str) else int(target) + offset = int(spec.get("offset", 0)) + size = int(spec.get("size", 8)) + kind = spec.get("kind", "w") + label = spec.get("name") or f"{target}+{offset:#x}" + + def callback(hit, label=label): + self.watch.append({"name": label, "pc": hit.pc, "symbol": hit.symbol.name if hit.symbol else None}) + + self.session.watch(base + offset, size=size, kind=kind, callback=callback) + + def _hook_addr(self, addr: int, name: str, stage: str = "handler", cmd: int | None = None) -> None: + def callback(hit, stage=stage, cmd=cmd): + item = { + "stage": stage, + "symbol": hit.symbol.name if hit.symbol else name, + "addr": hit.addr, + "args": [hit.arg(i) for i in range(4)], + } + if stage == "dispatch": + self.dispatch.append(item) + if hit.addr == self.target_addr: + self.entry = { + "symbol": hit.symbol.name if hit.symbol else name, + "pc": hit.addr, + "args": [hit.arg(i) for i in range(4)], + "ra": hit.regs.get("ra", 0), + "sp": hit.regs.get("sp", 0), + "cmd": cmd, + # The demo compiler's jump table clobbers a0 at handler entry; + # the dispatcher's own args are the authoritative call model. + "dispatch_args": self.dispatch[-1]["args"] if self.dispatch else None, + } + self.prior = list(self.hits[-self.options.max_prior :]) + self.hits.append(item) + + self.session.hook(addr, on_enter=callback, listener=f"reach:{name}") + + def _on_stop(self, hit) -> None: + stop = hit.stop + if stop is not None and stop.signal is not None: + self.signals.append({"signal": stop.signal, "pc": hit.pc}) + + # -- accounting -------------------------------------------------------- + + def snapshot_before(self) -> None: + if self.observer is not None: + self.before = self.observer.snapshot(self.session, self.base) + + def effects(self, retval: int | None) -> dict: + before = self.before + after = self.observer.snapshot(self.session, self.base) if self.observer is not None else {} + changed = [ + {"field": name, "before": before.get(name), "after": after.get(name)} + for name in before + if before.get(name) != after.get(name) + ] + faults: list[dict] = [] + if self.fault_log: + faults, _capped = read_faults_window(self.fault_log, self.fault_offset) + causes = [int(fault.get("cause", 0)) for fault in faults] + trap = any(cause == 3 for cause in causes) or any( + sig.get("signal") in TRAP_SIGNALS for sig in self.signals + ) or self.session.pc < 0x100 + # A trap often drags a fetch-fault loop behind it (mtvec=0); do not + # double-label the consequence as an independent fault. + fault = (not trap) and ( + any(cause != 3 for cause in causes) + or any(sig.get("signal") in FAULT_SIGNALS for sig in self.signals) + ) + effects = { + "changed": changed, + "watch": list(self.watch) + (list(self.observer.hits) if self.observer is not None else []), + "retval": retval, + "trap": bool(trap), + "fault": bool(fault), + "pc_after": self.session.pc, + } + if faults: + effects["faults"] = [ + {"cause": fault.get("cause"), "desc": fault.get("desc"), "epc": fault.get("epc"), + "access": fault.get("access")} + for fault in faults[:4] + ] + return effects + + +# --------------------------------------------------------------------------- +# session plumbing +# --------------------------------------------------------------------------- + + +class SessionHandle: + def __init__(self, session, record_path: str | None, fault_log: str | None = None): + self.session = session + self.path = record_path + self.fault_log = fault_log + + +def open_session(options: ReachOptions, method: str, record: bool) -> SessionHandle: + record_path = os.path.join(options.record, method) if record and options.record else None + if record_path: + ensure_dir(record_path) + fault_log = None + extra_args = None + if options.backend == "qemu": + fault_log = os.path.join(options.out, ".faults", f"{method}.log") + ensure_dir(os.path.dirname(fault_log)) + extra_args = ["-d", "guest_errors,unimp,int", "-D", fault_log] + session = launch( + kernel=options.kernel, + backend=options.backend, + name=f"reach-{method}", + record=record_path, + extra_args=extra_args, + ) + return SessionHandle(session, record_path, fault_log) + + +def settle(session, budget: float) -> bool: + deadline = time.monotonic() + budget + while time.monotonic() < deadline: + if SETTLE_MARKER in session.target.read_uart(): + return True + session.run(duration=SETTLE_SLICE, max_stops=1) + return SETTLE_MARKER in session.target.read_uart() + + +def run_window(session, collector: Collector, timeout: float) -> None: + """Run bounded until the target entry is hit, then a short grace window.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if collector.entry is not None: + break + if not session.target.alive(): + return + remaining = max(0.005, min(RUN_SLICE, deadline - time.monotonic())) + session.run(duration=remaining, max_stops=64) + if collector.entry is not None: + session.run(duration=min(GRACE, max(0.01, timeout)), max_stops=64) + + +# --------------------------------------------------------------------------- +# methods +# --------------------------------------------------------------------------- + + +def attempt_call(options: ReachOptions, surface: DemoSurface | None, target_addr: int, target_name: str, + record: bool) -> Attempt: + attempt = Attempt(method="call") + args = build_args(options) + handle = open_session(options, "call", record) + session = handle.session + attempt.session = handle.path + try: + collector = Collector(session, surface, target_addr, target_name, options, fault_log=handle.fault_log) + collector.install() + collector.snapshot_before() + retval: int | None = None + try: + retval = session.call(target_addr, args, timeout=options.timeout) + except RspTimeout as exc: + attempt.reason = f"call did not return: {exc}" + except GreenEyeError as exc: + attempt.reason = f"call failed: {exc}" + attempt.entry = collector.entry + attempt.prior = collector.prior + attempt.dispatch = collector.dispatch + attempt.hit_count = len(collector.hits) + attempt.retval = retval + attempt.effects = collector.effects(retval) + attempt.trap = attempt.effects["trap"] + attempt.fault = attempt.effects["fault"] + attempt.ok = collector.entry is not None + if not attempt.ok and attempt.reason is None: + attempt.reason = "target entry hook never fired" + finally: + session.close() + return attempt + + +def attempt_run(options: ReachOptions, surface: DemoSurface | None, target_addr: int, target_name: str, + record: bool) -> Attempt: + attempt = Attempt(method="run") + handle = open_session(options, "run", record) + session = handle.session + attempt.session = handle.path + try: + collector = Collector(session, surface, target_addr, target_name, options, fault_log=handle.fault_log) + collector.install() + collector.snapshot_before() + try: + session.run_until(target_addr, timeout=options.timeout) + except RspTimeout as exc: + attempt.reason = f"run_until not reached: {exc}" + except GreenEyeError as exc: + attempt.reason = f"run_until failed: {exc}" + if collector.entry is not None: + session.run(duration=min(GRACE, options.timeout), max_stops=64) + attempt.entry = collector.entry + attempt.prior = collector.prior + attempt.dispatch = collector.dispatch + attempt.hit_count = len(collector.hits) + attempt.effects = collector.effects(None) + attempt.trap = attempt.effects["trap"] + attempt.fault = attempt.effects["fault"] + attempt.ok = collector.entry is not None + if not attempt.ok and attempt.reason is None: + attempt.reason = "target never executed on the boot path" + finally: + session.close() + return attempt + + +def attempt_trigger(options: ReachOptions, surface: DemoSurface | None, target_addr: int, target_name: str, + record: bool) -> Attempt: + attempt = Attempt(method="trigger") + if surface is None: + attempt.reason = "no command surface available (gsp_ctrl/rpc_dispatch missing)" + return attempt + args = build_args(options)[:3] + while len(args) < 3: + args.append(0) + handle = open_session(options, "trigger", record) + session = handle.session + attempt.session = handle.path + try: + if not settle(session, options.settle): + attempt.reason = f"{SETTLE_MARKER!r} not observed; trigger state unknown" + return attempt + candidates = surface.command_for_addr(session, target_addr) + if not candidates: + attempt.reason = "target is not in the declared handler/dispatch table" + return attempt + # Prefer the documented switch case over the injected-slot alias. + candidates.sort(key=lambda item: (item["id"] >= 16, item["id"])) + chosen = candidates[0] + collector = Collector(session, surface, target_addr, target_name, options, fault_log=handle.fault_log) + collector.install() + collector.snapshot_before() + surface.probe(session, int(chosen["id"]), (args[0], args[1], args[2]), timeout=options.timeout) + run_window(session, collector, options.timeout) + attempt.entry = collector.entry + attempt.prior = collector.prior + attempt.dispatch = collector.dispatch + attempt.hit_count = len(collector.hits) + attempt.effects = collector.effects(None) + attempt.trap = attempt.effects["trap"] + attempt.fault = attempt.effects["fault"] + attempt.ok = collector.entry is not None + attempt.trigger = {"surface": surface.name, "id": chosen["id"], "handler": chosen["handler"], "args": args, + "candidates": candidates} + if not attempt.ok and attempt.reason is None: + attempt.reason = f"mailbox command {chosen['id']} did not reach the target within the budget" + finally: + session.close() + return attempt + + +def attempt_stimulus(options: ReachOptions, surface: DemoSurface | None, target_addr: int, target_name: str, + record: bool) -> Attempt: + attempt = Attempt(method="stimulus") + if not options.stimulus: + attempt.reason = "no --stimulus script supplied" + return attempt + handle = open_session(options, "stimulus", record) + session = handle.session + attempt.session = handle.path + try: + collector = Collector(session, surface, target_addr, target_name, options, fault_log=handle.fault_log) + collector.install() + collector.snapshot_before() + import greeneye as ge + + script_path = Path(options.stimulus) + namespace = { + "ge": ge, + "greeneye": ge, + "session": session, + "s": session, + "memory": session.memory, + "symbols": session.symbols, + "recorder": session.recorder, + } + try: + exec(compile(script_path.read_text(encoding="utf-8"), str(script_path), "exec"), namespace) + except Exception as exc: # a stimulus failure is a finding, not a crash + attempt.reason = f"stimulus raised {type(exc).__name__}: {exc}" + if collector.entry is None: + run_window(session, collector, options.timeout) + attempt.entry = collector.entry + attempt.prior = collector.prior + attempt.dispatch = collector.dispatch + attempt.hit_count = len(collector.hits) + attempt.effects = collector.effects(None) + attempt.trap = attempt.effects["trap"] + attempt.fault = attempt.effects["fault"] + attempt.ok = collector.entry is not None + if not attempt.ok and attempt.reason is None: + attempt.reason = "stimulus completed without reaching the target" + finally: + session.close() + return attempt + + +METHOD_FUNCS = { + "call": attempt_call, + "run": attempt_run, + "trigger": attempt_trigger, + "stimulus": attempt_stimulus, +} + + +# --------------------------------------------------------------------------- +# orchestration, report, probe spec +# --------------------------------------------------------------------------- + + +def attempt_order(options: ReachOptions, target_addr: int, surface: DemoSurface | None) -> list[str]: + if options.method != "auto": + return [options.method] + order = list(AUTO_ORDER) + if not options.stimulus: + order.remove("stimulus") + return order + + +def run_reach(options: ReachOptions) -> dict: + if options.backend == "qemu" and not qemu_available(): + raise GreenEyeError("qemu-system-riscv64 not found; install qemu-system-misc or set GREENEYE_QEMU") + if not os.path.isfile(options.kernel): + raise GreenEyeError(f"kernel not found: {options.kernel}") + if not options.allow_foreign_elf and os.path.abspath(options.kernel) != os.path.abspath(DEMO_ELF): + raise GreenEyeError( + "reachability is restricted to the bundled demo firmware; " + "pass --allow-foreign-elf to run against another ELF under QEMU" + ) + if not options.target: + raise GreenEyeError("--target is required (symbol name or 0x address)") + ensure_dir(options.out) + # Resolve the target once on a short-lived probe session so the report can + # name the address even if every method misses. + target_addr, target_name, symbols = resolve_target(options) + surface = DemoSurface() if symbols.get("gsp_ctrl") is not None else None + attempts: list[Attempt] = [] + for method in attempt_order(options, target_addr, surface): + attempt = METHOD_FUNCS[method](options, surface, target_addr, target_name, bool(options.record)) + attempts.append(attempt) + if attempt.ok and not options.all_methods: + break + success = next((attempt for attempt in attempts if attempt.ok), None) + report = build_report(options, target_addr, target_name, attempts, success) + shutil.rmtree(os.path.join(options.out, ".faults"), ignore_errors=True) + if report["reachable"]: + probe_path = options.emit_probe or os.path.join(options.out, "probe.json") + ensure_dir(os.path.dirname(os.path.abspath(probe_path))) + write_json(probe_path, suggested_probe(options, target_name, success)) + report["probe_spec"] = probe_path + write_json(os.path.join(options.out, "reach-report.json"), report) + with open(os.path.join(options.out, "reach-report.md"), "w", encoding="utf-8") as fh: + fh.write(render_markdown(report, options)) + return report + + +def resolve_target(options: ReachOptions) -> tuple[int, str, SymbolDB]: + """Resolve the target address with a paused session, then close it.""" + session = launch(kernel=options.kernel, backend=options.backend, name="reach-resolve") + try: + symbols = session.symbols + addr = session.resolve(options.target) + symbol = symbols.resolve(addr) + name = symbol.name if symbol else options.target + return addr, name, symbols + finally: + session.close() + + +def suggested_probe(options: ReachOptions, target_name: str, success: Attempt | None) -> dict: + method = success.method if success is not None else options.method + spec = { + "format": PROBE_FORMAT, + "target": target_name, + "kernel": options.kernel, + "method": method, + "args": list(options.args), + "arg_count": options.arg_count, + "arg_fill": options.arg_fill, + "timeout": options.timeout, + "settle": options.settle, + "stimulus": options.stimulus, + "trigger": success.trigger if success is not None and success.method == "trigger" else None, + "watch": options.watch, + } + return spec + + +def build_report(options: ReachOptions, target_addr: int, target_name: str, attempts: list[Attempt], + success: Attempt | None) -> dict: + reachable = success is not None + entry = success.entry if success is not None else None + prior = success.prior if success is not None else [] + return { + "format": FORMAT, + "tool": "harness/reach.py", + "target": target_name, + "target_addr": target_addr, + "kernel": options.kernel, + "kernel_sha256": sha256_file(options.kernel) if os.path.isfile(options.kernel) else None, + "backend": options.backend, + "reachable": reachable, + "method": success.method if success is not None else None, + "methods_tried": [attempt.summary() for attempt in attempts], + "entry": entry, + "prior_events": prior, + "dispatch": success.dispatch if success is not None else [], + "required_args": required_args(entry, success), + "effects": success.effects if success is not None else {}, + "trigger": success.trigger if success is not None else None, + "trace_window": trace_window(success), + "session": success.session if success is not None else next( + (attempt.session for attempt in attempts if attempt.session), None + ), + "probe_spec": None, + "reproduce": reproduce_command(options), + "reason": None if reachable else (attempts[-1].reason if attempts else "no method attempted"), + } + + +def required_args(entry: dict | None, success: Attempt | None) -> dict | None: + if entry is None: + return None + caller = entry.get("ra", 0) + dispatch_args = entry.get("dispatch_args") + if dispatch_args: + return { + "source": "firmware-dispatch", + "cmd": dispatch_args[0], + "a0": dispatch_args[1] if len(dispatch_args) > 1 else None, + "a1": dispatch_args[2] if len(dispatch_args) > 2 else None, + "a2": dispatch_args[3] if len(dispatch_args) > 3 else None, + "handler_entry_args": entry.get("args"), + "caller": f"{caller:#x}" if caller else None, + } + args = entry.get("args") or [] + return { + "source": "call-args" if success is not None and success.method == "call" else "firmware-dispatch", + "a0": args[0] if len(args) > 0 else None, + "a1": args[1] if len(args) > 1 else None, + "a2": args[2] if len(args) > 2 else None, + "a3": args[3] if len(args) > 3 else None, + "caller": f"{caller:#x}" if caller else None, + } + + +def trace_window(success: Attempt | None) -> dict | None: + if success is None: + return None + counts = Counter(item.get("stage", "hook") for item in success.prior) + window = {"hook_counts": dict(sorted(counts.items())), "prior": success.prior} + if success.session and os.path.isdir(success.session): + try: + events = load_events(success.session) + except Exception: + return window + window["session_event_counts"] = dict(sorted(Counter(event.get("type", "?") for event in events).items())) + return window + + +def render_markdown(report: dict, options: ReachOptions) -> str: + lines = [ + "# green-eye reachability report", + "", + "> TOOL IN DEVELOPMENT - NOT READY YET! Generated by `harness/reach.py`.", + "", + f"* format: `{FORMAT}`", + f"* target: `{report['target']}` @ `{report['target_addr']:#x}`", + f"* kernel: `{report['kernel']}` (sha256 `{(report['kernel_sha256'] or '')[:16]}`)", + f"* backend: `{report['backend']}`", + f"* reachable: **{'yes' if report['reachable'] else 'no'}**" + + (f" via `{report['method']}`" if report["reachable"] else ""), + "", + "## Methods tried", + "", + ] + lines += _table( + ["method", "result", "hits", "reason"], + [ + [ + attempt["method"], + "reachable" if attempt["ok"] else "miss", + attempt["hit_count"], + attempt["reason"] or "-", + ] + for attempt in report["methods_tried"] + ], + ) + if report["reachable"]: + entry = report["entry"] or {} + args = entry.get("args") or [] + req = report["required_args"] or {} + lines += ["", "## Entry state", ""] + lines += _table( + ["field", "value"], + [ + ["symbol", entry.get("symbol")], + ["pc", f"`{entry.get('pc', 0):#x}`"], + ["args a0..a3", ", ".join(f"`{value:#x}`" for value in args[:4]) or "-"], + ["ra", f"`{entry.get('ra', 0):#x}`"], + ["sp", f"`{entry.get('sp', 0):#x}`"], + ], + ) + lines += ["", "## Required args discovered", ""] + lines += _table( + ["source", "cmd", "a0", "a1", "a2", "a3", "caller"], + [[ + req.get("source"), + f"`{req['cmd']:#x}`" if req.get("cmd") is not None else "-", + *[f"`{req[key]:#x}`" if req.get(key) is not None else "-" for key in ("a0", "a1", "a2", "a3")], + f"`{req['caller']}`" if req.get("caller") else "-", + ]], + ) + if report.get("trigger"): + trigger = report["trigger"] + lines += ["", "## Trigger", ""] + lines += _table( + ["surface", "command id", "handler", "args"], + [[trigger["surface"], trigger["id"], trigger["handler"], + ", ".join(f"`{value:#x}`" for value in trigger["args"])]], + ) + lines += ["", "## Prior events (hooks that led here)", ""] + if report["prior_events"]: + lines += _table( + ["#", "stage", "symbol", "addr", "args"], + [ + [index, item.get("stage"), item.get("symbol"), f"`{item.get('addr', 0):#x}`", + ", ".join(f"`{value:#x}`" for value in item.get("args", []))] + for index, item in enumerate(report["prior_events"]) + ], + ) + else: + lines += ["_none: the target was the first hooked event_"] + lines += ["", "## Effects after resume", ""] + effects = report["effects"] + changed = effects.get("changed") or [] + lines += _table( + ["field", "before", "after"], + [[row["field"], _short(row["before"]), _short(row["after"])] for row in changed] or [["-", "-", "-"]], + ) + lines += ["", f"* return value: {_hex(effects.get('retval'))}", + f"* trap (ebreak) observed: {effects.get('trap')}", + f"* fault observed: {effects.get('fault')}", + f"* watchpoint stores: {len(effects.get('watch') or [])}"] + window = report.get("trace_window") or {} + if window.get("session_event_counts"): + lines += ["", "## Trace window (recorded session)", "", + f"`{json.dumps(window['session_event_counts'], sort_keys=True)}`"] + else: + lines += ["", "## Not reached", "", + f"* reason: {report.get('reason') or 'unknown'}", + "* absence of a hit is evidence about this stimulus at this fidelity, not proof of dead code."] + lines += ["", "## Reproduce", "", "```bash", report["reproduce"], "```"] + if report.get("probe_spec"): + lines += ["", f"* repeatable probe spec: `{report['probe_spec']}`"] + lines += [""] + return "\n".join(lines) + + +def _short(value) -> str: + text = json.dumps(value, sort_keys=True) + return f"`{text[:48]}`" if len(text) <= 48 else f"`{text[:47]}...`" + + +def _hex(value) -> str: + return f"`{value:#x}`" if isinstance(value, int) else "-" + + +def _table(headers: list[str], rows: list[list]) -> list[str]: + lines = ["| " + " | ".join(headers) + " |", "|" + "|".join("---" for _ in headers) + "|"] + for row in rows: + lines.append("| " + " | ".join(str(cell) for cell in row) + " |") + return lines + + +def reproduce_command(options: ReachOptions) -> str: + args = f" --args {','.join(hex(value) for value in options.args)}" if options.args else "" + arg_count = f" --arg-count {options.arg_count}" if options.arg_count else "" + stimulus = f" --stimulus {options.stimulus}" if options.stimulus else "" + methods = " --all-methods" if options.all_methods else "" + foreign = " --allow-foreign-elf" if options.allow_foreign_elf else "" + record = f" --record {options.record}" if options.record else "" + return ( + f"python3 harness/reach.py --target {options.target} --method {options.method} " + f"--kernel {options.kernel} --out {options.out} --timeout {options.timeout} " + f"--settle {options.settle}{args}{arg_count}{stimulus}{methods}{record}{foreign}" + ) + + +def write_json(path: str, payload: dict) -> str: + ensure_dir(os.path.dirname(path)) + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.write("\n") + return path + + +# --------------------------------------------------------------------------- +# probe spec loading +# --------------------------------------------------------------------------- + + +def load_probe_spec(path: str) -> dict: + with open(path, "r", encoding="utf-8") as fh: + spec = json.load(fh) + if not isinstance(spec, dict) or "target" not in spec: + raise GreenEyeError(f"{path}: reachability probe spec must be an object with a 'target'") + return spec + + +def apply_probe_spec(options: ReachOptions, spec: dict) -> None: + if not options.target: + options.target = str(spec.get("target", "")) + if spec.get("kernel") and os.path.abspath(options.kernel) == os.path.abspath(DEMO_ELF): + options.kernel = os.path.abspath(str(spec["kernel"])) + for attr, key in ( + ("method", "method"), + ("stimulus", "stimulus"), + ): + if getattr(options, attr) in (None, "", "auto") and spec.get(key): + setattr(options, attr, spec[key]) + if not options.args and spec.get("args"): + options.args = [int(value) for value in spec["args"]] + if options.arg_count == 0 and spec.get("arg_count"): + options.arg_count = int(spec["arg_count"]) + if options.arg_fill == "zero" and spec.get("arg_fill"): + options.arg_fill = str(spec["arg_fill"]) + if options.timeout == 0.5 and spec.get("timeout"): + options.timeout = float(spec["timeout"]) + if options.settle == 2.0 and spec.get("settle"): + options.settle = float(spec["settle"]) + if not options.watch and spec.get("watch"): + options.watch = list(spec["watch"]) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def parse_args_list(text: str) -> list[int]: + values: list[int] = [] + for part in text.replace(" ", ",").split(","): + if part: + values.append(int(part, 0)) + return values + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="reach", description=__doc__.splitlines()[0]) + parser.add_argument("--target", help="symbol name or 0x address to reach") + parser.add_argument("--kernel", default=DEMO_ELF, help="demo/re-hosted ELF (default: bundled demo)") + parser.add_argument("--out", default=None, help="output directory (default: dumps/reach-)") + parser.add_argument( + "--method", + choices=("auto", "stimulus", "trigger", "run", "call"), + default="auto", + help="methods to attempt; auto tries stimulus/trigger/run/call and stops at the first hit", + ) + parser.add_argument("--all-methods", action="store_true", help="attempt every method, not just until the first hit") + parser.add_argument("--args", type=parse_args_list, default=[], help="comma-separated integer args (a0..a7)") + parser.add_argument("--arg-count", type=int, default=0, help="auto-fill a0..a(n-1) when --args is short") + parser.add_argument("--arg-fill", choices=("zero", "sentinel"), default="zero", help="auto-fill value policy") + parser.add_argument("--timeout", type=float, default=0.5, help="per-attempt budget (seconds)") + parser.add_argument("--settle", type=float, default=2.0, help="self-test settle budget before trigger") + parser.add_argument("--stimulus", help="green-eye script to run under instrumentation") + parser.add_argument("--probe", help="JSON probe spec to replay (see --emit-probe)") + parser.add_argument("--emit-probe", help="where to write the repeatable probe spec (default /probe.json)") + parser.add_argument("--record", nargs="?", const=True, default=None, help="record attempts under DIR/") + parser.add_argument("--backend", choices=("qemu", "native"), default="qemu") + parser.add_argument("--max-prior", type=int, default=12, help="prior hook events kept in the report") + parser.add_argument("--allow-foreign-elf", action="store_true", help="permit a non-demo ELF under QEMU") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if args.probe: + try: + spec = load_probe_spec(args.probe) + except (OSError, ValueError, GreenEyeError) as exc: + print(f"reach error: {exc}", file=sys.stderr) + return 2 + else: + spec = {} + target_hint = args.target or str(spec.get("target", "target")) + out = args.out or os.path.join("dumps", "reach-" + _safe_name(target_hint)) + if isinstance(args.record, str): + record_base: str | None = os.path.abspath(args.record) + elif args.record: + record_base = os.path.join(os.path.abspath(out), "sessions") + else: + record_base = None + options = ReachOptions( + kernel=os.path.abspath(args.kernel), + out=os.path.abspath(out), + target=args.target or "", + method=args.method, + args=args.args, + arg_count=args.arg_count, + arg_fill=args.arg_fill, + timeout=args.timeout, + settle=args.settle, + stimulus=args.stimulus, + probe=args.probe, + emit_probe=os.path.abspath(args.emit_probe) if args.emit_probe else None, + record=record_base, + backend=args.backend, + max_prior=args.max_prior, + all_methods=args.all_methods, + allow_foreign_elf=args.allow_foreign_elf, + ) + if spec: + apply_probe_spec(options, spec) + if not options.target: + parser.error("--target is required (or provide a probe spec with 'target')") + try: + report = run_reach(options) + except GreenEyeError as exc: + print(f"reach error: {exc}", file=sys.stderr) + return 2 + if report["reachable"]: + print(f"[reach] {report['target']} reachable via {report['method']} ({os.path.join(options.out, 'reach-report.md')})") + else: + print(f"[reach] {report['target']} not reached: {report['reason']}") + return 0 if report["reachable"] else 1 + + +def _safe_name(text: str) -> str: + return "".join(ch if ch.isalnum() or ch in "-_." else "-" for ch in text) + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/README.md b/debug/risc-v-gsp-rm-vm/harness/value_demos/README.md new file mode 100644 index 0000000..e1a450c --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/README.md @@ -0,0 +1,83 @@ +# green-eye value-validation demos + +Runnable demonstrations that tie green-eye tool capabilities to **real** +Open-IOV scenarios (cited from `Handoff-vGPU-IO.md` and the project docs) and +measure the time-to-value against the static/Ghidra-only baseline. + +Each demo: + +* names the real scenario (handoff PART/addendum + date) and what it cost; +* prints the question, the green-eye commands used, the answer, the evidence + artifact and the elapsed wall-clock; +* writes a machine-readable `result.json` under `artifacts//`; +* labels what is **measured on the analog** vs **projected for the real + case**; device-model-blocked scenarios say so explicitly. + +> green-eye is development-only tooling (docs/00); these demos never touch +> hardware, a guest or a kernel module. The real `.fwimage` cannot boot +> without the H2 device model (docs/06 §3), so every demo is honest about +> being a *shape* reproduction on the bundled `uproc_demo` firmware + native +> RV64 simulator / QEMU. + +## Run + +From the tool root (`open-iov/debug/risc-v-gsp-rm-vm`): + +```bash +make demo # once: build firmware/uproc_demo/build/uproc_demo.elf +make sdk # optional (s06): builds sdk/build/injected_demo.elf + +python3 harness/value_demos/run_all.py # all 10, results JSON +python3 harness/value_demos/run_all.py --only s01,s04,s05 # subset +python3 harness/value_demos/s01_reply_consumer.py # one demo, verbose + +VALUE_DEMO_BACKEND=qemu python3 harness/value_demos/run_all.py # force QEMU +``` + +Defaults: native backend (deterministic, in-process) for s01–s08; QEMU for +s09/s10 (with a native fallback if QEMU is absent). `VALUE_DEMO_BACKEND` +overrides all of them. No hardware is required. + +The aggregate lands in `artifacts/results.json`; each demo writes under +`artifacts//` (results, sessions, TSVs, reports). The runner enforces a +per-demo timeout and the full suite runs in ~18 s on the reference host +(budget: each demo < ~30 s, suite < ~3 min). + +## Demos + +| # | Script | Real scenario (source, date) | Static/Ghidra-only baseline | Analog answer | +|---|---|---|---|---| +| s01 | `s01_reply_consumer.py` | 0x59 RM-init chase; fn72/fn73 hidden error (PART 91 + ADDENDA 2–7, 2026-10-01) | cycles 2–13, ~5 h, 10+ supervised boots; reply was a generic `0x59` | hook dispatcher + watch `result`: every command consumed and its exact reply decoded (1.14 s) | +| s02 | `s02_counter_publication.py` | 20 GR per-channel counters; only 4/20 moved (PARTs 46–64, 2026-09-24…29) | ~5 days; slot layout re-derived per boot; ~2–3 s pin vs ~2 s deadline | 20/20 slots published, each store PC captured (1.34 s) | +| s03 | `s03_unknown_command.py` | unknown/new control discovery; injected slots 16–23 (docs/19, 2026-10-02) | read the indirect dispatch table per stripped image | 24 IDs swept + classified: 6 known, 8 injected-slot, 10 default (5.96 s) | +| s04 | `s04_run_until_function.py` | RM-client ladder reachability per rung (PART 91 ADDENDUM 9 cycles 18–25, 2026-10-01) | ~1 h 41 m / 8 supervised cycles | natural run / timeout / trigger / direct call in one session (0.24 s) | +| s05 | `s05_snapshot_diff.py` | fn72 identity fix + 1656-B static-config reply (PART 91 ADDENDUM 6/7, 2026-10-01) | malformed field suspected statically; a live boot per fix iteration | every changed byte attributed to a named schema field (0.50 s) | +| s06 | `s06_inject_service.py` | P6 service wiring / guest CE copies (PART 92 ADDENDA 5–11, 2026-10-02) | kernel rebuild + supervised cycle (~5–8 min) per rung | service blob loaded, registered, invoked both routes (0.63 s) | +| s07 | `s07_version_diff.py` | 570.144 vs 570.211 pairing disproof; semantic diff (PART 91 cycles 2–3, 2026-10-01) | one full boot per version + manual log compare | same stimulus on two builds; exactly one reply field moved (1.14 s) | +| s08 | `s08_mutation_triage.py` | error-path corpus / fail-closed rules (R5; docs/19 §3.4, docs/18 §2) | hand-derived asserts and NOCAT records across cycles | 7 seeded mutations classified ok/fault/no-progress (1.82 s) | +| s09 | `s09_grabbing_a_ghidra_address.py` | static/dynamic join; GPFIFO GET fault `0x40000` vs `0x101000` (PART 91 ADDENDUM 9 cycle 24) | hypothesis unproven until the decoded status-queue fault (8 cycles) | Ghidra addr + bias = runtime; hook hit + `greeneye_hits.tsv` (0.72 s) | +| s10 | `s10_full_session_evidence.py` | evidence pack for a milestone (docs/14 §6; cycles 11–13, 2026-10-01) | hashes/sessions/logs/compare gathered by hand per cycle | one run → analyze + compare + hits + manifest + M1–M6 (4.92 s) | + +Times are the measured green-eye path on the reference host (native for +s01–s08, QEMU for s09/s10); see `artifacts/results.json` for a fresh run. + +## Projected vs measured + +* **Measured:** every analog result, elapsed time, session size, trace + slowdown and slot/reply count in `result.json`. +* **Projected (labelled `projected` in each result):** the real-case time + saving. No demo claims a real-GSP result the simulator cannot reach. +* **Blocked (`blocked_on_device_model: true`):** all real-firmware claims + wait on the H2.2 device model; the workflow is demonstrated on the analog. + +## Tests + +```bash +python3 -m unittest tests.test_value_demos -v # 5 tests, ~5 s +``` + +The suite runs the fastest demos end-to-end as subprocesses, asserts their +answers and checks `run_all.py` emits `artifacts/results.json`. It skips +cleanly when QEMU or the demo firmware is absent. See +[`docs/25-VALUE-VALIDATION.md`](../../docs/25-VALUE-VALIDATION.md) for the +methodology, overhead analysis and scenario representativeness argument. diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/__init__.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/__init__.py new file mode 100644 index 0000000..87cabe7 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/__init__.py @@ -0,0 +1 @@ +"""green-eye value-validation demos (see docs/25-VALUE-VALIDATION.md).""" diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/_common.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/_common.py new file mode 100644 index 0000000..f70ef1f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/_common.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Shared plumbing for the green-eye value-validation demos. + +Every demo maps one *real* Open-IOV scenario (cited from the handoffs) onto a +runnable demonstration on the bundled demo firmware / native RV64 simulator, +prints the question, the green-eye commands, the answer, the evidence artifact +and the measured wall-clock, and emits one machine-readable ``RESULT_JSON`` +line so ``run_all.py`` can aggregate. + +Stdlib only; no hardware; runs from the tool root +(``open-iov/debug/risc-v-gsp-rm-vm``). +""" +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.dirname(os.path.dirname(HERE)) +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +ARTIFACT_ROOT = os.path.join(HERE, "artifacts") +DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf") +SCHEMA_JSON = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "gsp_ctrl.schema.json") +SDK_ELF = os.path.join(TOOL_ROOT, "sdk", "build", "injected_demo.elf") + +CTRL_MAGIC = 0x47535031 +CTRL_RPC_COUNT = 0x10 +CTRL_LAST_RPC = 0x18 +CTRL_MAILBOX = 0x20 +CTRL_ARG0 = 0x28 +CTRL_ARG1 = 0x30 +CTRL_ARG2 = 0x38 +CTRL_RESULT = 0x40 +CTRL_BUF = 0x48 +CTRL_SIZE = 0x190 +PING_XOR = 0xBEEF +INJECT_CMD_BASE = 16 +SERVICE_SLOT = 6 + +#: Per-demo wall-clock ceiling the runner enforces (subprocess timeout). +DEMO_TIMEOUT_S = 45.0 +#: Runner ceiling so the whole suite stays well under ~3 min. +SUITE_BUDGET_S = 180.0 + + +class DemoError(RuntimeError): + """A demo precondition failed in a way the caller should report, not mask.""" + + +def qemu_available() -> bool: + return bool(shutil.which("qemu-system-riscv64")) + + +def demo_available() -> bool: + return os.path.isfile(DEMO_ELF) + + +def choose_backend(preferred: str = "native") -> str: + """Environment override wins; QEMU is used only when preferred and present.""" + env = os.environ.get("VALUE_DEMO_BACKEND") + if env: + return env + if preferred == "qemu": + return "qemu" if qemu_available() else "native" + return "native" + + +def sha256_file(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + + +class Stopwatch: + def __init__(self) -> None: + self.t0 = time.monotonic() + self.elapsed = 0.0 + + def stop(self) -> float: + self.elapsed = time.monotonic() - self.t0 + return self.elapsed + + +def artifact_dir(name: str) -> str: + path = os.path.join(ARTIFACT_ROOT, name) + os.makedirs(path, exist_ok=True) + return path + + +def write_json(path: str, payload) -> str: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.write("\n") + return path + + +def write_text(path: str, text: str) -> str: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(text) + return path + + +def load_schema(ge): + with open(SCHEMA_JSON, "r", encoding="utf-8") as fh: + return ge.schema_from_json(json.load(fh)) + + +def decode_ctrl(ge, data: bytes, schema=None) -> dict: + schema = schema or load_schema(ge) + out = schema.decode(data, base=0) + if isinstance(out.get("buf"), (bytes, bytearray)): + out["buf"] = bytes(out["buf"]).split(b"\x00", 1)[0].decode("utf-8", "replace") + return out + + +def short_hex(value) -> str: + if value is None: + return "None" + if isinstance(value, str): + return value + return f"{value:#x}" if isinstance(value, int) else str(value) + + +def render_banner(demo: str, question: str, scenario: str, baseline: str, commands) -> None: + print(f"=== {demo} ===") + print(f"real scenario : {scenario}") + print(f"baseline : {baseline}") + print(f"question : {question}") + print("commands :") + for command in commands: + print(f" - {command}") + + +def finish(result: dict, artifact: str, elapsed: float) -> dict: + """Persist the per-demo result and print the human + machine summary.""" + result["elapsed_s"] = round(elapsed, 4) + result["artifact"] = os.path.relpath(artifact, TOOL_ROOT) if artifact else None + result.setdefault("success", True) + if artifact: + write_json(os.path.join(artifact, "result.json"), result) + print(f"answer : {result.get('answer')}") + print(f"evidence : {result.get('artifact')}") + print(f"analog time : {elapsed:.3f} s ({result.get('backend')} backend)") + if result.get("real_baseline"): + print(f"real baseline : {result['real_baseline']}") + if result.get("projected"): + print(f"projected : {result['projected']}") + if result.get("blocked_on_device_model"): + print("blocked : real case blocked on H2 device model; workflow shown on analog") + if not result.get("success", True): + print(f"status : FAILED - {result.get('failure', 'unknown')}") + print("RESULT_JSON: " + json.dumps(result, sort_keys=True)) + sys.stdout.flush() + return result + + +def timed_demo(fn, artifact: str) -> dict: + watch = Stopwatch() + try: + result = fn() + except Exception as exc: # report, never hide + import traceback + + result = { + "success": False, + "failure": f"{type(exc).__name__}: {exc}", + "traceback": traceback.format_exc()[-2000:], + } + return finish(result, artifact, watch.stop()) + + +def scenario( + *, + ids, + baseline, + projection, + blocked=False, + device=None, +) -> dict: + return { + "citation_ids": ids, + "static_baseline": baseline, + "projection": projection, + "blocked_on_device_model": blocked, + "device_model_needed": device, + } diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/run_all.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/run_all.py new file mode 100644 index 0000000..808c227 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/run_all.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Run every value-validation demo and emit one machine-readable results JSON. + +Usage (from the tool root, ``open-iov/debug/risc-v-gsp-rm-vm``): + + python3 harness/value_demos/run_all.py + python3 harness/value_demos/run_all.py --only s01,s03,s05,s09 + VALUE_DEMO_BACKEND=qemu python3 harness/value_demos/run_all.py + +Each demo runs in its own subprocess with a hard timeout, so one wedged demo +cannot take the suite down. The aggregate lands in +``harness/value_demos/artifacts/results.json``. +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.dirname(os.path.dirname(HERE)) +sys.path.insert(0, HERE) + +from _common import ARTIFACT_ROOT, SUITE_BUDGET_S, write_json # noqa: E402 + +DEMOS = [ + ("s01", "s01_reply_consumer.py"), + ("s02", "s02_counter_publication.py"), + ("s03", "s03_unknown_command.py"), + ("s04", "s04_run_until_function.py"), + ("s05", "s05_snapshot_diff.py"), + ("s06", "s06_inject_service.py"), + ("s07", "s07_version_diff.py"), + ("s08", "s08_mutation_triage.py"), + ("s09", "s09_grabbing_a_ghidra_address.py"), + ("s10", "s10_full_session_evidence.py"), +] + + +def _run_one(key: str, script: str, timeout: float) -> dict: + path = os.path.join(HERE, script) + start = time.monotonic() + try: + proc = subprocess.run( + [sys.executable, path], + cwd=TOOL_ROOT, + capture_output=True, + text=True, + timeout=timeout, + ) + stdout, stderr, returncode = proc.stdout, proc.stderr, proc.returncode + except subprocess.TimeoutExpired as exc: + stdout = (exc.stdout or b"").decode("utf-8", "replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "") + stderr = (exc.stderr or b"").decode("utf-8", "replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "") + returncode = -9 + elapsed = time.monotonic() - start + result = None + for line in reversed(stdout.splitlines()): + if line.startswith("RESULT_JSON: "): + try: + result = json.loads(line[len("RESULT_JSON: ") :]) + except json.JSONDecodeError: + result = None + break + if result is None: + result = { + "success": False, + "failure": f"no RESULT_JSON (exit {returncode})", + "stderr_tail": stderr[-800:], + } + result["demo_key"] = key + result["runner_elapsed_s"] = round(elapsed, 4) + result["exit_code"] = returncode + return result + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--only", default="", help="comma-separated demo keys (e.g. s01,s03)") + parser.add_argument("--timeout", type=float, default=45.0, help="per-demo timeout seconds") + parser.add_argument("--out", default=os.path.join(ARTIFACT_ROOT, "results.json")) + args = parser.parse_args() + + selected = [item for item in DEMOS if not args.only or item[0] in args.only.split(",")] + if args.only and not selected: + print(f"no demos match --only {args.only!r}", file=sys.stderr) + return 2 + + started = time.monotonic() + results = [] + for key, script in selected: + print(f"--- running {script} ---", flush=True) + result = _run_one(key, script, args.timeout) + results.append(result) + status = "PASS" if result.get("success") else "FAIL" + print(f"--- {key}: {status} ({result['runner_elapsed_s']:.2f}s) ---", flush=True) + if time.monotonic() - started > SUITE_BUDGET_S: + print(f"[run_all] suite budget {SUITE_BUDGET_S:.0f}s exceeded; continuing", flush=True) + + total = time.monotonic() - started + passed = [r for r in results if r.get("success")] + payload = { + "format": "greeneye-value-demos-results/1", + "tool": "harness/value_demos/run_all.py", + "backend_env": os.environ.get("VALUE_DEMO_BACKEND", "native (default)"), + "demos_selected": [k for k, _ in selected], + "demos_passed": [r["demo_key"] for r in passed], + "demos_failed": [r["demo_key"] for r in results if not r.get("success")], + "total_elapsed_s": round(total, 3), + "measured_elapsed_s": round(sum(r["runner_elapsed_s"] for r in results), 3), + "results": results, + } + write_json(args.out, payload) + print(f"\n{len(passed)}/{len(results)} demos passed in {total:.1f}s; results: {args.out}") + print(f"RESULT_JSON: {json.dumps({k: payload[k] for k in ('format', 'demos_passed', 'demos_failed', 'total_elapsed_s')}, sort_keys=True)}") + return 0 if len(passed) == len(results) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s01_reply_consumer.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s01_reply_consumer.py new file mode 100644 index 0000000..b86f272 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s01_reply_consumer.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 +"""s01 - reply consumer: did the server consume the command, and what came back? + +Real scenario (Handoff-vGPU-IO.md, PART 91 + ADDENDA 2-7, 2026-10-01): +the physical backend booted the real GSP-RM and sent the pre-boot +``GSP_SET_SYSTEM_INFO`` (fn72) / ``SET_REGISTRY`` (fn73) RPCs; the only reply +the host could see was a generic ``GSP_INIT_DONE rpc_result=0x59`` +(``NV_ERR_OPERATING_SYSTEM``) wrapped by ``RmStartup``. Cycles 2-13 +(~11:40-16:52, ten supervised hardware cycles) were spent chasing which +collector step consumed which field because the RM log ring was never mapped +(LOGRM id bug) and the failure was un-journaled. Static/Ghidra-only work +(REPORT3-10) produced hypotheses (display, devid, chipset, ACPI) that needed a +live ``0x59``/success reply to accept or reject. + +Analog: hook the demo dispatcher, watch the control block's ``result`` field, +post known/unknown/fault commands and decode the exact reply per command. +""" +from __future__ import annotations + +import os + +try: # package import (tests) or direct script execution (run_all) + from . import _common as C +except ImportError: + import sys as _sys + + _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import _common as C + + +def run() -> dict: + import greeneye as ge + + backend = C.choose_backend("native") + out = C.artifact_dir("s01_reply_consumer") + commands = [ + 'ge.launch(kernel=DEMO_ELF, backend="%s")' % backend, + 's.hook("rpc_dispatch", on_enter=record cmd/args)', + "s.watch(gsp_ctrl+0x40, size=8, kind='w') # result", + "s.memory.write_u64(ctrl+0x20, cmd); s.run(duration=...) # mailbox post", + "s.memory.read_u64(ctrl+0x40) # decode reply", + ] + scenario = C.scenario( + ids=[ + "Handoff-vGPU-IO.md PART 91 (2026-10-01) cycles 2-3", + "PART 91 ADDENDUM 2-7 (2026-10-01): NOCAT, fn72/fn73, LOGRM id, identity", + ], + baseline=( + "cycles 2-13, ~5 h wall-clock and 10+ supervised hardware boots; " + "each candidate fix cost one full boot cycle (~5-6 min) and the " + "only visible reply was a generic 0x59" + ), + projection=( + "real GSP-RM reply consumption is blocked on the H2 device model; " + "on the analog the same observe->decode step is one hook + one " + "watchpoint per command" + ), + blocked=True, + device="H2.2 QEMU device model (RM message loop / real .fwimage)", + ) + C.render_banner( + "s01_reply_consumer", + "When the host posts a command, did the dispatcher consume it and what exact reply came back?", + "0x59 RM-init chase: generic reply hid which collector step consumed which field", + scenario["static_baseline"], + commands, + ) + + cases = [ + {"cmd": 1, "arg0": 0x1234, "label": "PING"}, + {"cmd": 7, "arg0": 0, "label": "unknown-id"}, + {"cmd": 6, "arg0": 0, "label": "FAULT"}, + ] + replies = [] + session = ge.launch(kernel=C.DEMO_ELF, backend=backend, name="vd-s01") + try: + ctrl = session.symbols.lookup("gsp_ctrl") + dispatches = [] + stores = [] + session.hook( + "rpc_dispatch", + on_enter=lambda hit: dispatches.append( + {"cmd": hit.arg(0), "arg0": hit.arg(1), "arg1": hit.arg(2), "arg2": hit.arg(3)} + ), + ) + session.watch(ctrl + C.CTRL_RESULT, size=8, kind="w", callback=lambda hit: stores.append(hit.pc)) + session.run(duration=0.3) + for case in cases: + before_count = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT) + session.memory.write_u64(ctrl + C.CTRL_ARG0, case["arg0"]) + session.memory.write_u64(ctrl + C.CTRL_MAILBOX, case["cmd"]) + session.run(duration=0.25) + after_count = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT) + result = session.memory.read_u64(ctrl + C.CTRL_RESULT) + expected = { + 1: (0x1234 ^ C.PING_XOR), + 7: (~7) & 0xFFFFFFFFFFFFFFFF, + 6: None, + }[case["cmd"]] + replies.append( + { + "label": case["label"], + "cmd": case["cmd"], + "arg0": case["arg0"], + "consumed": after_count > before_count, + "result": result, + "expected": expected, + "result_ok": (expected is None) or (result == expected), + "rpc_count_delta": after_count - before_count, + } + ) + if case["cmd"] == 6: + # ebreak trap: the dispatcher's post-handler stores never run. + session.step(1) + known = replies[0] + unknown = replies[1] + fault = replies[2] + payload = { + "success": known["result_ok"] and unknown["result_ok"] and not fault["consumed"], + "backend": backend, + "firmware": os.path.relpath(C.DEMO_ELF, C.TOOL_ROOT), + "firmware_sha256": C.sha256_file(C.DEMO_ELF), + "dispatches": dispatches, + "reply_store_pcs": stores, + "replies": replies, + "question": "consumed and decoded every posted command", + "answer": ( + f"PING consumed, reply {known['result']:#x} == arg0 ^ 0xbeef " + f"(expected {known['expected']:#x}); unknown id returns ~cmd " + f"({unknown['result']:#x}); FAULT consumed={fault['consumed']} " + f"(trap before the reply store) - the exact first-error visibility " + "gap the 0x59 chase lacked" + ), + "success_criteria": { + "pass": "3/3 dispatches observed; PING/unknown replies match the ABI; FAULT traps before result", + "fail": "a posted command is not consumed or a reply disagrees with the ABI", + }, + "real_case": scenario, + } + C.write_json(os.path.join(out, "reply.json"), payload) + finally: + session.close() + return payload + + +def main() -> int: + return 0 if C.timed_demo(run, C.artifact_dir("s01_reply_consumer"))["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s02_counter_publication.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s02_counter_publication.py new file mode 100644 index 0000000..690f60f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s02_counter_publication.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""s02 - counter publication: which of N slots advanced, and when? + +Real scenario (Handoff-vGPU-IO.md PART 46-64, 2026-09-24..2026-09-29): +``cuCtxCreate`` waits on 20 GR per-channel completion counters. The host saw +the client's releases but could not translate the counter page; for many boots +only 4 of 20 client-visible slots ever moved (PART 49). The exact slot layout +(page VA ``0x20400f000``, slots at ``+0xec0``, 16-byte stride) was only proven +by a raw live page dump (PART 64, 2026-09-29 ~16:55), and the host raced a +~2-3 s pin path against the client's ~2 s GR-phase deadline. Static reading +could not tell which slots were published: the page's GPA changed every boot +and stale translations pointed at the previous client's page (PART 51). + +Analog: post 20 sequential commands and watch the demo's published result +field; prove every slot was observed, with the publish PC recorded per slot. +""" +from __future__ import annotations + +import os + +try: # package import (tests) or direct script execution (run_all) + from . import _common as C +except ImportError: + import sys as _sys + + _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import _common as C + +SLOTS = 20 + + +def run() -> dict: + import greeneye as ge + + backend = C.choose_backend("native") + out = C.artifact_dir("s02_counter_publication") + commands = [ + 'ge.launch(kernel=DEMO_ELF, backend="%s")' % backend, + "s.watch(gsp_ctrl+0x40, size=8, kind='w', callback=record pc) # publication", + "for slot in 1..20: write mailbox cmd=5 (STATE); run(duration=0.05)", + "read gsp_ctrl.result after every slot; compare against the expected stage value", + ] + scenario = C.scenario( + ids=[ + "Handoff-vGPU-IO.md PART 49/50 (2026-09-29): only 4/20 counters moved; all 20 reached 1 after the queue fix", + "PART 64 (2026-09-29 ~16:55): raw page dump proves 20 slots at 16-byte stride", + "PART 46/48 (2026-09-29): rescue/sweep latency and tracker waits", + ], + baseline=( + "PARTs 46->64: ~5 days of sessions; PART 49 'only 4 of 20 counters " + "ever moved'; each boot re-derived the slot layout because the " + "counter page GPA changed (PART 51/53)" + ), + projection=( + "on real silicon the 20 slots are per-channel memory pages; the " + "analog publishes 20 logical slots through the same watchpoint " + "shape, one store each" + ), + blocked=True, + device="H2.2 device model + real GSP-RM counter pages", + ) + C.render_banner( + "s02_counter_publication", + "Of the N client-visible slots, which ones advanced, and where was each published?", + "20 GR per-channel completion counters: 4/20 published for days until a live page dump", + scenario["static_baseline"], + commands, + ) + + session = ge.launch(kernel=C.DEMO_ELF, backend=backend, name="vd-s02") + slots = [] + try: + ctrl = session.symbols.lookup("gsp_ctrl") + publishes = [] + session.watch(ctrl + C.CTRL_RESULT, size=8, kind="w", callback=lambda hit: publishes.append(hit.pc)) + session.run(duration=0.25) + before = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT) + for slot in range(SLOTS): + session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 5) # STATE + session.run(duration=0.05) + value = session.memory.read_u64(ctrl + C.CTRL_RESULT) + slots.append( + { + "slot": slot + 1, + "result": value, + "result_hex": f"{value:#x}", + "published": value in (0x10, 0x20, 0x30, 0x40), + } + ) + after = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT) + published = [s for s in slots if s["published"]] + payload = { + "success": len(published) == SLOTS and (after - before) == SLOTS, + "backend": backend, + "slots_requested": SLOTS, + "slots_published": len(published), + "rpc_count_delta": after - before, + "watch_stores": len(publishes), + "distinct_values": sorted({s["result_hex"] for s in published}), + "per_slot": slots, + "publish_pcs": sorted(set(publishes)), + "question": f"did all {SLOTS} slots publish, and can each publication be attributed?", + "answer": ( + f"{len(published)}/{SLOTS} slots published ({after - before} dispatcher " + f"completions, {len(publishes)} stores to gsp_ctrl.result, values " + f"{sorted({s['result_hex'] for s in published})}); every store PC is captured, " + "exactly the evidence class that took a live 512-byte page dump on the real path" + ), + "success_criteria": { + "pass": f"{SLOTS}/{SLOTS} slots observed with rpc_count advancing once per slot", + "fail": "any slot does not publish or the count delta disagrees", + }, + "real_case": scenario, + } + C.write_json(os.path.join(out, "counters.json"), payload) + finally: + session.close() + return payload + + +def main() -> int: + return 0 if C.timed_demo(run, C.artifact_dir("s02_counter_publication"))["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s03_unknown_command.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s03_unknown_command.py new file mode 100644 index 0000000..162bc47 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s03_unknown_command.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""s03 - unknown command discovery: which IDs are handled, injected, or new? + +Real scenario (docs/19-CONTROL-DISCOVERY.md, measured 2026-10-02): a swept +input surface is how new/undocumented controls are found between firmware +versions. On the demo, IDs 16..23 were *discovered* to fall through the +switch into the injected-service handler table (16..21 alias the built-in +handlers, 22..23 are NULL slots returning ``~id``) - wiring a new image can +add silently. Statically this requires reading an indirect dispatch table +per version; behaviorally it is a bounded sweep. + +Analog: 24 command IDs, zero arguments, deterministic, fresh session per ID +(the same safety strategy as the real discovery tool), classified by hooks on +the dispatcher and every handler-table entry. +""" +from __future__ import annotations + +import json +import os + +try: # package import (tests) or direct script execution (run_all) + from . import _common as C +except ImportError: + import sys as _sys + + _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import _common as C + +HANDLERS = ("rpc_ping", "rpc_echo", "rpc_fill", "rpc_sum", "rpc_state", "rpc_fault") + + +def _classify(cmd: int, handler_hits, dispatch_seen, result, trap) -> str: + if 16 <= cmd <= 23: + return "injected-slot" + if 1 <= cmd <= 6: + return "known-handler" + if trap: + return "trap" + if result is not None and result == (~cmd) & 0xFFFFFFFFFFFFFFFF: + return "default-path" + return "no-progress" + + +def run() -> dict: + import greeneye as ge + + backend = C.choose_backend("native") + out = C.artifact_dir("s03_unknown_command") + commands = [ + 'for cmd in 1..24: ge.launch(backend="%s"); hook(rpc_dispatch + 6 handlers)' % backend, + "write mailbox cmd with zero args; run bounded; classify", + "record: outcome, handler alias, result, trap signal, rpc_count delta", + ] + scenario = C.scenario( + ids=[ + "docs/19-CONTROL-DISCOVERY.md (2026-10-02): 189-probe demo sweep in ~36 s", + "Handoff-vGPU-IO.md PART 91 (2026-10-01): new controls appear between images (0x59 era)", + "docs/14-ADVERSARIAL-INTEROP-GUIDE.md 4.1: static diff says code changed, not which input activates it", + ], + baseline=( + "static-only: read the indirect dispatcher table in Ghidra for every " + "image; on the real corpus the demo sweep took ~36 s for 189 probes " + "and still found an undocumented injected-slot class" + ), + projection=( + "the RM surface (control IDs by class, method opcodes) is gated on " + "H1.3/H2.2; the sweep/classification shape demonstrated here is " + "exactly what docs/19 section 5 designs for it" + ), + blocked=True, + device="H1.3/H2.2: RM dispatch table exposed to the simulator", + ) + C.render_banner( + "s03_unknown_command", + "Which command IDs are handled, which route to injected slots, and which are new/default?", + "unknown-control discovery between stripped firmware versions", + scenario["static_baseline"], + commands, + ) + + findings = [] + for cmd in range(1, 25): + session = ge.launch(kernel=C.DEMO_ELF, backend=backend, name=f"vd-s03-{cmd:02d}") + try: + ctrl = session.symbols.lookup("gsp_ctrl") + dispatch = [] + handler_hits = [] + signals = [] + session.hook( + "rpc_dispatch", + on_enter=lambda hit, d=dispatch: d.append( + {"cmd": hit.arg(0), "arg0": hit.arg(1), "arg1": hit.arg(2), "arg2": hit.arg(3)} + ), + ) + for name in HANDLERS: + try: + session.hook(name, on_enter=lambda hit, n=name: handler_hits.append(n)) + except Exception: + pass + session.on_event("stop", lambda hit, sig=signals: sig.append(hit.stop.signal) if hit.stop else None) + session.run(duration=0.15) + session.memory.write_u64(ctrl + C.CTRL_MAILBOX, cmd) + session.run(duration=0.15) + result = session.memory.read_u64(ctrl + C.CTRL_RESULT) + trap = 5 in [s for s in signals if s] + outcome = _classify(cmd, handler_hits, bool(dispatch), result, trap) + findings.append( + { + "cmd": cmd, + "outcome": outcome, + "trap": trap, + "signals": [s for s in signals if s], + "handler_alias": handler_hits[0] if handler_hits else None, + "dispatch_seen": bool(dispatch), + "result": result, + "result_hex": f"{result:#x}", + "expected_default": f"{(~cmd) & 0xFFFFFFFFFFFFFFFF:#x}", + } + ) + finally: + session.close() + + by_class: dict[str, list[int]] = {} + for finding in findings: + by_class.setdefault(finding["outcome"], []).append(finding["cmd"]) + injected = by_class.get("injected-slot", []) + aliases = {f["cmd"]: f["handler_alias"] for f in findings if f["cmd"] in injected} + payload = { + "success": len(injected) == 8 and len(by_class.get("known-handler", [])) == 6, + "backend": backend, + "ids_swept": len(findings), + "classes": {key: {"count": len(v), "ids": v} for key, v in sorted(by_class.items())}, + "injected_aliases": aliases, + "findings": findings, + "question": "enumerate handled / injected / default command space in one bounded sweep", + "answer": ( + f"{len(by_class.get('known-handler', []))} known handlers, " + f"{len(injected)} injected-slot IDs ({injected}), aliases {aliases}, " + f"{len(by_class.get('default-path', []))} default-path IDs; " + "the same shape (sweep + dispatcher/handler hooks) is what found the " + "silent 16..23 wiring the static map did not highlight" + ), + "success_criteria": { + "pass": "IDs 1..6 classified known, 16..23 classified injected-slot, remaining default; cmd 6 traps", + "fail": "an ID is misclassified or a fresh-session probe cannot be replayed", + }, + "real_case": scenario, + } + os.makedirs(out, exist_ok=True) + with open(os.path.join(out, "findings.jsonl"), "w", encoding="utf-8") as fh: + for finding in findings: + fh.write(json.dumps(finding, sort_keys=True) + "\n") + lines = ["# control discovery (analog)", "", f"* backend: {backend}", f"* ids swept: {len(findings)}", ""] + for key, value in sorted(by_class.items()): + lines.append(f"* {key}: {value}") + lines.append("") + C.write_text(os.path.join(out, "control-discovery.md"), "\n".join(lines) + "\n") + C.write_json(os.path.join(out, "discovery.json"), payload) + return payload + + +def main() -> int: + return 0 if C.timed_demo(run, C.artifact_dir("s03_unknown_command"))["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s04_run_until_function.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s04_run_until_function.py new file mode 100644 index 0000000..a74c83f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s04_run_until_function.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""s04 - run-until a function: is this code on the live path, and with what args? + +Real scenario (docs/19-CONTROL-DISCOVERY.md 4.4 and +Handoff-vGPU-IO.md PART 91 ADDENDUM 9, 2026-10-01 cycles 18-25): the +``gsp-phys-rm-call`` RM-client ladder asked, per cycle, whether a symbolized +function had executed under the current stimulus (VAS commit, token, BIND, +fetch, semaphore). Cycles 18->25 (~1 h 41 m, eight supervised boots) walked +the ladder one reachability question at a time; ``reach.py`` answers that +question in ~0.5 s per target, and a ``run_until`` timeout means "not executed +under this stimulus at this fidelity", never "unreachable". + +Analog: run to the self-test's ``rpc_sum``, show ``rpc_fault`` is *not* on the +natural path (timeout), then trigger it through the mailbox and reach it with +the dispatch arguments captured; finally call ``rpc_ping`` directly +(Frida NativeFunction) to show the unit-test route. +""" +from __future__ import annotations + +import os + +try: # package import (tests) or direct script execution (run_all) + from . import _common as C +except ImportError: + import sys as _sys + + _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import _common as C + + +def run() -> dict: + import greeneye as ge + from greeneye.errors import RspTimeout + + backend = C.choose_backend("native") + out = C.artifact_dir("s04_run_until_function") + commands = [ + 's.run_until("rpc_sum", timeout=5.0) # natural path', + 's.run_until("rpc_fault", timeout=0.2) # not on natural path', + 'write mailbox cmd=6; s.run_until("rpc_fault") # trigger route', + 's.call("rpc_ping", (0x41,)) # Frida-like direct call', + ] + scenario = C.scenario( + ids=[ + "docs/19-CONTROL-DISCOVERY.md 4.4 (2026-10-02): reach.py measured ~0.5 s per target", + "Handoff-vGPU-IO.md PART 91 ADDENDUM 9 (2026-10-01): RM-client ladder cycles 18-25", + "docs/14-ADVERSARIAL-INTEROP-GUIDE.md 5: run_until timeouts are not unreachability", + ], + baseline=( + "cycles 18-25 took ~1 h 41 m of supervised hardware cycles (full " + "boot + RPC ladder per hypothesis) to answer the same class of " + "'did this function execute under this stimulus' question" + ), + projection=( + "real RM ladder reachability needs the live GSP-RM (H1.3/H2.2); " + "run_until/hook/call already work against the re-hosted firmware " + "shape and every probe spec emitted here replays" + ), + blocked=True, + device="H1.3 live RM method entry / H2.2 device model", + ) + C.render_banner( + "s04_run_until_function", + "Is a symbolized function on the live path under this stimulus, what args does it take, and can it be called directly?", + "RM-client ladder: reachability of each rung, one supervised boot per question", + scenario["static_baseline"], + commands, + ) + + session = ge.launch(kernel=C.DEMO_ELF, backend=backend, name="vd-s04") + try: + ctrl = session.symbols.lookup("gsp_ctrl") + dispatch_args = [] + session.hook( + "rpc_dispatch", + on_enter=lambda hit: dispatch_args.append( + {"cmd": hit.arg(0), "a0": hit.arg(1), "a1": hit.arg(2), "a2": hit.arg(3)} + ), + ) + natural_hit = session.run_until("rpc_sum", timeout=5.0) + natural = { + "target": "rpc_sum", + "method": "run", + "reachable": True, + "pc": f"{natural_hit.addr:#x}", + "dispatch_args": list(dispatch_args), + } + timeout_hit = None + try: + timeout_hit = session.run_until("rpc_fault", timeout=0.2) + timeout_seen = False + except RspTimeout: + timeout_seen = True + fault_before = { + "target": "rpc_fault", + "method": "run_until", + "reachable": timeout_hit is not None, + "timeout": timeout_seen, + "reading": "not executed under this stimulus at this fidelity (docs/14 section 5)", + } + session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 6) + trigger_hit = session.run_until("rpc_fault", timeout=5.0) + triggered = { + "target": "rpc_fault", + "method": "trigger (mailbox cmd=6)", + "reachable": True, + "pc": f"{trigger_hit.addr:#x}", + "dispatch_args": list(dispatch_args[-1:]), + } + direct = session.call("rpc_ping", (0x41,)) + payload = { + "success": natural["reachable"] and fault_before["timeout"] and triggered["reachable"] + and direct == (0x41 ^ C.PING_XOR), + "backend": backend, + "natural_path": natural, + "before_trigger": fault_before, + "after_trigger": triggered, + "direct_call": {"target": "rpc_ping", "args": [0x41], "result": direct, "expected": 0x41 ^ C.PING_XOR}, + "question": "reachability and required arguments per symbolized function", + "answer": ( + f"rpc_sum reached on the self-test path (pc {natural['pc']}); rpc_fault " + f"times out (reachable=False) until mailbox cmd=6 triggers it " + f"(pc {triggered['pc']}, args {triggered['dispatch_args']}); direct call " + f"rpc_ping(0x41)={direct:#x} - three reachability answers, one session" + ), + "success_criteria": { + "pass": "natural target reached, un-triggered target times out, trigger route reaches it, direct call matches", + "fail": "a route reports reachable without a hit, or the direct call disagrees", + }, + "real_case": scenario, + } + C.write_json(os.path.join(out, "reach.json"), payload) + finally: + session.close() + return payload + + +def main() -> int: + return 0 if C.timed_demo(run, C.artifact_dir("s04_run_until_function"))["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s05_snapshot_diff.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s05_snapshot_diff.py new file mode 100644 index 0000000..fd89131 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s05_snapshot_diff.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""s05 - snapshot diff: which control-block fields did one command change? + +Real scenario (Handoff-vGPU-IO.md PART 91 ADDENDUM 6, 2026-10-01 cycle 12/13): +the fn72 ``GSP_SET_SYSTEM_INFO`` payload carried *unpacked* identity fields and +the reference sends raw config-space dwords; the server read the device id +back as zero and only a field-level comparison of the live payload/reply +could confirm the fix ("all zeroed fn72 fields cannot matter to this failure" +was settled by decoding, not by reading). Static review (REVIEW-FN72.md, +cycle 13's 1656-byte static-config reply) still needed a live before/after of +the control block to call the fix verified. + +Analog: snapshot the decoded control block before and after one command, +attribute every changed byte run to its named schema field, and show ECHO's +buffer side-effect separately. +""" +from __future__ import annotations + +import os + +try: # package import (tests) or direct script execution (run_all) + from . import _common as C +except ImportError: + import sys as _sys + + _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import _common as C + + +def _fields_hit(schema, diffs) -> list[str]: + names = [] + for diff in diffs: + for field in schema.fields: + start = field.offset + end = field.offset + field.nbytes + if start <= diff.addr < end: + if field.name not in names: + names.append(field.name) + return names + + +def run() -> dict: + import greeneye as ge + from greeneye.memory import Memory + + backend = C.choose_backend("native") + out = C.artifact_dir("s05_snapshot_diff") + commands = [ + 's.snapshot(gsp_ctrl, 0x188, "before")', + "write mailbox cmd=2 (ECHO) with a known string; run", + 's.snapshot(gsp_ctrl, 0x188, "after")', + "Memory.diff(before, after); decode both via gsp_ctrl.schema.json", + ] + scenario = C.scenario( + ids=[ + "Handoff-vGPU-IO.md PART 91 ADDENDUM 6 cycle 12 (2026-10-01): unpacked fn72 identity fields", + "PART 91 ADDENDUM 7 (2026-10-01): 1656-byte static-config reply decoded field-by-field", + "docs/06-ACCURACY.md M6: memory side-effect agreement / snapshot hashes", + ], + baseline=( + "the malformed identity value was suspected statically (REVIEW-FN72) " + "but accepting the fix required a live boot per iteration; ten NOCAT " + "cycles ran before the field-level root cause was visible" + ), + projection=( + "the same before/after diff runs against any firmware region once " + "the H2 device model exposes the real RM control pages" + ), + blocked=True, + device="H2.2 RM control/reply pages for the real GSP-RM", + ) + C.render_banner( + "s05_snapshot_diff", + "Which named control-block fields did this one command change?", + "fn72/static-config field-level validation (identity fix, cycle 12/13)", + scenario["static_baseline"], + commands, + ) + + schema = C.load_schema(ge) + session = ge.launch(kernel=C.DEMO_ELF, backend=backend, name="vd-s05") + try: + ctrl = session.symbols.lookup("gsp_ctrl") + session.run(duration=0.25) + before = session.snapshot(ctrl, C.CTRL_SIZE, "before-echo") + message = b"hello-green-eye" + # Locate the string literal in the loaded image so the stimulus is + # self-describing (no hardcoded address). + hits = session.memory.search(0x80000000, 0x2000, message) + literal = hits[0] if hits else 0 + session.memory.write_u64(ctrl + C.CTRL_ARG0, literal) + session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 2) + session.run(duration=0.25) + after = session.snapshot(ctrl, C.CTRL_SIZE, "after-echo") + + before_dec = C.decode_ctrl(ge, before, schema) + after_dec = C.decode_ctrl(ge, after, schema) + diffs = Memory.diff(before, after) + changed_fields = _fields_hit(schema, diffs) + changed_names = [name for name in changed_fields if before_dec.get(name) != after_dec.get(name)] + rendered = Memory.render_diff(diffs, symbols=session.symbols) + payload = { + "success": bool(changed_names) + and set(changed_names) >= {"rpc_count", "last_rpc", "result", "buf"} + and after_dec.get("result") == len(message), + "backend": backend, + "changed_field_names": changed_names, + "changed_byte_runs": len(diffs), + "before": {k: v for k, v in before_dec.items() if k in changed_names}, + "after": {k: v for k, v in after_dec.items() if k in changed_names}, + "rendered_diff": rendered, + "question": "attribute every changed byte in one region to a named field", + "answer": ( + f"ECHO changed {len(changed_names)} named fields {changed_names}; " + f"buf={after_dec.get('buf')!r} result={after_dec.get('result')} " + f"(len={len(message)}); {len(diffs)} byte runs exactly covered by the schema - " + "the field-level 'what did the reply actually consume' check" + ), + "success_criteria": { + "pass": "rpc_count/last_rpc/result/buf change, result equals the ECHO length, no unattributed run", + "fail": "a region changes with no named field, or the ECHO reply length disagrees", + }, + "real_case": scenario, + } + C.write_json(os.path.join(out, "snapshot-diff.json"), payload) + C.write_text( + os.path.join(out, "snapshot-diff.md"), + "# snapshot diff (analog)\n\n" + f"* changed fields: {changed_names}\n" + f"* changed byte runs: {len(diffs)}\n\n" + "```\n" + rendered + "\n```\n", + ) + finally: + session.close() + return payload + + +def main() -> int: + return 0 if C.timed_demo(run, C.artifact_dir("s05_snapshot_diff"))["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s06_inject_service.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s06_inject_service.py new file mode 100644 index 0000000..04dd001 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s06_inject_service.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +"""s06 - inject a service: add an RM engine without rebuilding the firmware. + +Real scenario (Handoff-vGPU-IO.md PART 92 ADDENDUM 5-11, 2026-10-02): the P6 +work wired guest-originated CE copies through the real engine; every change to +the service/wiring path cost a kernel rebuild plus a supervised physical boot +cycle (P6a/b/c, ~5-8 min each, one device-write experiment per cycle) and was +only accepted after guest smoke ``ALL OK 64/64``. docs/12-INJECTION-SDK.md +defines the in-simulator equivalent: load a position-independent service blob +and register it with the firmware's dispatch table (commands 16..23). + +Analog: load ``sdk/build/injected_demo.elf`` into slot 6, invoke it directly +(Frida NativeFunction) and through the firmware mailbox, and record the +``service_injected`` evidence event. No kernel rebuild, no hardware. +""" +from __future__ import annotations + +import os + +try: # package import (tests) or direct script execution (run_all) + from . import _common as C +except ImportError: + import sys as _sys + + _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import _common as C + + +def run() -> dict: + import greeneye as ge + from greeneye.inject import invoke_remote, invoke_via_firmware, load_service + + backend = C.choose_backend("native") + out = C.artifact_dir("s06_inject_service") + commands = [ + 'load_service(s, "sdk/build/injected_demo.elf", slot=6)', + "invoke_remote(s, svc, cmd=1, a0=0x41) # debugger-side call", + "invoke_via_firmware(s, svc, cmd=1, a0=0x41) # mailbox cmd=16", + "read gsp_ctrl.result / handlers[6]", + ] + scenario = C.scenario( + ids=[ + "Handoff-vGPU-IO.md PART 92 ADDENDUM 5-11 (2026-10-02): P6 service/wiring rungs", + "PART 92 ADDENDUM 6 (2026-10-02): p6run7 first real engine copy after service wiring", + "docs/12-INJECTION-SDK.md: blob/ABI contract for injecting RM services", + ], + baseline=( + "each P6 rung was a kernel rebuild plus a supervised physical cycle " + "(~5-8 min boots; P6a->P6c spanned ~02:20-08:30) with fail-safe " + "release discipline after every run" + ), + projection=( + "on real GSP-RM the registry is the RM service table (M5), gated on " + "H2.2; the load/register/invoke/observe shape is identical to the " + "demo handler-table slot" + ), + blocked=True, + device="H2.2 device model + M5 RM service registry", + ) + C.render_banner( + "s06_inject_service", + "Can a new service be loaded, registered and invoked without rebuilding the firmware?", + "guest-originated CE service wiring (P6a-P6c): kernel rebuild + supervised boot per rung", + scenario["static_baseline"], + commands, + ) + + if not os.path.isfile(C.SDK_ELF): + payload = { + "success": False, + "skipped": True, + "failure": "sdk/build/injected_demo.elf not built (run: make sdk)", + "backend": backend, + "real_case": scenario, + } + C.write_json(os.path.join(out, "inject.json"), payload) + return payload + + session = ge.launch(kernel=C.DEMO_ELF, backend=backend, record=os.path.join(out, "session"), name="vd-s06") + try: + ctrl = session.symbols.lookup("gsp_ctrl") + session.run(duration=0.25) + service = load_service(session, C.SDK_ELF, slot=C.SERVICE_SLOT) + remote_ping = invoke_remote(session, service, 1, 0x41) + remote_sum = invoke_remote(session, service, 2, 0x10, 0x20) + via_fw_ping = invoke_via_firmware(session, service, 1, 0x41) + via_fw_sum = invoke_via_firmware(session, service, 2, 0x10, 0x20) + handlers7 = session.memory.read_u64(ctrl + 0x150 + 8 * C.SERVICE_SLOT) + payload = { + "success": remote_ping == (0x41 ^ C.PING_XOR) and remote_sum == 0x30 + and via_fw_ping == remote_ping and via_fw_sum == remote_sum, + "backend": backend, + "service": service.summary(), + "service_sha256": C.sha256_file(C.SDK_ELF), + "handlers_slot6": f"{handlers7:#x}", + "remote": {"ping": remote_ping, "sum": remote_sum}, + "via_firmware": {"ping": via_fw_ping, "sum": via_fw_sum}, + "mailbox_command": C.INJECT_CMD_BASE + C.SERVICE_SLOT, + "session": os.path.relpath(os.path.join(out, "session"), C.TOOL_ROOT), + "question": "load/register/invoke a service blob end-to-end, both routes", + "answer": ( + f"service loaded at {service.load_addr:#x}, slot {service.slot} " + f"(handlers[{service.slot}]={handlers7:#x}); remote ping={remote_ping:#x} " + f"sum={remote_sum:#x}; via mailbox cmd={C.INJECT_CMD_BASE + C.SERVICE_SLOT} " + f"ping={via_fw_ping:#x} sum={via_fw_sum:#x} - both paths agree, one session" + ), + "success_criteria": { + "pass": "remote and firmware-mailbox invocations return the SDK ABI values and agree", + "fail": "a route disagrees, the slot is not registered, or the blob cannot load", + }, + "real_case": scenario, + } + C.write_json(os.path.join(out, "inject.json"), payload) + finally: + session.close() + return payload + + +def main() -> int: + return 0 if C.timed_demo(run, C.artifact_dir("s06_inject_service"))["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s07_version_diff.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s07_version_diff.py new file mode 100644 index 0000000..e4748d7 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s07_version_diff.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""s07 - version diff: same stimulus against two firmware versions. + +Real scenario (Handoff-vGPU-IO.md PART 91, 2026-10-01 cycles 2-3): the staged +570.211 image and a verified 570.144 image both failed ``GSP_INIT_DONE`` with +``rpc_result=0x59``; disproving the image-pairing hypothesis cost one full +supervised boot cycle per version, and docs/14 section 4.1 exists because +"the code changed" and "the behaviour moved" must be separated before the +emulation is touched. Sorting a new image into the decision table +(static changed x hit/write profile changed) is the H4.1 triage. + +Analog: byte-patch the demo's PING reply constant (0xbeef -> 0xbef0) to make a +labelled "new version", run the *same* recorded stimulus on both, and diff the +sessions and the decoded control block. The answer is one live-path field. +""" +from __future__ import annotations + +import os +import shutil + +try: # package import (tests) or direct script execution (run_all) + from . import _common as C +except ImportError: + import sys as _sys + + _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import _common as C + +#: addiw a1, a1, -0x111 (the 0xbeef PING key) -> -0x110 (0xbef0) +ADDW_OLD = bytes.fromhex("9b85f5ee") +ADDW_NEW = bytes.fromhex("9b8505ef") +STRING_OLD = b"hello-green-eye" +STRING_NEW = b"hello-green-eyf" + + +def _make_variant(src: str, dst: str) -> dict: + with open(src, "rb") as fh: + data = bytearray(fh.read()) + count = data.count(ADDW_OLD) + if count == 1: + offset = data.find(ADDW_OLD) + old_bytes = bytes(data[offset : offset + 4]) + data[offset : offset + 4] = ADDW_NEW + kind = "ping-key-0xbeef->0xbef0" + else: + count = data.count(STRING_OLD) + if count != 1: + raise C.DemoError(f"no unique patch site (addiw x{data.count(ADDW_OLD)}, string x{count})") + offset = data.find(STRING_OLD) + old_bytes = bytes(data[offset : offset + len(STRING_OLD)]) + data[offset : offset + len(STRING_OLD)] = STRING_NEW + kind = "echo-string" + with open(dst, "wb") as fh: + fh.write(data) + return { + "kind": kind, + "offset": offset, + "old_bytes": old_bytes.hex(), + "source_sha256": C.sha256_file(src), + "variant_sha256": C.sha256_file(dst), + } + + +def run() -> dict: + import greeneye as ge + from greeneye.analyze import compare_sessions + + backend = C.choose_backend("native") + out = C.artifact_dir("s07_version_diff") + va = os.path.join(out, "uproc_demo_vA.elf") + vb = os.path.join(out, "uproc_demo_vB.elf") + shutil.copyfile(C.DEMO_ELF, va) + patch = _make_variant(C.DEMO_ELF, vb) + + commands = [ + "byte-patch PING key 0xbeef -> 0xbef0 to create version B", + 'for v in (A,B): s = ge.launch(v, backend="%s", record=...)' % backend, + "identical stimulus: mailbox cmd=1 arg0=0x1234; snapshot+decode gsp_ctrl", + "compare_sessions(A, B, symbols) + decoded field diff", + ] + scenario = C.scenario( + ids=[ + "Handoff-vGPU-IO.md PART 91 (2026-10-01) cycles 2-3: 570.211 vs 570.144 pairing disproven", + "PART 91 ADDENDUM 6 (2026-10-01): LOGRM id + fn72 identity changed behaviour between cycles", + "docs/14-ADVERSARIAL-INTEROP-GUIDE.md 4.1: version-diff decision table", + ], + baseline=( + "one full supervised boot (~5-6 min) per version plus manual log " + "comparison; the pairing hypothesis took cycles 2 and 3 back-to-back" + ), + projection=( + "green-eye cannot boot the real image without the H2 device model; " + "the same same-stimulus/compare-sessions loop runs on re-hosted " + "firmware and the produced symbol/session deltas are the H4.1 artifact" + ), + blocked=True, + device="H2.2 device model for real .fwimage versions", + ) + C.render_banner( + "s07_version_diff", + "On the same stimulus, which live-path behaviour moved between two firmware versions?", + "570.211 vs 570.144 image pairing; semantic diff before touching the emulation", + scenario["static_baseline"], + commands, + ) + + schema = C.load_schema(ge) + results = {} + for label, path in (("A", va), ("B", vb)): + session = ge.launch(kernel=path, backend=backend, record=os.path.join(out, f"session-{label}"), name=f"vd-s07-{label}") + try: + ctrl = session.symbols.lookup("gsp_ctrl") + dispatches = [] + session.hook( + "rpc_dispatch", + on_enter=lambda hit, d=dispatches: d.append({"cmd": hit.arg(0), "arg0": hit.arg(1)}), + ) + session.watch(ctrl + C.CTRL_RESULT, size=8, kind="w") + session.run(duration=0.25) + session.memory.write_u64(ctrl + C.CTRL_ARG0, 0x1234) + session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 1) + session.run(duration=0.25) + decoded = C.decode_ctrl(ge, session.snapshot(ctrl, C.CTRL_SIZE, f"ctrl-{label}"), schema) + session.note("version", label=label, patch=patch["kind"], reply=decoded.get("result")) + results[label] = { + "path": os.path.relpath(path, C.TOOL_ROOT), + "sha256": C.sha256_file(path), + "dispatches": dispatches, + "reply": decoded.get("result"), + "rpc_count": decoded.get("rpc_count"), + "last_rpc": decoded.get("last_rpc"), + } + finally: + session.close() + + compare_md = compare_sessions( + os.path.join(out, "session-A"), os.path.join(out, "session-B"), symbols=session.symbols, max_rows=20 + ) + C.write_text(os.path.join(out, "compare-sessions.md"), compare_md) + moved = sorted( + key + for key in ("reply", "rpc_count", "last_rpc") + if results["A"][key] != results["B"][key] + ) + payload = { + "success": results["A"]["reply"] == 0xACDB and results["B"]["reply"] == 0xACC4 and moved == ["reply"], + "backend": backend, + "patch": patch, + "version_A": results["A"], + "version_B": results["B"], + "moved_fields": moved, + "decision_table_row": { + "static_code_changed": True, + "live_profile_changed": bool(moved), + "reading": "new logic on a live path - prioritise (docs/14 4.1)", + }, + "compare_report": os.path.relpath(os.path.join(out, "compare-sessions.md"), C.TOOL_ROOT), + "question": "separate 'the code changed' from 'the behaviour moved' on a fixed stimulus", + "answer": ( + f"version A replies {results['A']['reply']:#x}, version B replies {results['B']['reply']:#x}; " + f"moved fields {moved}; the patch site is {patch['kind']} at file offset {patch['offset']:#x} - " + "the decision-table row is 'code changed + profile changed -> prioritise'" + ), + "success_criteria": { + "pass": "exactly the patched reply field moves on the live path; both sessions replay from the same stimulus", + "fail": "unrelated fields move or a version cannot be replayed", + }, + "real_case": scenario, + } + C.write_json(os.path.join(out, "version-diff.json"), payload) + return payload + + +def main() -> int: + return 0 if C.timed_demo(run, C.artifact_dir("s07_version_diff"))["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s08_mutation_triage.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s08_mutation_triage.py new file mode 100644 index 0000000..8fcaed1 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s08_mutation_triage.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +"""s08 - mutation triage: mutate one field, classify the outcome. + +Real scenario (docs/18-HARNESS-AUTOMATION.md R5 and +Handoff-vGPU-IO.md PART 77 / docs/19 3.4, 2026-09-30..2026-10-02): the +error-path corpus (sentinel/boundary pointers on ECHO/SUM, invalid command +IDs) is how the emulation's fail-closed rules are derived. Statically the +demo's 4-of-24 outcomes were invisible; the discovery sweep found +``rpc_echo``/``rpc_sum`` fault on sentinel/boundary pointers while +``rpc_ping``/``rpc_state``/``rpc_fill`` stay on the handler path. + +Analog: a seeded mutation table over command/arguments, one fresh session per +attempt (the documented safety strategy), classified as ok / trap / fault / +no-progress from stop signals, ``rpc_count`` and the result field. +""" +from __future__ import annotations + +import json +import os + +try: # package import (tests) or direct script execution (run_all) + from . import _common as C +except ImportError: + import sys as _sys + + _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import _common as C + +FAULT_SIGNALS = {4, 7, 11} # SIGILL, SIGBUS, SIGSEGV +TRAP_SIGNAL = 5 # SIGTRAP (ebreak) + + +def _probe(ge, mutation: dict, backend: str) -> dict: + session = ge.launch(kernel=C.DEMO_ELF, backend=backend, name="vd-s08") + try: + ctrl = session.symbols.lookup("gsp_ctrl") + signals = [] + session.on_event("stop", lambda hit, sig=signals: sig.append(hit.stop.signal) if hit.stop else None) + session.run(duration=0.15) + literal = 0 + if mutation.get("arg0") == "hello": + hits = session.memory.search(0x80000000, 0x2000, b"hello-green-eye") + literal = hits[0] if hits else ctrl + C.CTRL_BUF + before = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT) + session.memory.write_u64(ctrl + C.CTRL_ARG0, literal if mutation.get("arg0") == "hello" else mutation.get("arg0", 0)) + session.memory.write_u64(ctrl + C.CTRL_ARG1, mutation.get("arg1", 0)) + session.memory.write_u64(ctrl + C.CTRL_MAILBOX, mutation["cmd"]) + # max_stops=1: a faulted target keeps re-faulting at the same PC, so + # stop at the first signal instead of spinning the fault loop. + session.run(duration=0.2, max_stops=1) + after = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT) + if after == before and not any(signals): + session.run(duration=0.3, max_stops=1) + after = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT) + result = session.memory.read_u64(ctrl + C.CTRL_RESULT) + if any(s == TRAP_SIGNAL for s in signals): + outcome = "trap" + elif any(s in FAULT_SIGNALS for s in signals): + outcome = "fault" + elif after > before: + outcome = "ok" + else: + outcome = "no-progress" + return { + "outcome": outcome, + "rpc_count_delta": after - before, + "result": result, + "result_hex": f"{result:#x}", + "signals": [s for s in signals if s], + "consumed": after > before, + } + finally: + session.close() + + +def run() -> dict: + import greeneye as ge + + out = C.artifact_dir("s08_mutation_triage") + backend = C.choose_backend("native") + mutations = [ + {"strategy": "arg-boundary", "cmd": 2, "arg0": 0, "arg1": 0, "expect": "fault"}, + {"strategy": "valid", "cmd": 2, "arg0": "hello", "arg1": 0, "expect": "ok"}, + {"strategy": "arg-boundary", "cmd": 2, "arg0": 0xFFFFFFFFFFFFFFFF, "arg1": 0, "expect": "fault"}, + {"strategy": "valid", "cmd": 4, "arg0": "hello", "arg1": 5, "expect": "ok"}, + {"strategy": "buffer-length", "cmd": 4, "arg0": "hello", "arg1": 0x8000000000000000, "expect": "no-progress"}, + {"strategy": "buffer-length", "cmd": 3, "arg0": 0x41, "arg1": 4096, "expect": "ok"}, + {"strategy": "cmd-invalid", "cmd": 7, "arg0": 0, "arg1": 0, "expect": "ok"}, + ] + commands = [ + "for each seeded mutation: fresh native session, settle, one mailbox write", + "run bounded; classify from stop signals + rpc_count + result", + "write findings.jsonl (deterministic order) for corpus diffing", + ] + scenario = C.scenario( + ids=[ + "docs/18-HARNESS-AUTOMATION.md 2 (R5): bounded seeded mutation, outcome classes", + "docs/19-CONTROL-DISCOVERY.md 3.4 (2026-10-02): sentinel/boundary pointer faults found by the sweep", + "Handoff-vGPU-IO.md PARTs 3-9 (2026-09-21..23): hand-derived assert/error paths", + ], + baseline=( + "statically the fault surface is invisible; the project derived error " + "paths by hand from asserts and NOCAT records across many physical " + "cycles; a 64-iteration QEMU mutation run is the tracked baseline" + ), + projection=( + "real RM controls land in the same mutation harness once the RM " + "surface exists (docs/19 section 5); one device-write experiment " + "per physical cycle is the current cap" + ), + blocked=True, + device="H1.3 RM control surface for mutation of real controls", + ) + C.render_banner( + "s08_mutation_triage", + "Mutate one input field deterministically and classify the outcome (ok/trap/fault/no-progress).", + "error-path corpus for fail-closed emulation rules (R5/H4.3)", + scenario["static_baseline"], + commands, + ) + + findings = [] + for index, mutation in enumerate(mutations): + observed = _probe(ge, mutation, backend) + finding = {"iteration": index, **mutation, **observed, "matched_expectation": observed["outcome"] == mutation["expect"]} + findings.append(finding) + classes: dict[str, int] = {} + for finding in findings: + classes[finding["outcome"]] = classes.get(finding["outcome"], 0) + 1 + payload = { + "success": all(f["matched_expectation"] for f in findings), + "backend": backend, + "mutations": len(findings), + "outcome_classes": classes, + "findings": findings, + "question": "enumerate and classify one-field mutations without hardware", + "answer": ( + f"{len(findings)} mutations -> {classes}; all expectations matched; " + "faults localised to the dereferencing handlers, the oversized SUM " + "stays no-progress, FILL clamps at the buffer size - a corpus entry " + "per mutation with no device writes" + ), + "success_criteria": { + "pass": "every seeded mutation produces its documented outcome class", + "fail": "an outcome contradicts the case table or a session leaks", + }, + "real_case": scenario, + } + with open(os.path.join(out, "findings.jsonl"), "w", encoding="utf-8") as fh: + for finding in findings: + fh.write(json.dumps(finding, sort_keys=True) + "\n") + C.write_json(os.path.join(out, "mutation.json"), payload) + return payload + + +def main() -> int: + return 0 if C.timed_demo(run, C.artifact_dir("s08_mutation_triage"))["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s09_grabbing_a_ghidra_address.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s09_grabbing_a_ghidra_address.py new file mode 100644 index 0000000..1577772 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s09_grabbing_a_ghidra_address.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""s09 - grab a Ghidra address: did the firmware actually execute it? + +Real scenario (docs/05-GHIDRA-BRIDGE.md, docs/13, docs/14 5; and +Handoff-vGPU-IO.md PART 91 ADDENDUM 9 cycle 24, 2026-10-01): a static +hypothesis (the GPFIFO GET encodes entry VA ``0x101000``) was only accepted +after the decoded status-queue fault showed the engine actually fetched +``0x40000`` because the GET field dropped the high VA bits (fix ``6f9cbfa``). +The static/dynamic join is exactly this: Ghidra owns addresses and +decompilation, green-eye answers "did it execute here". + +Analog: export a Ghidra-style ``names.tsv`` from the demo ELF *with a base +offset* (the classic program-address vs runtime-address trap), load it with +the right bias, hook the raw Ghidra address, trigger the path, and write the +resulting ``greeneye_hits.tsv`` annotation back for Ghidra. +""" +from __future__ import annotations + +import os + +try: # package import (tests) or direct script execution (run_all) + from . import _common as C +except ImportError: + import sys as _sys + + _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import _common as C + +#: Pretend the Ghidra project was based at runtime - 0x1000 (the bias pitfall). +BIAS = 0x1000 + + +def _ghidra_tsv(ge, out_path: str) -> dict: + image = ge.load_elf(C.DEMO_ELF) + rows = [] + for symbol in image.symbols: + if not getattr(symbol, "is_function", False) or not symbol.name: + continue + program = symbol.value - BIAS + rows.append((program, symbol.name, symbol.size)) + rows.sort() + with open(out_path, "w", encoding="utf-8") as fh: + for program, name, size in rows: + fh.write(f"{program:#x}\t{name}\tsize={size}\n") + return {"functions": len(rows), "bias": BIAS} + + +def run() -> dict: + import greeneye as ge + from greeneye.ghidra import export_hits + + backend = C.choose_backend("qemu") + out = C.artifact_dir("s09_grabbing_a_ghidra_address") + names_tsv = os.path.join(out, "ghidra_names.tsv") + facts = _ghidra_tsv(ge, names_tsv) + runtime_rpc_fault = ge.load_elf(C.DEMO_ELF) + addr_rpc_fault = None + for symbol in runtime_rpc_fault.symbols: + if symbol.name == "rpc_fault": + addr_rpc_fault = symbol.value + if addr_rpc_fault is None: + raise C.DemoError("rpc_fault not found in demo ELF") + ghidra_addr = addr_rpc_fault - BIAS + + commands = [ + f'write Ghidra names.tsv from ELF (program = runtime - {BIAS:#x})', + f'SymbolDB.load_names_tsv(names, bias={BIAS:#x})', + f'hook(ghidra_addr {ghidra_addr:#x} + bias) # raw static address', + "trigger mailbox cmd=6; hits recorded; export_hits -> greeneye_hits.tsv", + ] + scenario = C.scenario( + ids=[ + "docs/05-GHIDRA-BRIDGE.md / docs/13: static/dynamic address join", + "Handoff-vGPU-IO.md PART 91 ADDENDUM 9 cycle 24 (2026-10-01): GET fault at 0x40000 vs hypothesis 0x101000", + "docs/14-ADVERSARIAL-INTEROP-GUIDE.md 5: program vs runtime addresses and bias", + ], + baseline=( + "cycle 24's decoded status-queue fault ended a run of eight " + "supervised cycles (18-25, ~1 h 41 m); the static GET-encoding " + "hypothesis was unproven until a live PC/fault address existed" + ), + projection=( + "for the real image the same names.tsv + bias + hook loop runs once " + "H2.2 exposes execution of the .fwimage; the bias arithmetic demoed " + "here is exactly what docs/05 warns about" + ), + blocked=True, + device="H2.2 device model to execute the real .fwimage", + ) + C.render_banner( + "s09_grabbing_a_ghidra_address", + "Ghidra gave an address: does the firmware execute exactly there, and can the hit be annotated back?", + "static/dynamic join for the GPFIFO GET fault (0x40000 vs 0x101000)", + scenario["static_baseline"], + commands, + ) + + session = ge.launch(kernel=C.DEMO_ELF, backend=backend, record=os.path.join(out, "session"), name="vd-s09") + try: + session.symbols.load_names_tsv(names_tsv, bias=BIAS) + ctrl = session.symbols.lookup("gsp_ctrl") + resolved = session.resolve(ghidra_addr + BIAS) + hits = [] + session.hook(ghidra_addr + BIAS, on_enter=lambda hit: hits.append(hit.addr)) + session.run(duration=0.25) + session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 6) + session.run(duration=0.25) + hits_tsv = export_hits(os.path.join(out, "session"), os.path.join(out, "greeneye_hits.tsv"), symbols=session.symbols) + payload = { + "success": len(hits) >= 1 and resolved == addr_rpc_fault, + "backend": backend, + "ghidra_functions": facts["functions"], + "ghidra_names_tsv": os.path.relpath(names_tsv, C.TOOL_ROOT), + "bias": BIAS, + "ghidra_addr": f"{ghidra_addr:#x}", + "runtime_addr": f"{resolved:#x}", + "program_addr_used_without_bias": f"{ghidra_addr:#x}", + "hits": hits, + "hits_tsv": os.path.relpath(hits_tsv, C.TOOL_ROOT), + "question": "join a static Ghidra address to a runtime hit, with the bias handled explicitly", + "answer": ( + f"Ghidra addr {ghidra_addr:#x} + bias {BIAS:#x} = runtime {resolved:#x}; " + f"the hook fired {len(hits)}x at that exact address; export_hits wrote " + "the annotated greeneye_hits.tsv - the static hypothesis is now a " + "recorded dynamic fact (and using the program address raw would miss by 0x1000)" + ), + "success_criteria": { + "pass": "the biased address resolves to the real function and the hook records the hit; TSV annotates it", + "fail": "the hit lands at a different address or the TSV is empty", + }, + "real_case": scenario, + } + with open(hits_tsv, "r", encoding="utf-8") as fh: + payload["greeneye_hits_tsv"] = fh.read() + C.write_json(os.path.join(out, "ghidra-join.json"), payload) + finally: + session.close() + return payload + + +def main() -> int: + return 0 if C.timed_demo(run, C.artifact_dir("s09_grabbing_a_ghidra_address"))["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/value_demos/s10_full_session_evidence.py b/debug/risc-v-gsp-rm-vm/harness/value_demos/s10_full_session_evidence.py new file mode 100644 index 0000000..f6cd48f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/value_demos/s10_full_session_evidence.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""s10 - full session evidence: one run produces the whole evidence pack. + +Real scenario (docs/14-ADVERSARIAL-INTEROP-GUIDE.md 6; Handoff-vGPU-IO.md +PART 91 ADDENDA 6-7 and PART 92 ADDENDA 6-11, 2026-10-01..10-02): a finding +is only a finding with firmware hashes, baseline/candidate sessions, an +analyze report, a compare report, the M1-M6 table, ``greeneye_hits.tsv`` and +an unexplained-delta list. The physical milestone cycles each gathered this +by hand across separate boots and log windows (cycle evidence dirs +``backups/evidence-20261001-cycle{11,12,13}``, P6 evidence dirs). + +Analog: one recorded session with hooks/watchpoints/notes/snapshots, a second +identical run for determinism, an analyze report, a session compare, an +exported hits TSV and a manifest with hashes - plus measured tool overhead +(startup, session size, trace slowdown). +""" +from __future__ import annotations + +import os + +try: # package import (tests) or direct script execution (run_all) + from . import _common as C +except ImportError: + import sys as _sys + + _sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import _common as C + + +def _stimulus(ge, record, backend: str) -> dict: + session = ge.launch(kernel=C.DEMO_ELF, backend=backend, record=record, name=os.path.basename(record)) + try: + ctrl = session.symbols.lookup("gsp_ctrl") + stats = {"dispatches": [], "sum_hits": 0, "result_stores": 0} + session.hook( + "rpc_dispatch", + on_enter=lambda hit: stats["dispatches"].append(hit.arg(0)), + ) + session.hook("rpc_sum", on_enter=lambda hit: stats.__setitem__("sum_hits", stats["sum_hits"] + 1)) + session.watch(ctrl + C.CTRL_RESULT, size=8, kind="w", callback=lambda hit: stats.__setitem__("result_stores", stats["result_stores"] + 1)) + session.run(duration=0.4) + session.snapshot(ctrl, C.CTRL_SIZE, "selftest-done") + session.memory.write_u64(ctrl + C.CTRL_ARG0, 0x1234) + session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 1) + session.run(duration=0.2) + session.note("stimulus", cmd=1, arg0=0x1234, expected=0x1234 ^ C.PING_XOR) + session.snapshot(ctrl, C.CTRL_SIZE, "after-ping") + stats["ping_reply"] = session.memory.read_u64(ctrl + C.CTRL_RESULT) + session.memory.write_u64(ctrl + C.CTRL_MAILBOX, 5) + session.run(duration=0.2) + stats["state_reply"] = session.memory.read_u64(ctrl + C.CTRL_RESULT) + stats["rpc_count"] = session.memory.read_u64(ctrl + C.CTRL_RPC_COUNT) + return stats + finally: + session.close() + + +def _dir_size(path: str) -> int: + total = 0 + for root, _dirs, files in os.walk(path): + for name in files: + total += os.path.getsize(os.path.join(root, name)) + return total + + +def _trace_overhead(ge, out: str) -> dict: + import time + + def timed(trace): + record = os.path.join(out, "trace-off" if not trace else "trace-on") + start = time.monotonic() + session = ge.launch(kernel=C.DEMO_ELF, backend="native", record=record, trace=trace, name="vd-s10-trace") + run_start = time.monotonic() + try: + session.run(duration=0.25) + finally: + run_s = time.monotonic() - run_start + session.close() + return run_s, time.monotonic() - start + + off_run, off_total = timed(None) + on_run, on_total = timed(True) + return { + "untraced_run_s": round(off_run, 4), + "traced_run_s": round(on_run, 4), + "run_slowdown_x": round(on_run / off_run, 2) if off_run else None, + "untraced_total_s": round(off_total, 4), + "traced_total_s": round(on_total, 4), + "total_slowdown_x": round(on_total / off_total, 2) if off_total else None, + "note": "total includes writing blobs/trace.jsonl (~200k exec lines) at close", + } + + +def run() -> dict: + import time + + import greeneye as ge + from greeneye.analyze import compare_sessions, render_summary + from greeneye.ghidra import export_hits + + backend = C.choose_backend("qemu") + out = C.artifact_dir("s10_full_session_evidence") + commands = [ + 'ge.launch(backend="%s", record="session-A") + hooks/watch/notes/snapshots' % backend, + "repeat the identical stimulus into session-B", + "render_summary(A); compare_sessions(A, B, --symbols); export_hits(A)", + "hash firmware + artifacts into manifest.json with the M1-M6 skeleton", + ] + scenario = C.scenario( + ids=[ + "docs/14-ADVERSARIAL-INTEROP-GUIDE.md 6: evidence-pack checklist and report template", + "Handoff-vGPU-IO.md PART 91 ADDENDUM 6-7 (2026-10-01): cycle 11-13 evidence dirs", + "PART 92 ADDENDUM 6-11 (2026-10-02): P6 evidence dirs and per-cycle release transcripts", + "docs/06-ACCURACY.md 5: M1-M6 measurement contract", + ], + baseline=( + "every physical milestone gathered hash + session + logs + compare " + "by hand across separate boots and log windows; cycles 11-13 and " + "the P6 runs each have their own evidence directory" + ), + projection=( + "on real firmware the same one-session pack is produced once H2.2 " + "executes the image; the manifest/M1-M6 format is backend-agnostic" + ), + blocked=True, + device="H2.2 device model for real .fwimage session evidence", + ) + C.render_banner( + "s10_full_session_evidence", + "Can one run produce the complete evidence pack (sessions, analyze, compare, hits, hashes, M1-M6)?", + "milestone evidence packs gathered manually across boots", + scenario["static_baseline"], + commands, + ) + + start = time.monotonic() + session_a = _stimulus(ge, os.path.join(out, "session-A"), backend) + session_b = _stimulus(ge, os.path.join(out, "session-B"), backend) + + symbols = ge.SymbolDB() + symbols.load_elf(ge.load_elf(C.DEMO_ELF)) + report = render_summary(os.path.join(out, "session-A"), symbols=symbols, max_rows=30) + C.write_text(os.path.join(out, "analyze.md"), report) + compare = compare_sessions(os.path.join(out, "session-A"), os.path.join(out, "session-B"), symbols=symbols, max_rows=30) + C.write_text(os.path.join(out, "compare.md"), compare) + hits = export_hits(os.path.join(out, "session-A"), os.path.join(out, "greeneye_hits.tsv"), symbols=symbols) + trace = _trace_overhead(ge, out) + size_a = _dir_size(os.path.join(out, "session-A")) + size_b = _dir_size(os.path.join(out, "session-B")) + deltas_match = session_a["dispatches"] == session_b["dispatches"] and session_a["ping_reply"] == session_b["ping_reply"] + manifest = { + "format": "greeneye-value-evidence/1", + "firmware": { + "path": os.path.relpath(C.DEMO_ELF, C.TOOL_ROOT), + "sha256": C.sha256_file(C.DEMO_ELF), + }, + "sessions": { + "a": {"path": os.path.relpath(os.path.join(out, "session-A"), C.TOOL_ROOT), "bytes": size_a, "sha256_manifest": C.sha256_file(os.path.join(out, "session-A", "events.jsonl"))}, + "b": {"path": os.path.relpath(os.path.join(out, "session-B"), C.TOOL_ROOT), "bytes": size_b, "sha256_manifest": C.sha256_file(os.path.join(out, "session-B", "events.jsonl"))}, + }, + "stimulus": {"commands": [1, 5], "arg0": 0x1234, "note": "PING then STATE"}, + "observed": {"a": session_a, "b": session_b, "delta_match": deltas_match}, + "metrics": [ + {"id": "M1", "name": "inventory coverage", "status": "observed", "value": "dispatch inventory [1,2,3,4,5,5,5] self-test + [1,5] stimulus"}, + {"id": "M2", "name": "reply agreement", "status": "not measured", "reason": "no physical reference capture (needs H2/production trace)"}, + {"id": "M3", "name": "ordering agreement", "status": "observed", "value": "identical dispatch sequence across A/B"}, + {"id": "M4", "name": "state agreement", "status": "observed", "value": "rpc_state stage cycling on the control block"}, + {"id": "M5", "name": "error-path agreement", "status": "not measured", "reason": "error path demoed in s08; no reference to agree against"}, + {"id": "M6", "name": "memory side-effects", "status": "observed", "value": "2 named region snapshots (selftest-done, after-ping) per session"}, + ], + "artifacts": { + "analyze": "analyze.md", + "compare": "compare.md", + "hits": os.path.relpath(hits, out), + "manifest": "manifest.json", + }, + "overhead": { + "session_a_bytes": size_a, + "session_b_bytes": size_b, + "trace": trace, + "doc16_trace_slowdown": "~2.4x exec trace on the demo workload (docs/16 measured 0.54 MIPS traced vs 1.5 MIPS untraced)", + }, + "unexplained_deltas": [] if deltas_match else ["session A/B observed values differ"], + } + payload = { + "success": deltas_match and session_a["ping_reply"] == (0x1234 ^ C.PING_XOR) and size_a > 0, + "backend": backend, + "elapsed_within_demo_s": round(time.monotonic() - start, 3), + "manifest": manifest, + "question": "one run -> hash + sessions + analyze + compare + hits + M1-M6, plus tool overhead", + "answer": ( + f"one {backend} stimulus pair produced {session_a['dispatches']} dispatches, " + f"PING reply {session_a['ping_reply']:#x}, STATE reply {session_a['state_reply']:#x}, " + f"{session_a['result_stores']} publication stores; session {size_a} B; " + f"A/B deterministic={deltas_match}; analyze+compare+hits+manifest written" + ), + "success_criteria": { + "pass": "reply matches the ABI, both sessions agree, all artifacts exist, deltas list is empty", + "fail": "nondeterminism, missing artifact, or an unexplained delta", + }, + "real_case": scenario, + } + C.write_json(os.path.join(out, "manifest.json"), manifest) + C.write_json(os.path.join(out, "evidence.json"), payload) + return payload + + +def main() -> int: + return 0 if C.timed_demo(run, C.artifact_dir("s10_full_session_evidence"))["success"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/version_diff.py b/debug/risc-v-gsp-rm-vm/harness/version_diff.py new file mode 100644 index 0000000..a95f244 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/version_diff.py @@ -0,0 +1,1509 @@ +#!/usr/bin/env python3 +"""Version diff: old vs new firmware targets (roadmap H4.1, planned docs/21). + +Compares two firmware images (a built ELF or a raw ``.fwimage``) section by +section; every section degrades to ``status: skipped`` with a reason instead +of aborting the report: + +1. image/shape -- entry, PT_LOAD layout, ELF section sizes, hashes, span; +2. symbol inventory -- ELF ``.symtab`` plus Ghidra names/index/exports TSVs + (including the 3-column ``ExportForGreenEye`` form) and + green-eye JSON DBs; added/removed/moved/resized with + the anonymous-shift matching discipline of + ``userspace/cuda/tools/diff_corpus.py``; +3. decompiled hints -- a corpus diff over two ``decompiled_index.tsv`` files, + an attached corpus report, or an explicit "not + supplied"; +4. probe -- bounded QEMU first-touch probe of both targets with + ``parse_qemu_faults`` and firmware-vaddr translation of + the epc and access addresses; +5. behavior -- the shared demo scenario (or a ``--stimulus`` script) + under QEMU, ``analyze.compare_sessions`` plus + hit/watch/write deltas and the docs/06 M1-M6 skeleton. + +Everything is local and stdlib-only: nothing is downloaded. The RM 610 gate +(docs/23) is enforced before any target file is opened. + +Run from the tool root: + + python3 harness/version_diff.py --old old.elf --new new.elf --out dumps/vdiff +""" +from __future__ import annotations + +import argparse +import contextlib +import os +import re +import shutil +import statistics +import struct +import sys +import tempfile +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path + +try: + from .common import ( + LOAD_ADDR, + MAX_FLAT_BYTES, + MAX_LOG_BYTES, + SymbolDB, + counter_delta, + ensure_dir, + memory_mb_for, + qemu_available, + read_log_window, + sha256_file, + write_json, + write_text, + ) + from .portability_matrix import ( + CTRL_RPC_COUNT, + METRIC_DEFS, + NO_REFERENCE, + install_scenario, + metric_rows, + profile_from_events, + read_ctrl, + ) +except ImportError: # direct script execution (python3 harness/version_diff.py) + from common import ( # type: ignore[no-redef] + LOAD_ADDR, + MAX_FLAT_BYTES, + MAX_LOG_BYTES, + SymbolDB, + counter_delta, + ensure_dir, + memory_mb_for, + qemu_available, + read_log_window, + sha256_file, + write_json, + write_text, + ) + from portability_matrix import ( # type: ignore[no-redef] + CTRL_RPC_COUNT, + METRIC_DEFS, + NO_REFERENCE, + install_scenario, + metric_rows, + profile_from_events, + read_ctrl, + ) + +from greeneye.analyze import compare_sessions, parse_qemu_faults # noqa: E402 +from greeneye.elf import EM_RISCV, FirmwareImage, Segment, load_elf, write_flat_kernel # noqa: E402 +from greeneye.errors import GreenEyeError # noqa: E402 +from greeneye.record import load_events # noqa: E402 +from greeneye.rsp import RspClient # noqa: E402 +from greeneye.session import Session # noqa: E402 +from greeneye.symbols import Symbol # noqa: E402 +from greeneye.target import QemuTarget # noqa: E402 + +FORMAT = "greeneye-version-diff/1" +RM610_DOC = "docs/23-RM610-READINESS.md" +RM610_TOKEN = "RM610-APPROVED" +RM610_ENV = "GREENEYE_RM610_APPROVED" +RM610_RE = re.compile(r"rm[\s._-]*610", re.IGNORECASE) +PT_LOAD = 1 + + +class Rm610GateError(GreenEyeError): + """A target/release carries an RM 610 label without explicit approval.""" + + +def is_rm610_label(value: str | None) -> bool: + return bool(value) and RM610_RE.search(str(value)) is not None + + +def rm610_approved(token: str | None = None, environ: dict | None = None) -> bool: + """Explicit approval is ``--approval-token RM610-APPROVED`` or the env flag.""" + env = os.environ if environ is None else environ + token_ok = (token or "").strip() == RM610_TOKEN + env_ok = str(env.get(RM610_ENV, "")).strip().lower() in ("1", "true", "yes", "approved") + return token_ok or env_ok + + +def check_rm610_gate( + labels: list[str] | tuple[str, ...], + *, + approval_token: str | None = None, + environ: dict | None = None, +) -> None: + """Refuse RM 610 labels before anything is read, opened or downloaded.""" + blocked = sorted({str(label) for label in labels if is_rm610_label(label)}) + if not blocked: + return + if rm610_approved(approval_token, environ): + return + raise Rm610GateError( + "target/release label(s) " + ", ".join(repr(label) for label in blocked) + " indicate RM 610. " + f"Refused without explicit approval: pass --approval-token {RM610_TOKEN} or set " + f"{RM610_ENV}=1. See {RM610_DOC}. Nothing was downloaded and no target file was opened." + ) + + +# --------------------------------------------------------------------------- +# target loading (ELF or raw) +# --------------------------------------------------------------------------- + + +@dataclass +class LoadedTarget: + path: str + name: str + kind: str # elf | raw | unsupported | missing + image: FirmwareImage | None = None + sha256: str = "" + size: int = 0 + entry: int = 0 + bias: int = 0 + reason: str | None = None + + @property + def ok(self) -> bool: + return self.kind in ("elf", "raw") + + +def _read_bytes(path: str) -> bytes: + with open(path, "rb") as fh: + return fh.read() + + +def load_any_target(path: str) -> LoadedTarget: + """Read a target as RISC-V ELF or as a raw relocated image (no download).""" + name = Path(path).stem + if not os.path.isfile(path): + return LoadedTarget(path=path, name=name, kind="missing", reason="file not found") + size = os.path.getsize(path) + sha = sha256_file(path) + with open(path, "rb") as fh: + magic = fh.read(4) + if magic == b"\x7fELF": + try: + image = load_elf(path) + except Exception as exc: # malformed ELF -> skipped, never fatal + return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=size, + reason=f"{type(exc).__name__}: {exc}") + if not image.is_riscv: + return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=size, + reason=f"not a RISC-V ELF (e_machine={image.e_machine})") + span = image.max_vaddr - image.min_vaddr + if span > MAX_FLAT_BYTES: + return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=size, + reason=f"image span {span:#x} exceeds the {MAX_FLAT_BYTES:#x} harness cap") + return LoadedTarget(path=path, name=name, kind="elf", image=image, sha256=sha, size=size, + entry=image.entry, bias=LOAD_ADDR - image.min_vaddr) + if size == 0: + return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=0, + reason="empty raw image") + if size > MAX_FLAT_BYTES: + return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=size, + reason=f"raw image {size:#x} exceeds the {MAX_FLAT_BYTES:#x} harness cap") + data = _read_bytes(path) + segment = Segment(p_type=PT_LOAD, p_flags=0x7, p_offset=0, p_vaddr=0, p_paddr=0, + p_filesz=len(data), p_memsz=len(data), p_align=0x1000) + image = FirmwareImage(path=path, is64=True, e_machine=EM_RISCV, entry=0, segments=[segment], + symbols=[], _blob=data) + return LoadedTarget(path=path, name=name, kind="raw", image=image, sha256=sha, size=size, + entry=0, bias=LOAD_ADDR) + + +# --------------------------------------------------------------------------- +# 1. image / shape +# --------------------------------------------------------------------------- + + +def _cstr(blob: bytes, offset: int) -> str: + if offset >= len(blob): + return "" + end = blob.find(b"\x00", offset) + if end == -1: + end = len(blob) + return blob[offset:end].decode("utf-8", "replace") + + +def elf_sections(data: bytes) -> list[dict]: + """Section name/type/addr/size table (minimal reader; [] when unusable).""" + if len(data) < 64 or data[:4] != b"\x7fELF" or data[4] not in (1, 2): + return [] + is64 = data[4] == 2 + try: + if is64: + sh_off = struct.unpack_from(" len(data): + return [] + shstr = b"" + if 0 <= shstrndx < shnum: + base = sh_off + shstrndx * shentsize + if base + sh_size <= len(data): + sh = struct.unpack_from(sh_fmt, data, base) + shstr = data[sh[4] : sh[4] + sh[5]] + sections: list[dict] = [] + for index in range(shnum): + base = sh_off + index * shentsize + if base + sh_size > len(data): + break + sh = struct.unpack_from(sh_fmt, data, base) + sections.append( + { + "name": _cstr(shstr, sh[0]) or f"", + "type": sh[1], + "addr": sh[3], + "offset": sh[4], + "size": sh[5], + } + ) + return sections + + +def shape_payload(loaded: LoadedTarget) -> dict: + payload = { + "kind": loaded.kind, + "sha256": loaded.sha256, + "size": loaded.size, + "reason": loaded.reason, + } + if not loaded.ok or loaded.image is None: + return payload + image = loaded.image + payload.update( + { + "entry": loaded.entry, + "bias": loaded.bias, + "min_vaddr": image.min_vaddr, + "max_vaddr": image.max_vaddr, + "span": image.max_vaddr - image.min_vaddr, + "loads": [ + { + "index": index, + "vaddr": seg.p_vaddr, + "paddr": seg.p_paddr, + "filesz": seg.p_filesz, + "memsz": seg.p_memsz, + "flags": seg.p_flags, + "align": seg.p_align, + } + for index, seg in enumerate(image.load_segments) + ], + } + ) + if loaded.kind == "elf": + payload["sections"] = elf_sections(image.read()) + else: + payload["sections"] = [] + payload["note"] = "raw image: one implicit load at vaddr 0; no ELF section table" + return payload + + +def _scalar_delta(a, b, name: str) -> dict: + return {"a": a, "b": b, "delta": b - a if isinstance(a, int) and isinstance(b, int) else None, + "changed": a != b} + + +def _cap(rows: list, max_rows: int) -> list: + return rows[:max_rows] + + +def image_diff(old: LoadedTarget, new: LoadedTarget, max_rows: int) -> dict: + a, b = shape_payload(old), shape_payload(new) + result = {"status": "skipped", "reason": None, "a": a, "b": b, "same": None, "deltas": None} + if not old.ok or not new.ok: + result["reason"] = "; ".join( + f"{side}: {loaded.reason or loaded.kind}" for side, loaded in (("old", old), ("new", new)) if not loaded.ok + ) + return result + + loads_a, loads_b = a["loads"], b["loads"] + load_added = [payload for index, payload in enumerate(loads_b) if index >= len(loads_a)] + load_removed = [payload for index, payload in enumerate(loads_a) if index >= len(loads_b)] + load_changed = [] + for index, (la, lb) in enumerate(zip(loads_a, loads_b)): + fields = {key: _scalar_delta(la[key], lb[key], key) for key in ("vaddr", "filesz", "memsz", "flags")} + if any(row["changed"] for row in fields.values()): + load_changed.append({"index": index, "fields": fields}) + + sec_a = {row["name"]: row for row in a.get("sections", [])} + sec_b = {row["name"]: row for row in b.get("sections", [])} + sec_added = [{key: sec_b[name][key] for key in ("name", "type", "addr", "size")} for name in sorted(set(sec_b) - set(sec_a))] + sec_removed = [{key: sec_a[name][key] for key in ("name", "type", "addr", "size")} for name in sorted(set(sec_a) - set(sec_b))] + sec_moved, sec_resized = [], [] + for name in sorted(set(sec_a) & set(sec_b)): + ra, rb = sec_a[name], sec_b[name] + if ra["addr"] != rb["addr"]: + sec_moved.append({"name": name, "old_addr": ra["addr"], "new_addr": rb["addr"]}) + if ra["size"] != rb["size"]: + sec_resized.append({"name": name, "old_size": ra["size"], "new_size": rb["size"]}) + + deltas = { + "entry": _scalar_delta(a["entry"], b["entry"], "entry"), + "span": _scalar_delta(a["span"], b["span"], "span"), + "file_size": _scalar_delta(a["size"], b["size"], "size"), + "loads": { + "added": _cap(load_added, max_rows), + "removed": _cap(load_removed, max_rows), + "changed": _cap(load_changed, max_rows), + }, + "sections": { + "added": _cap(sec_added, max_rows), + "removed": _cap(sec_removed, max_rows), + "moved": _cap(sec_moved, max_rows), + "resized": _cap(sec_resized, max_rows), + }, + } + same = ( + not deltas["entry"]["changed"] + and not deltas["span"]["changed"] + and not deltas["file_size"]["changed"] + and not any(deltas["loads"][key] for key in ("added", "removed", "changed")) + and not any(deltas["sections"][key] for key in ("added", "removed", "moved", "resized")) + ) + result.update({"status": "ok", "same": same, "deltas": deltas}) + return result + + +# --------------------------------------------------------------------------- +# 2. symbol inventory (diff_corpus.py matching discipline) +# --------------------------------------------------------------------------- + + +def looks_like_three_column_export(path: str) -> bool: + """``addrnamesize`` (ExportForGreenEye) vs names/exports TSVs.""" + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + line = line.strip() + if not line or line.startswith("#"): + continue + cols = line.split("\t") + if len(cols) >= 3 and [c.strip() for c in cols[:3]] == ["addr", "name", "size"]: + return True + if len(cols) == 3: + try: + int(cols[0], 16) + int(cols[2], 0) + return True + except ValueError: + return False + return False + except OSError: + return False + return False + + +def load_three_column_export(db: SymbolDB, path: str, bias: int = 0) -> int: + count = 0 + with open(path, "r", encoding="utf-8", errors="replace") as fh: + for line in fh: + line = line.rstrip("\n") + if not line or line.startswith("#"): + continue + cols = line.split("\t") + if len(cols) != 3 or cols[0].strip() == "addr": + continue + try: + addr = int(cols[0], 16) + size = int(cols[2], 0) + except ValueError: + continue + if not cols[1]: + continue + db.add(Symbol(addr=addr + bias, name=cols[1], size=size, source="ghidra-export")) + count += 1 + return count + + +def load_symbol_sources( + loaded: LoadedTarget, + *, + symbols_json: str | None = None, + ghidra_names: str | None = None, + ghidra_index: str | None = None, + ghidra_exports: str | None = None, + ghidra_bias: int = 0, +) -> tuple[SymbolDB, list[str], list[str]]: + """Merge every supplied symbol source for one side into one SymbolDB.""" + db = SymbolDB() + sources: list[str] = [] + notes: list[str] = [] + if loaded.ok and loaded.image is not None and loaded.image.symbols: + count = db.load_elf(loaded.image, bias=loaded.bias, source="elf") + if count: + sources.append(f"elf:{count}") + if symbols_json: + if os.path.isfile(symbols_json): + count = db.load_json(symbols_json) + sources.append(f"{os.path.basename(symbols_json)}:{count}") + else: + notes.append(f"symbol DB not found: {symbols_json}") + if ghidra_names: + if os.path.isfile(ghidra_names): + count = db.load_names_tsv(ghidra_names, bias=ghidra_bias) + sources.append(f"{os.path.basename(ghidra_names)}:{count}") + else: + notes.append(f"Ghidra names TSV not found: {ghidra_names}") + if ghidra_index: + if os.path.isfile(ghidra_index): + count = db.load_index_tsv(ghidra_index, bias=ghidra_bias) + sources.append(f"{os.path.basename(ghidra_index)}:{count}") + else: + notes.append(f"Ghidra index TSV not found: {ghidra_index}") + if ghidra_exports: + if os.path.isfile(ghidra_exports): + if looks_like_three_column_export(ghidra_exports): + count = load_three_column_export(db, ghidra_exports, bias=ghidra_bias) + sources.append(f"{os.path.basename(ghidra_exports)}:{count} (3-column export)") + else: + count = db.load_index_tsv(ghidra_exports, bias=ghidra_bias) + sources.append(f"{os.path.basename(ghidra_exports)}:{count}") + else: + notes.append(f"Ghidra exports TSV not found: {ghidra_exports}") + return db, sources, notes + + +def inventory_records(db: SymbolDB) -> list[dict]: + records = [ + { + "name": symbol.name, + "addr": symbol.addr, + "size": symbol.size, + "source": symbol.source, + } + for symbol in db + ] + records.sort(key=lambda row: (row["addr"], row["name"], row["size"])) + return records + + +def _is_named(name: str) -> bool: + return bool(name) and not name.startswith("FUN_") + + +def _record_brief(record: dict | None) -> dict | None: + if record is None: + return None + return {"addr": record["addr"], "name": record["name"], "size": record["size"]} + + +def inventory_diff(a_records: list[dict], b_records: list[dict], max_rows: int = 40) -> dict: + """Named-by-name + anonymous-at-shifted-address matching (diff_corpus.py).""" + a_by_addr = {row["addr"]: row for row in a_records} + b_by_addr = {row["addr"]: row for row in b_records} + named_a: dict[str, list[int]] = defaultdict(list) + named_b: dict[str, list[int]] = defaultdict(list) + for row in a_records: + if _is_named(row["name"]): + named_a[row["name"]].append(row["addr"]) + for row in b_records: + if _is_named(row["name"]): + named_b[row["name"]].append(row["addr"]) + + matched_named: list[tuple[str, int, int]] = [] + for name in sorted(set(named_a) & set(named_b)): + for addr_a, addr_b in zip(sorted(named_a[name]), sorted(named_b[name])): + matched_named.append((name, addr_a, addr_b)) + + deltas = [addr_b - addr_a for _, addr_a, addr_b in matched_named] + shift = int(statistics.median(deltas)) if deltas else 0 + dominant = (sum(1 for value in deltas if abs(value - shift) <= 0x40) / len(deltas)) if deltas else 0.0 + + used_a = {addr_a for _, addr_a, _ in matched_named} + used_b = {addr_b for _, _, addr_b in matched_named} + moved = [(name, addr_a, addr_b) for name, addr_a, addr_b in matched_named if addr_a != addr_b] + resized_named = [ + (name, addr_a, addr_b) + for name, addr_a, addr_b in matched_named + if a_by_addr[addr_a]["size"] != b_by_addr[addr_b]["size"] + ] + + shifted: list[tuple[int, int]] = [] + for row in sorted(a_records, key=lambda item: item["addr"]): + addr_a = row["addr"] + if addr_a in used_a or _is_named(row["name"]): + continue + addr_b = addr_a + shift + if addr_b in b_by_addr and addr_b not in used_b: + shifted.append((addr_a, addr_b)) + used_a.add(addr_a) + used_b.add(addr_b) + shifted_resized = [(fa, fb) for fa, fb in shifted if a_by_addr[fa]["size"] != b_by_addr[fb]["size"]] + shifted_ok = [(fa, fb) for fa, fb in shifted if a_by_addr[fa]["size"] == b_by_addr[fb]["size"]] + + removed = sorted(addr for addr in a_by_addr if addr not in used_a) + added = sorted(addr for addr in b_by_addr if addr not in used_b) + matched_total = len(matched_named) + len(shifted) + named_count_a = sum(len(v) for v in named_a.values()) + named_count_b = sum(len(v) for v in named_b.values()) + + return { + "shift": shift, + "shift_dominant": round(dominant, 4), + "matched_named": len(matched_named), + "matched_shifted": len(shifted_ok), + "matched_shifted_resized": len(shifted_resized), + "moved": _cap( + [{"name": name, "old_addr": fa, "new_addr": fb, "delta": fb - fa} for name, fa, fb in moved], + max_rows, + ), + "resized": _cap( + [ + { + "name": name, + "old_addr": fa, + "new_addr": fb, + "old_size": a_by_addr[fa]["size"], + "new_size": b_by_addr[fb]["size"], + } + for name, fa, fb in resized_named + ], + max_rows, + ), + "resized_anonymous": _cap( + [ + { + "old_addr": fa, + "new_addr": fb, + "old_size": a_by_addr[fa]["size"], + "new_size": b_by_addr[fb]["size"], + } + for fa, fb in shifted_resized + ], + max_rows, + ), + "added": _cap([_record_brief(b_by_addr[addr]) for addr in added], max_rows), + "removed": _cap([_record_brief(a_by_addr[addr]) for addr in removed], max_rows), + "totals": { + "a": len(a_records), + "b": len(b_records), + "named_a": named_count_a, + "named_b": named_count_b, + "anonymous_a": sum(1 for row in a_records if not _is_named(row["name"])), + "anonymous_b": sum(1 for row in b_records if not _is_named(row["name"])), + "added": len(added), + "removed": len(removed), + "moved": len(moved), + "resized_named": len(resized_named), + }, + "rates": { + "match_a": round(matched_total / len(a_records), 4) if a_records else 0.0, + "match_b": round(matched_total / len(b_records), 4) if b_records else 0.0, + "named_transfer": round(len(matched_named) / named_count_a, 4) if named_count_a else 0.0, + }, + } + + +def symbol_section( + old: LoadedTarget, + new: LoadedTarget, + old_db: SymbolDB, + old_sources: list[str], + old_notes: list[str], + new_db: SymbolDB, + new_sources: list[str], + new_notes: list[str], + options: "VersionDiffOptions", + out_dir: str, +) -> dict: + result = { + "status": "skipped", + "reason": None, + "a": {"count": 0, "sources": old_sources, "notes": old_notes, "db": None}, + "b": {"count": 0, "sources": new_sources, "notes": new_notes, "db": None}, + } + if not old.ok or not new.ok: + result["reason"] = "; ".join( + f"{side}: {loaded.reason or loaded.kind}" for side, loaded in (("old", old), ("new", new)) if not loaded.ok + ) + return result + a_records = inventory_records(old_db) + b_records = inventory_records(new_db) + result["a"]["count"] = len(a_records) + result["b"]["count"] = len(b_records) + if not a_records and not b_records: + result["reason"] = ( + "no symbols on either side: no ELF .symtab and no Ghidra/JSON symbol sources supplied" + ) + return result + result.update(inventory_diff(a_records, b_records, options.max_rows)) + result["status"] = "ok" + for key, db, loaded, sources, notes in ( + ("a", old_db, old, old_sources, old_notes), + ("b", new_db, new, new_sources, new_notes), + ): + if not len(db): + continue + rel = os.path.join("symbols", f"{loaded.name}.json") + path = os.path.join(out_dir, rel) + ensure_dir(os.path.dirname(path)) + db.save_json(path, meta={"firmware": os.path.basename(loaded.path), "bias": loaded.bias, + "sources": sources, "notes": notes}) + result[key]["db"] = rel + return result + + +# --------------------------------------------------------------------------- +# 3. decompiled-change hints +# --------------------------------------------------------------------------- + + +def corpus_records(path: str) -> list[dict]: + db = SymbolDB() + if looks_like_three_column_export(path): + load_three_column_export(db, path, bias=0) + else: + db.load_index_tsv(path, bias=0) + return inventory_records(db) + + +def corpus_diff(path_a: str, path_b: str, max_rows: int = 40) -> dict: + diff = inventory_diff(corpus_records(path_a), corpus_records(path_b), max_rows) + diff["a"] = os.path.basename(path_a) + diff["b"] = os.path.basename(path_b) + return diff + + +def corpus_section(options: "VersionDiffOptions", out_dir: str) -> dict: + if options.corpus_report: + if not os.path.isfile(options.corpus_report): + return {"status": "skipped", "reason": f"corpus report not found: {options.corpus_report}", + "mode": "corpus-report"} + data = _read_bytes(options.corpus_report) + preview = data.decode("utf-8", "replace") + return { + "status": "ok", + "reason": None, + "mode": "corpus-report", + "report": { + "path": os.path.basename(options.corpus_report), + "sha256": sha256_file(options.corpus_report), + "bytes": len(data), + "preview": preview[:4000], + "truncated": len(preview) > 4000, + }, + } + if options.corpus_diff: + path_a, path_b = options.corpus_diff + missing = [path for path in (path_a, path_b) if not os.path.isfile(path)] + if missing: + return {"status": "skipped", "mode": "corpus-diff", + "reason": "corpus TSV not found: " + ", ".join(missing)} + diff = corpus_diff(path_a, path_b, options.max_rows) + rel = os.path.join("corpus", "corpus-diff.json") + write_json(os.path.join(out_dir, rel), diff) + return {"status": "ok", "reason": None, "mode": "corpus-diff", "diff": diff, "report": rel} + return { + "status": "skipped", + "reason": "not supplied: pass --corpus-diff OLD_TSV NEW_TSV or --corpus-report FILE", + "mode": None, + } + + +# --------------------------------------------------------------------------- +# QEMU sessions for ELF and raw targets +# --------------------------------------------------------------------------- + + +@dataclass +class OpenTarget: + loaded: LoadedTarget + session: Session + target: QemuTarget + workdir: str + owns_workdir: bool = False + + +@contextlib.contextmanager +def open_target_session( + loaded: LoadedTarget, + *, + record: str | bool | None, + name: str, + symbols: SymbolDB | None = None, + log_faults: bool = True, + workdir: str | None = None, +): + """Relocate the target to LOAD_ADDR, start QEMU and yield a live session.""" + owns_workdir = workdir is None + if workdir is None: + workdir = tempfile.mkdtemp(prefix="greeneye-vdiff-") + flat_kernel = os.path.join(workdir, "flat.bin") + assert loaded.image is not None + if loaded.kind == "elf": + write_flat_kernel(loaded.path, flat_kernel, load_addr=LOAD_ADDR) + else: + with open(flat_kernel, "wb") as fh: + fh.write(loaded.image.read()) + target = QemuTarget( + kernel=flat_kernel, + memory_mb=memory_mb_for(loaded.image), + dlog="guest_errors,unimp,int" if log_faults else None, + workdir=workdir, + ) + target.start(paused=True) + client = RspClient(target.host, target.port) + client.connect() + client.sync() + db = symbols if symbols is not None else SymbolDB() + session = Session( + client=client, + target=target, + symbols=db, + record=record, + name=name, + meta={ + "backend": "qemu", + "bias": loaded.bias, + "firmware": loaded.path, + "kind": loaded.kind, + "harness": "version_diff", + }, + ) + session.load_image(loaded.image, bias=loaded.bias, load_symbols=symbols is None) + try: + yield OpenTarget(loaded=loaded, session=session, target=target, workdir=workdir, + owns_workdir=owns_workdir) + finally: + try: + session.close() + finally: + if owns_workdir: + shutil.rmtree(workdir, ignore_errors=True) + + +def enrich_fault(fault: dict, bias: int, span: int) -> dict: + """Add firmware-vaddr translations for epc and the access address.""" + out = { + "cause": fault.get("cause"), + "desc": fault.get("desc"), + "epc": fault.get("epc"), + "tval": fault.get("tval"), + "access": fault.get("access"), + } + epc = fault.get("epc") + out["epc_firmware"] = epc - bias if isinstance(epc, int) else None + access = fault.get("access") or {} + access_addr = access.get("access_addr") + if isinstance(access_addr, int): + if LOAD_ADDR <= access_addr < LOAD_ADDR + span: + out["access_firmware"] = access_addr - bias + else: + out["access_firmware"] = None + else: + out["access_firmware"] = None + return out + + +# --------------------------------------------------------------------------- +# 4. probe first-touch diff +# --------------------------------------------------------------------------- + + +def _skipped_probe(reason: str | None) -> dict: + return { + "status": "skipped", + "reason": reason, + "session": None, + "instructions": None, + "stopped_pc": None, + "stopped_symbol": None, + "fault_count": 0, + "faults_capped": False, + "first_fault": None, + } + + +def run_probe( + loaded: LoadedTarget, + db: SymbolDB, + options: "VersionDiffOptions", + out_dir: str, + qemu_ok: bool, +) -> dict: + if not qemu_ok: + return _skipped_probe("qemu-system-riscv64 not found") + if not loaded.ok: + return _skipped_probe(loaded.reason) + result = _skipped_probe(None) + result["instructions"] = options.probe_instructions + session_rel = os.path.join("sessions", f"{loaded.name}-probe") + try: + with open_target_session( + loaded, + record=os.path.join(out_dir, session_rel), + name=f"{loaded.name}-probe", + symbols=db, + log_faults=True, + ) as ot: + session = ot.session + if options.probe_instructions > 0: + session.step(options.probe_instructions) + else: + session.run(duration=options.probe_duration) + result["stopped_pc"] = session.pc + symbol = session.symbols.resolve(session.pc) + result["stopped_symbol"] = symbol.name if symbol else None + text, capped = read_log_window(ot.target.qemu_log, 0, options.log_bytes) + faults = parse_qemu_faults(text) + result["fault_count"] = len(faults) + result["faults_capped"] = capped + span = loaded.image.max_vaddr - loaded.image.min_vaddr if loaded.image else 0 + result["first_fault"] = enrich_fault(faults[0], loaded.bias, span) if faults else None + result["session"] = session_rel + if text: + session.recorder.meta["qemu_log_blob"] = session.recorder.blob("qemu.log", text.encode("utf-8")) + session.recorder.meta["faults_capped"] = capped + result["status"] = "ok" + except Exception as exc: # a failed probe must not abort the diff + result["status"] = "error" + result["reason"] = f"{type(exc).__name__}: {exc}" + return result + + +def _probe_signature(probe: dict) -> tuple: + first = probe.get("first_fault") or {} + return ( + probe.get("stopped_pc"), + first.get("desc"), + first.get("cause"), + first.get("epc_firmware"), + first.get("access_firmware"), + (first.get("access") or {}).get("access_addr"), + ) + + +def probe_diff( + old: LoadedTarget, + new: LoadedTarget, + old_db: SymbolDB, + new_db: SymbolDB, + options: "VersionDiffOptions", + out_dir: str, + qemu_ok: bool, +) -> dict: + a = run_probe(old, old_db, options, out_dir, qemu_ok) + b = run_probe(new, new_db, options, out_dir, qemu_ok) + statuses = {a["status"], b["status"]} + if statuses == {"ok"}: + status, reason = "ok", None + elif "error" in statuses: + status = "error" + reason = "; ".join(row["reason"] for row in (a, b) if row.get("reason")) or "probe failed" + else: + status = "skipped" + reason = "; ".join(row["reason"] for row in (a, b) if row.get("reason")) or "probe not run" + changed = _probe_signature(a) != _probe_signature(b) + if status == "ok": + summary = "first fault and stopped PC unchanged" if not changed else "first fault or stopped PC changed" + else: + summary = None + return {"status": status, "reason": reason, "a": a, "b": b, "changed": changed, "summary": summary} + + +# --------------------------------------------------------------------------- +# 5. behavior diff +# --------------------------------------------------------------------------- + + +def _skipped_behavior(reason: str | None) -> dict: + return { + "status": "skipped", + "reason": reason, + "session": None, + "hooks": [], + "uart_banner": None, + "ctrl": None, + "event_counts": {}, + "bp": {}, + "watch": {}, + "writes": {}, + "fault_count": 0, + "faults_capped": False, + "first_fault": None, + } + + +def run_behavior( + loaded: LoadedTarget, + db: SymbolDB, + options: "VersionDiffOptions", + out_dir: str, + qemu_ok: bool, +) -> dict: + if not qemu_ok: + return _skipped_behavior("qemu-system-riscv64 not found") + if not loaded.ok: + return _skipped_behavior(loaded.reason) + result = _skipped_behavior(None) + session_rel = os.path.join("sessions", f"{loaded.name}-behavior") + try: + with open_target_session( + loaded, + record=os.path.join(out_dir, session_rel), + name=f"{loaded.name}-behavior", + symbols=db, + log_faults=True, + ) as ot: + session = ot.session + result["hooks"] = install_scenario(session) + ctrl_symbol = session.symbols.get("gsp_ctrl") + ctrl_addr = ctrl_symbol.addr if ctrl_symbol else None + if ctrl_addr is not None: + session.watch(ctrl_addr + CTRL_RPC_COUNT, size=8, kind="w", + listener="version-diff:rpc_count") + if options.stimulus: + import greeneye as ge + + script_path = Path(options.stimulus) + namespace = { + "ge": ge, + "greeneye": ge, + "session": session, + "s": session, + "memory": session.memory, + "symbols": session.symbols, + "recorder": session.recorder, + } + exec(compile(script_path.read_text(encoding="utf-8"), str(script_path), "exec"), namespace) + else: + session.run(duration=options.behavior_duration, max_stops=options.max_stops) + if ctrl_addr is not None: + session.memory.write_u64(ctrl_addr + 32, 1) # PING + session.memory.write_u64(ctrl_addr + 40, 0x1234) + session.run(duration=options.behavior_duration, max_stops=options.max_stops) + result["ctrl"] = read_ctrl(session, ctrl_symbol) + uart = ot.target.read_uart() + result["uart_banner"] = uart.strip().splitlines()[0] if uart.strip() else None + text, capped = read_log_window(ot.target.qemu_log, 0, options.log_bytes) + faults = parse_qemu_faults(text) + result["fault_count"] = len(faults) + result["faults_capped"] = capped + span = loaded.image.max_vaddr - loaded.image.min_vaddr if loaded.image else 0 + result["first_fault"] = enrich_fault(faults[0], loaded.bias, span) if faults else None + result.update(profile_from_events(load_events(os.path.join(out_dir, session_rel)))) + result["session"] = session_rel + result["status"] = "ok" + except Exception as exc: # a failed scenario must not abort the diff + result["status"] = "error" + result["reason"] = f"{type(exc).__name__}: {exc}" + return result + + +def behavior_diff( + old: LoadedTarget, + new: LoadedTarget, + old_db: SymbolDB, + new_db: SymbolDB, + probe_a: dict, + probe_b: dict, + options: "VersionDiffOptions", + out_dir: str, + qemu_ok: bool, +) -> dict: + a = run_behavior(old, old_db, options, out_dir, qemu_ok) + b = run_behavior(new, new_db, options, out_dir, qemu_ok) + entry = { + "status": "skipped", + "reason": None, + "a": a, + "b": b, + "compare_report": None, + "deltas": {"bp": [], "watch": [], "writes": []}, + "all_zero": None, + "metrics": [], + } + a["metrics"] = metric_rows(probe_a, a) + b["metrics"] = metric_rows(probe_b, b) + if a["status"] != "ok" or b["status"] != "ok": + reasons = [row.get("reason") for row in (a, b) if row.get("reason")] + entry["reason"] = "; ".join(reasons) or "a behavior session is not available on both targets" + entry["status"] = "error" if "error" in {a["status"], b["status"]} else "skipped" + return entry + a_dir = os.path.join(out_dir, a["session"]) + b_dir = os.path.join(out_dir, b["session"]) + try: + report = compare_sessions(a_dir, b_dir, max_rows=options.max_rows) + rel = os.path.join("compare", f"{old.name}--{new.name}.md") + write_text(os.path.join(out_dir, rel), report) + entry["compare_report"] = rel + except Exception as exc: + entry["reason"] = f"{type(exc).__name__}: {exc}" + deltas = { + name: counter_delta(a.get(name) or {}, b.get(name) or {}) + for name in ("bp", "watch", "writes") + } + entry["deltas"] = deltas + entry["all_zero"] = all(row["delta"] == 0 for rows in deltas.values() for row in rows) + a_metrics = {row["id"]: row for row in a["metrics"]} + b_metrics = {row["id"]: row for row in b["metrics"]} + entry["metrics"] = [ + { + "id": metric_id, + "name": name, + "status": "not measured", + "reason": NO_REFERENCE, + "a_observed": (a_metrics.get(metric_id) or {}).get("observed"), + "b_observed": (b_metrics.get(metric_id) or {}).get("observed"), + } + for metric_id, name in METRIC_DEFS + ] + entry["status"] = "ok" + return entry + + +# --------------------------------------------------------------------------- +# report assembly + rendering +# --------------------------------------------------------------------------- + + +@dataclass +class VersionDiffOptions: + old: str + new: str + out: str + old_label: str | None = None + new_label: str | None = None + release: str | None = None + old_symbols: str | None = None + new_symbols: str | None = None + old_ghidra_names: str | None = None + new_ghidra_names: str | None = None + old_ghidra_index: str | None = None + new_ghidra_index: str | None = None + old_ghidra_exports: str | None = None + new_ghidra_exports: str | None = None + old_ghidra_bias: int = 0 + new_ghidra_bias: int = 0 + corpus_diff: tuple[str, str] | None = None + corpus_report: str | None = None + stimulus: str | None = None + probe_instructions: int = 2000 + probe_duration: float = 0.5 + behavior_duration: float = 0.4 + max_stops: int = 5000 + max_rows: int = 40 + log_bytes: int = MAX_LOG_BYTES + approval_token: str | None = None + + def labels(self) -> list[str]: + return [ + value + for value in ( + self.release, + self.old_label, + self.new_label, + os.path.basename(self.old), + os.path.basename(self.new), + ) + if value + ] + + def label_for(self, side: str) -> str: + explicit = self.old_label if side == "old" else self.new_label + path = self.old if side == "old" else self.new + return explicit or Path(path).stem + + +def _target_brief(label: str, loaded: LoadedTarget, qemu_ok: bool, options: VersionDiffOptions) -> dict: + return { + "label": label, + "path": os.path.basename(loaded.path), + "kind": loaded.kind, + "status": "ok" if loaded.ok else "skipped", + "reason": loaded.reason, + "sha256": loaded.sha256, + "size": loaded.size, + "entry": loaded.entry if loaded.ok else None, + "bias": loaded.bias if loaded.ok else None, + "probe_instructions": options.probe_instructions, + "qemu_available": qemu_ok, + } + + +def run_version_diff(options: VersionDiffOptions) -> dict: + """Run every section, write JSON+markdown under ``--out``, return the report.""" + check_rm610_gate(options.labels(), approval_token=options.approval_token) + ensure_dir(options.out) + old_label, new_label = options.label_for("old"), options.label_for("new") + qemu_ok = qemu_available() + old = load_any_target(options.old) + new = load_any_target(options.new) + + if old.ok: + old_db, old_sources, old_notes = load_symbol_sources( + old, symbols_json=options.old_symbols, ghidra_names=options.old_ghidra_names, + ghidra_index=options.old_ghidra_index, ghidra_exports=options.old_ghidra_exports, + ghidra_bias=options.old_ghidra_bias, + ) + else: + old_db, old_sources, old_notes = SymbolDB(), [], [] + if new.ok: + new_db, new_sources, new_notes = load_symbol_sources( + new, symbols_json=options.new_symbols, ghidra_names=options.new_ghidra_names, + ghidra_index=options.new_ghidra_index, ghidra_exports=options.new_ghidra_exports, + ghidra_bias=options.new_ghidra_bias, + ) + else: + new_db, new_sources, new_notes = SymbolDB(), [], [] + + image = image_diff(old, new, options.max_rows) + symbols = symbol_section(old, new, old_db, old_sources, old_notes, new_db, new_sources, new_notes, + options, options.out) + corpus = corpus_section(options, options.out) + probe = probe_diff(old, new, old_db, new_db, options, options.out, qemu_ok) + behavior = behavior_diff(old, new, old_db, new_db, probe["a"], probe["b"], options, options.out, qemu_ok) + + sections = {"image": image, "symbols": symbols, "corpus": corpus, "probe": probe, "behavior": behavior} + report = { + "format": FORMAT, + "tool": "harness/version_diff.py", + "backend": "qemu", + "qemu_available": qemu_ok, + "old": _target_brief(old_label, old, qemu_ok, options), + "new": _target_brief(new_label, new, qemu_ok, options), + "options": { + "probe_instructions": options.probe_instructions, + "probe_duration": options.probe_duration, + "behavior_duration": options.behavior_duration, + "max_rows": options.max_rows, + "stimulus": os.path.basename(options.stimulus) if options.stimulus else None, + "corpus_report": os.path.basename(options.corpus_report) if options.corpus_report else None, + "corpus_diff": [os.path.basename(p) for p in options.corpus_diff] if options.corpus_diff else None, + }, + "sections": sections, + "summary": { + "sections_ok": sorted(name for name, section in sections.items() if section["status"] == "ok"), + "sections_skipped": { + name: section.get("reason") + for name, section in sections.items() + if section["status"] == "skipped" + }, + "sections_error": { + name: section.get("reason") + for name, section in sections.items() + if section["status"] == "error" + }, + }, + } + write_json(os.path.join(options.out, "version-diff.json"), report) + write_text(os.path.join(options.out, "version-diff.md"), render_markdown(report, options)) + return report + + +# --------------------------------------------------------------------------- +# markdown rendering +# --------------------------------------------------------------------------- + + +def _table(headers: list[str], rows: list[list]) -> list[str]: + lines = ["| " + " | ".join(headers) + " |", "|" + "|".join("---" for _ in headers) + "|"] + for row in rows: + lines.append("| " + " | ".join(str(cell) for cell in row) + " |") + return lines + + +def _hex(value) -> str: + return f"`{value:#x}`" if isinstance(value, int) else "-" + + +def render_markdown(report: dict, options: VersionDiffOptions) -> str: + old, new = report["old"], report["new"] + lines = [ + "# green-eye version diff", + "", + "> **# TOOL IN DEVELOPMENT - NOT READY YET!** Generated by `harness/version_diff.py`.", + "", + f"* format: `{report['format']}`", + f"* targets: `{old['label']}` -> `{new['label']}`", + f"* backend: `{report['backend']}` (qemu available: {report['qemu_available']})", + "", + "## Targets", + "", + ] + lines += _table( + ["side", "label", "kind", "size", "sha256", "entry"], + [ + [side, f"`{item['label']}`", item["kind"], item["size"], f"`{(item['sha256'] or '')[:16]}`", + _hex(item["entry"])] + for side, item in (("old", old), ("new", new)) + ], + ) + lines += [""] + + image = report["sections"]["image"] + lines += ["## 1. Image / shape", "", f"* status: {image['status']}"] + if image.get("reason"): + lines += [f"* reason: {image['reason']}"] + if image["status"] == "ok": + deltas = image["deltas"] + lines += _table( + ["field", "old", "new", "delta"], + [ + ["entry", _hex(deltas["entry"]["a"]), _hex(deltas["entry"]["b"]), _hex(deltas["entry"]["delta"])], + ["span", _hex(deltas["span"]["a"]), _hex(deltas["span"]["b"]), _hex(deltas["span"]["delta"])], + ["file size", deltas["file_size"]["a"], deltas["file_size"]["b"], deltas["file_size"]["delta"]], + ], + ) + lines += ["", f"* identical shape: {'yes' if image['same'] else 'no'}", ""] + lines += _table( + ["load delta", "count"], + [[key, len(image["deltas"]["loads"][key])] for key in ("added", "removed", "changed")] + + [["section " + key, len(image["deltas"]["sections"][key])] + for key in ("added", "removed", "moved", "resized")], + ) + lines += [""] + + symbols = report["sections"]["symbols"] + lines += ["## 2. Symbol inventory", "", f"* status: {symbols['status']}"] + if symbols.get("reason"): + lines += [f"* reason: {symbols['reason']}"] + if symbols["status"] == "ok": + lines += _table( + ["side", "symbols", "named", "anonymous", "sources"], + [ + [side, symbols[side]["count"], symbols["totals"][f"named_{side}"], + symbols["totals"][f"anonymous_{side}"], ", ".join(symbols[side]["sources"]) or "-"] + for side in ("a", "b") + ], + ) + lines += [""] + lines += _table( + ["address shift", "dominant", "matched named", "matched anon", "moved", "resized", "added", "removed"], + [[ + _hex(symbols["shift"]), f"{symbols['shift_dominant'] * 100:.1f}%", symbols["matched_named"], + symbols["matched_shifted"], symbols["totals"]["moved"], symbols["totals"]["resized_named"], + symbols["totals"]["added"], symbols["totals"]["removed"], + ]], + ) + lines += [""] + for key, title in (("moved", "Moved named"), ("resized", "Resized named"), ("added", "Added"), + ("removed", "Removed")): + rows = symbols.get(key) or [] + lines += [f"**{title} (first {options.max_rows})**", ""] + if not rows: + lines += ["_none_", ""] + continue + if key in ("added", "removed"): + lines += _table(["addr", "name", "size"], [[_hex(r["addr"]), f"`{r['name']}`", r["size"]] for r in rows]) + elif key == "moved": + lines += _table(["name", "old addr", "new addr", "delta"], + [[f"`{r['name']}`", _hex(r["old_addr"]), _hex(r["new_addr"]), _hex(r["delta"])] for r in rows]) + else: + lines += _table(["name", "old addr", "new addr", "old size", "new size"], + [[f"`{r['name']}`", _hex(r["old_addr"]), _hex(r["new_addr"]), r["old_size"], r["new_size"]] + for r in rows]) + lines += [""] + else: + lines += [""] + + corpus = report["sections"]["corpus"] + lines += ["## 3. Decompiled-change hints", "", f"* status: {corpus['status']}", + f"* mode: {corpus.get('mode') or '-'}"] + if corpus.get("reason"): + lines += [f"* reason: {corpus['reason']}"] + if corpus["status"] == "ok" and corpus.get("mode") == "corpus-diff": + diff = corpus["diff"] + lines += _table( + ["functions A", "functions B", "shift", "added", "removed", "moved", "resized"], + [[diff["totals"]["a"], diff["totals"]["b"], _hex(diff["shift"]), diff["totals"]["added"], + diff["totals"]["removed"], diff["totals"]["moved"], diff["totals"]["resized_named"]]], + ) + elif corpus["status"] == "ok" and corpus.get("mode") == "corpus-report": + info = corpus["report"] + lines += [f"* report: `{info['path']}` ({info['bytes']} bytes, sha256 `{info['sha256'][:16]}`)"] + lines += [""] + + probe = report["sections"]["probe"] + lines += ["## 4. Probe first-touch", "", f"* status: {probe['status']}"] + if probe.get("reason"): + lines += [f"* reason: {probe['reason']}"] + if probe["status"] == "ok": + lines += [f"* {probe['summary']}", ""] + lines += _table( + ["side", "stopped pc", "stopped symbol", "fault", "epc (fw vaddr)", "access", "access (fw vaddr)"], + [ + [ + side, + _hex(probe[side].get("stopped_pc")), + probe[side].get("stopped_symbol") or "-", + (probe[side].get("first_fault") or {}).get("desc") or "none", + _hex((probe[side].get("first_fault") or {}).get("epc_firmware")), + _hex(((probe[side].get("first_fault") or {}).get("access") or {}).get("access_addr")), + _hex((probe[side].get("first_fault") or {}).get("access_firmware")), + ] + for side in ("a", "b") + ], + ) + lines += [""] + + behavior = report["sections"]["behavior"] + lines += ["## 5. Behavior", "", f"* status: {behavior['status']}"] + if behavior.get("reason"): + lines += [f"* reason: {behavior['reason']}"] + if behavior["status"] == "ok": + lines += _table( + ["side", "session", "hooks", "uart banner", "faults", "events"], + [ + [ + side, + f"`{behavior[side].get('session')}`", + ", ".join(behavior[side].get("hooks") or []), + behavior[side].get("uart_banner") or "-", + behavior[side].get("fault_count"), + sum((behavior[side].get("event_counts") or {}).values()), + ] + for side in ("a", "b") + ], + ) + lines += [f"* zero deltas: {'yes' if behavior['all_zero'] else 'no'}"] + if behavior.get("compare_report"): + lines += [f"* compare report: `{behavior['compare_report']}`"] + lines += [""] + for name in ("bp", "watch", "writes"): + rows = [row for row in behavior["deltas"][name] if row["delta"] != 0] + rows.sort(key=lambda row: (-abs(row["delta"]), str(row["key"]))) + lines += [f"**{name} delta (nonzero, first {options.max_rows})**", ""] + if rows: + lines += _table(["key", "old", "new", "delta"], + [[f"`{row['key']}`", row["a"], row["b"], row["delta"]] for row in rows[: options.max_rows]]) + else: + lines += ["_no deltas_"] + lines += [""] + lines += [f"> {NO_REFERENCE}", ""] + lines += _table( + ["#", "metric", "old observed", "new observed"], + [[row["id"], row["name"], _short_json(row["a_observed"]), _short_json(row["b_observed"])] + for row in behavior["metrics"]], + ) + lines += [""] + + summary = report["summary"] + lines += ["## Summary", ""] + lines += [f"* ok: {', '.join(summary['sections_ok']) or '-'}"] + if summary["sections_skipped"]: + for name, reason in sorted(summary["sections_skipped"].items()): + lines += [f"* skipped `{name}`: {reason}"] + if summary["sections_error"]: + for name, reason in sorted(summary["sections_error"].items()): + lines += [f"* error `{name}`: {reason}"] + return "\n".join(lines) + "\n" + + +def _short_json(value, limit: int = 72) -> str: + import json + + if value is None: + return "-" + text = json.dumps(value, sort_keys=True) + return f"`{text}`" if len(text) <= limit else f"`{text[: limit - 1]}...`" + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def _add_side_symbols(parser: argparse.ArgumentParser, side: str) -> None: + parser.add_argument(f"--{side}-symbols", help="green-eye symbol DB JSON for the %s target" % side) + parser.add_argument(f"--{side}-ghidra-names", help="Ghidra names.tsv for the %s target" % side) + parser.add_argument(f"--{side}-ghidra-index", help="Ghidra decompiled_index.tsv for the %s target" % side) + parser.add_argument( + f"--{side}-ghidra-exports", + help="Ghidra exports.tsv or 3-column ExportForGreenEye TSV for the %s target" % side, + ) + parser.add_argument(f"--{side}-ghidra-bias", type=lambda value: int(value, 0), default=0, + help="bias added to %s Ghidra symbol addresses" % side) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="version_diff", description=__doc__.splitlines()[0]) + parser.add_argument("--old", required=True, help="old target (RISC-V ELF or raw .fwimage)") + parser.add_argument("--new", required=True, help="new target (RISC-V ELF or raw .fwimage)") + parser.add_argument("--out", required=True, help="output directory for the report, sessions and DBs") + parser.add_argument("--old-label", help="report label for the old target (default: file stem)") + parser.add_argument("--new-label", help="report label for the new target (default: file stem)") + parser.add_argument("--release", help="release label; an rm610 release is gated by docs/23") + parser.add_argument("--approval-token", help=f"explicit RM610 token ({RM610_TOKEN})") + _add_side_symbols(parser, "old") + _add_side_symbols(parser, "new") + parser.add_argument("--corpus-diff", nargs=2, metavar=("OLD_TSV", "NEW_TSV"), + help="diff two decompiled_index.tsv corpus exports") + parser.add_argument("--corpus-report", help="attach a previously produced corpus diff report") + parser.add_argument("--stimulus", help="green-eye script replacing the builtin demo scenario") + parser.add_argument("--probe-instructions", type=int, default=2000, + help="probe step budget (0 = wall-clock --probe-duration)") + parser.add_argument("--probe-duration", type=float, default=0.5, + help="probe wall budget when --probe-instructions=0") + parser.add_argument("--behavior-duration", type=float, default=0.4, + help="budget for each behavior phase") + parser.add_argument("--max-stops", type=int, default=5000, help="safety bound on serviced stops per phase") + parser.add_argument("--max-rows", type=int, default=40, help="table row cap in reports") + parser.add_argument("--log-bytes", type=int, default=MAX_LOG_BYTES, help="QEMU log window per fault check") + return parser + + +def options_from_args(args: argparse.Namespace) -> VersionDiffOptions: + corpus = tuple(args.corpus_diff) if args.corpus_diff else None + return VersionDiffOptions( + old=args.old, + new=args.new, + out=os.path.abspath(args.out), + old_label=args.old_label, + new_label=args.new_label, + release=args.release, + old_symbols=args.old_symbols, + new_symbols=args.new_symbols, + old_ghidra_names=args.old_ghidra_names, + new_ghidra_names=args.new_ghidra_names, + old_ghidra_index=args.old_ghidra_index, + new_ghidra_index=args.new_ghidra_index, + old_ghidra_exports=args.old_ghidra_exports, + new_ghidra_exports=args.new_ghidra_exports, + old_ghidra_bias=args.old_ghidra_bias, + new_ghidra_bias=args.new_ghidra_bias, + corpus_diff=corpus, # type: ignore[arg-type] + corpus_report=args.corpus_report, + stimulus=args.stimulus, + probe_instructions=args.probe_instructions, + probe_duration=args.probe_duration, + behavior_duration=args.behavior_duration, + max_stops=args.max_stops, + max_rows=args.max_rows, + log_bytes=args.log_bytes, + approval_token=args.approval_token, + ) + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + options = options_from_args(args) + try: + report = run_version_diff(options) + except Rm610GateError as exc: + print(f"[version-diff] RM610 gate refused: {exc}", file=sys.stderr) + return 3 + except GreenEyeError as exc: + print(f"[version-diff] error: {exc}", file=sys.stderr) + return 2 + print(f"[version-diff] {report['old']['label']} -> {report['new']['label']}") + for name in ("image", "symbols", "corpus", "probe", "behavior"): + section = report["sections"][name] + print(f"[version-diff] {name}: status={section['status']}" + + (f" reason={section['reason']}" if section.get("reason") else "")) + print(f"[version-diff] wrote {os.path.join(options.out, 'version-diff.json')}") + print(f"[version-diff] wrote {os.path.join(options.out, 'version-diff.md')}") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/plugins/README.md b/debug/risc-v-gsp-rm-vm/plugins/README.md index aa9bc72..7fe69da 100644 --- a/debug/risc-v-gsp-rm-vm/plugins/README.md +++ b/debug/risc-v-gsp-rm-vm/plugins/README.md @@ -27,8 +27,8 @@ Types of plugins that fit green-eye: recorded events. 3. **Virtualization-environment updates** — replay/record wrappers, QEMU argument tweaks, device-model shims. -4. **AI/harness integration** — export sessions to analysis services; - **MCP servers are planned as a plugin** (see `docs/08-ROADMAP.md`), not - part of v0.1. +4. **AI/harness integration** — export sessions to analysis services; the + **MCP server is implemented as a bundled plugin** (`mcp_server.py`, see + `docs/20-MCP-SERVER.md`), stdlib-only and read-only by default. The example plugin is `example_plugin.py`. diff --git a/debug/risc-v-gsp-rm-vm/plugins/mcp_server.py b/debug/risc-v-gsp-rm-vm/plugins/mcp_server.py new file mode 100644 index 0000000..b5e8f90 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/plugins/mcp_server.py @@ -0,0 +1,1155 @@ +"""green-eye MCP server plugin (roadmap R3). + +A stdlib-only Model Context Protocol server (JSON-RPC 2.0) that exposes the +*active* green-eye session as MCP tools. Two ways to run it: + +* **plugin mode** - load it into a live session and serve that session:: + + ./greeneye.sh attach --plugin plugins/mcp_server.py + greeneye> mcp-serve [--allow-write] [--allow-inject] + +* **standalone mode** - launch its own session and serve it:: + + python3 plugins/mcp_server.py --stdio [--kernel ] + [--backend qemu|native] [--record] [--plugins PATH] + +Transport is newline-delimited JSON-RPC over stdin/stdout by default; an +optional TCP mode binds to loopback only (``--tcp 127.0.0.1:PORT``). stdout +carries protocol frames only - all logs go to stderr. + +Safety: read/observe tools are always available; memory writes and remote +calls require ``--allow-write`` and service injection requires +``--allow-inject``. Capabilities owned by other components (the QEMU-only +harnesses, the native trace) are probed defensively and return a structured +``not_available`` tool error when absent. See ``docs/20-MCP-SERVER.md``. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import socket +import subprocess +import sys +from typing import Iterable + +_TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _TOOL_ROOT not in sys.path: # `python3 plugins/mcp_server.py` must import greeneye + sys.path.insert(0, _TOOL_ROOT) + +from greeneye.errors import GreenEyeError # noqa: E402 + +SERVER_NAME = "greeneye-mcp" +JSONRPC_VERSION = "2.0" +SUPPORTED_PROTOCOLS = ("2024-11-05", "2025-03-26", "2025-06-18") +DEFAULT_PROTOCOL = "2024-11-05" + +PARSE_ERROR = -32700 +INVALID_REQUEST = -32600 +METHOD_NOT_FOUND = -32601 +INVALID_PARAMS = -32602 + +_DUMPS = os.path.join(_TOOL_ROOT, "dumps") +_PLUGINS_DIR = os.path.join(_TOOL_ROOT, "plugins") +_DEMO_ELF = os.path.join(_TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf") +_FWIMAGE = os.path.join( + os.path.dirname(_TOOL_ROOT), "firmware", "57X-GSP", "570.144", "gsp-570.144.fwimage.bin" +) +MUTATION_SCRIPT = os.path.join(_TOOL_ROOT, "harness", "mutation_harness.py") +MATRIX_SCRIPT = os.path.join(_TOOL_ROOT, "harness", "portability_matrix.py") +LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1") + +#: Per-response size caps so a single tool call can never flood a client. +MAX_READ_BYTES = 1 << 20 +MAX_HEXDUMP_BYTES = 1 << 20 +MAX_SNAPSHOT_BYTES = 1 << 24 + + +class McpError(Exception): + """A JSON-RPC level error (unknown method/tool, bad params).""" + + def __init__(self, code: int, message: str, data=None): + super().__init__(message) + self.code = code + self.message = message + self.data = data + + +class ToolError(Exception): + """A tool-execution error returned inside a tools/call result (isError).""" + + def __init__(self, message: str, *, code: str = "error", **details): + super().__init__(message) + self.payload = {"error": code, "message": message, **details} + + +def _unavailable(message: str, **details) -> ToolError: + return ToolError(message, code="not_available", **details) + + +# --------------------------------------------------------------------------- +# tool catalogue +# --------------------------------------------------------------------------- + +def _address(description: str) -> dict: + return {"type": ["string", "integer"], "description": description} + + +def _integer(description: str, default=None, minimum=None, maximum=None) -> dict: + schema: dict = {"type": "integer", "description": description} + if default is not None: + schema["default"] = default + if minimum is not None: + schema["minimum"] = minimum + if maximum is not None: + schema["maximum"] = maximum + return schema + + +def _number(description: str, default=None) -> dict: + schema: dict = {"type": "number", "description": description} + if default is not None: + schema["default"] = default + return schema + + +def _string(description: str, default=None) -> dict: + schema: dict = {"type": "string", "description": description} + if default is not None: + schema["default"] = default + return schema + + +def _boolean(description: str, default=None) -> dict: + schema: dict = {"type": "boolean", "description": description} + if default is not None: + schema["default"] = default + return schema + + +def _tool(name: str, description: str, properties: dict | None = None, required: Iterable[str] = ()) -> dict: + schema: dict = {"type": "object", "properties": properties or {}} + if required: + schema["required"] = list(required) + return {"name": name, "description": description, "inputSchema": schema} + + +TOOLS: list[dict] = [ + _tool("session_info", "Active session state: backend, PC, symbols, recording, breakpoints/watchpoints, enabled capabilities."), + _tool( + "symbols_resolve", + "Resolve a symbol name, 'symbol+0xNN' expression or hex address against the session symbol DB.", + {"target": _address("symbol name, symbol+offset or address")}, + ("target",), + ), + _tool( + "bp_set", + "Set a software or hardware breakpoint at a symbol/address; hits are recorded in the session.", + {"target": _address("symbol or address"), "kind": {"type": "string", "enum": ["sw", "hw"], "default": "sw"}}, + ("target",), + ), + _tool("bp_clear", "Clear every breakpoint at a symbol/address.", {"target": _address("symbol or address")}, ("target",)), + _tool( + "watch_set", + "Set a read/write/access watchpoint; hits are recorded in the session.", + { + "target": _address("symbol or address"), + "size": _integer("watched region size in bytes", 8, 1, 4096), + "kind": {"type": "string", "enum": ["w", "r", "a"], "default": "w"}, + }, + ("target",), + ), + _tool( + "run", + "Resume and service hooks/watchpoints for a wall-clock budget (bounded; default 1.0s).", + { + "duration": _number("wall-clock budget in seconds", 1.0), + "max_stops": _integer("stop budget (optional)", None, 1), + "timeout": _number("alias for duration when duration is null"), + }, + ), + _tool( + "run_until", + "Run until execution reaches a target address/symbol, servicing intervening stops.", + {"target": _address("symbol or address"), "timeout": _number("seconds to wait", 5.0)}, + ("target",), + ), + _tool("step", "Single-step the target n times, dispatching hooks and watchpoints.", {"count": _integer("instructions", 1, 1, 10000)}), + _tool( + "read_mem", + "Read memory and return lowercase hex.", + {"addr": _address("address or symbol"), "size": _integer("bytes to read", 64, 1, MAX_READ_BYTES)}, + ("addr",), + ), + _tool( + "write_mem", + "Write hex bytes to target memory. Requires --allow-write at server startup.", + {"addr": _address("address or symbol"), "hex": _string("hex bytes, e.g. '01 02 ff'")}, + ("addr", "hex"), + ), + _tool( + "hexdump", + "Symbol-annotated hexdump of target memory.", + {"addr": _address("address or symbol"), "size": _integer("bytes", 64, 1, MAX_HEXDUMP_BYTES)}, + ("addr",), + ), + _tool( + "snapshot", + "Capture a memory region into the recording (zlib + sha256) and return its digest.", + {"addr": _address("address or symbol"), "size": _integer("bytes", None, 1, MAX_SNAPSHOT_BYTES), "label": _string("snapshot label", "mcp")}, + ("addr", "size"), + ), + _tool( + "call_function", + "Remote-call a target function (Frida-like). Requires --allow-write at server startup.", + {"target": _address("symbol or address"), "args": {"type": "array", "items": {"type": "integer"}, "description": "a0..a7"}, "timeout": _number("seconds", 5.0)}, + ("target",), + ), + _tool( + "inject_service", + "Load and invoke an injected RV64 service blob. Requires --allow-inject at server startup.", + { + "service": _string("path to the service ELF"), + "slot": _integer("demo handler slot", 6, 0, 7), + "load_addr": _address("load address (optional)"), + "cmd": _integer("service command", 1, 0), + "arg0": _integer("argument 0", 0, 0), + "arg1": _integer("argument 1", 0, 0), + "path": {"type": "string", "enum": ["remote", "firmware", "both"], "default": "remote"}, + "settle": _number("seconds to settle before the firmware mailbox call", 0.25), + }, + ("service",), + ), + _tool( + "trace_start", + "Start the native exec/access trace (native backend, recording session only).", + { + "exec": _boolean("record exec events", True), + "start": _address("only PCs >= start"), + "end": _address("only PCs <= end"), + "max_events": _integer("ring budget (0 = unlimited)"), + }, + ), + _tool("trace_stop", "Stop the native trace, flush it to blobs/trace.jsonl and return event counts."), + _tool("analyze_current", "Render the symbol-annotated summary of the active recording.", {"max_rows": _integer("table row cap", 40, 1, 1000)}), + _tool( + "compare_sessions", + "A/B two recorded session directories at the symbolic event level.", + {"a": _string("session A directory"), "b": _string("session B directory"), "symbols": _boolean("resolve with the active symbol DB", True), "max_rows": _integer("table row cap", 40, 1, 1000)}, + ("a", "b"), + ), + _tool( + "export_hits", + "Export breakpoint/watchpoint hits as Ghidra's greeneye_hits.tsv.", + {"session": _string("session directory (default: active recording)"), "out": _string("output TSV path")}, + ), + _tool( + "probe_firmware", + "Run the QEMU probe CLI on a firmware image and report the first divergence (QEMU required).", + { + "elf": _string("firmware ELF (default: the project fwimage)"), + "instructions": _integer("step budget (0 = wall-clock duration)", 2000, 0), + "duration": _number("wall-clock seconds when instructions=0", 1.0), + "no_relocate": _boolean("do not relocate to 0x80000000", False), + "symbols": _string("green-eye symbol DB JSON"), + "timeout": _number("subprocess timeout seconds", 300.0), + }, + ), + _tool( + "mutation_run", + "Run harness/mutation_harness.py (QEMU-only) and return the outcome-class counts.", + { + "kernel": _string("demo/re-hosted ELF (default: active kernel or bundled demo)"), + "out": _string("output directory (default: dumps/mcp-mutation)"), + "iterations": _integer("mutation count", 10, 1, 10000), + "seed": _integer("deterministic seed", 1), + "timeout": _number("observation budget per mutation", 0.25), + "settle": _number("settle budget per attempt", 0.2), + "memory_region": _string("optional RAM region ADDR:SIZE"), + "allow_foreign_elf": _boolean("permit a non-demo ELF", False), + }, + ), + _tool( + "portability_matrix_run", + "Run harness/portability_matrix.py (QEMU-only) over one or more firmware ELFs.", + { + "targets": {"type": "array", "items": {"type": "string"}, "description": "firmware ELF paths"}, + "out": _string("output directory (default: dumps/mcp-matrix)"), + "probe_instructions": _integer("probe step budget", 2000, 0), + "scenario_duration": _number("per-phase wall budget", 0.4), + "symbols": _string("green-eye symbol DB JSON"), + "max_rows": _integer("report row cap", 40, 1, 1000), + }, + ("targets",), + ), + _tool("plugins_list", "List bundled plugins, GREENEYE_PLUGINS and plugin-provided REPL commands."), + _tool("close", "Stop serving MCP; in standalone mode also close the launched session."), +] + +_TOOL_NAMES = {tool["name"] for tool in TOOLS} + + +# --------------------------------------------------------------------------- +# server +# --------------------------------------------------------------------------- + + +class GreenEyeMCPServer: + """JSON-RPC 2.0 MCP server over newline-delimited streams.""" + + def __init__( + self, + session, + *, + allow_write: bool = False, + allow_inject: bool = False, + api=None, + owns_session: bool = False, + input_stream=None, + output_stream=None, + log_stream=None, + ): + self.session = session + self.allow_write = bool(allow_write) + self.allow_inject = bool(allow_inject) + self.api = api # GreenEyeAPI when loaded as a plugin (plugins_list) + self.owns_session = bool(owns_session) + self._input = input_stream if input_stream is not None else sys.stdin + self._output = output_stream if output_stream is not None else sys.stdout + self._log_stream = log_stream if log_stream is not None else sys.stderr + self._stop = False + self.session_closed = False + self._initialized = False + + # -- transport --------------------------------------------------------- + + def serve_stdio(self) -> None: + self._log(f"serving {self.session.name!r} over stdio (write={self.allow_write}, inject={self.allow_inject})") + self.serve_stream(self._input, self._output) + self._log("stdio server stopped") + + def serve_stream(self, stream_in, stream_out) -> None: + for raw in stream_in: + if self._stop: + break + if isinstance(raw, bytes): + raw = raw.decode("utf-8", "replace") + self.process_line(raw, stream_out) + + def serve_tcp(self, host: str, port: int) -> None: + if host not in LOOPBACK_HOSTS: + raise ValueError(f"refusing non-loopback bind {host!r}; MCP is local-only") + server = socket.socket(socket.AF_INET if ":" not in host else socket.AF_INET6, socket.SOCK_STREAM) + server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server.bind((host, port)) + server.listen(2) + bound = server.getsockname() + self._log(f"serving {self.session.name!r} on {bound[0]}:{bound[1]} (loopback only)") + try: + while not self._stop: + try: + conn, _addr = server.accept() + except OSError: + break + with conn: + reader = conn.makefile("r", encoding="utf-8", newline="\n") + writer = conn.makefile("w", encoding="utf-8", newline="\n") + try: + self.serve_stream(reader, writer) + finally: + reader.close() + writer.close() + finally: + server.close() + self._log("tcp server stopped") + + def process_line(self, line: str, stream_out=None) -> None: + stream = stream_out if stream_out is not None else self._output + line = line.strip() + if not line: + return + try: + message = json.loads(line) + except json.JSONDecodeError as exc: + self._write(stream, self._error(None, PARSE_ERROR, f"parse error: {exc}")) + return + try: + response = self.handle_message(message) + except McpError as exc: # defensive: protocol bugs must not kill the loop + self._log(f"protocol error: {exc}") + response = self._error(message.get("id") if isinstance(message, dict) else None, exc.code, exc.message, exc.data) + if response is not None: + self._write(stream, response) + + def _write(self, stream, payload: dict) -> None: + stream.write(json.dumps(payload, separators=(",", ":"), default=str) + "\n") + stream.flush() + + # -- JSON-RPC ---------------------------------------------------------- + + @staticmethod + def _result(request_id, result: dict) -> dict: + return {"jsonrpc": JSONRPC_VERSION, "id": request_id, "result": result} + + @staticmethod + def _error(request_id, code: int, message: str, data=None) -> dict: + payload = {"jsonrpc": JSONRPC_VERSION, "id": request_id, "error": {"code": code, "message": message}} + if data is not None: + payload["error"]["data"] = data + return payload + + def handle_message(self, message) -> dict | None: + if not isinstance(message, dict): + return self._error(None, INVALID_REQUEST, "message must be a JSON object") + method = message.get("method") + request_id = message.get("id") + if not isinstance(method, str): + return self._error(request_id, INVALID_REQUEST, "missing method") + params = message.get("params") + if params is None: + params = {} + if not isinstance(params, dict): + return self._error(request_id, INVALID_PARAMS, "params must be an object") + + if method.startswith("notifications/") or "id" not in message: + return None # notifications never get a response + + if method == "initialize": + return self._result(request_id, self._initialize(params)) + if method == "ping": + return self._result(request_id, {}) + if method == "tools/list": + return self._result(request_id, {"tools": TOOLS}) + if method == "tools/call": + try: + result = self._dispatch_tool(params) + except McpError as exc: + return self._error(request_id, exc.code, exc.message, exc.data) + return self._result(request_id, result) + return self._error(request_id, METHOD_NOT_FOUND, f"unknown method {method!r}", {"method": method}) + + def _initialize(self, params: dict) -> dict: + requested = params.get("protocolVersion") + version = requested if requested in SUPPORTED_PROTOCOLS else DEFAULT_PROTOCOL + self._initialized = True + return { + "protocolVersion": version, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": SERVER_NAME, "version": _version()}, + "instructions": ( + "green-eye session server. Read/observe tools are always on; writes need " + "--allow-write and injection needs --allow-inject. Never claim timing, cycles " + "or hardware behaviour; sessions are the evidence." + ), + } + + # -- tool dispatch ----------------------------------------------------- + + def _dispatch_tool(self, params: dict) -> dict: + name = params.get("name") + if not isinstance(name, str): + raise McpError(INVALID_PARAMS, "tools/call requires a tool name") + handler = getattr(self, f"tool_{name}", None) + if handler is None or name not in _TOOL_NAMES: + raise McpError(INVALID_PARAMS, f"unknown tool {name!r}", {"tool": name}) + arguments = params.get("arguments") + if arguments is None: + arguments = {} + if not isinstance(arguments, dict): + raise McpError(INVALID_PARAMS, "tool arguments must be an object") + try: + result = handler(arguments) + except ToolError as exc: + return _tool_error(exc.payload) + except GreenEyeError as exc: + return _tool_error({"error": "greeneye", "message": str(exc)}) + except Exception as exc: # one bad call must never take the server down + self._log(f"tool {name} failed: {exc!r}") + return _tool_error({"error": "internal", "message": f"{type(exc).__name__}: {exc}"}) + return {"content": [{"type": "text", "text": json.dumps(result, sort_keys=True, default=str)}]} + + def _require_capability(self, name: str) -> None: + if name == "write" and not self.allow_write: + raise ToolError( + "target writes and remote calls are disabled; restart the server with --allow-write", + code="write_disabled", + ) + if name == "inject" and not self.allow_inject: + raise ToolError( + "service injection is disabled; restart the server with --allow-inject", + code="inject_disabled", + ) + + def _annotate(self, addr: int) -> str: + return self.session.symbols.annotate(addr) + + def _session_kernel(self) -> str | None: + target = self.session.target + kernel = getattr(target, "kernel", None) + if kernel: + return str(kernel) + return getattr(self.session.image, "path", None) + + # -- tools: session ---------------------------------------------------- + + def tool_session_info(self, args: dict) -> dict: + session = self.session + backend = "native" if hasattr(session.client, "trace") else ("qemu" if session.target is not None else "custom") + return { + "name": session.name, + "backend": backend, + "pc": session.pc, + "pc_symbol": self._annotate(session.pc), + "symbols": len(session.symbols), + "recording": getattr(session.recorder, "dir", None), + "kernel": self._session_kernel(), + "closed": self.session_closed, + "capabilities": { + "write": self.allow_write, + "inject": self.allow_inject, + "trace": backend == "native", + }, + "breakpoints": [ + {"addr": bp.addr, "label": bp.label, "kind": bp.kind, "hits": bp.hits} + for bp in session._bps.values() # noqa: SLF001 - the MCP server is a privileged client + ], + "watches": [ + {"addr": wp.addr, "size": wp.size, "kind": wp.kind, "hits": wp.hits} + for wp in session._watches.values() # noqa: SLF001 + ], + "tools": len(TOOLS), + } + + def tool_symbols_resolve(self, args: dict) -> dict: + addr = self.session.resolve(_require(args, "target")) + sym = self.session.symbols.resolve(addr) + return {"address": addr, "symbol": sym.name if sym else None, "annotation": self._annotate(addr)} + + # -- tools: instrumentation ------------------------------------------- + + def tool_bp_set(self, args: dict) -> dict: + target = _require(args, "target") + kind = args.get("kind", "sw") + if kind not in ("sw", "hw"): + raise ToolError("kind must be 'sw' or 'hw'", code="invalid_arguments") + handle = self.session.hook(target, kind=kind, listener="mcp") + return {"addr": handle.addr, "label": handle.label, "kind": kind} + + def tool_bp_clear(self, args: dict) -> dict: + addr = self.session.resolve(_require(args, "target")) + removed = 0 + for bp in list(self.session._bps.values()): # noqa: SLF001 + if bp.addr == addr: + self.session.remove_breakpoint(bp) + removed += 1 + return {"addr": addr, "cleared": removed} + + def tool_watch_set(self, args: dict) -> dict: + kind = args.get("kind", "w") + if kind not in ("w", "r", "a"): + raise ToolError("kind must be 'w', 'r' or 'a'", code="invalid_arguments") + wp = self.session.watch( + _require(args, "target"), + size=_as_int(args.get("size", 8), "size"), + kind=kind, + listener="mcp", + ) + return {"addr": wp.addr, "size": wp.size, "kind": wp.kind, "label": wp.label} + + # -- tools: execution -------------------------------------------------- + + def tool_run(self, args: dict) -> dict: + duration = args.get("duration", 1.0) + timeout = args.get("timeout") + if duration is None: + duration = timeout if timeout is not None else 1.0 + max_stops = args.get("max_stops") + stops = self.session.run( + duration=float(duration), + max_stops=_as_int(max_stops, "max_stops") if max_stops is not None else None, + timeout=float(timeout) if timeout is not None else None, + ) + return {"stops": stops, "pc": self.session.pc, "pc_symbol": self._annotate(self.session.pc)} + + def tool_run_until(self, args: dict) -> dict: + hit = self.session.run_until(_require(args, "target"), timeout=float(args.get("timeout", 5.0))) + return { + "addr": hit.addr, + "kind": hit.kind, + "symbol": hit.symbol.name if hit.symbol else None, + "pc": hit.pc, + "pc_symbol": self._annotate(hit.pc), + } + + def tool_step(self, args: dict) -> dict: + count = _as_int(args.get("count", 1), "count") + if not 1 <= count <= 10000: + raise ToolError("count must be in 1..10000", code="invalid_arguments") + hits = self.session.step(count) + return { + "steps": len(hits), + "pc": self.session.pc, + "pc_symbol": self._annotate(self.session.pc), + "hits": [ + {"kind": hit.kind, "addr": hit.addr, "symbol": hit.symbol.name if hit.symbol else None} + for hit in hits + ], + } + + # -- tools: memory ----------------------------------------------------- + + def tool_read_mem(self, args: dict) -> dict: + addr = self.session.resolve(_require(args, "addr")) + size = _bounded(_as_int(args.get("size", 64), "size"), 1, MAX_READ_BYTES, "size") + data = self.session.memory.read(addr, size) + return {"addr": addr, "size": len(data), "hex": data.hex(), "annotation": self._annotate(addr)} + + def tool_write_mem(self, args: dict) -> dict: + self._require_capability("write") + addr = self.session.resolve(_require(args, "addr")) + data = _hex_bytes(_require(args, "hex")) + self.session.memory.write(addr, data) + return {"addr": addr, "written": len(data), "hex": data.hex(), "annotation": self._annotate(addr)} + + def tool_hexdump(self, args: dict) -> dict: + addr = self.session.resolve(_require(args, "addr")) + size = _bounded(_as_int(args.get("size", 64), "size"), 1, MAX_HEXDUMP_BYTES, "size") + return {"addr": addr, "size": size, "text": self.session.memory.hexdump(addr, size)} + + def tool_snapshot(self, args: dict) -> dict: + addr = self.session.resolve(_require(args, "addr")) + size = _bounded(_as_int(_require(args, "size"), "size"), 1, MAX_SNAPSHOT_BYTES, "size") + label = str(args.get("label", "mcp")) + data = self.session.memory.snapshot(addr, size, name=label) + return { + "label": label, + "addr": addr, + "size": len(data), + "sha256": hashlib.sha256(data).hexdigest(), + "recording": getattr(self.session.recorder, "dir", None), + } + + def tool_call_function(self, args: dict) -> dict: + self._require_capability("write") + target = _require(args, "target") + call_args = args.get("args") or [] + if not isinstance(call_args, list): + raise ToolError("args must be an array of integers", code="invalid_arguments") + if len(call_args) > 8: + raise ToolError("at most 8 arguments (a0..a7)", code="invalid_arguments") + values = [_as_int(value, "args[]") for value in call_args] + retval = self.session.call(target, values, timeout=float(args.get("timeout", 5.0))) + return {"retval": retval, "retval_hex": hex(retval), "target": str(target)} + + def tool_inject_service(self, args: dict) -> dict: + self._require_capability("inject") + service_path = str(_require(args, "service")) + if not os.path.isfile(service_path): + raise ToolError( + f"service not found: {service_path} (build it with `make sdk`)", code="invalid_arguments" + ) + try: + from greeneye.inject import invoke_remote, invoke_via_firmware, load_service + except Exception as exc: + raise _unavailable(f"injection support unavailable: {exc}") from exc + load_addr = args.get("load_addr") + service = load_service( + self.session, + service_path, + load_addr=_as_int(load_addr, "load_addr") if load_addr is not None else None, + slot=_as_int(args.get("slot", 6), "slot"), + ) + cmd = _as_int(args.get("cmd", 1), "cmd") + a0 = _as_int(args.get("arg0", 0), "arg0") + a1 = _as_int(args.get("arg1", 0), "arg1") + mode = args.get("path", "remote") + if mode not in ("remote", "firmware", "both"): + raise ToolError("path must be 'remote', 'firmware' or 'both'", code="invalid_arguments") + result = { + "service": { + "name": service.name, + "load_addr": service.load_addr, + "entry_addr": service.entry_addr, + "slot": service.slot, + "size": service.size, + }, + "cmd": cmd, + "args": [a0, a1], + } + if mode in ("remote", "both"): + result["remote"] = invoke_remote(self.session, service, cmd, a0, a1) + if mode in ("firmware", "both"): + self.session.run(duration=float(args.get("settle", 0.25))) + result["firmware"] = invoke_via_firmware(self.session, service, cmd, a0, a1) + return result + + # -- tools: native trace ---------------------------------------------- + + def tool_trace_start(self, args: dict) -> dict: + if not hasattr(self.session.client, "trace"): + raise _unavailable("trace start/stop is native-backend only (start the server with --backend native)") + if getattr(self.session.recorder, "dir", None) is None: + raise _unavailable("trace_start requires a recording session (start the server/session with --record)") + if getattr(self.session.client, "trace", None) is not None: + raise ToolError("a trace is already active; call trace_stop first", code="invalid_arguments") + try: + from sim.trace import make_trace + except Exception as exc: + raise _unavailable(f"native trace module unavailable: {exc}") from exc + spec: dict = {"exec": bool(args.get("exec", True))} + for key in ("start", "end", "max_events"): + if args.get(key) is not None: + spec[key] = _as_int(args[key], key) + tracer = make_trace(spec) + self.session.client.trace = tracer + self.session.client._recorder = self.session.recorder # noqa: SLF001 - SimClient boundary + self.session.recorder.meta["trace"] = tracer.config() + return {"tracing": True, "config": tracer.config()} + + def tool_trace_stop(self, args: dict) -> dict: + tracer = getattr(self.session.client, "trace", None) + if tracer is None: + raise ToolError("no active trace", code="invalid_arguments") + self.session.client.trace = None + blob = tracer.flush(self.session.recorder) + return {"tracing": False, "blob": blob, "counts": tracer.counts()} + + # -- tools: analysis --------------------------------------------------- + + def tool_analyze_current(self, args: dict) -> dict: + directory = getattr(self.session.recorder, "dir", None) + if not directory: + raise _unavailable("the session is not recording; start it with --record") + try: + from greeneye.analyze import load_events, load_meta, load_trace, render_summary_from + except Exception as exc: + raise _unavailable(f"analysis support unavailable: {exc}") from exc + # An active recording has no session.json until close(), so merge the + # live recorder metadata instead of requiring the closed-session files. + if os.path.isfile(os.path.join(directory, "session.json")): + meta = load_meta(directory) + else: + meta = dict(getattr(self.session.recorder, "meta", {})) + meta.setdefault("format", "greeneye-session/1") + meta.setdefault("name", self.session.name) + meta.setdefault("dir", directory) + events = load_events(directory) + trace = load_trace(directory, meta) + report = render_summary_from( + meta, events, symbols=self.session.symbols, max_rows=_as_int(args.get("max_rows", 40), "max_rows"), trace=trace + ) + return {"session": directory, "report": report} + + def tool_compare_sessions(self, args: dict) -> dict: + a = str(_require(args, "a")) + b = str(_require(args, "b")) + for path in (a, b): + if not os.path.isdir(path): + raise ToolError(f"session directory not found: {path}", code="invalid_arguments") + try: + from greeneye.analyze import compare_sessions + except Exception as exc: + raise _unavailable(f"analysis support unavailable: {exc}") from exc + symbols = self.session.symbols if args.get("symbols", True) else None + report = compare_sessions(a, b, symbols=symbols, max_rows=_as_int(args.get("max_rows", 40), "max_rows")) + return {"a": a, "b": b, "report": report} + + def tool_export_hits(self, args: dict) -> dict: + directory = args.get("session") or getattr(self.session.recorder, "dir", None) + if not directory: + raise _unavailable("no session directory given and the active session is not recording") + if not os.path.isdir(str(directory)): + raise ToolError(f"session directory not found: {directory}", code="invalid_arguments") + out = args.get("out") or os.path.join(str(directory), "greeneye_hits.tsv") + try: + from greeneye.ghidra import export_hits + except Exception as exc: + raise _unavailable(f"Ghidra export unavailable: {exc}") from exc + path = export_hits(str(directory), str(out), symbols=self.session.symbols) + return {"session": str(directory), "path": path} + + # -- tools: external harnesses (defensive) ----------------------------- + + def tool_probe_firmware(self, args: dict) -> dict: + elf = args.get("elf") or (_FWIMAGE if os.path.isfile(_FWIMAGE) else None) + if not elf: + raise _unavailable(f"default fwimage not found and no elf argument given ({_FWIMAGE})") + if not os.path.isfile(str(elf)): + raise ToolError(f"firmware not found: {elf}", code="invalid_arguments") + cmd = [sys.executable, "-m", "greeneye", "probe", str(elf), "--record"] + if args.get("instructions") is not None: + cmd += ["--instructions", str(_as_int(args["instructions"], "instructions"))] + elif args.get("duration") is not None: + cmd += ["--duration", str(float(args["duration"]))] + if args.get("no_relocate"): + cmd.append("--no-relocate") + if args.get("symbols"): + cmd += ["--symbols", str(args["symbols"])] + rc, stdout, stderr = self._run_cli(cmd, timeout=float(args.get("timeout", 300.0))) + if rc != 0: + raise ToolError( + f"probe failed (rc={rc})", code="probe_failed", elf=str(elf), stdout=stdout[-4000:], stderr=stderr[-4000:] + ) + return {"elf": str(elf), "returncode": rc, "stdout": stdout[-20000:], "stderr": stderr[-4000:]} + + def tool_mutation_run(self, args: dict) -> dict: + if not os.path.isfile(MUTATION_SCRIPT): + raise _unavailable(f"mutation harness not present: {MUTATION_SCRIPT}") + kernel = args.get("kernel") or self._session_kernel() or _DEMO_ELF + if not os.path.isfile(str(kernel)): + raise ToolError(f"kernel not found: {kernel}", code="invalid_arguments") + out = str(args.get("out") or os.path.join(_DUMPS, "mcp-mutation")) + cmd = [ + sys.executable, + MUTATION_SCRIPT, + "--kernel", + str(kernel), + "--out", + out, + "--iterations", + str(_as_int(args.get("iterations", 10), "iterations")), + "--seed", + str(_as_int(args.get("seed", 1), "seed")), + "--timeout", + str(float(args.get("timeout", 0.25))), + "--settle", + str(float(args.get("settle", 0.2))), + ] + if args.get("memory_region"): + cmd += ["--memory-region", str(args["memory_region"])] + if args.get("allow_foreign_elf"): + cmd.append("--allow-foreign-elf") + rc, stdout, stderr = self._run_cli(cmd, timeout=float(args.get("subprocess_timeout", 1800.0))) + outcomes = _count_findings(os.path.join(out, "findings.jsonl")) + result = { + "kernel": str(kernel), + "out": out, + "returncode": rc, + "outcomes": outcomes, + "findings": sum(outcomes.values()), + "stdout": stdout[-4000:], + "stderr": stderr[-4000:], + } + if rc != 0: + raise ToolError("mutation harness failed", code="harness_failed", **result) + return result + + def tool_portability_matrix_run(self, args: dict) -> dict: + if not os.path.isfile(MATRIX_SCRIPT): + raise _unavailable(f"portability matrix not present: {MATRIX_SCRIPT}") + targets = args.get("targets") + if not isinstance(targets, list) or not targets: + raise ToolError("targets must be a non-empty array of ELF paths", code="invalid_arguments") + for target in targets: + if not os.path.isfile(str(target)): + raise ToolError(f"target not found: {target}", code="invalid_arguments") + out = str(args.get("out") or os.path.join(_DUMPS, "mcp-matrix")) + cmd = [sys.executable, MATRIX_SCRIPT, "--out", out] + for target in targets: + cmd += ["--target", str(target)] + cmd += [ + "--probe-instructions", + str(_as_int(args.get("probe_instructions", 2000), "probe_instructions")), + "--scenario-duration", + str(float(args.get("scenario_duration", 0.4))), + "--max-rows", + str(_as_int(args.get("max_rows", 40), "max_rows")), + ] + if args.get("symbols"): + cmd += ["--symbols", str(args["symbols"])] + rc, stdout, stderr = self._run_cli(cmd, timeout=float(args.get("subprocess_timeout", 3600.0))) + report_path = os.path.join(out, "portability-report.json") + summary = None + if os.path.isfile(report_path): + try: + with open(report_path, "r", encoding="utf-8") as fh: + report = json.load(fh) + summary = { + "targets": [ + {"name": t.get("name"), "status": t.get("status"), "sha256": (t.get("sha256") or "")[:16]} + for t in report.get("targets", []) + ], + "comparisons": len(report.get("comparisons", [])), + } + except (OSError, ValueError) as exc: + summary = {"parse_error": str(exc)} + result = { + "targets": [str(target) for target in targets], + "out": out, + "returncode": rc, + "report": report_path if os.path.isfile(report_path) else None, + "summary": summary, + "stdout": stdout[-4000:], + "stderr": stderr[-4000:], + } + if rc != 0: + raise ToolError("portability matrix failed", code="harness_failed", **result) + return result + + def tool_plugins_list(self, args: dict) -> dict: + bundled: list[str] = [] + if os.path.isdir(_PLUGINS_DIR): + bundled = sorted( + name + for name in os.listdir(_PLUGINS_DIR) + if name.endswith(".py") and not name.startswith("_") + ) + commands = sorted(self.api.commands) if self.api is not None else [] + return { + "bundled": bundled, + "GREENEYE_PLUGINS": os.environ.get("GREENEYE_PLUGINS"), + "commands": commands, + "mcp_plugin": "mcp_server.py" in bundled, + } + + def tool_close(self, args: dict) -> dict: + if self.owns_session and not self.session_closed: + self.session.close() + self.session_closed = True + self._stop = True + return {"closed": True, "session_closed": self.session_closed} + + # -- helpers ----------------------------------------------------------- + + def _run_cli(self, cmd: list[str], timeout: float) -> tuple[int, str, str]: + env = dict(os.environ) + env.setdefault("GREENEYE_DUMPS", _DUMPS) + try: + completed = subprocess.run( + cmd, + cwd=_TOOL_ROOT, + env=env, + capture_output=True, + text=True, + timeout=timeout, + ) + except FileNotFoundError as exc: + raise _unavailable(f"cannot run {cmd[0]}: {exc}") from exc + except subprocess.TimeoutExpired as exc: + raise ToolError(f"command timed out after {timeout}s", code="timeout", cmd=cmd) from exc + return completed.returncode, completed.stdout or "", completed.stderr or "" + + def _log(self, message: str) -> None: + try: + print(f"[greeneye-mcp] {message}", file=self._log_stream, flush=True) + except Exception: # a broken log stream must not break serving + pass + + +# --------------------------------------------------------------------------- +# small argument helpers +# --------------------------------------------------------------------------- + + +def _require(args: dict, key: str): + if key not in args or args[key] is None: + raise ToolError(f"missing required argument {key!r}", code="invalid_arguments") + return args[key] + + +def _as_int(value, name: str) -> int: + if isinstance(value, bool): + raise ToolError(f"{name} must be an integer", code="invalid_arguments") + if isinstance(value, int): + return int(value) + if isinstance(value, str): + try: + return int(value, 0) + except ValueError as exc: + raise ToolError(f"{name}: cannot parse integer from {value!r}", code="invalid_arguments") from exc + raise ToolError(f"{name} must be an integer", code="invalid_arguments") + + +def _bounded(value: int, minimum: int, maximum: int, name: str) -> int: + if not minimum <= value <= maximum: + raise ToolError(f"{name} must be in {minimum}..{maximum}", code="invalid_arguments") + return value + + +def _hex_bytes(text: str) -> bytes: + cleaned = str(text).strip().replace(" ", "").replace(":", "").replace(",", "") + if cleaned.startswith("0x") or cleaned.startswith("0X"): + cleaned = cleaned[2:] + try: + return bytes.fromhex(cleaned) + except ValueError as exc: + raise ToolError(f"bad hex data: {exc}", code="invalid_arguments") from exc + + +def _tool_error(payload: dict) -> dict: + return { + "content": [{"type": "text", "text": json.dumps(payload, sort_keys=True, default=str)}], + "isError": True, + } + + +def _count_findings(path: str) -> dict: + counts: dict[str, int] = {} + if not os.path.isfile(path): + return counts + try: + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + outcome = json.loads(line).get("outcome", "?") + except ValueError: + continue + counts[outcome] = counts.get(outcome, 0) + 1 + except OSError: + return counts + return counts + + +def _version() -> str: + try: + import greeneye + + return greeneye.__version__ + except Exception: # pragma: no cover - only on a broken install + return "unknown" + + +# --------------------------------------------------------------------------- +# plugin entry point +# --------------------------------------------------------------------------- + + +def build_serve_parser(prog: str = "mcp-serve") -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog=prog, + description="serve the active green-eye session over MCP (JSON-RPC over stdio or loopback TCP)", + ) + parser.add_argument("--stdio", action="store_true", help="serve over stdin/stdout (default)") + parser.add_argument("--tcp", metavar="HOST:PORT", type=parse_tcp, help="serve over loopback TCP instead") + parser.add_argument("--allow-write", action="store_true", help="enable memory writes and remote calls") + parser.add_argument("--allow-inject", action="store_true", help="enable service injection") + return parser + + +def register(ge) -> None: + """green-eye plugin hook: add the ``mcp-serve`` command.""" + + def mcp_serve(session, argv: list[str]) -> int: + parser = build_serve_parser() + try: + options = parser.parse_args(argv) + except SystemExit as exc: # --help must not kill the REPL + return int(exc.code or 0) + server = GreenEyeMCPServer( + session, + allow_write=options.allow_write, + allow_inject=options.allow_inject, + api=ge, + owns_session=False, + ) + ge.log( + "mcp server", + phase="start", + transport=f"tcp:{options.tcp[0]}:{options.tcp[1]}" if options.tcp else "stdio", + allow_write=options.allow_write, + allow_inject=options.allow_inject, + ) + try: + if options.tcp: + server.serve_tcp(*options.tcp) + else: + server.serve_stdio() + finally: + if getattr(session.recorder, "dir", None): + ge.log("mcp server", phase="stop", requests="see events") + return 0 + + ge.add_command( + "mcp-serve", + mcp_serve, + help="serve this live session over MCP (stdlib JSON-RPC; --allow-write/--allow-inject to enable writes)", + ) + + +# --------------------------------------------------------------------------- +# standalone entry point +# --------------------------------------------------------------------------- + + +def parse_tcp(text: str) -> tuple[str, int]: + host, _, port_text = text.rpartition(":") + host = host or "127.0.0.1" + if host not in LOOPBACK_HOSTS: + raise argparse.ArgumentTypeError(f"MCP is local-only; {host!r} is not loopback (use 127.0.0.1)") + try: + port = int(port_text) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"expected HOST:PORT, got {text!r}") from exc + if not 0 <= port <= 65535: + raise argparse.ArgumentTypeError(f"port out of range: {port}") + return host, port + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="mcp_server", description="green-eye MCP server (roadmap R3)") + transport = parser.add_mutually_exclusive_group() + transport.add_argument("--stdio", action="store_true", help="serve over stdin/stdout (default)") + transport.add_argument("--tcp", metavar="HOST:PORT", type=parse_tcp, help="serve over loopback TCP instead") + parser.add_argument("--kernel", help="firmware ELF to launch (default: bundled demo when built)") + parser.add_argument("--backend", choices=("qemu", "native"), default="qemu", help="execution backend") + parser.add_argument("--record", nargs="?", const=True, default=None, help="record to a path (or dumps/)") + parser.add_argument("--name", default="mcp", help="session name") + parser.add_argument("--plugins", action="append", default=[], help="extra plugin path (repeatable)") + parser.add_argument("--allow-write", action="store_true", help="enable memory writes and remote calls") + parser.add_argument("--allow-inject", action="store_true", help="enable service injection") + return parser + + +def _launch_standalone(args) -> tuple[object, object]: + import greeneye as ge + from greeneye.plugins import PluginManager + + os.environ.setdefault("GREENEYE_DUMPS", _DUMPS) + kernel = args.kernel + if kernel and not os.path.isfile(kernel): + raise GreenEyeError(f"kernel not found: {kernel}") + if not kernel and os.path.isfile(_DEMO_ELF): + kernel = _DEMO_ELF + session = ge.launch(kernel=kernel, backend=args.backend, record=args.record, name=args.name) + manager = PluginManager(session) + manager.load_all(args.plugins) + return session, manager.api + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + session, api = _launch_standalone(args) + except Exception as exc: + print(f"greeneye-mcp error: {exc}", file=sys.stderr) + return 2 + server = GreenEyeMCPServer( + session, + allow_write=args.allow_write, + allow_inject=args.allow_inject, + api=api, + owns_session=True, + ) + try: + if args.tcp: + server.serve_tcp(*args.tcp) + else: + server.serve_stdio() + except KeyboardInterrupt: + print("[greeneye-mcp] interrupted", file=sys.stderr) + finally: + if not server.session_closed: + try: + session.close() + except Exception: # closing is best-effort on shutdown + pass + return 0 + + +if __name__ == "__main__": # pragma: no cover - process entry point + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/plugins/rmharness_plugin.py b/debug/risc-v-gsp-rm-vm/plugins/rmharness_plugin.py new file mode 100644 index 0000000..085751c --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/plugins/rmharness_plugin.py @@ -0,0 +1,120 @@ +"""green-eye plugin: RM host-oracle harness commands. + +Registers REPL commands that run the read-only Open-IOV RM tools through the +``rmharness`` wrapper scripts and record each run into the active session: + + greeneye> rmtool gpu list + greeneye> rmtool mem stats --format=json + greeneye> rmtop --once + greeneye> rmq + +Every run is recorded as a ``rm_harness_run`` event with a captured +stdout+stderr blob (via ``ge.recorder.blob``); write/ownership operations are +refused and recorded as ``rm_harness_skip``. The plugin is stdlib-only and +never touches kernel modules, VFIO, mdevs or the live GPU state. +""" +from __future__ import annotations + +import hashlib +import os +import subprocess +import sys +import time + +_TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +_HARNESS = os.path.join(_TOOL_ROOT, "rmharness") +_WRAPPERS = { + "rmtool": os.path.join(_HARNESS, "rmtool.sh"), + "rmtop": os.path.join(_HARNESS, "rmtop.sh"), + "rmq": os.path.join(_HARNESS, "rmq.sh"), +} +#: arguments that would name a write/ownership operation on any tool. +_BLOCKED = frozenset({ + "alloc", "allocate", "map", "unmap", "pin", "free", "create", + "destroy", "submit", "bind", "unbind", "reset", "start", "stop", + "load", "unload", "reboot", "write", "poke", "set", +}) + + +def _blocked_arg(args): + for arg in args: + token = arg.split("=", 1)[0].lstrip("-").lower() + if token in _BLOCKED: + return arg + return None + + +def _record(ge, tool, args, proc, duration): + stdout = proc.stdout or "" + stderr = proc.stderr or "" + payload = (stdout + stderr).encode("utf-8", "replace") + blob = None + try: + blob = ge.recorder.blob( + f"rmharness/{tool}-{time.time_ns()}.txt", payload) + except Exception: + blob = None + fields = dict( + plugin="rmharness", + tool=tool, + argv=list(args), + exit=proc.returncode, + duration_s=round(duration, 6), + stdout_sha256=hashlib.sha256( + stdout.encode("utf-8", "replace")).hexdigest(), + stderr_sha256=hashlib.sha256( + stderr.encode("utf-8", "replace")).hexdigest(), + stdout_bytes=len(stdout.encode("utf-8", "replace")), + stderr_bytes=len(stderr.encode("utf-8", "replace")), + blob=blob, + readonly=True, + ) + ge.recorder.event("rm_harness_run", **fields) + + +def _run(ge, tool, args): + wrapper = _WRAPPERS[tool] + if not os.path.isfile(wrapper): + print(f" rmharness: wrapper missing: {wrapper}", file=sys.stderr) + ge.recorder.event("rm_harness_error", plugin="rmharness", + tool=tool, error="wrapper missing") + return 2 + bad = _blocked_arg(args) + if bad is not None: + print(f" rmharness: refused '{bad}': read-only policy " + f"(see docs/17-RM-HARNESS.md)", file=sys.stderr) + ge.recorder.event("rm_harness_skip", plugin="rmharness", tool=tool, + argv=list(args), reason=f"blocked argument {bad!r}") + return 2 + start = time.monotonic() + proc = subprocess.run([wrapper, *args], capture_output=True, text=True) + duration = time.monotonic() - start + if proc.stdout: + print(proc.stdout, end="") + if proc.stderr: + print(proc.stderr, end="", file=sys.stderr) + _record(ge, tool, args, proc, duration) + directory = getattr(ge.recorder, "dir", None) + if directory: + print(f" rmharness: recorded {tool} run -> {directory}") + return proc.returncode + + +def register(ge): + ge.log("rmharness plugin loaded; tools=" + ",".join(sorted(_WRAPPERS))) + + def cmd_rmtool(session, args): + return _run(ge, "rmtool", args) + + def cmd_rmtop(session, args): + return _run(ge, "rmtop", args) + + def cmd_rmq(session, args): + return _run(ge, "rmq", args) + + ge.add_command("rmtool", cmd_rmtool, + help="rmtool : read-only RM oracle (gpu/engine/mem)") + ge.add_command("rmtop", cmd_rmtop, + help="rmtop : dashboard; use --once for capture") + ge.add_command("rmq", cmd_rmq, + help="rmq: existing tools/rmq host oracle (read-only)") diff --git a/debug/risc-v-gsp-rm-vm/rmharness/README.md b/debug/risc-v-gsp-rm-vm/rmharness/README.md new file mode 100644 index 0000000..6d33138 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/rmharness/README.md @@ -0,0 +1,117 @@ +# rmharness - host RM oracle integration for green-eye + +`rmharness` wires the Open-IOV host RM userspace tools (`rmtool`, `rmtop`, +`rmq`) into the green-eye workflow so host ground truth can be captured next +to simulator sessions and compared later (docs/06-ACCURACY.md M1/M6). + +All three tools are **read-only**: they query the host RM through +`/dev/nvidiactl` control ioctls plus `/proc` and `/sys` reads. The harness +never loads/unloads modules, never binds/unbinds the driver, never creates +mdevs, never claims the GPU and never touches `/sys/fs/pstore`. + +## Tools + +| wrapper | tool | what it is for | +|---|---|---| +| `rmtool.sh` | `tools/rmtool` | CLI oracle: GPU/engine inventory, VRAM/heap/BAR1 stats, allowlisted NV2080 controls, GPU timer sampling | +| `rmtop.sh` | `tools/rmtop` | ncurses dashboard; `--once` renders the same panels as table/json/csv for capture | +| `rmq.sh` | `tools/rmq` | the original host RM oracle (device info tables); unmodified | + +## Layout + +``` +rmharness/ + README.md this file + rmtool.sh rmtop.sh rmq.sh + build-if-needed wrappers (pass arguments through) + harness.sh run a wrapper, capture stdout/stderr, record a session + tests/test_rmharness_plugin.py + fake-`ge` plugin import/registration check +``` + +The green-eye plugin lives next to the other bundled plugins: +`plugins/rmharness_plugin.py` (REPL commands `rmtool`, `rmtop`, `rmq`). + +## Build and smoke + +The wrappers build their tool on first use. To build explicitly: + +```bash +make -C tools/rmtool +make -C tools/rmtop +make -C tools/rmq # already existed; unchanged +``` + +Safe smoke checks (no RM ioctl, no device access): + +```bash +./rmharness/rmtool.sh --help +./rmharness/rmtool.sh --version +./rmharness/rmtool.sh gpu list # reads /proc/driver/nvidia only +./rmharness/rmtool.sh gpu topology # reads /sys/bus/pci only +./rmharness/rmtop.sh --once # read-only RM queries (see note) +``` + +`rmtop --once` and `gpu info`/`engine list`/`mem stats`/`sample timer` open a +short-lived RM client (exactly what `nvidia-smi` does) and issue read-only +controls. They do not disturb a running CUDA job. If the driver is not bound +they fail cleanly. Commands that would allocate or mutate anything are refused +by the tools themselves. + +## Capturing into a green-eye session + +```bash +cd debug/risc-v-gsp-rm-vm +./rmharness/harness.sh --session dumps/my-run --name before \ + -- rmtool engine list --format=json +./rmharness/harness.sh --session dumps/my-run -- rmtop --once +``` + +`harness.sh` runs the wrapper, streams stdout/stderr through, writes the +captured streams to `/blobs/`, and appends one line to +`/events.jsonl`: + +```json +{"type": "rm_harness_run", "tool": "rmtool", "argv": [...], "exit": 0, + "duration_s": 0.02, "stdout": {"path": "blobs/...", "size": 123, + "sha256": "..."}, "stderr": {...}, "readonly": true} +``` + +Recording is additive: existing sessions are never rewritten. If the +directory has no `session.json`, a minimal `greeneye-session/1` one is +created. The event `t` field is `0.0` for externally recorded runs; use +`start`/`end` for wall-clock ordering. + +## Plugin + +`plugins/rmharness_plugin.py` registers REPL commands that run the wrappers +and record the output through the live recorder: + +``` +greeneye> rmtool gpu list +greeneye> rmtool mem stats --format=json +greeneye> rmtop --once +greeneye> rmq +``` + +Arguments naming write operations (`alloc`, `map`, `pin`, `start`, ...) are +refused and recorded as `rm_harness_skip`. With a null recorder the plugin +still runs and prints; it just does not persist. + +Check it without QEMU: + +```bash +python3 rmharness/tests/test_rmharness_plugin.py +``` + +## Not implemented / not run + +* `rmq` device-info tables (`FIFO_GET_DEVICE_INFO_TABLE`) and + `nv2080 class list` return `NV_ERR_INSUFFICIENT_PERMISSIONS` / + `NV_ERR_NOT_SUPPORTED` for an unprivileged client on this driver; the tools + report the status instead of escalating. +* `rmtool channel/context/resserve/cudasw/resman/gsp` and sampling of + clocks/power/thermal are documented but not implemented in the minimal + read-only build; they return a clear "not implemented / refused" status. +* Nothing in this harness was validated against a live migration or a second + GPU; multi-GPU selection (`-d N`) uses RM `deviceId` = minor. diff --git a/debug/risc-v-gsp-rm-vm/rmharness/harness.sh b/debug/risc-v-gsp-rm-vm/rmharness/harness.sh new file mode 100755 index 0000000..60fce8a --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/rmharness/harness.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash +# harness.sh - run a read-only RM tool, capture stdout/stderr, and record the +# run into a green-eye session directory (blob + events.jsonl line). +# +# usage: harness.sh [--session DIR] [--name LABEL] -- TOOL [ARGS...] +# TOOL is one of: rmtool rmtop rmq +# +# When --session is given the harness appends a "rm_harness_run" event and +# writes the captured streams under /blobs/. Existing session files +# are never rewritten; recording is additive. Without --session the tool runs +# and streams pass through unchanged. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ALLOWED="rmtool rmtop rmq" + +usage() { + cat <<'EOF' +usage: harness.sh [--session DIR] [--name LABEL] -- TOOL [ARGS...] + +TOOL is one of: rmtool rmtop rmq (wrapper scripts in this directory). +Everything after -- is passed to the tool unchanged. +EOF +} + +session="" +label="" +while [[ $# -gt 0 ]]; do + case "$1" in + --session) + session="${2:?--session needs a directory}" + shift 2 + ;; + --name) + label="${2:?--name needs a label}" + shift 2 + ;; + -h | --help) + usage + exit 0 + ;; + --) + shift + break + ;; + -*) + echo "harness.sh: unknown option '$1'" >&2 + usage >&2 + exit 2 + ;; + *) + break + ;; + esac +done + +tool="${1:-}" +if [[ -z "$tool" ]]; then + usage >&2 + exit 2 +fi +shift +tool="${tool%.sh}" +case " $ALLOWED " in +*" $tool "*) ;; +*) + echo "harness.sh: tool '$tool' is not on the read-only allowlist" \ + "($ALLOWED)" >&2 + exit 2 + ;; +esac + +wrapper="$HERE/$tool.sh" +if [[ ! -x "$wrapper" ]]; then + echo "harness.sh: wrapper not found: $wrapper" >&2 + exit 2 +fi + +argv_json="$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1:]))' \ + "$tool" "$@")" +out_file="$(mktemp)" +err_file="$(mktemp)" +trap 'rm -f "$out_file" "$err_file"' EXIT + +start="$(python3 -c 'import time; print(time.time())')" +rc=0 +"$wrapper" "$@" >"$out_file" 2>"$err_file" || rc=$? +end="$(python3 -c 'import time; print(time.time())')" + +cat "$out_file" +cat "$err_file" >&2 + +if [[ -n "$session" ]]; then + mkdir -p "$session/blobs" + stamp="$(date -u +%Y%m%dT%H%M%SZ)" + out_rel="blobs/rmharness-${stamp}-${tool}-stdout.txt" + err_rel="blobs/rmharness-${stamp}-${tool}-stderr.txt" + cp "$out_file" "$session/$out_rel" + cp "$err_file" "$session/$err_rel" + SESSION="$session" TOOL="$tool" ARGV_JSON="$argv_json" \ + RC="$rc" START="$start" END="$end" LABEL="$label" \ + OUT_REL="$out_rel" ERR_REL="$err_rel" \ + python3 - <<'PY' +import hashlib +import json +import os +import time + +session = os.environ["SESSION"] + + +def blob_info(rel): + path = os.path.join(session, rel) + with open(path, "rb") as fh: + data = fh.read() + return {"path": rel, "size": len(data), + "sha256": hashlib.sha256(data).hexdigest()} + + +event = { + "t": 0.0, + "type": "rm_harness_run", + "tool": os.environ["TOOL"], + "label": os.environ["LABEL"] or None, + "argv": json.loads(os.environ["ARGV_JSON"]), + "exit": int(os.environ["RC"]), + "start": float(os.environ["START"]), + "end": float(os.environ["END"]), + "duration_s": round(float(os.environ["END"]) - float(os.environ["START"]), 6), + "stdout": blob_info(os.environ["OUT_REL"]), + "stderr": blob_info(os.environ["ERR_REL"]), + "readonly": True, +} +meta = os.path.join(session, "session.json") +if not os.path.exists(meta): + with open(meta, "w", encoding="utf-8") as fh: + json.dump({"format": "greeneye-session/1", "name": "rmharness", + "created": time.time(), + "greeneye": "external-rmharness/1"}, fh, indent=1, + sort_keys=True) +with open(os.path.join(session, "events.jsonl"), "a", encoding="utf-8") as fh: + fh.write(json.dumps(event, sort_keys=True) + "\n") +print(f"[rmharness] recorded {os.environ['TOOL']} -> {session} " + f"({os.environ['OUT_REL']})") +PY +fi + +exit "$rc" diff --git a/debug/risc-v-gsp-rm-vm/rmharness/rmq.sh b/debug/risc-v-gsp-rm-vm/rmharness/rmq.sh new file mode 100755 index 0000000..ce0670e --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/rmharness/rmq.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# rmq.sh - build-if-needed wrapper for the existing tools/rmq host RM oracle. +# rmq itself is not modified by the harness; this only locates/builds it. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" +BIN="$ROOT/tools/rmq/rmq" +# rmq has no --help flag; answer it here so the smoke check never touches the +# device (see rmharness/README.md "Safe smoke checks"). +case "${1:-}" in +-h | --help) + echo "usage: $(basename "$0") [rmq-index options] (read-only RM oracle)" + echo " rmq has no launcher of its own; see $ROOT/tools/rmq/." + echo " Built on first use from $ROOT/tools/rmq." + exit 0 + ;; +esac +if [[ ! -x "$BIN" ]]; then + make -C "$ROOT/tools/rmq" >&2 +fi +exec "$BIN" "$@" diff --git a/debug/risc-v-gsp-rm-vm/rmharness/rmtool.sh b/debug/risc-v-gsp-rm-vm/rmharness/rmtool.sh new file mode 100755 index 0000000..405a766 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/rmharness/rmtool.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# rmtool.sh - build-if-needed wrapper for the Open-IOV rmtool RM oracle. +# Read-only tool; see ../docs/17-RM-HARNESS.md for the safety contract. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" +BIN="$ROOT/tools/rmtool/rmtool" +if [[ ! -x "$BIN" ]]; then + make -C "$ROOT/tools/rmtool" >&2 +fi +exec "$BIN" "$@" diff --git a/debug/risc-v-gsp-rm-vm/rmharness/rmtop.sh b/debug/risc-v-gsp-rm-vm/rmharness/rmtop.sh new file mode 100755 index 0000000..5cde6c7 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/rmharness/rmtop.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# rmtop.sh - build-if-needed wrapper for the Open-IOV rmtop dashboard. +# Use --once for non-interactive capture; interactive mode needs a TTY. +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$HERE/../../.." && pwd)" +BIN="$ROOT/tools/rmtop/rmtop" +if [[ ! -x "$BIN" ]]; then + make -C "$ROOT/tools/rmtop" >&2 +fi +exec "$BIN" "$@" diff --git a/debug/risc-v-gsp-rm-vm/rmharness/tests/test_rmharness_plugin.py b/debug/risc-v-gsp-rm-vm/rmharness/tests/test_rmharness_plugin.py new file mode 100644 index 0000000..8d74f6e --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/rmharness/tests/test_rmharness_plugin.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Unit-style check for plugins/rmharness_plugin.py (stdlib only). + +Run from the risc-v-gsp-rm-vm root: + + python3 rmharness/tests/test_rmharness_plugin.py + +It loads the plugin with a fake ``ge`` object and verifies: + * register() adds the rmtool/rmtop/rmq commands; + * write arguments are refused and recorded as rm_harness_skip; + * a real ``rmtool --version`` run is recorded as rm_harness_run with a blob. +""" +from __future__ import annotations + +import importlib.util +import os +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_TOOL_ROOT = os.path.dirname(os.path.dirname(_HERE)) +_PLUGIN = os.path.join(_TOOL_ROOT, "plugins", "rmharness_plugin.py") + + +class FakeRecorder: + def __init__(self, directory=None): + self.dir = directory + self.events = [] + self.blobs = [] + + def event(self, event_type, **fields): + self.events.append(dict(type=event_type, **fields)) + + def blob(self, name, data): + self.blobs.append((name, data)) + return name + + +class FakeGe: + def __init__(self, directory=None): + self.recorder = FakeRecorder(directory) + self.commands = {} + self.logs = [] + + def log(self, message, **fields): + self.logs.append(message) + + def add_command(self, name, fn, help=""): + self.commands[name] = (fn, help) + + +def load_plugin(): + spec = importlib.util.spec_from_file_location("rmharness_plugin_test", + _PLUGIN) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class RmHarnessPluginTest(unittest.TestCase): + def setUp(self): + self.plugin = load_plugin() + self.ge = FakeGe() + self.plugin.register(self.ge) + + def test_registers_commands(self): + self.assertEqual(sorted(self.ge.commands), + ["rmq", "rmtool", "rmtop"]) + self.assertTrue(any("rmharness" in msg for msg in self.ge.logs)) + + def test_blocked_write_is_refused(self): + fn, _help = self.ge.commands["rmtool"] + rc = fn(None, ["mem", "alloc", "-d", "0", "--size=1G"]) + self.assertEqual(rc, 2) + types = [e["type"] for e in self.ge.recorder.events] + self.assertIn("rm_harness_skip", types) + self.assertNotIn("rm_harness_run", types) + + def test_readonly_run_is_recorded_with_blob(self): + if not os.path.isfile( + os.path.join(_TOOL_ROOT, "rmharness", "rmtool.sh")): + self.skipTest("rmharness wrapper not present") + fn, _help = self.ge.commands["rmtool"] + rc = fn(None, ["--version"]) + self.assertEqual(rc, 0) + types = [e["type"] for e in self.ge.recorder.events] + self.assertIn("rm_harness_run", types) + self.assertTrue(self.ge.recorder.blobs) + run = [e for e in self.ge.recorder.events + if e["type"] == "rm_harness_run"][0] + self.assertTrue(run["readonly"]) + self.assertEqual(run["tool"], "rmtool") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/debug/risc-v-gsp-rm-vm/sim/__init__.py b/debug/risc-v-gsp-rm-vm/sim/__init__.py new file mode 100644 index 0000000..5d7ab1f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/__init__.py @@ -0,0 +1,43 @@ +"""green-eye native RV64IMAC simulator (M1). + +Pure-Python, deterministic, stdlib-only. :class:`sim.machine.Machine` executes +RV64IMAC from a 256 MiB RAM region with an NS16550A UART; the debugger-facing +adapter lives in ``greeneye.sim_backend``. A machine can be given a +``greeneye.device_map.DeviceMap`` and ``unknown_mmio="lenient"`` to serve +unmodelled MMIO accesses from harvested first-touch data instead of faulting. +""" +from .decode import decode16, decode32 # noqa: F401 +from .machine import ( # noqa: F401 + R_BP, + R_BUDGET, + R_EBREAK, + R_ECALL, + R_FAULT, + R_STEP, + R_WATCH, + UNKNOWN_MMIO_MODES, + Fault, + Machine, + SimMachine, + Uart16550, +) +from .trace import Trace, make_trace # noqa: F401 + +__all__ = [ + "Machine", + "SimMachine", + "Uart16550", + "Fault", + "decode16", + "decode32", + "Trace", + "make_trace", + "R_BUDGET", + "R_BP", + "R_WATCH", + "R_EBREAK", + "R_FAULT", + "R_STEP", + "R_ECALL", + "UNKNOWN_MMIO_MODES", +] diff --git a/debug/risc-v-gsp-rm-vm/sim/decode.py b/debug/risc-v-gsp-rm-vm/sim/decode.py new file mode 100644 index 0000000..0f3ef3a --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/decode.py @@ -0,0 +1,399 @@ +"""RV64IMAC instruction decoder (pure Python, stdlib only). + +The decoder canonicalises every compressed instruction into the equivalent +32-bit operation, so breakpoints, single-step and watchpoints behave uniformly +regardless of encoding. ``decode16``/``decode32`` return a 7-tuple + + (kind, sub, rd, rs1, rs2, imm, next_pc) + +where ``next_pc`` is the fall-through (or branch/jump target for the +unconditional control transfers), ``imm`` is already sign/zero-normalised for +its use, and branch/jump targets are absolute virtual addresses. Results are +cached per PC by the Machine; the cache is dropped when the debugger writes +memory (M1 does not model self-modifying code). +""" +from __future__ import annotations + +MASK64 = (1 << 64) - 1 + +# Instruction kinds. +( + K_LUI, + K_AUIPC, + K_JAL, + K_JALR, + K_BRANCH, + K_LOAD, + K_STORE, + K_OPI, + K_OP, + K_OPI32, + K_OP32, + K_FENCE, + K_CSR, + K_ECALL, + K_EBREAK, + K_MRET, + K_AMO, + K_ILLEGAL, +) = range(18) + +# ALU ops shared by K_OP and (width-truncated) K_OP32. +( + A_ADD, + A_SLL, + A_SLT, + A_SLTU, + A_XOR, + A_SRL, + A_OR, + A_AND, + A_SUB, + A_SRA, + A_MUL, + A_MULH, + A_MULHSU, + A_MULHU, + A_DIV, + A_DIVU, + A_REM, + A_REMU, +) = range(18) + +# K_OPI / K_OPI32 immediate ops (funct3 values, with srai/sraiw offset). +I_ADDI, I_SLLI, I_SLTI, I_SLTIU, I_XORI, I_SRLI, I_ORI, I_ANDI = range(8) +I_SRAI = 0x15 +I_ADDIW, I_SLLIW, I_SRLIW, I_SRAIW = 0, 1, 5, 0x15 + +# LOAD/STORE funct3. +LOAD_SIZE = (1, 2, 4, 8, 1, 2, 4) +STORE_SIZE = (1, 2, 4, 8) + +_ILLEGAL = (K_ILLEGAL, 0, 0, 0, 0, 0, 0) + + +def sext(value: int, bits: int) -> int: + sign = 1 << (bits - 1) + return (value & (sign - 1)) - (value & sign) + + +def _bits(value: int, hi: int, lo: int) -> int: + return (value >> lo) & ((1 << (hi - lo + 1)) - 1) + + +def _op_alu(sub: int) -> int: + """Map an R-type funct3 to the shared ALU op code (non-M).""" + return { + 0: A_ADD, + 1: A_SLL, + 2: A_SLT, + 3: A_SLTU, + 4: A_XOR, + 5: A_SRL, + 6: A_OR, + 7: A_AND, + }[sub] + + +def decode32(pc: int, insn: int): + opcode = insn & 0x7F + rd = (insn >> 7) & 0x1F + funct3 = (insn >> 12) & 7 + rs1 = (insn >> 15) & 0x1F + rs2 = (insn >> 20) & 0x1F + funct7 = (insn >> 25) & 0x7F + nxt = pc + 4 + + if opcode == 0x37: # LUI + return (K_LUI, 0, rd, 0, 0, (insn & 0xFFFFF000) & MASK64, nxt) + if opcode == 0x17: # AUIPC + return (K_AUIPC, 0, rd, 0, 0, sext(insn & 0xFFFFF000, 32) & MASK64, nxt) + if opcode == 0x6F: # JAL + imm = sext( + (_bits(insn, 31, 31) << 20) + | (_bits(insn, 30, 21) << 1) + | (_bits(insn, 20, 20) << 11) + | (_bits(insn, 19, 12) << 12), + 21, + ) + return (K_JAL, 0, rd, 0, 0, (pc + imm) & MASK64, nxt) + if opcode == 0x67: # JALR + return (K_JALR, funct3, rd, rs1, 0, sext(_bits(insn, 31, 20), 12), nxt) + if opcode == 0x63: # BRANCH + imm = sext( + (_bits(insn, 31, 31) << 12) + | (_bits(insn, 30, 25) << 5) + | (_bits(insn, 11, 8) << 1) + | (_bits(insn, 7, 7) << 11), + 13, + ) + return (K_BRANCH, funct3, 0, rs1, rs2, (pc + imm) & MASK64, nxt) + if opcode == 0x03: # LOAD + if funct3 == 7: + return _ILLEGAL + return (K_LOAD, funct3, rd, rs1, 0, sext(_bits(insn, 31, 20), 12), nxt) + if opcode == 0x23: # STORE + if funct3 > 3: + return _ILLEGAL + imm = sext((_bits(insn, 31, 25) << 5) | _bits(insn, 11, 7), 12) + return (K_STORE, funct3, 0, rs1, rs2, imm, nxt) + if opcode == 0x13: # OP-IMM + shamt = (insn >> 20) & 0x3F + top = (insn >> 26) & 0x3F + if funct3 == 1: + if top: + return _ILLEGAL + return (K_OPI, I_SLLI, rd, rs1, 0, shamt, nxt) + if funct3 == 5: + if top == 0: + sub = I_SRLI + elif top == 0x10: + sub = I_SRAI + else: + return _ILLEGAL + return (K_OPI, sub, rd, rs1, 0, shamt, nxt) + return (K_OPI, funct3, rd, rs1, 0, sext(_bits(insn, 31, 20), 12), nxt) + if opcode == 0x33: # OP + if funct7 == 0x01: # M extension + return (K_OP, A_MUL + funct3, rd, rs1, rs2, 0, nxt) + if funct7 == 0x00: + sub = _op_alu(funct3) + elif funct7 == 0x20 and funct3 == 0: + sub = A_SUB + elif funct7 == 0x20 and funct3 == 5: + sub = A_SRA + else: + return _ILLEGAL + return (K_OP, sub, rd, rs1, rs2, 0, nxt) + if opcode == 0x1B: # OP-IMM-32 + if funct3 == 0: + return (K_OPI32, I_ADDIW, rd, rs1, 0, sext(_bits(insn, 31, 20), 12), nxt) + if funct3 == 1: + if (insn >> 25) & 0x7F: + return _ILLEGAL + return (K_OPI32, I_SLLIW, rd, rs1, 0, (insn >> 20) & 0x1F, nxt) + if funct3 == 5: + top = (insn >> 25) & 0x7F + if top == 0: + sub = I_SRLIW + elif top == 0x20: + sub = I_SRAIW + else: + return _ILLEGAL + return (K_OPI32, sub, rd, rs1, 0, (insn >> 20) & 0x1F, nxt) + return _ILLEGAL + if opcode == 0x3B: # OP-32 + if funct7 == 0x00: + if funct3 == 0: + sub = A_ADD + elif funct3 == 1: + sub = A_SLL + elif funct3 == 5: + sub = A_SRL + else: + return _ILLEGAL + elif funct7 == 0x20: + if funct3 == 0: + sub = A_SUB + elif funct3 == 5: + sub = A_SRA + else: + return _ILLEGAL + elif funct7 == 0x01: + if funct3 == 0: + sub = A_MUL + elif funct3 == 4: + sub = A_DIV + elif funct3 == 5: + sub = A_DIVU + elif funct3 == 6: + sub = A_REM + elif funct3 == 7: + sub = A_REMU + else: + return _ILLEGAL + else: + return _ILLEGAL + return (K_OP32, sub, rd, rs1, rs2, 0, nxt) + if opcode == 0x0F: # FENCE / FENCE.I: ordering is not modelled (single core) + if funct3 in (0, 1): + return (K_FENCE, 0, 0, 0, 0, 0, nxt) + return _ILLEGAL + if opcode == 0x73: # SYSTEM + if funct3 == 0: + imm12 = insn >> 20 + if imm12 == 0: + return (K_ECALL, 0, 0, 0, 0, 0, nxt) + if imm12 == 1: + return (K_EBREAK, 0, 0, 0, 0, 0, nxt) + if imm12 in (0x302, 0x102): # mret / sret (treated alike, M1) + return (K_MRET, 0, 0, 0, 0, 0, nxt) + return _ILLEGAL + return (K_CSR, funct3, rd, rs1, 0, insn >> 20, nxt) + if opcode == 0x2F: # AMO + if funct3 not in (2, 3): + return _ILLEGAL + funct5 = funct7 >> 2 + if funct5 not in (0x00, 0x01, 0x02, 0x03, 0x04, 0x08, 0x0C, 0x10, 0x14, 0x18, 0x1C): + return _ILLEGAL + sub = funct5 | (0x40 if funct3 == 3 else 0) + return (K_AMO, sub, rd, rs1, rs2, 0, nxt) + return _ILLEGAL + + +def _rvc_j_imm(insn: int) -> int: + return sext( + (_bits(insn, 12, 12) << 11) + | (_bits(insn, 11, 11) << 4) + | (_bits(insn, 10, 9) << 8) + | (_bits(insn, 8, 8) << 10) + | (_bits(insn, 7, 7) << 6) + | (_bits(insn, 6, 6) << 7) + | (_bits(insn, 5, 3) << 1) + | (_bits(insn, 2, 2) << 5), + 12, + ) + + +def _rvc_b_imm(insn: int) -> int: + return sext( + (_bits(insn, 12, 12) << 8) + | (_bits(insn, 11, 10) << 3) + | (_bits(insn, 6, 5) << 6) + | (_bits(insn, 4, 3) << 1) + | (_bits(insn, 2, 2) << 5), + 9, + ) + + +def decode16(pc: int, insn: int): + op = insn & 3 + funct3 = (insn >> 13) & 7 + nxt = pc + 2 + rd = (insn >> 7) & 0x1F + + if op == 0: + if funct3 == 0: # c.addi4spn + imm = ( + ((insn >> 7) & 0x30) + | ((insn >> 1) & 0x3C0) + | ((insn >> 4) & 0x4) + | ((insn >> 2) & 0x8) + ) + if imm == 0: + return _ILLEGAL + return (K_OPI, I_ADDI, 8 + ((insn >> 2) & 7), 2, 0, imm, nxt) + if funct3 == 2: # c.lw + imm = ((insn >> 7) & 0x38) | ((insn >> 4) & 0x4) | ((insn << 1) & 0x40) + return (K_LOAD, 2, 8 + ((insn >> 2) & 7), 8 + ((insn >> 7) & 7), 0, imm, nxt) + if funct3 == 3: # c.ld + imm = ((insn >> 7) & 0x38) | ((insn << 1) & 0xC0) + return (K_LOAD, 3, 8 + ((insn >> 2) & 7), 8 + ((insn >> 7) & 7), 0, imm, nxt) + if funct3 == 6: # c.sw + imm = ((insn >> 7) & 0x38) | ((insn >> 4) & 0x4) | ((insn << 1) & 0x40) + return (K_STORE, 2, 0, 8 + ((insn >> 7) & 7), 8 + ((insn >> 2) & 7), imm, nxt) + if funct3 == 7: # c.sd + imm = ((insn >> 7) & 0x38) | ((insn << 1) & 0xC0) + return (K_STORE, 3, 0, 8 + ((insn >> 7) & 7), 8 + ((insn >> 2) & 7), imm, nxt) + return _ILLEGAL + + if op == 1: + if funct3 == 0: # c.addi / c.nop + imm = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6) + return (K_OPI, I_ADDI, rd, rd, 0, imm, nxt) + if funct3 == 1: # c.addiw + if rd == 0: + return _ILLEGAL + imm = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6) + return (K_OPI32, I_ADDIW, rd, rd, 0, imm, nxt) + if funct3 == 2: # c.li + imm = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6) + return (K_OPI, I_ADDI, rd, 0, 0, imm, nxt) + if funct3 == 3: + if rd == 2: # c.addi16sp + imm = sext( + ((insn >> 3) & 0x200) + | ((insn >> 2) & 0x10) + | ((insn << 1) & 0x40) + | ((insn << 4) & 0x180) + | ((insn << 3) & 0x20), + 10, + ) + if imm == 0: + return _ILLEGAL + return (K_OPI, I_ADDI, 2, 2, 0, imm, nxt) + imm6 = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6) + if rd == 0 or imm6 == 0: # c.lui + return _ILLEGAL + return (K_LUI, 0, rd, 0, 0, (imm6 << 12) & MASK64, nxt) + if funct3 == 4: + sub3 = (insn >> 10) & 3 + rd_p = 8 + ((insn >> 7) & 7) + if sub3 == 0: # c.srli + shamt = (_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2) + return (K_OPI, I_SRLI, rd_p, rd_p, 0, shamt, nxt) + if sub3 == 1: # c.srai + shamt = (_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2) + return (K_OPI, I_SRAI, rd_p, rd_p, 0, shamt, nxt) + if sub3 == 2: # c.andi + imm = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6) + return (K_OPI, I_ANDI, rd_p, rd_p, 0, imm, nxt) + f2 = (insn >> 5) & 3 + if _bits(insn, 12, 12) == 0: + sub = (A_SUB, A_XOR, A_OR, A_AND)[f2] + return (K_OP, sub, rd_p, rd_p, 8 + ((insn >> 2) & 7), 0, nxt) + if f2 == 0: # c.subw + return (K_OP32, A_SUB, rd_p, rd_p, 8 + ((insn >> 2) & 7), 0, nxt) + if f2 == 1: # c.addw + return (K_OP32, A_ADD, rd_p, rd_p, 8 + ((insn >> 2) & 7), 0, nxt) + return _ILLEGAL + if funct3 == 5: # c.j + return (K_JAL, 0, 0, 0, 0, (pc + _rvc_j_imm(insn)) & MASK64, nxt) + if funct3 == 6: # c.beqz + return (K_BRANCH, 0, 0, 8 + ((insn >> 7) & 7), 0, (pc + _rvc_b_imm(insn)) & MASK64, nxt) + if funct3 == 7: # c.bnez + return (K_BRANCH, 1, 0, 8 + ((insn >> 7) & 7), 0, (pc + _rvc_b_imm(insn)) & MASK64, nxt) + return _ILLEGAL + + if op == 2: + if funct3 == 0: # c.slli + if rd == 0: + return _ILLEGAL + shamt = (_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2) + return (K_OPI, I_SLLI, rd, rd, 0, shamt, nxt) + if funct3 == 2: # c.lwsp + if rd == 0: + return _ILLEGAL + imm = ((insn >> 7) & 0x20) | ((insn >> 2) & 0x1C) | ((insn << 4) & 0xC0) + return (K_LOAD, 2, rd, 2, 0, imm, nxt) + if funct3 == 3: # c.ldsp + if rd == 0: + return _ILLEGAL + imm = ((insn >> 7) & 0x20) | ((insn >> 2) & 0x18) | ((insn << 4) & 0x1C0) + return (K_LOAD, 3, rd, 2, 0, imm, nxt) + if funct3 == 4: + rs2 = (insn >> 2) & 0x1F + if _bits(insn, 12, 12) == 0: + if rs2 == 0: # c.jr + if rd == 0: + return _ILLEGAL + return (K_JALR, 0, 0, rd, 0, 0, nxt) + if rd == 0: # c.mv + return _ILLEGAL + return (K_OP, A_ADD, rd, 0, rs2, 0, nxt) + if rs2 == 0 and rd == 0: # c.ebreak + return (K_EBREAK, 0, 0, 0, 0, 0, nxt) + if rs2 == 0: # c.jalr + if rd == 0: + return _ILLEGAL + return (K_JALR, 0, 1, rd, 0, 0, nxt) + return (K_OP, A_ADD, rd, rd, rs2, 0, nxt) # c.add + if funct3 == 6: # c.swsp + imm = ((insn >> 7) & 0x3C) | ((insn >> 1) & 0xC0) + return (K_STORE, 2, 0, 2, (insn >> 2) & 0x1F, imm, nxt) + if funct3 == 7: # c.sdsp + imm = ((insn >> 7) & 0x38) | ((insn >> 1) & 0x1C0) + return (K_STORE, 3, 0, 2, (insn >> 2) & 0x1F, imm, nxt) + return _ILLEGAL + + return _ILLEGAL diff --git a/debug/risc-v-gsp-rm-vm/sim/machine.py b/debug/risc-v-gsp-rm-vm/sim/machine.py new file mode 100644 index 0000000..8d77dd8 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/machine.py @@ -0,0 +1,825 @@ +"""Deterministic RV64IMAC machine: RAM, NS16550A UART, debugger-visible state. + +The execution loop is a monolithic dispatch over the cached decode tuples from +``sim.decode``. Everything guest-visible is a pure function of the loaded +image and the instruction budget handed to ``run``: no host clock, no threads, +no entropy. RAM is an anonymous ``mmap`` (lazily paged, so a 256 MiB region is +cheap); all other addresses resolve to the UART or fault. +""" +from __future__ import annotations + +import mmap +import struct + +from .decode import ( + A_ADD, + A_AND, + A_DIV, + A_DIVU, + A_MUL, + A_MULH, + A_MULHSU, + A_MULHU, + A_OR, + A_REM, + A_REMU, + A_SLL, + A_SLT, + A_SLTU, + A_SRA, + A_SRL, + A_SUB, + A_XOR, + I_ADDI, + I_ADDIW, + I_ANDI, + I_ORI, + I_SLLI, + I_SLLIW, + I_SLTI, + I_SLTIU, + I_SRAI, + I_SRAIW, + I_SRLI, + I_SRLIW, + I_XORI, + K_AMO, + K_AUIPC, + K_BRANCH, + K_CSR, + K_EBREAK, + K_ECALL, + K_FENCE, + K_ILLEGAL, + K_JAL, + K_JALR, + K_LOAD, + K_LUI, + K_MRET, + K_OP, + K_OP32, + K_OPI, + K_OPI32, + K_STORE, + LOAD_SIZE, + MASK64, + STORE_SIZE, + decode16, + decode32, +) + +# Stop reasons returned by Machine.run(). +R_BUDGET = 0 +R_BP = 1 +R_WATCH = 2 +R_EBREAK = 3 +R_FAULT = 4 +R_STEP = 5 +R_ECALL = 6 + +#: Accepted ``unknown_mmio`` policies for accesses outside RAM/UART/the map. +UNKNOWN_MMIO_MODES = ("fault", "lenient") + +UART_BASE = 0x10000000 +UART_SIZE = 0x100 + +# RV64I M A C advertised in misa (MXL=2, I=bit8, M=bit12, A=bit0, C=bit2). +MISA_RV64IMAC = 0x8000000000001105 + + +class Fault(Exception): + """Guest-visible trap: unmapped access or illegal instruction.""" + + def __init__(self, cause: int, tval: int = 0): + super().__init__(f"cause={cause:#x} tval={tval:#x}") + self.cause = cause + self.tval = tval + + +class Uart16550: + """Behavioural NS16550A: TX is captured, LSR always reports THR empty.""" + + def __init__(self): + self.tx = bytearray() + self.ier = 0 + self.lcr = 0 + self.mcr = 0 + self.scr = 0 + self.dll = 0 + self.dlm = 0 + + def read(self, off: int, size: int = 1) -> int: + if off == 0: + return self.dll # RBR (no RX modelled) / DLL when DLAB=1 + if off == 1: + return self.ier + if off == 2: + return 0x01 # IIR: no pending interrupt + if off == 3: + return self.lcr + if off == 4: + return self.mcr + if off == 5: + return 0x60 # LSR: THRE | TEMT + if off == 6: + return 0xB0 # MSR: CTS|DSR|DCD + if off == 7: + return self.scr + return 0 + + def write(self, off: int, value: int, size: int = 1) -> None: + value &= 0xFF + if off == 0: + if self.lcr & 0x80: + self.dll = value + else: + self.tx.append(value) + elif off == 1: + self.ier = value + elif off == 3: + self.lcr = value + elif off == 4: + self.mcr = value + elif off == 7: + self.scr = value + # FCR (2) and reserved offsets are write-only/no-ops. + + def text(self) -> str: + return self.tx.decode("utf-8", "replace") + + +def _signed64(value: int) -> int: + return value - 0x10000000000000000 if value & 0x8000000000000000 else value + + +def _signed32(value: int) -> int: + value &= 0xFFFFFFFF + return value - 0x100000000 if value & 0x80000000 else value + + +def _sext32(value: int) -> int: + value &= 0xFFFFFFFF + return (value ^ 0x80000000) - 0x80000000 + + +def _watch_match(watchers, ea: int, size: int, rw_bit: int): + end = ea + size + for wa, we, wk, mask in watchers: + if (mask & rw_bit) and ea < we and wa < end: + return wk + return None + + +class Machine: + """One deterministic RV64IMAC hart plus RAM and UART. + + ``device_map`` is a :class:`greeneye.device_map.DeviceMap` (or any object + with the same ``lookup``/``record`` surface) describing modelled MMIO. + ``unknown_mmio`` selects what happens to an access that is neither RAM, + UART nor in the map: ``"fault"`` (default, M1 parity) stops as before; + ``"lenient"`` returns the mapped behaviour or zero, records the access in + :attr:`mmio_log`, counts it in :attr:`mmio_unknown`, and, with + ``learn_mmio=True``, writes the observation back into the map. Nothing is + logged or counted when lenient mode is off, and debugger ``read_mem``/ + ``write_mem`` stay strict. + """ + + def __init__( + self, + memory_mb: int = 256, + ram_base: int = 0x80000000, + device_map=None, + unknown_mmio: str = "fault", + learn_mmio: bool = False, + bias: int = 0, + ): + if unknown_mmio not in UNKNOWN_MMIO_MODES: + raise ValueError(f"unknown_mmio must be one of {UNKNOWN_MMIO_MODES}, got {unknown_mmio!r}") + self.ram_base = ram_base + self.ram_size = memory_mb * 1024 * 1024 + self.ram = mmap.mmap(-1, self.ram_size) + self.uart = Uart16550() + self.device_map = device_map + self.unknown_mmio = unknown_mmio + self.learn_mmio = bool(learn_mmio) + self.bias = bias # PC relocation for learned-map firmware addresses + self.mmio_log: list[dict] = [] + self.mmio_unknown = 0 + self.x = [0] * 32 + self.pc = ram_base + self.insns = 0 + self.csrs: dict[int, int] = {0x301: MISA_RV64IMAC} + self._cache: dict[int, tuple] = {} + self._u16 = struct.Struct(" None: + """Place ``(addr, bytes)`` PT_LOAD results and set the PC.""" + for addr, data in plan: + offset = addr - self.ram_base + if offset < 0 or offset + len(data) > self.ram_size: + raise Fault(1, addr) + self.ram[offset : offset + len(data)] = data + self.pc = entry & MASK64 + self.invalidate() + + def invalidate(self) -> None: + """Drop the decode cache after debugger-visible code changes.""" + self._cache.clear() + + # -- register access (debugger surface) -------------------------------- + + def reg(self, index: int) -> int: + if index == 0: + return 0 + return self.x[index] + + def set_reg(self, index: int, value: int) -> None: + if index != 0: + self.x[index] = value & MASK64 + + # -- raw memory access (debugger surface) ------------------------------ + + def read_mem(self, addr: int, size: int) -> bytes: + out = bytearray() + while size > 0: + if self.ram_base <= addr < self.ram_base + self.ram_size: + take = min(size, self.ram_base + self.ram_size - addr) + off = addr - self.ram_base + out += self.ram[off : off + take] + addr += take + size -= take + elif UART_BASE <= addr < UART_BASE + UART_SIZE: + out.append(self.uart.read(addr - UART_BASE)) + addr += 1 + size -= 1 + else: + raise Fault(5, addr) + return bytes(out) + + def write_mem(self, addr: int, data: bytes) -> None: + self.invalidate() + offset = 0 + while offset < len(data): + here = addr + offset + remaining = len(data) - offset + if self.ram_base <= here < self.ram_base + self.ram_size: + take = min(remaining, self.ram_base + self.ram_size - here) + ro = here - self.ram_base + self.ram[ro : ro + take] = data[offset : offset + take] + offset += take + elif UART_BASE <= here < UART_BASE + UART_SIZE: + self.uart.write(here - UART_BASE, data[offset]) + offset += 1 + else: + raise Fault(7, here) + + # -- MMIO (guest side) ------------------------------------------------- + + def _mapped_mmio(self, addr: int, size: int, write: bool) -> tuple[bool, int]: + """Consult the device map; ``(handled, value)`` for a modelled access.""" + dm = self.device_map + if dm is None: + return False, 0 + spec = dm.lookup(addr, size, write) + if spec == "unimplemented": + return False, 0 + if spec.startswith("value:"): + return True, int(spec[6:], 16) + return True, 0 # "zero": mapped but no read value observed + + def _log_unknown(self, pc: int, addr: int, size: int, rw: str, value: int = 0) -> None: + """Lenient fallback: log, count, and optionally learn one access.""" + self.mmio_unknown += 1 + self.mmio_log.append({"pc": pc, "addr": addr, "size": size, "rw": rw}) + if self.learn_mmio: + if self.device_map is None: + from greeneye.device_map import DeviceMap # lazy: sim stays usable alone + + self.device_map = DeviceMap(meta={"source": "learn"}) + self.device_map.record(addr, size, rw, value, pc=pc, bias=self.bias, source="trace") + + def _mmio_load(self, addr: int, size: int, pc: int = 0) -> int: + if UART_BASE <= addr < UART_BASE + UART_SIZE: + return self.uart.read(addr - UART_BASE) + handled, value = self._mapped_mmio(addr, size, False) + if handled: + return value + if self.unknown_mmio == "lenient": + self._log_unknown(pc, addr, size, "r") + return 0 + raise Fault(5, addr) + + def _mmio_store(self, addr: int, size: int, value: int, pc: int = 0) -> None: + if UART_BASE <= addr < UART_BASE + UART_SIZE: + self.uart.write(addr - UART_BASE, value) + return + handled, _ = self._mapped_mmio(addr, size, True) + if handled: + if self.learn_mmio and self.device_map is not None: + self.device_map.record(addr, size, "w", value, pc=pc, bias=self.bias, source="trace") + return + if self.unknown_mmio == "lenient": + self._log_unknown(pc, addr, size, "w", value) + return + raise Fault(7, addr) + + # -- execution --------------------------------------------------------- + + def step(self, watchers=(), trace=None): + return self.run(1, breakpoints=(), watchers=watchers, single_step=True, trace=trace) + + def run(self, budget: int, breakpoints=(), watchers=(), single_step: bool = False, + trace=None): + """Execute up to ``budget`` instructions. + + Returns ``(reason, pc, a, b)``; ``a``/``b`` carry the watched address + and kind (R_WATCH) or the trap cause and tval (R_FAULT). When + ``trace`` is a :class:`sim.trace.Trace`, every executed instruction + and every completed memory access in its address range is recorded. + """ + x = self.x + ram = self.ram + base = self.ram_base + limit = self.ram_size + cache = self._cache + i8 = self._i8.unpack_from + i16 = self._i16.unpack_from + i32 = self._i32.unpack_from + u16 = self._u16.unpack_from + u32 = self._u32.unpack_from + u64 = self._u64.unpack_from + u16p = self._u16p + u32p = self._u32p + u64p = self._u64p + has_bps = bool(breakpoints) + pc = self.pc + n = 0 + reason = R_BUDGET + a = b = 0 + traced = trace is not None + active = False + + while n < budget: + if has_bps and pc in breakpoints: + reason = R_BP + a = pc + break + + off = pc - base + if off < 0 or off + 4 > limit: + reason = R_FAULT + a = 1 + b = pc + break + hw = ram[off] | (ram[off + 1] << 8) + if hw & 3 == 3: + d = cache.get(pc) + if d is None: + d = decode32(pc, hw | (ram[off + 2] << 16) | (ram[off + 3] << 24)) + cache[pc] = d + else: + d = cache.get(pc) + if d is None: + d = decode16(pc, hw) + cache[pc] = d + kind, sub, rd, r1, r2, imm, nxt = d + n += 1 + if traced: + insn_pc = pc + active = trace.begin(insn_pc, self.insns + n) + + # -- loads / stores (most frequent in firmware hot loops) ------ + if kind == K_LOAD: + ea = (x[r1] + imm) & MASK64 + size = LOAD_SIZE[sub] + if watchers: + hit = _watch_match(watchers, ea, size, 2) + if hit is not None: + reason = R_WATCH + a = ea + b = hit + break + o = ea - base + if 0 <= o <= limit - size: + if active: + trace.mem(insn_pc, ea, size, False, self.insns + n) + if sub == 3: + if rd: + x[rd] = u64(ram, o)[0] + elif sub == 2: + if rd: + x[rd] = i32(ram, o)[0] + elif sub == 4: + if rd: + x[rd] = ram[o] + elif sub == 0: + if rd: + x[rd] = i8(ram, o)[0] + elif sub == 5: + if rd: + x[rd] = u16(ram, o)[0] + elif sub == 6: + if rd: + x[rd] = u32(ram, o)[0] + else: + if rd: + x[rd] = i16(ram, o)[0] + pc = nxt + else: + try: + v = self._mmio_load(ea, size, pc) + except Fault as f: + reason = R_FAULT + a = f.cause + b = f.tval + break + if rd: + x[rd] = v + if active: + trace.mem(insn_pc, ea, size, False, self.insns + n) + pc = nxt + continue + + if kind == K_STORE: + ea = (x[r1] + imm) & MASK64 + size = STORE_SIZE[sub] + if watchers: + hit = _watch_match(watchers, ea, size, 1) + if hit is not None: + reason = R_WATCH + a = ea + b = hit + break + o = ea - base + value = x[r2] + if 0 <= o <= limit - size: + if active: + trace.mem(insn_pc, ea, size, True, self.insns + n) + if sub == 3: + u64p(ram, o, value) + elif sub == 2: + u32p(ram, o, value & 0xFFFFFFFF) + elif sub == 1: + u16p(ram, o, value & 0xFFFF) + else: + ram[o] = value & 0xFF + pc = nxt + else: + try: + self._mmio_store(ea, size, value, pc) + except Fault as f: + reason = R_FAULT + a = f.cause + b = f.tval + break + if active: + trace.mem(insn_pc, ea, size, True, self.insns + n) + pc = nxt + continue + + # -- control flow ---------------------------------------------- + if kind == K_BRANCH: + va = x[r1] + vb = x[r2] + if sub == 0: + take = va == vb + elif sub == 1: + take = va != vb + elif sub == 4: + take = _signed64(va) < _signed64(vb) + elif sub == 5: + take = _signed64(va) >= _signed64(vb) + elif sub == 6: + take = va < vb + else: + take = va >= vb + pc = imm if take else nxt + elif kind == K_JAL: + if rd: + x[rd] = nxt + pc = imm + elif kind == K_JALR: + target = (x[r1] + imm) & MASK64 + target &= ~1 + if rd: + x[rd] = nxt + pc = target + elif kind == K_AUIPC: + if rd: + x[rd] = (pc + imm) & MASK64 + pc = nxt + elif kind == K_LUI: + if rd: + x[rd] = imm + pc = nxt + + # -- integer ALU ----------------------------------------------- + elif kind == K_OPI: + va = x[r1] if r1 else 0 + if sub == I_ADDI: + rv = (va + imm) & MASK64 + elif sub == I_SLLI: + rv = (va << (imm & 63)) & MASK64 + elif sub == I_SLTI: + rv = 1 if _signed64(va) < imm else 0 + elif sub == I_SLTIU: + rv = 1 if va < (imm & MASK64) else 0 + elif sub == I_XORI: + rv = va ^ (imm & MASK64) + elif sub == I_ORI: + rv = va | (imm & MASK64) + elif sub == I_ANDI: + rv = va & (imm & MASK64) + elif sub == I_SRLI: + rv = va >> (imm & 63) + else: # I_SRAI + rv = (_signed64(va) >> (imm & 63)) & MASK64 + if rd: + x[rd] = rv + pc = nxt + + elif kind == K_OP: + va = x[r1] + vb = x[r2] + if sub == A_ADD: + rv = (va + vb) & MASK64 + elif sub == A_SLL: + rv = (va << (vb & 63)) & MASK64 + elif sub == A_SLT: + rv = 1 if _signed64(va) < _signed64(vb) else 0 + elif sub == A_SLTU: + rv = 1 if va < vb else 0 + elif sub == A_XOR: + rv = va ^ vb + elif sub == A_SRL: + rv = va >> (vb & 63) + elif sub == A_OR: + rv = va | vb + elif sub == A_AND: + rv = va & vb + elif sub == A_SUB: + rv = (va - vb) & MASK64 + elif sub == A_SRA: + rv = (_signed64(va) >> (vb & 63)) & MASK64 + elif sub == A_MUL: + rv = (va * vb) & MASK64 + elif sub == A_MULH: + rv = ((_signed64(va) * _signed64(vb)) >> 64) & MASK64 + elif sub == A_MULHSU: + rv = ((_signed64(va) * vb) >> 64) & MASK64 + elif sub == A_MULHU: + rv = ((va * vb) >> 64) & MASK64 + elif sub == A_DIV: + sa, sb = _signed64(va), _signed64(vb) + if sb == 0: + rv = MASK64 + elif sa == -(1 << 63) and sb == -1: + rv = 1 << 63 + else: + rv = (abs(sa) // abs(sb)) & MASK64 + if (sa < 0) != (sb < 0): + rv = (-rv) & MASK64 + elif sub == A_DIVU: + rv = MASK64 if vb == 0 else va // vb + elif sub == A_REM: + sa, sb = _signed64(va), _signed64(vb) + if sb == 0 or (sa == -(1 << 63) and sb == -1): + rv = 0 + else: + rv = (abs(sa) % abs(sb)) & MASK64 + if sa < 0: + rv = (-rv) & MASK64 + else: # A_REMU + rv = va if vb == 0 else va % vb + if rd: + x[rd] = rv + pc = nxt + + elif kind == K_OPI32: + va = x[r1] if r1 else 0 + if sub == I_ADDIW: + rv = (va + imm) & 0xFFFFFFFF + elif sub == I_SLLIW: + rv = (va << (imm & 31)) & 0xFFFFFFFF + elif sub == I_SRLIW: + rv = (va & 0xFFFFFFFF) >> (imm & 31) + else: # I_SRAIW + rv = (_signed32(va) >> (imm & 31)) & 0xFFFFFFFF + if rd: + x[rd] = _sext32(rv) & MASK64 + pc = nxt + + elif kind == K_OP32: + va = x[r1] + vb = x[r2] + if sub == A_ADD: + rv = (va + vb) & 0xFFFFFFFF + elif sub == A_SUB: + rv = (va - vb) & 0xFFFFFFFF + elif sub == A_SLL: + rv = (va << (vb & 31)) & 0xFFFFFFFF + elif sub == A_SRL: + rv = (va & 0xFFFFFFFF) >> (vb & 31) + elif sub == A_SRA: + rv = (_signed32(va) >> (vb & 31)) & 0xFFFFFFFF + elif sub == A_MUL: + rv = (va * vb) & 0xFFFFFFFF + elif sub == A_DIV: + sa, sb = _signed32(va), _signed32(vb) + if sb == 0: + rv = 0xFFFFFFFF + elif sa == -(1 << 31) and sb == -1: + rv = 1 << 31 + else: + rv = (abs(sa) // abs(sb)) & 0xFFFFFFFF + if (sa < 0) != (sb < 0): + rv = (-rv) & 0xFFFFFFFF + elif sub == A_DIVU: + b32 = vb & 0xFFFFFFFF + rv = 0xFFFFFFFF if b32 == 0 else (va & 0xFFFFFFFF) // b32 + elif sub == A_REM: + sa, sb = _signed32(va), _signed32(vb) + if sb == 0 or (sa == -(1 << 31) and sb == -1): + rv = 0 + else: + rv = (abs(sa) % abs(sb)) & 0xFFFFFFFF + if sa < 0: + rv = (-rv) & 0xFFFFFFFF + else: # A_REMU + b32 = vb & 0xFFFFFFFF + rv = (va & 0xFFFFFFFF) if b32 == 0 else (va & 0xFFFFFFFF) % b32 + if rd: + x[rd] = _sext32(rv) & MASK64 + pc = nxt + + # -- system / atomics / traps ---------------------------------- + elif kind == K_FENCE: + pc = nxt + + elif kind == K_CSR: + csr = imm + old = self.csrs.get(csr, 0) + if sub in (5, 6, 7): + src = r1 # zimm + if sub == 5: + if rd: + x[rd] = old + self.csrs[csr] = src + else: + if rd: + x[rd] = old + if src: + self.csrs[csr] = (old | src) if sub == 6 else (old & ~src) + else: + src = x[r1] + if sub == 1: + if rd: + x[rd] = old + self.csrs[csr] = src & MASK64 + elif sub == 2: + if rd: + x[rd] = old + if r1: + self.csrs[csr] = (old | src) & MASK64 + elif sub == 3: + if rd: + x[rd] = old + if r1: + self.csrs[csr] = (old & ~src) & MASK64 + else: + reason = R_FAULT + a = 2 + b = csr + break + pc = nxt + + elif kind == K_AMO: + width = 8 if sub & 0x40 else 4 + op5 = sub & 0x3F + ea = x[r1] & MASK64 + if watchers: + hit = _watch_match(watchers, ea, width, 1) + if hit is not None: + reason = R_WATCH + a = ea + b = hit + break + try: + old = self._guest_load(ea, width, pc) + if active: + trace.mem(insn_pc, ea, width, False, self.insns + n) + if op5 == 0x02: # lr + if rd: + x[rd] = old + elif op5 == 0x03: # sc + self._guest_store(ea, width, x[r2], pc) + if active: + trace.mem(insn_pc, ea, width, True, self.insns + n) + if rd: + x[rd] = 0 + else: + rhs = x[r2] + mask = 0xFFFFFFFF if width == 4 else MASK64 + ov = old & mask + rv = rhs & mask + if op5 == 0x00: + new = (ov + rv) & mask + elif op5 == 0x01: + new = rv + elif op5 == 0x04: + new = ov ^ rv + elif op5 == 0x08: + new = ov | rv + elif op5 == 0x0C: + new = ov & rv + elif op5 == 0x10: + new = ov if _signed64(ov) < _signed64(rv) else rv + elif op5 == 0x14: + new = ov if _signed64(ov) > _signed64(rv) else rv + elif op5 == 0x18: + new = ov if ov < rv else rv + else: # 0x1C + new = ov if ov > rv else rv + self._guest_store(ea, width, new, pc) + if active: + trace.mem(insn_pc, ea, width, True, self.insns + n) + if rd: + x[rd] = (_sext32(old) & MASK64) if width == 4 else old + pc = nxt + except Fault as f: + reason = R_FAULT + a = f.cause + b = f.tval + break + + elif kind == K_EBREAK: + if single_step: + pc = nxt + else: + reason = R_EBREAK + a = pc + break + + elif kind == K_ECALL: + if single_step: + pc = nxt + else: + reason = R_ECALL + a = pc + break + + elif kind == K_MRET: + target = self.csrs.get(0x341, 0) & MASK64 + if target: + pc = target + else: + reason = R_FAULT + a = 2 + b = 0 + break + + else: # K_ILLEGAL + reason = R_FAULT + a = 2 + b = 0 + break + + self.pc = pc + self.insns += n + if single_step and reason == R_BUDGET: + reason = R_STEP + return (reason, pc, a, b) + + # -- guest memory helpers (AMO) ---------------------------------------- + + def _guest_load(self, addr: int, width: int, pc: int = 0) -> int: + off = addr - self.ram_base + if 0 <= off <= self.ram_size - width: + if width == 8: + return self._u64.unpack_from(self.ram, off)[0] + return self._u32.unpack_from(self.ram, off)[0] + return self._mmio_load(addr, width, pc) + + def _guest_store(self, addr: int, width: int, value: int, pc: int = 0) -> None: + off = addr - self.ram_base + if 0 <= off <= self.ram_size - width: + if width == 8: + self._u64p(self.ram, off, value) + else: + self._u32p(self.ram, off, value & 0xFFFFFFFF) + return + self._mmio_store(addr, width, value, pc) + + +#: Task-facing alias: the native simulator machine is :class:`Machine`. +SimMachine = Machine diff --git a/debug/risc-v-gsp-rm-vm/sim/trace.py b/debug/risc-v-gsp-rm-vm/sim/trace.py new file mode 100644 index 0000000..04373b1 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/sim/trace.py @@ -0,0 +1,145 @@ +"""Native execution/access trace (roadmap R4): the in-process equivalent of +``qemu/greeneye_trace.c``. + +The QEMU TCG plugin records every translated instruction and every memory +access as JSONL. This module does the same from inside +:class:`sim.machine.Machine`, with the same knobs and line shape: + +* ``exec=on|off`` - emit one ``exec`` line per executed instruction; +* ``start=``/``end=`` - only instrument PCs in the inclusive range (matching + the plugin, which filters at translation time, so memory accesses are also + filtered by the *instruction* PC); +* a bounded ring budget - once full, further lines are counted as ``dropped`` + instead of growing without limit (deterministically). + +Lines are JSON objects in the TCG plugin's schema (``t``/``pc``/``addr``/ +``size``, hex strings), plus a ``vt`` virtual-time field (the machine's +instruction count). Nothing here reads the host clock, so the payload is +byte-identical for identical execution. +""" +from __future__ import annotations + +#: Default cap on buffered trace lines (~50 bytes each in memory). +DEFAULT_MAX_EVENTS = 1_000_000 + + +class Trace: + """Collects compact exec/access lines for one native session.""" + + def __init__(self, *, exec_enabled: bool = True, start: int | None = None, + end: int | None = None, max_events: int | None = None): + self.exec_enabled = bool(exec_enabled) + self.start = start + self.end = end + self.max_events = DEFAULT_MAX_EVENTS if max_events is None else max(0, int(max_events)) + self.execs = 0 + self.reads = 0 + self.writes = 0 + self.events = 0 + self.dropped = 0 + self._lines: list[str] = [] + self._flushed = False + + # -- configuration ----------------------------------------------------- + + def in_range(self, pc: int) -> bool: + if self.start is not None and pc < self.start: + return False + if self.end is not None and pc > self.end: + return False + return True + + def config(self) -> dict: + return { + "exec": self.exec_enabled, + "start": self.start, + "end": self.end, + "max_events": self.max_events, + } + + def counts(self) -> dict: + return { + "exec": self.execs, + "read": self.reads, + "write": self.writes, + "events": self.events, + "dropped": self.dropped, + } + + # -- collection (called from Machine.run's hot loop) -------------------- + + def begin(self, pc: int, vt: int) -> bool: + """Called once per executed instruction; returns access-capture.""" + if not self.in_range(pc): + return False + if self.exec_enabled: + self._append(f'{{"t":"exec","vcpu":0,"pc":"0x{pc:x}","vt":{vt}}}') + self.execs += 1 + return True + + def mem(self, pc: int, addr: int, size: int, is_write: bool, vt: int) -> None: + kind = "write" if is_write else "read" + if is_write: + self.writes += 1 + else: + self.reads += 1 + self._append( + f'{{"t":"{kind}","vcpu":0,"addr":"0x{addr:x}","size":{size},' + f'"pc":"0x{pc:x}","vt":{vt}}}' + ) + + def _append(self, line: str) -> None: + self.events += 1 + if self.max_events and len(self._lines) >= self.max_events: + self.dropped += 1 + return + self._lines.append(line) + + # -- output ------------------------------------------------------------ + + def payload(self) -> bytes: + """The complete JSONL document, including the plugin's summary line.""" + body = "\n".join(self._lines) + if body: + body += "\n" + summary = ( + f'{{"t":"summary","insns":{self.execs},"mem":{self.reads + self.writes},' + f'"events":{self.events},"dropped":{self.dropped},' + f'"exec":{str(self.exec_enabled).lower()},' + f'"start":{_json_addr(self.start)},"end":{_json_addr(self.end)}}}' + ) + return (body + summary + "\n").encode("utf-8") + + def flush(self, recorder) -> str | None: + """Write the trace to ``recorder`` as ``blobs/trace.jsonl`` (once).""" + if self._flushed: + return None + self._flushed = True + if recorder is None or getattr(recorder, "dir", None) is None: + return None + rel = recorder.blob("trace.jsonl", self.payload()) + recorder.meta["trace_blob"] = rel + recorder.meta["trace"] = {**self.config(), **self.counts()} + return rel + + +def make_trace(spec): + """Normalise a trace spec (None/False, True, dict or Trace) to a Trace.""" + if spec is None or spec is False: + return None + if isinstance(spec, Trace): + return spec + if spec is True: + return Trace() + if isinstance(spec, dict): + return Trace( + exec_enabled=spec.get("exec", spec.get("exec_enabled", True)), + start=spec.get("start"), + end=spec.get("end"), + max_events=spec.get("max_events", spec.get("max")), + ) + raise ValueError(f"trace spec must be None, bool, dict or Trace, not {type(spec).__name__}") + + +def _json_addr(value: int | None) -> str: + return "null" if value is None else f"\"0x{value:x}\"" diff --git a/debug/risc-v-gsp-rm-vm/tests/test_bridge.py b/debug/risc-v-gsp-rm-vm/tests/test_bridge.py index 472bc49..7eeb46c 100644 --- a/debug/risc-v-gsp-rm-vm/tests/test_bridge.py +++ b/debug/risc-v-gsp-rm-vm/tests/test_bridge.py @@ -9,11 +9,20 @@ import unittest import greeneye as ge from greeneye.gdbridge import GhidraBridge +from greeneye.ghidra import render_ghidra_command HERE = os.path.dirname(os.path.abspath(__file__)) DEMO_ELF = os.path.join(HERE, "..", "firmware", "uproc_demo", "build", "uproc_demo.elf") +class GhidraCommandTests(unittest.TestCase): + def test_render_command_names_project_program_and_hits(self): + cmd = render_ghidra_command("/proj", "P", "gsp.bin", "/tmp/hits.tsv") + self.assertIn("analyzeHeadless /proj P", cmd) + self.assertIn("-process gsp.bin", cmd) + self.assertIn("MarkGreenEyeHits.java /tmp/hits.tsv", cmd) + + def qemu_available() -> bool: return bool(os.environ.get("GREENEYE_QEMU") or shutil.which("qemu-system-riscv64")) diff --git a/debug/risc-v-gsp-rm-vm/tests/test_core.py b/debug/risc-v-gsp-rm-vm/tests/test_core.py index 907a2eb..f02b2e6 100644 --- a/debug/risc-v-gsp-rm-vm/tests/test_core.py +++ b/debug/risc-v-gsp-rm-vm/tests/test_core.py @@ -3,10 +3,12 @@ from __future__ import annotations import json import os +import struct import tempfile import unittest -from greeneye.elf import EM_RISCV, parse_elf, load_elf +from greeneye.elf import EM_RISCV, parse_elf, load_elf, write_relocated_elf +from greeneye.errors import GreenEyeError from greeneye.structs import Field, Schema, decode, render, schema_from_json from greeneye.symbols import Symbol, SymbolDB @@ -36,6 +38,19 @@ class ElfTests(unittest.TestCase): with self.assertRaises(Exception): parse_elf(b"not an elf") + def test_write_relocated_elf(self): + if not os.path.exists(DEMO_ELF): + self.skipTest("demo firmware not built") + image = load_elf(DEMO_ELF) + with tempfile.TemporaryDirectory(prefix="greeneye-reloc-") as tmp: + out = os.path.join(tmp, "relocated.elf") + path = write_relocated_elf(DEMO_ELF, out, 0x1000) + with open(path, "rb") as fh: + data = fh.read() + self.assertEqual(struct.unpack_from(" DeviceMap: + dm = DeviceMap(meta={"firmware": "gsp.bin", "bias": BIAS}) + dm.record(TOUCH_A, 4, "r", pc=0x84009014, bias=BIAS, source="probe") + dm.record(0x2000, 8, "w", value=7, pc=0x84009020, bias=BIAS, source="manual") + return dm + + def test_roundtrip_is_byte_identical(self): + dm = self._map() + with tempfile.TemporaryDirectory() as root: + path = os.path.join(root, "map.json") + dm.save(path) + first = open(path, "rb").read() + again = DeviceMap.load(path) + again.save(path) + self.assertEqual(open(path, "rb").read(), first) + self.assertEqual(again.to_dict(), dm.to_dict()) + self.assertEqual(again.meta["bias"], BIAS) + + def test_entries_are_deterministically_ordered(self): + dm = DeviceMap() + for addr in (0x3000, 0x1000, 0x2000): + dm.record(addr, 4, "r") + self.assertEqual([e.address for e in dm.entries()], [0x1000, 0x2000, 0x3000]) + + def test_merge_sums_hits_and_unions_values(self): + left = DeviceMap() + left.record(0x1000, 4, "r", value=1) + left.record(0x1000, 4, "r", value=1) + right = DeviceMap(meta={"source": "manual"}) + right.record(0x1000, 4, "r", value=2) + right.record(0x2000, 8, "w") + merged = left.merge(right) + entries = {(e.address, e.kind): e for e in merged.entries()} + self.assertEqual(entries[(0x1000, "r")].hits, 3) + self.assertEqual(entries[(0x1000, "r")].values, (1, 2)) + self.assertEqual(entries[(0x2000, "w")].hits, 1) + self.assertEqual(merged.meta["source"], "manual") + + def test_rejects_unknown_schema(self): + with self.assertRaises(GreenEyeError): + DeviceMap.from_json(json.dumps({"schema": "greeneye-device-map/2", "entries": []})) + self.assertEqual(SCHEMA, "greeneye-device-map/1") + + +class LookupTests(unittest.TestCase): + def test_unimplemented_zero_and_value(self): + dm = DeviceMap() + self.assertEqual(dm.lookup(0x1234, 4), "unimplemented") + dm.record(0x2000, 4, "r", value=0xAB) + self.assertEqual(dm.lookup(0x2000, 4), "value:0xab") + self.assertEqual(dm.lookup(0x2000, 8), "value:0xab") # width fallback + dm.record(0x3000, 4, "r", value=None) + self.assertEqual(dm.lookup(0x3000, 4), "zero") + + def test_write_and_opposite_direction(self): + dm = DeviceMap() + dm.record(0x2000, 4, "r", value=5) + self.assertEqual(dm.lookup(0x2000, 4, write=True), "zero") # absorb + dm.record(0x3000, 4, "w", value=9) + self.assertEqual(dm.lookup(0x3000, 4, write=True), "zero") + self.assertEqual(dm.lookup(0x3000, 4), "zero") # write-only reads back zero + + +class LenientMachineTests(unittest.TestCase): + def test_strict_default_faults_without_logging(self): + machine, result = _run_program(PROGRAM_LOAD_STORE) + self.assertEqual(result[0], R_FAULT) + self.assertEqual(result[1], BASE + 16) # PC of the faulting load + self.assertEqual(result[2], 5) # load access fault + self.assertEqual(result[3], TOUCH_A) + self.assertEqual(machine.unknown_mmio, "fault") + self.assertEqual(machine.mmio_log, []) + self.assertEqual(machine.mmio_unknown, 0) + + def test_bad_policy_rejected(self): + with self.assertRaises(ValueError): + Machine(unknown_mmio="ignore") + + def test_lenient_returns_zero_and_logs(self): + machine, result = _run_program(PROGRAM_TWO_TOUCHES, unknown_mmio="lenient") + self.assertEqual(result[0], R_EBREAK) + self.assertEqual(machine.x[10], 0) # load returned 0 + self.assertEqual(machine.mmio_unknown, 2) + self.assertEqual( + machine.mmio_log, + [ + {"pc": BASE + 16, "addr": TOUCH_A, "size": 4, "rw": "r"}, + {"pc": BASE + 32, "addr": TOUCH_B, "size": 8, "rw": "w"}, + ], + ) + self.assertIsNone(machine.device_map) # no learning requested + + def test_lenient_learns_observed_values(self): + machine, result = _run_program( + PROGRAM_LOAD_STORE, unknown_mmio="lenient", learn_mmio=True + ) + self.assertEqual(result[0], R_EBREAK) + self.assertEqual(machine.mmio_unknown, 1) # the store is served once learned + entry = machine.device_map.entries()[0] + self.assertEqual((entry.address, entry.kind, entry.values), (TOUCH_A, "r", (0,))) + self.assertEqual(entry.first_epc_fw, BASE + 16) # bias 0: PC unchanged + self.assertEqual(machine.device_map.lookup(TOUCH_A, 4), "value:0x0") + + def test_map_serves_mapped_values_even_in_strict_mode(self): + dm = DeviceMap() + dm.record(TOUCH_A, 4, "r", value=0x12345678) + machine, result = _run_program(PROGRAM_LOAD_STORE, device_map=dm) + self.assertEqual(result[0], R_EBREAK) + self.assertEqual(machine.x[10], 0x12345678) + self.assertEqual(machine.mmio_unknown, 0) + + def test_ram_and_uart_are_unaffected(self): + # lui t0,0x10000; addi a0,zero,'A'; sb a0,0(t0); lbu a1,5(t0); ebreak + program = bytes.fromhex("b7020010130510042380a20083c5520073001000") + machine, result = _run_program(program, unknown_mmio="lenient") + self.assertEqual(result[0], R_EBREAK) + self.assertEqual(machine.uart.text(), "A") + self.assertEqual(machine.x[11], 0x60) # LSR: THRE | TEMT + self.assertEqual(machine.mmio_unknown, 0) + + +class LaunchNativeTests(unittest.TestCase): + @unittest.skipUnless(os.path.isfile(DEMO_ELF), "demo firmware absent") + def test_launch_native_accepts_lenient_options(self): + from greeneye.sim_backend import launch_native + + with tempfile.TemporaryDirectory() as root: + map_path = os.path.join(root, "map.json") + DeviceMap(meta={"firmware": "demo"}).save(map_path) + session = launch_native( + kernel=DEMO_ELF, + device_map=map_path, + unknown_mmio="lenient", + name="unittest-device-map", + ) + try: + self.assertEqual(session.target.machine.unknown_mmio, "lenient") + self.assertEqual(session.target.mmio_unknown, 0) + self.assertEqual(session.target.mmio_log, []) + finally: + session.close() + + +@unittest.skipUnless(os.path.isfile(REAL_IMAGE), "TU104 570.144 fwimage absent") +class RealImageLenientTests(unittest.TestCase): + def test_first_touch_matches_qemu_probe(self): + from greeneye.elf import load_elf + + image = load_elf(REAL_IMAGE) + dm = DeviceMap(meta={"bias": BIAS}) + machine = Machine( + device_map=dm, unknown_mmio="lenient", learn_mmio=True, bias=BIAS + ) + machine.load_segments(image.flat_load_plan(bias=BIAS), image.relocated_entry(bias=BIAS)) + self.assertEqual(machine.pc, 0x84009000) + reason, pc, _, _ = machine.run(200_000) + self.assertGreaterEqual(machine.mmio_unknown, 1) + self.assertEqual( + machine.mmio_log[0], + {"pc": 0x84009014, "addr": TOUCH_A, "size": 4, "rw": "r"}, + ) + # Lenient execution runs past the QEMU first fault to the budget. + self.assertEqual(reason, R_BUDGET) + self.assertEqual(machine.insns, 200_000) + self.assertEqual(pc, 0x84009070) + entry = dm.entries()[0] + self.assertEqual(entry.first_epc_fw, 0x4009014) + self.assertEqual(entry.values, (0,)) + self.assertEqual(dm.meta["bias"], BIAS) + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_discovery.py b/debug/risc-v-gsp-rm-vm/tests/test_discovery.py new file mode 100644 index 0000000..4a1cfc2 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_discovery.py @@ -0,0 +1,214 @@ +"""Integration tests for unknown-call discovery and reachability (docs/19). + +The integration tests drive the two harness CLIs as subprocesses against the +bundled demo firmware and skip cleanly when qemu-system-riscv64 or the demo +ELF is absent. The pure-function tests need neither. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.abspath(os.path.join(HERE, "..")) +DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf") +DISCOVERY = os.path.join(TOOL_ROOT, "harness", "control_discovery.py") +REACH = os.path.join(TOOL_ROOT, "harness", "reach.py") +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from harness.common import qemu_available # noqa: E402 +from harness.control_discovery import ( # noqa: E402 + DEMO_CASE_HANDLERS, + classify_outcome, + generate_probes, + pattern_args, +) + +MASK64 = (1 << 64) - 1 + + +def _run(args: list[str], timeout: float = 120.0) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable] + args, + capture_output=True, + text=True, + cwd=TOOL_ROOT, + timeout=timeout, + ) + + +def _read_findings(out: str) -> list[dict]: + with open(os.path.join(out, "findings.jsonl"), encoding="utf-8") as fh: + return [json.loads(line) for line in fh if line.strip()] + + +class DiscoveryUnitTests(unittest.TestCase): + def test_pattern_args_deterministic(self): + self.assertEqual(pattern_args("zero", 1, 3), (0, 0, 0)) + self.assertEqual(pattern_args("boundary", 1, 3), (MASK64, 1, 0x7FFF_FFFF_FFFF_FFFF)) + self.assertEqual(pattern_args("sentinel", 5, 3), pattern_args("sentinel", 5, 3)) + self.assertNotEqual(pattern_args("sentinel", 5, 3), pattern_args("sentinel", 5, 4)) + self.assertTrue(all(0 <= value <= MASK64 for value in pattern_args("sentinel", 9, 1))) + + def test_generate_probes_order_and_cap(self): + probes = generate_probes(4, ["zero", "sentinel"], seed=1) + self.assertEqual([probe.cmd for probe in probes], [1, 1, 2, 2, 3, 3, 4, 4]) + self.assertEqual([probe.pattern for probe in probes[0:2]], ["zero", "sentinel"]) + self.assertEqual(len(generate_probes(64, ["zero", "sentinel", "boundary"], seed=1, iterations=5)), 5) + + def test_classify_outcome_priority(self): + base = dict(cmd=6, is_injected=False, handler="rpc_fault", dispatch_seen=True, call_retval=None) + self.assertEqual(classify_outcome(faults=[{"cause": 3}], signals=[], settle_ok=True, **base), "trap") + self.assertEqual(classify_outcome(faults=[{"cause": 5}], signals=[], settle_ok=True, **base), "fault") + self.assertEqual(classify_outcome(faults=[], signals=[], settle_ok=True, **base), "known-handler") + self.assertEqual( + classify_outcome( + cmd=16, is_injected=True, handler="rpc_ping", dispatch_seen=True, call_retval=None, + faults=[], signals=[], settle_ok=True, + ), + "injected-slot", + ) + self.assertEqual( + classify_outcome( + cmd=7, is_injected=False, handler=None, dispatch_seen=True, call_retval=None, + faults=[], signals=[], settle_ok=True, + ), + "default-path", + ) + self.assertEqual( + classify_outcome( + cmd=7, is_injected=False, handler=None, dispatch_seen=False, call_retval=None, + faults=[], signals=[], settle_ok=True, + ), + "no-progress", + ) + self.assertEqual(classify_outcome(faults=[], signals=[], settle_ok=False, **base), "settle-failed") + + +@unittest.skipUnless(qemu_available(), "qemu-system-riscv64 not installed") +@unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)") +class DiscoveryIntegrationTests(unittest.TestCase): + def test_sweep_classifies_known_and_unknown(self): + with tempfile.TemporaryDirectory(prefix="discovery-sweep-") as out: + result = _run( + [ + DISCOVERY, + "--out", out, + "--max-id", "32", + "--patterns", "zero", + "--seed", "5", + "--timeout", "0.25", + "--settle", "1.0", + ] + ) + self.assertEqual(result.returncode, 0, result.stderr[-2000:]) + findings = _read_findings(out) + self.assertEqual(len(findings), 32) + by_cmd: dict[int, dict] = {finding["cmd"]: finding for finding in findings} + + # Known commands 1..6 are attributed to their handlers; zero args + # make ECHO fault (NULL string) and FAULT trap (ebreak). + for cmd, name in DEMO_CASE_HANDLERS.items(): + self.assertEqual(by_cmd[cmd]["handler"], name, f"cmd {cmd} handler") + for cmd in (1, 3, 4, 5): + self.assertEqual(by_cmd[cmd]["outcome"], "known-handler") + self.assertEqual(by_cmd[2]["outcome"], "fault") + self.assertEqual(by_cmd[6]["outcome"], "trap") + + # At least one default/unused id reaches the catch-all with ~id. + self.assertEqual(by_cmd[7]["outcome"], "default-path") + self.assertEqual(by_cmd[7]["effects"]["after"]["result"], (~7) & MASK64) + self.assertEqual(by_cmd[32]["outcome"], "default-path") + + # Injected-service dispatch: 16..21 alias handler slots 0..5. + self.assertEqual(by_cmd[16]["outcome"], "injected-slot") + self.assertEqual(by_cmd[16]["handler"], "rpc_ping") + self.assertEqual(by_cmd[21]["outcome"], "trap") + + report_path = os.path.join(out, "control-discovery.md") + self.assertTrue(os.path.exists(report_path)) + with open(report_path, encoding="utf-8") as fh: + report = fh.read() + self.assertIn("TOOL IN DEVELOPMENT - NOT READY YET!", report) + self.assertIn("Unknown / undiscovered ids", report) + self.assertIn("Injected-slot dispatch ids", report) + self.assertIn("rpc_fault", report) + self.assertTrue(os.path.isdir(os.path.join(out, "sessions"))) + + def test_sweep_deterministic(self): + base = [ + DISCOVERY, + "--max-id", "8", + "--patterns", "zero", + "--seed", "11", + "--timeout", "0.25", + "--settle", "1.0", + ] + with tempfile.TemporaryDirectory(prefix="discovery-det-a-") as out_a, tempfile.TemporaryDirectory( + prefix="discovery-det-b-" + ) as out_b: + first = _run(base + ["--out", out_a]) + second = _run(base + ["--out", out_b]) + self.assertEqual(first.returncode, 0, first.stderr[-2000:]) + self.assertEqual(second.returncode, 0, second.stderr[-2000:]) + with open(os.path.join(out_a, "findings.jsonl"), encoding="utf-8") as fa, open( + os.path.join(out_b, "findings.jsonl"), encoding="utf-8" + ) as fb: + self.assertEqual(fa.read(), fb.read(), "same seed/params must replay byte-for-byte") + + def test_rm_surface_exits_cleanly(self): + with tempfile.TemporaryDirectory(prefix="discovery-rm-") as out: + result = _run([DISCOVERY, "--surface", "rm", "--out", out]) + self.assertEqual(result.returncode, 0, result.stderr[-2000:]) + self.assertIn("skipped", result.stdout) + self.assertFalse(os.path.exists(os.path.join(out, "findings.jsonl"))) + + def test_reach_rpc_ping_via_call(self): + with tempfile.TemporaryDirectory(prefix="reach-ping-") as out: + result = _run([REACH, "--target", "rpc_ping", "--method", "call", "--args", "0x41", "--out", out]) + self.assertEqual(result.returncode, 0, result.stderr[-2000:]) + with open(os.path.join(out, "reach-report.json"), encoding="utf-8") as fh: + report = json.load(fh) + self.assertTrue(report["reachable"]) + self.assertEqual(report["method"], "call") + self.assertEqual(report["entry"]["symbol"], "rpc_ping") + self.assertEqual(report["effects"]["retval"], 0x41 ^ 0xBEEF) + self.assertTrue(os.path.exists(os.path.join(out, "probe.json"))) + with open(os.path.join(out, "probe.json"), encoding="utf-8") as fh: + probe = json.load(fh) + self.assertEqual(probe["method"], "call") + self.assertEqual(probe["args"], [0x41]) + + def test_reach_rpc_sum_via_run(self): + with tempfile.TemporaryDirectory(prefix="reach-sum-") as out: + result = _run([REACH, "--target", "rpc_sum", "--method", "run", "--out", out]) + self.assertEqual(result.returncode, 0, result.stderr[-2000:]) + with open(os.path.join(out, "reach-report.json"), encoding="utf-8") as fh: + report = json.load(fh) + self.assertTrue(report["reachable"]) + self.assertEqual(report["method"], "run") + self.assertEqual(report["entry"]["symbol"], "rpc_sum") + self.assertTrue(report["prior_events"], "the self-test hook history must be recorded") + + def test_reach_rpc_fault_via_trigger(self): + with tempfile.TemporaryDirectory(prefix="reach-fault-") as out: + result = _run([REACH, "--target", "rpc_fault", "--method", "trigger", "--out", out]) + self.assertEqual(result.returncode, 0, result.stderr[-2000:]) + with open(os.path.join(out, "reach-report.json"), encoding="utf-8") as fh: + report = json.load(fh) + self.assertTrue(report["reachable"]) + self.assertEqual(report["method"], "trigger") + self.assertEqual(report["trigger"]["id"], 6) + self.assertEqual(report["trigger"]["handler"], "rpc_fault") + self.assertEqual(report["required_args"]["cmd"], 6) + self.assertTrue(report["effects"]["trap"], "rpc_fault must show the ebreak trap") + self.assertTrue(os.path.exists(os.path.join(out, "probe.json"))) + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_harness.py b/debug/risc-v-gsp-rm-vm/tests/test_harness.py new file mode 100644 index 0000000..55938ae --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_harness.py @@ -0,0 +1,144 @@ +"""Tests for the green-eye automation harness (roadmap R5/R6, H4.1/H4.3). + +The integration tests drive the two harness CLIs as subprocesses against the +bundled demo firmware and skip cleanly when qemu-system-riscv64 or the demo +ELF is absent. The pure-function tests need neither. +""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.abspath(os.path.join(HERE, "..")) +DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf") +MATRIX = os.path.join(TOOL_ROOT, "harness", "portability_matrix.py") +MUTATION = os.path.join(TOOL_ROOT, "harness", "mutation_harness.py") +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from harness.common import qemu_available # noqa: E402 +from harness.mutation_harness import Mutation, expected_for, expected_ok, generate_mutations # noqa: E402 +from harness.portability_matrix import counter_delta # noqa: E402 + + +def _run(args: list[str], timeout: float = 600.0) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable] + args, + capture_output=True, + text=True, + cwd=TOOL_ROOT, + timeout=timeout, + ) + + +class HarnessUnitTests(unittest.TestCase): + def test_generate_mutations_deterministic(self): + first = generate_mutations(9, seed=5) + second = generate_mutations(9, seed=5) + self.assertEqual([m.to_json() for m in first], [m.to_json() for m in second]) + self.assertEqual( + {m.strategy for m in first}, + {"cmd-invalid", "arg-boundary", "bit-flip", "buffer-length"}, + ) + self.assertNotEqual([m.to_json() for m in first], [m.to_json() for m in generate_mutations(9, seed=6)]) + + def test_memory_region_strategy(self): + mutations = generate_mutations(5, seed=1, region=(0x80001000, 64)) + region = [m for m in mutations if m.strategy == "mem-region"] + self.assertEqual(len(region), 1) + self.assertEqual(region[0].region["addr"], 0x80001000) + self.assertTrue(0 <= region[0].region["offset"] < 64) + + def test_demo_oracle(self): + before = {"result": 0x30, "last_rpc": 5} + ping = Mutation(0, "bit-flip", cmd=1, arg0=0x1234) + self.assertEqual(expected_for(ping, before, 0x80000800)["value"], 0x1234 ^ 0xBEEF) + fill = Mutation(1, "arg-boundary", cmd=3, arg1=1 << 40) + self.assertEqual(expected_for(fill, before, 0)["value"], 256) + invalid = Mutation(2, "cmd-invalid", cmd=13) + after = {"result": (~13) & ((1 << 64) - 1), "last_rpc": 13} + self.assertTrue(expected_ok(expected_for(invalid, before, 0), invalid, before, after)) + idle = Mutation(3, "cmd-invalid", cmd=0) + unchanged = {"result": 0x30, "last_rpc": 5} + self.assertTrue(expected_ok(expected_for(idle, before, 0), idle, before, unchanged)) + + def test_counter_delta_zero_for_identical_profiles(self): + rows = counter_delta({"rpc_ping": 2}, {"rpc_ping": 2}) + self.assertTrue(all(row["delta"] == 0 for row in rows)) + + +@unittest.skipUnless(qemu_available(), "qemu-system-riscv64 not installed") +@unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)") +class HarnessIntegrationTests(unittest.TestCase): + def test_portability_matrix_self_comparison(self): + with tempfile.TemporaryDirectory(prefix="harness-matrix-") as out: + result = _run( + [ + MATRIX, + "--target", + DEMO_ELF, + "--target", + DEMO_ELF, + "--out", + out, + "--probe-instructions", + "200", + "--scenario-duration", + "0.2", + ] + ) + self.assertEqual(result.returncode, 0, result.stderr[-2000:]) + with open(os.path.join(out, "portability-report.json"), encoding="utf-8") as fh: + report = json.load(fh) + self.assertEqual([t["status"] for t in report["targets"]], ["ok", "ok"]) + self.assertEqual(len(report["comparisons"]), 1) + comparison = report["comparisons"][0] + self.assertTrue(comparison["all_zero"], comparison["deltas"]) + for rows in comparison["deltas"].values(): + for row in rows: + self.assertEqual(row["delta"], 0) + self.assertTrue(os.path.exists(os.path.join(out, "portability-report.md"))) + self.assertTrue(all(t["scenario"]["event_counts"] for t in report["targets"])) + + def test_mutation_run_and_seed_determinism(self): + with tempfile.TemporaryDirectory(prefix="harness-mut-a-") as out_a, tempfile.TemporaryDirectory( + prefix="harness-mut-b-" + ) as out_b: + base = [ + MUTATION, + "--kernel", + DEMO_ELF, + "--iterations", + "12", + "--seed", + "7", + "--timeout", + "0.3", + "--settle", + "0.1", + ] + first = _run(base + ["--out", out_a]) + self.assertEqual(first.returncode, 0, first.stderr[-2000:]) + second = _run(base + ["--out", out_b]) + self.assertEqual(second.returncode, 0, second.stderr[-2000:]) + findings_path = os.path.join(out_a, "findings.jsonl") + self.assertTrue(os.path.exists(findings_path)) + with open(findings_path, encoding="utf-8") as fh: + findings = [json.loads(line) for line in fh if line.strip()] + self.assertEqual(len(findings), 12) + self.assertTrue({finding["outcome"] for finding in findings}) + self.assertTrue(all("mutation" in finding and "detail" in finding for finding in findings)) + self.assertTrue(os.path.exists(os.path.join(out_a, "summary.md"))) + with open(findings_path, encoding="utf-8") as fa, open( + os.path.join(out_b, "findings.jsonl"), encoding="utf-8" + ) as fb: + self.assertEqual(fa.read(), fb.read(), "the same seed must replay byte-for-byte") + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_mcp.py b/debug/risc-v-gsp-rm-vm/tests/test_mcp.py new file mode 100644 index 0000000..614d397 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_mcp.py @@ -0,0 +1,431 @@ +"""Tests for the green-eye MCP server plugin (roadmap R3). + +The protocol and tool tests drive an in-memory Session over a stub RSP-shaped +client, so they need no QEMU and no simulator. A standalone subprocess test +launches the native backend end-to-end and skips when that backend is absent. +""" +from __future__ import annotations + +import io +import json +import os +import subprocess +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.abspath(os.path.join(HERE, "..")) +PLUGINS_DIR = os.path.join(TOOL_ROOT, "plugins") +MCP_SERVER = os.path.join(PLUGINS_DIR, "mcp_server.py") +DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf") +for _path in (TOOL_ROOT, PLUGINS_DIR): + if _path not in sys.path: + sys.path.insert(0, _path) + +import mcp_server as mcp # noqa: E402 +from greeneye.rsp import StopReply # noqa: E402 +from greeneye.session import Session # noqa: E402 +from greeneye.symbols import Symbol, SymbolDB # noqa: E402 + +BASE = 0x80000000 + + +class StubClient: + """RSP-shaped in-memory client: enough for Session without an emulator.""" + + def __init__(self, size: int = 0x20000): + self.base = BASE + self.mem = bytearray(size) + self.regs = {"pc": BASE, "ra": 0, "sp": BASE + size, "a0": 0, "a1": 0} + self.sw_bps: set[int] = set() + self.hw_bps: set[int] = set() + self.watches: dict[tuple[int, int, str], None] = {} + self.closed = False + self.features = "swbreak+;hwbreak+" + + def _span(self, addr: int, size: int) -> int: + start = addr - self.base + if start < 0 or start + size > len(self.mem): + raise ValueError(f"out of range: {addr:#x}+{size}") + return start + + def read_mem(self, addr: int, size: int, chunk: int = 0) -> bytes: + start = self._span(addr, size) + return bytes(self.mem[start : start + size]) + + def write_mem(self, addr: int, data: bytes, chunk: int = 0) -> None: + start = self._span(addr, len(data)) + self.mem[start : start + len(data)] = data + + def read_registers(self) -> dict[str, int]: + return dict(self.regs) + + def read_reg(self, name: str) -> int: + return int(self.regs.get(str(name), 0)) + + def write_reg(self, name: str, value: int) -> None: + self.regs[str(name)] = int(value) + + @property + def pc(self) -> int: + return self.regs["pc"] + + def set_sw_break(self, addr: int, kind: int = 4) -> None: + self.sw_bps.add(addr) + + def clear_sw_break(self, addr: int, kind: int = 4) -> None: + self.sw_bps.discard(addr) + + def set_hw_break(self, addr: int, kind: int = 4) -> None: + self.hw_bps.add(addr) + + def clear_hw_break(self, addr: int, kind: int = 4) -> None: + self.hw_bps.discard(addr) + + def set_watch(self, addr: int, size: int = 8, kind: str = "w") -> None: + self.watches[(addr, size, kind)] = None + + def clear_watch(self, addr: int, size: int = 8, kind: str = "w") -> None: + self.watches.pop((addr, size, kind), None) + + def continue_(self, timeout: float | None = None) -> StopReply: + return StopReply(raw="T05", signal=5) + + def step(self, timeout: float | None = None) -> StopReply: + return StopReply(raw="T05", signal=5) + + def interrupt(self, timeout: float | None = None) -> StopReply: + return StopReply(raw="T02", signal=2) + + def close(self) -> None: + self.closed = True + + +class McpTestCase(unittest.TestCase): + def setUp(self): + self.client = StubClient() + symbols = SymbolDB() + symbols.add(Symbol(addr=BASE + 0x100, name="rpc_ping", size=0x20, source="test")) + symbols.add(Symbol(addr=BASE + 0x200, name="gsp_ctrl", size=0x40, source="test")) + self.session = Session(client=self.client, symbols=symbols, record=False, name="mcp-test") + self.addCleanup(self.session.close) + + def make_server(self, **kw): + self.inp = io.StringIO() + self.out = io.StringIO() + self.log = io.StringIO() + options = dict(input_stream=self.inp, output_stream=self.out, log_stream=self.log) + options.update(kw) + return mcp.GreenEyeMCPServer(self.session, **options) + + def request(self, server, method, params=None, request_id=1): + message = {"jsonrpc": "2.0", "id": request_id, "method": method} + if params is not None: + message["params"] = params + return server.handle_message(message) + + def call_tool(self, server, name, **arguments): + response = self.request(server, "tools/call", {"name": name, "arguments": arguments}, request_id=9) + self.assertIn("result", response, response) + result = response["result"] + payload = json.loads(result["content"][0]["text"]) + return payload, result.get("isError", False) + + +class ProtocolTests(McpTestCase): + def test_initialize(self): + response = self.request( + self.make_server(), + "initialize", + {"protocolVersion": "2024-11-05", "capabilities": {}, "clientInfo": {"name": "t", "version": "0"}}, + ) + result = response["result"] + self.assertEqual(response["jsonrpc"], "2.0") + self.assertEqual(result["protocolVersion"], "2024-11-05") + self.assertEqual(result["serverInfo"]["name"], "greeneye-mcp") + self.assertIn("tools", result["capabilities"]) + + def test_initialize_negotiation_and_fallback(self): + server = self.make_server() + self.assertEqual( + self.request(server, "initialize", {"protocolVersion": "2025-03-26"})["result"]["protocolVersion"], + "2025-03-26", + ) + self.assertEqual( + self.request(server, "initialize", {"protocolVersion": "1999-01-01"})["result"]["protocolVersion"], + mcp.DEFAULT_PROTOCOL, + ) + + def test_initialized_notification_has_no_response(self): + server = self.make_server() + self.assertIsNone(server.handle_message({"jsonrpc": "2.0", "method": "notifications/initialized"})) + + def test_ping(self): + self.assertEqual(self.request(self.make_server(), "ping")["result"], {}) + + def test_tools_list_catalogue(self): + tools = self.request(self.make_server(), "tools/list")["result"]["tools"] + names = [tool["name"] for tool in tools] + self.assertEqual(len(names), 24) + self.assertEqual(set(names), mcp._TOOL_NAMES) + for tool in tools: + self.assertEqual(tool["inputSchema"]["type"], "object") + self.assertTrue(tool["description"]) + + def test_unknown_method(self): + response = self.request(self.make_server(), "no/such") + self.assertEqual(response["error"]["code"], mcp.METHOD_NOT_FOUND) + self.assertEqual(response["id"], 1) + + def test_unknown_tool(self): + response = self.request(self.make_server(), "tools/call", {"name": "nope", "arguments": {}}) + self.assertEqual(response["error"]["code"], mcp.INVALID_PARAMS) + + def test_tools_call_before_initialize_is_allowed(self): + payload, is_error = self.call_tool(self.make_server(), "session_info") + self.assertFalse(is_error) + self.assertEqual(payload["name"], "mcp-test") + + def test_parse_error_is_reported(self): + server = self.make_server() + server.process_line("{not json") + line = self.out.getvalue().strip() + self.assertEqual(json.loads(line)["error"]["code"], mcp.PARSE_ERROR) + + def test_serve_stream_end_to_end_and_no_stdout_pollution(self): + server = self.make_server() + lines = [ + json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}), + json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}), + json.dumps({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}), + json.dumps( + {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "session_info", "arguments": {}}} + ), + ] + server.serve_stream(io.StringIO("\n".join(lines) + "\n"), self.out) + raw = self.out.getvalue() + responses = [json.loads(line) for line in raw.splitlines()] + self.assertEqual([response["id"] for response in responses], [1, 2, 3]) + self.assertTrue(all(response["jsonrpc"] == "2.0" for response in responses)) + self.assertNotIn("[greeneye-mcp]", raw) + self.assertNotIn("[greeneye-mcp]", self.inp.getvalue()) + + +class ReadOnlyToolTests(McpTestCase): + def test_symbols_resolve(self): + payload, is_error = self.call_tool(self.make_server(), "symbols_resolve", target="rpc_ping") + self.assertFalse(is_error) + self.assertEqual(payload["address"], BASE + 0x100) + self.assertEqual(payload["symbol"], "rpc_ping") + + def test_read_mem_and_hexdump(self): + self.client.mem[0x10:0x14] = b"\xde\xad\xbe\xef" + server = self.make_server() + payload, is_error = self.call_tool(server, "read_mem", addr=BASE + 0x10, size=4) + self.assertFalse(is_error) + self.assertEqual(payload["hex"], "deadbeef") + payload, is_error = self.call_tool(server, "hexdump", addr=BASE + 0x10, size=4) + self.assertFalse(is_error) + self.assertIn("de ad be ef", payload["text"]) + + def test_bp_set_and_clear(self): + server = self.make_server() + payload, is_error = self.call_tool(server, "bp_set", target="rpc_ping") + self.assertFalse(is_error) + self.assertIn(BASE + 0x100, self.client.sw_bps) + payload, is_error = self.call_tool(server, "bp_clear", target="rpc_ping") + self.assertFalse(is_error) + self.assertEqual(payload["cleared"], 1) + self.assertNotIn(BASE + 0x100, self.client.sw_bps) + + def test_watch_set(self): + payload, is_error = self.call_tool(self.make_server(), "watch_set", target="gsp_ctrl", size=8, kind="w") + self.assertFalse(is_error) + self.assertEqual(payload["addr"], BASE + 0x200) + self.assertIn((BASE + 0x200, 8, "w"), self.client.watches) + + def test_snapshot_digest(self): + payload, is_error = self.call_tool(self.make_server(), "snapshot", addr=BASE, size=16, label="unit") + self.assertFalse(is_error) + self.assertEqual(payload["size"], 16) + self.assertEqual(len(payload["sha256"]), 64) + + def test_session_info_capabilities(self): + payload, is_error = self.call_tool( + self.make_server(allow_write=True, allow_inject=True), "session_info" + ) + self.assertFalse(is_error) + self.assertEqual(payload["name"], "mcp-test") + self.assertTrue(payload["capabilities"]["write"]) + self.assertTrue(payload["capabilities"]["inject"]) + self.assertFalse(payload["capabilities"]["trace"]) + + def test_plugins_list(self): + payload, is_error = self.call_tool(self.make_server(), "plugins_list") + self.assertFalse(is_error) + self.assertIn("mcp_server.py", payload["bundled"]) + + def test_analyze_current_reads_active_recording(self): + with tempfile.TemporaryDirectory(prefix="mcp-rec-") as directory: + session = Session(client=StubClient(), symbols=SymbolDB(), record=directory, name="active") + try: + server = mcp.GreenEyeMCPServer( + session, + input_stream=io.StringIO(), + output_stream=io.StringIO(), + log_stream=io.StringIO(), + ) + payload, is_error = self.call_tool(server, "analyze_current") + self.assertFalse(is_error) + self.assertIn("# green-eye session summary", payload["report"]) + finally: + session.close() + + +class GatingTests(McpTestCase): + def test_write_mem_refused_without_allow_write(self): + server = self.make_server() + payload, is_error = self.call_tool(server, "write_mem", addr=BASE + 0x10, hex="0102") + self.assertTrue(is_error) + self.assertEqual(payload["error"], "write_disabled") + self.assertEqual(bytes(self.client.mem[0x10:0x12]), b"\x00\x00") + + def test_write_mem_enabled(self): + server = self.make_server(allow_write=True) + payload, is_error = self.call_tool(server, "write_mem", addr=BASE + 0x10, hex="0x01 02") + self.assertFalse(is_error) + self.assertEqual(payload["written"], 2) + self.assertEqual(bytes(self.client.mem[0x10:0x12]), b"\x01\x02") + + def test_call_function_refused_without_allow_write(self): + payload, is_error = self.call_tool(self.make_server(), "call_function", target="rpc_ping", args=[1]) + self.assertTrue(is_error) + self.assertEqual(payload["error"], "write_disabled") + + def test_inject_refused_without_allow_inject(self): + payload, is_error = self.call_tool(self.make_server(), "inject_service", service="/nonexistent.elf") + self.assertTrue(is_error) + self.assertEqual(payload["error"], "inject_disabled") + + def test_inject_missing_file_after_gate(self): + payload, is_error = self.call_tool( + self.make_server(allow_inject=True), "inject_service", service="/nonexistent.elf" + ) + self.assertTrue(is_error) + self.assertEqual(payload["error"], "invalid_arguments") + + +class AvailabilityTests(McpTestCase): + def test_analyze_current_without_recording(self): + payload, is_error = self.call_tool(self.make_server(), "analyze_current") + self.assertTrue(is_error) + self.assertEqual(payload["error"], "not_available") + + def test_trace_start_on_non_native_backend(self): + payload, is_error = self.call_tool(self.make_server(), "trace_start") + self.assertTrue(is_error) + self.assertEqual(payload["error"], "not_available") + + def test_trace_stop_without_active_trace(self): + payload, is_error = self.call_tool(self.make_server(), "trace_stop") + self.assertTrue(is_error) + self.assertEqual(payload["error"], "invalid_arguments") + + def test_mutation_missing_script_is_structured(self): + server = self.make_server() + original = mcp.MUTATION_SCRIPT + mcp.MUTATION_SCRIPT = "/nonexistent/mutation_harness.py" + try: + payload, is_error = self.call_tool(server, "mutation_run") + finally: + mcp.MUTATION_SCRIPT = original + self.assertTrue(is_error) + self.assertEqual(payload["error"], "not_available") + + def test_portability_missing_script_is_structured(self): + server = self.make_server() + original = mcp.MATRIX_SCRIPT + mcp.MATRIX_SCRIPT = "/nonexistent/portability_matrix.py" + try: + payload, is_error = self.call_tool(server, "portability_matrix_run", targets=["/x.elf"]) + finally: + mcp.MATRIX_SCRIPT = original + self.assertTrue(is_error) + self.assertEqual(payload["error"], "not_available") + + def test_probe_missing_elf(self): + payload, is_error = self.call_tool(self.make_server(), "probe_firmware", elf="/nonexistent/fw.elf") + self.assertTrue(is_error) + self.assertEqual(payload["error"], "invalid_arguments") + + +class CloseAndTransportTests(McpTestCase): + def test_close_stops_serving_but_leaves_plugin_session(self): + server = self.make_server(owns_session=False) + payload, is_error = self.call_tool(server, "close") + self.assertFalse(is_error) + self.assertFalse(payload["session_closed"]) + self.assertTrue(server._stop) + self.assertFalse(self.client.closed) + + def test_close_closes_owned_session(self): + server = self.make_server(owns_session=True) + payload, is_error = self.call_tool(server, "close") + self.assertFalse(is_error) + self.assertTrue(payload["session_closed"]) + self.assertTrue(self.client.closed) + + def test_parse_tcp_is_loopback_only(self): + self.assertEqual(mcp.parse_tcp("127.0.0.1:8765"), ("127.0.0.1", 8765)) + self.assertEqual(mcp.parse_tcp("localhost:0")[0], "localhost") + with self.assertRaises(Exception): + mcp.parse_tcp("0.0.0.0:8765") + + +class StandaloneEntryTests(unittest.TestCase): + @unittest.skipUnless(os.path.isfile(MCP_SERVER), "mcp_server.py missing") + def test_help(self): + result = subprocess.run( + [sys.executable, MCP_SERVER, "--help"], capture_output=True, text=True, cwd=TOOL_ROOT + ) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn("--stdio", result.stdout) + self.assertIn("--allow-write", result.stdout) + + @unittest.skipUnless(os.path.isfile(MCP_SERVER), "mcp_server.py missing") + def test_stdio_native_session(self): + try: + import greeneye.sim_backend # noqa: F401 + except Exception as exc: + self.skipTest(f"native backend unavailable: {exc}") + with tempfile.TemporaryDirectory(prefix="mcp-native-") as dumps: + env = dict(os.environ, GREENEYE_DUMPS=dumps) + requests = [ + {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2024-11-05"}}, + {"jsonrpc": "2.0", "method": "notifications/initialized"}, + {"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {"name": "session_info", "arguments": {}}}, + {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "close", "arguments": {}}}, + ] + payload = "".join(json.dumps(request) + "\n" for request in requests) + result = subprocess.run( + [sys.executable, MCP_SERVER, "--stdio", "--backend", "native", "--record"], + input=payload, + capture_output=True, + text=True, + cwd=TOOL_ROOT, + env=env, + timeout=180, + ) + self.assertEqual(result.returncode, 0, result.stderr[-2000:]) + responses = [json.loads(line) for line in result.stdout.splitlines() if line.strip()] + self.assertEqual(len(responses), 3) + by_id = {response.get("id"): response for response in responses} + info = json.loads(by_id[2]["result"]["content"][0]["text"]) + self.assertEqual(info["backend"], "native") + self.assertFalse(by_id[3]["result"].get("isError", False)) + self.assertTrue(any(name.endswith("-mcp") for name in os.listdir(dumps))) + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_sim_backend.py b/debug/risc-v-gsp-rm-vm/tests/test_sim_backend.py new file mode 100644 index 0000000..a0635ab --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_sim_backend.py @@ -0,0 +1,244 @@ +"""Native (M1) backend tests: demo parity with QEMU and engine determinism. + +The native simulator is pure Python and always available; every test still +skips cleanly when the demo ELF cannot be loaded. The differential class +additionally requires qemu-system-riscv64 and compares the scripted +hook/watch/leave profile of both backends event for event. +""" +from __future__ import annotations + +import os +import shutil +import tempfile +import unittest + +import greeneye as ge +from greeneye.errors import RspTimeout + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.join(HERE, "..") +DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf") +RPC_HANDLERS = ["rpc_ping", "rpc_echo", "rpc_fill", "rpc_sum", "rpc_state"] + + +def native_available() -> bool: + try: + import greeneye.sim_backend # noqa: F401 + except Exception: + return False + return True + + +def qemu_available() -> bool: + return bool(os.environ.get("GREENEYE_QEMU") or shutil.which("qemu-system-riscv64")) + + +def demo_available() -> bool: + return os.path.exists(DEMO_ELF) and native_available() + + +def launch(backend: str, record=None, name: str = "sim"): + return ge.launch(kernel=DEMO_ELF, backend=backend, record=record, name=name) + + +@unittest.skipUnless(native_available(), "native simulator unavailable") +class NativeIsaTests(unittest.TestCase): + """Exercise RV64M/A/C paths the demo firmware does not reach.""" + + # Assembled with llvm-mc -triple=riscv64 -mattr=+m,+a,+c (encodings are + # data, so this runs without a toolchain): + # li a0,6; li a1,7; mul a2,a0,a1; div a5,a1,a0; rem a6,a1,a0; + # srai a4,a1,1; subw a3,a0,a1; lui s0,0x12345; addi sp,sp,-16; + # sd a0,8(sp); ld s1,8(sp); lh s2,8(sp); lbu s3,8(sp); + # amoadd.d s4,a0,(sp); lr.d s5,(sp); sc.d s6,a0,(sp); + # li t0,1; c.addi t0,3; c.mv a2,t0; c.andi a2,15; ebreak + PROGRAM = bytes.fromhex( + "".join( + """ + 19 45 9d 45 33 06 b5 02 b3 c7 a5 02 33 e8 a5 02 13 d7 15 40 + bb 06 b5 40 37 54 34 12 41 11 2a e4 a2 64 03 19 81 00 83 49 81 00 + 2f 3a a1 00 af 3a 01 10 2f 3b a1 18 85 42 8d 02 16 86 3d 8a 02 90 + """.split() + ) + ) + BASE = 0x80000000 + STACK = 0x80004000 + + def test_m_a_c_execution(self): + from sim.machine import Machine + + machine = Machine() + machine.load_segments([(self.BASE, self.PROGRAM)], self.BASE) + machine.set_reg(2, self.STACK) # sp + reason, pc, _, _ = machine.run(1000) + self.assertEqual(reason, 3) # R_EBREAK: stops before executing it + self.assertEqual(pc, self.BASE + len(self.PROGRAM) - 2) + expected = { + 5: 4, # t0: c.addi + 8: 0x12345000, # s0: lui + 9: 6, # s1: c.sdsp/c.ldsp round-trip + 10: 6, # a0 + 11: 7, # a1 + 12: 4, # a2: mul then c.mv/c.andi + 13: 0xFFFFFFFFFFFFFFFF, # a3: subw sign extension + 14: 3, # a4: srai + 15: 1, # a5: div + 16: 1, # a6: rem + 18: 6, # s2: lh + 19: 6, # s3: lbu + 20: 0, # s4: amoadd old value + 21: 6, # s5: lr + 22: 0, # s6: sc success + 2: self.STACK - 16, # c.addi16sp + } + got = {index: machine.x[index] for index in expected} + self.assertEqual(got, expected) + self.assertEqual(machine.read_mem(self.STACK - 16, 8), (6).to_bytes(8, "little")) + + +@unittest.skipUnless(demo_available(), "demo firmware or native simulator unavailable") +class NativeSmokeTests(unittest.TestCase): + def test_start_sequence_sets_stack_and_enters_main(self): + session = launch("native", name="unittest-sim-start") + try: + session.step(4) # auipc sp / addi sp / auipc ra / jalr main + stack_top = next(s.value for s in session.image.symbols if s.name == "_stack_top") + self.assertEqual(session.pc, session.symbols.lookup("main")) + self.assertEqual(session.read_regs()["sp"], stack_top) + finally: + session.close() + + def test_boot_hook_watch_snapshot_analyze(self): + root = tempfile.mkdtemp(prefix="greeneye-sim-") + session = launch("native", record=root, name="unittest-sim-smoke") + hits = [] + try: + ctrl = session.symbols.lookup("gsp_ctrl") + session.hook("rpc_sum", on_enter=lambda hit: hits.append(hit)) + watch_hits = [] + session.watch(ctrl + 16, size=8, kind="w", callback=lambda hit: watch_hits.append(hit)) + session.run(duration=0.6) + self.assertTrue(session.target.wait_for_uart("GREEN-EYE UPROC DEMO", timeout=1.0)) + self.assertGreaterEqual(len(hits), 1, "rpc_sum hook never fired") + self.assertGreaterEqual(len(watch_hits), 7, "rpc_count watchpoint never fired") + self.assertEqual(watch_hits[0].addr, ctrl + 16) + self.assertEqual(watch_hits[0].watch.kind, "w") + after = session.snapshot(ctrl, 0x188, "ctrl") + self.assertEqual(int.from_bytes(after[0:4], "little"), 0x47535031) + self.assertGreaterEqual(int.from_bytes(after[16:24], "little"), 7) + finally: + directory = getattr(session.recorder, "dir", None) + session.close() + from greeneye.analyze import render_summary + + report = render_summary(directory) + self.assertIn("rpc_sum", report) + self.assertIn("watch", report) + + def test_hook_args_and_leave_value(self): + session = launch("native", name="unittest-sim-hookargs") + events = [] + try: + ctrl = session.symbols.lookup("gsp_ctrl") + session.hook( + "rpc_sum", + on_enter=lambda hit: events.append(("enter", hit.arg(0), hit.arg(1))), + on_leave=lambda hit: events.append(("leave", hit.retval)), + ) + session.run(duration=0.2) + # Self-test: rpc_dispatch(4, ctl->buf, 16, 0) after fill(0x5a, 32). + self.assertIn(("enter", ctrl + 0x48, 16), events) + self.assertIn(("leave", 16 * 0x5A), events) + finally: + session.close() + + def test_call_and_run_until(self): + session = launch("native", name="unittest-sim-call") + try: + before = session.pc + self.assertEqual(session.call("rpc_ping", (0x1111,)), 0x1111 ^ 0xBEEF) + self.assertEqual(session.pc, before) + hit = session.run_until("rpc_sum", timeout=2.0) + self.assertEqual(hit.kind, "breakpoint") + self.assertEqual(hit.addr, session.symbols.lookup("rpc_sum")) + finally: + session.close() + + def test_run_until_timeout(self): + session = launch("native", name="unittest-sim-timeout") + try: + with self.assertRaises(RspTimeout): + session.run_until("rpc_fault", timeout=0.05) # self-test never calls it + finally: + session.close() + + +@unittest.skipUnless(demo_available(), "demo firmware or native simulator unavailable") +class NativeDeterminismTests(unittest.TestCase): + def _scripted_profile(self): + """Drive fixed instruction budgets: no host clock in the result.""" + session = ge.launch(kernel=DEMO_ELF, backend="native", name="unittest-sim-determinism") + events = [] + stops = [] + try: + session.hook( + "rpc_sum", + on_enter=lambda hit: events.append(("enter", hit.arg(0), hit.arg(1))), + on_leave=lambda hit: events.append(("leave", hit.retval)), + ) + ctrl = session.symbols.lookup("gsp_ctrl") + session.watch(ctrl + 16, size=8, kind="w") + for _ in range(20): + try: + stop = session.client.continue_(timeout=0.02) # exactly 20k insns + except RspTimeout: + stops.append(("budget", session.pc)) + continue + session._dispatch_stop(stop) + stops.append(("stop", session.pc)) + ctrl_image = session.read_regs() + snapshot = session.client.read_mem(ctrl, 0x188) + finally: + session.close() + return events, stops, ctrl_image["pc"], snapshot + + def test_two_runs_identical(self): + first = self._scripted_profile() + second = self._scripted_profile() + self.assertEqual(first, second) + + +@unittest.skipUnless(demo_available() and qemu_available(), "native simulator and QEMU required") +class DifferentialTests(unittest.TestCase): + """Run one scripted scenario on both backends and compare the profiles.""" + + def _profile(self, backend: str): + session = launch(backend, name=f"unittest-diff-{backend}") + events = [] + watch_hits = [] + try: + for name in RPC_HANDLERS: + session.hook( + name, + on_enter=lambda hit, n=name: events.append(("enter", n, hit.arg(0), hit.arg(1))), + on_leave=lambda hit, n=name: events.append(("leave", n, hit.retval)), + ) + ctrl = session.symbols.lookup("gsp_ctrl") + session.watch(ctrl + 16, size=8, kind="w", callback=lambda hit: watch_hits.append(hit.addr)) + session.run(duration=0.3) + uart = session.target.read_uart() + bp_counts = [handle.hits for handle in session._bps.values()] + finally: + session.close() + return events, len(watch_hits), bp_counts, uart.splitlines()[0] if uart else "" + + def test_hook_watch_profile_parity(self): + qemu = self._profile("qemu") + native = self._profile("native") + self.assertEqual(qemu, native) + self.assertEqual(native[1], 8) # 7 self-test dispatches + ctrl init + self.assertEqual(native[2], [1, 1, 1, 1, 3]) + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_sim_trace.py b/debug/risc-v-gsp-rm-vm/tests/test_sim_trace.py new file mode 100644 index 0000000..96934e9 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_sim_trace.py @@ -0,0 +1,224 @@ +"""Native R4 trace tests: collection, filters, session blob, analyzer, CLI. + +The engine tests are pure (no QEMU, no demo build needed); the session/CLI +tests skip cleanly when the demo ELF is absent. +""" +from __future__ import annotations + +import contextlib +import io +import json +import os +import tempfile +import unittest + +import greeneye as ge +from greeneye.analyze import render_summary, render_summary_from +from greeneye.errors import RspTimeout +from sim.machine import Machine +from sim.trace import Trace + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.join(HERE, "..") +DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf") + +# lui a0,0x80000; addi a1,zero,0x2a; sw a1,0(a0); lw a2,0(a0); ebreak +PROGRAM = bytes.fromhex("37 05 00 80 93 05 a0 02 23 20 b5 00 03 26 05 00 73 00 10 00") +BASE = 0x80000000 + + +def demo_available() -> bool: + return os.path.exists(DEMO_ELF) + + +def records(payload: bytes) -> list[dict]: + return [json.loads(line) for line in payload.decode().splitlines() if line.strip()] + + +def read_blob(session_dir: str) -> bytes: + with open(os.path.join(session_dir, "blobs", "trace.jsonl"), "rb") as fh: + return fh.read() + + +class TraceEngineTests(unittest.TestCase): + def _machine(self): + machine = Machine() + machine.load_segments([(BASE, PROGRAM)], BASE) + return machine + + def test_exec_and_access_lines(self): + trace = Trace() + reason, _, _, _ = self._machine().run(100, trace=trace) + self.assertEqual(reason, 3) # R_EBREAK, stopped at the trap + self.assertEqual(trace.counts(), { + "exec": 5, "read": 1, "write": 1, "events": 7, "dropped": 0, + }) + lines = records(trace.payload()) + self.assertEqual(lines[-1]["t"], "summary") + self.assertEqual(lines[-1]["insns"], 5) + self.assertEqual(lines[-1]["mem"], 2) + self.assertEqual( + [(r["t"], r["pc"]) for r in lines[:-1] if r["t"] == "exec"], + [("exec", "0x80000000"), ("exec", "0x80000004"), ("exec", "0x80000008"), + ("exec", "0x8000000c"), ("exec", "0x80000010")], + ) + write = next(r for r in lines if r["t"] == "write") + read = next(r for r in lines if r["t"] == "read") + self.assertEqual((write["addr"], write["size"], write["pc"], write["vt"]), ("0x80000000", 4, "0x80000008", 3)) + self.assertEqual((read["addr"], read["size"], read["pc"], read["vt"]), ("0x80000000", 4, "0x8000000c", 4)) + + def test_address_filters_and_exec_off(self): + trace = Trace(exec_enabled=False, start=BASE + 8, end=BASE + 8) + self._machine().run(100, trace=trace) + lines = records(trace.payload()) + self.assertEqual([r["t"] for r in lines], ["write", "summary"]) + self.assertEqual(lines[0]["pc"], "0x80000008") # the in-range store only + self.assertFalse(lines[1]["exec"]) + self.assertEqual(lines[1]["start"], "0x80000008") + + def test_ring_budget_is_deterministic(self): + first = Trace(max_events=3) + second = Trace(max_events=3) + self._machine().run(100, trace=first) + self._machine().run(100, trace=second) + self.assertEqual(first.payload(), second.payload()) + self.assertEqual(first.counts()["events"], 7) + self.assertEqual(first.counts()["dropped"], 4) + + def test_trace_none_path_unchanged(self): + machine = self._machine() + machine.run(100) # the default fast path still works untouched + self.assertEqual(machine.x[10], 0x80000000) + self.assertEqual(machine.x[11], 0x2A) + self.assertEqual(machine.x[12], 0x2A) + + +@unittest.skipUnless(demo_available(), "demo firmware unavailable") +class SessionTraceTests(unittest.TestCase): + def _recorded_trace(self, trace) -> tuple[str, bytes]: + session_dir = tempfile.mkdtemp(prefix="greeneye-trace-") + session = ge.launch(kernel=DEMO_ELF, backend="native", record=session_dir, + name="unittest-sim-trace", trace=trace) + try: + try: + session.client.continue_(timeout=0.02) # exactly 20k instructions + except RspTimeout: + pass + finally: + session.close() + return session_dir, read_blob(session_dir) + + def test_blob_recorded_and_analyzed(self): + session_dir, blob = self._recorded_trace({"exec": True}) + meta = json.load(open(os.path.join(session_dir, "session.json"), encoding="utf-8")) + self.assertEqual(meta["trace_blob"], "blobs/trace.jsonl") + self.assertTrue(meta["trace"]["exec"]) + lines = blob.decode().splitlines() + self.assertTrue(lines[0].startswith('{"t":"exec"')) + self.assertIn('"t":"summary"', lines[-1]) + events = [json.loads(line) for line in open(os.path.join(session_dir, "events.jsonl"), encoding="utf-8")] + self.assertTrue(any(e.get("type") == "blob_saved" for e in events)) + self.assertFalse(any(e.get("type") in ("exec", "read", "write") for e in events), + "trace volume must stay out of events.jsonl") + report = render_summary(session_dir) + self.assertIn("## Execution trace", report) + self.assertIn("unique PCs", report) + self.assertIn("### Accessed pages", report) + + def test_trace_is_deterministic(self): + _, first = self._recorded_trace(True) + _, second = self._recorded_trace(True) + self.assertEqual(first, second) + + def test_exec_off_records_accesses_only(self): + session_dir, blob = self._recorded_trace({"exec": False}) + lines = blob.decode().splitlines() + self.assertFalse(any('"t":"exec"' in line for line in lines)) + self.assertTrue(any('"t":"read"' in line or '"t":"write"' in line for line in lines)) + report = render_summary(session_dir) + self.assertIn("exec=off", report) + + def test_launch_trace_implies_recording(self): + old = os.environ.get("GREENEYE_DUMPS") + os.environ["GREENEYE_DUMPS"] = tempfile.mkdtemp(prefix="greeneye-trace-dumps-") + try: + session = ge.launch(kernel=DEMO_ELF, backend="native", name="unittest-sim-trace-auto", trace=True) + try: + directory = session.recorder.dir + self.assertIsNotNone(directory) + finally: + session.close() + self.assertTrue(os.path.exists(os.path.join(directory, "blobs", "trace.jsonl"))) + finally: + if old is None: + os.environ.pop("GREENEYE_DUMPS", None) + else: + os.environ["GREENEYE_DUMPS"] = old + + def test_render_from_folded_events(self): + meta = {"name": "synthetic", "format": "greeneye-session/1", "trace": {"exec": True}} + events = [ + {"type": "exec", "pc": 0x80000010, "vt": 1}, + {"type": "exec", "pc": 0x80000010, "vt": 2}, + {"type": "read", "pc": 0x80000010, "addr": 0x80000100, "size": 4}, + {"type": "write", "pc": 0x80000014, "addr": 0x80000200, "size": 8}, + {"type": "access", "pc": 0x80000018, "addr": 0x80000200, "size": 4, "rw": "w"}, + ] + report = render_summary_from(meta, events) + self.assertIn("## Execution trace", report) + self.assertIn("unique PCs: 1", report) + self.assertIn("1 reads (1 pages)", report) + self.assertIn("2 writes (1 pages)", report) + self.assertIn("| `0x80000000` | 1 | 2 |", report) + + +@unittest.skipUnless(demo_available(), "demo firmware unavailable") +class CliTraceTests(unittest.TestCase): + def test_info_reports_native(self): + from greeneye import cli + + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main(["info"]) + self.assertEqual(code, 0) + self.assertIn("native simulator: available", out.getvalue()) + self.assertIn("native trace: available", out.getvalue()) + + def test_record_native_trace_cli(self): + from greeneye import cli + + session_dir = tempfile.mkdtemp(prefix="greeneye-trace-cli-") + out = io.StringIO() + with contextlib.redirect_stdout(out): + code = cli.main([ + "record", "--kernel", DEMO_ELF, "--backend", "native", + "--duration", "0.05", "--trace", "--record", session_dir, + ]) + self.assertEqual(code, 0) + summary = open(os.path.join(session_dir, "summary.md"), encoding="utf-8").read() + self.assertIn("## Execution trace", summary) + lines = read_blob(session_dir).decode().splitlines() + self.assertTrue(any('"t":"exec"' in line for line in lines)) + + def test_record_trace_address_filter_cli(self): + from greeneye import cli + + session_dir = tempfile.mkdtemp(prefix="greeneye-trace-cli-filter-") + with contextlib.redirect_stdout(io.StringIO()): + code = cli.main([ + "record", "--kernel", DEMO_ELF, "--backend", "native", + "--duration", "0.05", "--trace", "--trace-exec", "off", + "--trace-start", "0x80000000", "--trace-end", "0x800000ff", + "--record", session_dir, + ]) + self.assertEqual(code, 0) + for line in read_blob(session_dir).decode().splitlines(): + record = json.loads(line) + if record["t"] in ("read", "write"): + pc = int(record["pc"], 16) + self.assertLessEqual(pc, 0x800000FF) + self.assertNotEqual(record["t"], "exec") + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_value_demos.py b/debug/risc-v-gsp-rm-vm/tests/test_value_demos.py new file mode 100644 index 0000000..7640e13 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_value_demos.py @@ -0,0 +1,107 @@ +"""End-to-end tests for the value-validation demos (docs/25). + +Runs the fastest demos as subprocesses exactly as a developer would, parses +their machine-readable ``RESULT_JSON`` and asserts the answers, then checks +that ``run_all.py`` emits its aggregate results JSON. + +Skipped cleanly when qemu-system-riscv64 or the bundled demo firmware is +absent; the demos that use the native backend still need the demo ELF. +""" +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.dirname(HERE) +DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf") +DEMO_DIR = os.path.join(TOOL_ROOT, "harness", "value_demos") +RESULTS_JSON = os.path.join(DEMO_DIR, "artifacts", "results.json") + + +def qemu_available() -> bool: + return bool(os.environ.get("GREENEYE_QEMU") or shutil.which("qemu-system-riscv64")) + + +def _run_demo(script: str, backend: str, timeout: float = 60.0) -> dict: + env = dict(os.environ) + env["VALUE_DEMO_BACKEND"] = backend + proc = subprocess.run( + [sys.executable, os.path.join(DEMO_DIR, script)], + cwd=TOOL_ROOT, + capture_output=True, + text=True, + timeout=timeout, + env=env, + ) + for line in reversed(proc.stdout.splitlines()): + if line.startswith("RESULT_JSON: "): + return json.loads(line[len("RESULT_JSON: ") :]) + raise AssertionError( + f"{script}: no RESULT_JSON (exit {proc.returncode})\n" + f"stdout tail:\n{proc.stdout[-1200:]}\nstderr tail:\n{proc.stderr[-1200:]}" + ) + + +@unittest.skipUnless(qemu_available(), "qemu-system-riscv64 not installed") +@unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)") +class ValueDemoTests(unittest.TestCase): + def test_s01_reply_consumer(self): + result = _run_demo("s01_reply_consumer.py", "qemu") + self.assertTrue(result["success"], result) + replies = {r["cmd"]: r for r in result["replies"]} + self.assertEqual(replies[1]["result"], 0x1234 ^ 0xBEEF) + self.assertTrue(replies[1]["consumed"]) + self.assertEqual(replies[7]["result"], (~7) & 0xFFFFFFFFFFFFFFFF) + self.assertFalse(replies[6]["consumed"], "FAULT must trap before the reply store") + self.assertGreater(result["elapsed_s"], 0) + + def test_s04_run_until_function(self): + result = _run_demo("s04_run_until_function.py", "native") + self.assertTrue(result["success"], result) + self.assertTrue(result["natural_path"]["reachable"]) + self.assertTrue(result["before_trigger"]["timeout"]) + self.assertTrue(result["after_trigger"]["reachable"]) + self.assertEqual(result["after_trigger"]["dispatch_args"][0]["cmd"], 6) + self.assertEqual(result["direct_call"]["result"], 0x41 ^ 0xBEEF) + + def test_s05_snapshot_diff(self): + result = _run_demo("s05_snapshot_diff.py", "native") + self.assertTrue(result["success"], result) + self.assertTrue({"result", "rpc_count", "last_rpc", "buf"}.issubset(set(result["changed_field_names"]))) + self.assertEqual(result["after"]["result"], 15) + + def test_s09_ghidra_join(self): + result = _run_demo("s09_grabbing_a_ghidra_address.py", "qemu") + self.assertTrue(result["success"], result) + self.assertEqual(result["hits"], [int(result["runtime_addr"], 16)]) + self.assertEqual(int(result["ghidra_addr"], 16) + result["bias"], int(result["runtime_addr"], 16)) + self.assertIn("rpc_fault", result["greeneye_hits_tsv"]) + + def test_run_all_emits_results_json(self): + proc = subprocess.run( + [sys.executable, os.path.join(DEMO_DIR, "run_all.py"), "--only", "s01,s04,s05"], + cwd=TOOL_ROOT, + capture_output=True, + text=True, + timeout=120, + env={**os.environ, "VALUE_DEMO_BACKEND": "native"}, + ) + self.assertEqual(proc.returncode, 0, proc.stdout[-1500:] + proc.stderr[-1500:]) + self.assertTrue(os.path.exists(RESULTS_JSON), "run_all.py did not write results.json") + with open(RESULTS_JSON, "r", encoding="utf-8") as fh: + payload = json.load(fh) + self.assertEqual(payload["format"], "greeneye-value-demos-results/1") + self.assertEqual(payload["demos_selected"], ["s01", "s04", "s05"]) + self.assertEqual(payload["demos_failed"], []) + self.assertEqual(len(payload["demos_passed"]), 3) + for result in payload["results"]: + self.assertGreater(result["runner_elapsed_s"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/debug/risc-v-gsp-rm-vm/tests/test_version_diff.py b/debug/risc-v-gsp-rm-vm/tests/test_version_diff.py new file mode 100644 index 0000000..df704df --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/tests/test_version_diff.py @@ -0,0 +1,333 @@ +"""Tests for version_diff / ci_release (roadmap H4.1, planned docs/21). + +Pure-function tests need neither QEMU nor the demo; the self-diff integration +test boots the demo twice and skips cleanly when qemu-system-riscv64 or the +demo ELF is absent. +""" +from __future__ import annotations + +import contextlib +import io +import json +import os +import sys +import tempfile +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.abspath(os.path.join(HERE, "..")) +DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf") +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from harness.ci_release import main as ci_release_main # noqa: E402 +from harness.common import SymbolDB, qemu_available # noqa: E402 +from harness.version_diff import ( # noqa: E402 + LOAD_ADDR, + Rm610GateError, + VersionDiffOptions, + check_rm610_gate, + corpus_diff, + corpus_section, + image_diff, + inventory_diff, + inventory_records, + is_rm610_label, + load_any_target, + load_symbol_sources, + load_three_column_export, + looks_like_three_column_export, + main as version_diff_main, + rm610_approved, + run_version_diff, + shape_payload, +) + +NOP = b"\x13\x00\x00\x00" # addi x0, x0, 0 + + +def _run_main(main_fn, argv: list[str]) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + rc = main_fn(argv) + return rc, out.getvalue(), err.getvalue() + + +def _write(path: str, text: str) -> str: + with open(path, "w", encoding="utf-8") as fh: + fh.write(text) + return path + + +class Rm610GateTests(unittest.TestCase): + def test_label_detection(self): + for label in ("rm610", "RM610", "rm-610-fw.bin", "gsp_rm_610.elf", "release-RM610"): + self.assertTrue(is_rm610_label(label), label) + for label in ("570.144", "uproc_demo", "gsp-570.211.fwimage.bin", ""): + self.assertFalse(is_rm610_label(label), label) + + def test_refused_by_default(self): + with self.assertRaises(Rm610GateError) as ctx: + check_rm610_gate(["rm610"], environ={}) + message = str(ctx.exception) + self.assertIn("docs/23", message) + self.assertIn("RM610-APPROVED", message) + self.assertIn("no target file was opened", message) + + def test_token_and_env_approve(self): + check_rm610_gate(["rm610"], approval_token="RM610-APPROVED", environ={}) + check_rm610_gate(["rm_610"], environ={"GREENEYE_RM610_APPROVED": "1"}) + self.assertTrue(rm610_approved("RM610-APPROVED", {})) + self.assertTrue(rm610_approved(None, {"GREENEYE_RM610_APPROVED": "1"})) + with self.assertRaises(Rm610GateError): + check_rm610_gate(["rm610"], approval_token="rm610-approved", environ={}) + with self.assertRaises(Rm610GateError): + check_rm610_gate(["rm610"], environ={"GREENEYE_RM610_APPROVED": "0"}) + + def test_other_labels_are_not_gated(self): + check_rm610_gate(["570.144", "uproc_demo"], environ={}) + + +class InventoryDiffTests(unittest.TestCase): + def test_matching_discipline(self): + records_a = [ + {"name": "keep", "addr": 0x100, "size": 16, "source": "elf"}, + {"name": "gone", "addr": 0x180, "size": 8, "source": "elf"}, + {"name": "FUN_00000200", "addr": 0x200, "size": 12, "source": "elf"}, + ] + records_b = [ + {"name": "keep", "addr": 0x110, "size": 16, "source": "elf"}, + {"name": "newfn", "addr": 0x190, "size": 8, "source": "elf"}, + {"name": "FUN_00000210", "addr": 0x210, "size": 12, "source": "elf"}, + ] + diff = inventory_diff(records_a, records_b) + self.assertEqual(diff["shift"], 0x10) + self.assertEqual([row["name"] for row in diff["moved"]], ["keep"]) + self.assertEqual(diff["matched_shifted"], 1) + self.assertEqual(diff["totals"]["added"], 1) + self.assertEqual(diff["totals"]["removed"], 1) + self.assertEqual(diff["added"][0]["name"], "newfn") + self.assertEqual(diff["removed"][0]["name"], "gone") + + def test_resize_and_self_diff(self): + records = [ + {"name": "a", "addr": 0x10, "size": 4, "source": "elf"}, + {"name": "FUN_00000020", "addr": 0x20, "size": 4, "source": "elf"}, + ] + resized = [dict(row) for row in records] + resized[1]["size"] = 8 + diff = inventory_diff(records, resized) + self.assertEqual(diff["resized_anonymous"][0]["new_size"], 8) + zero = inventory_diff(records, records) + self.assertEqual(zero["totals"]["added"], 0) + self.assertEqual(zero["totals"]["removed"], 0) + self.assertEqual(zero["totals"]["moved"], 0) + self.assertEqual(zero["totals"]["resized_named"], 0) + + +class SymbolSourceTests(unittest.TestCase): + def test_three_column_export(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write( + os.path.join(tmp, "symbols.tsv"), + "addr\tname\tsize\n0x80000010\trpc_ping\t16\n0x80000020\trpc_sum\t32\n", + ) + self.assertTrue(looks_like_three_column_export(path)) + db = SymbolDB() + self.assertEqual(load_three_column_export(db, path, bias=0), 2) + self.assertEqual(db.get("rpc_ping").size, 16) + self.assertEqual(db.get("rpc_sum").addr, 0x80000020) + + def test_names_tsv_is_not_mistaken_for_export(self): + with tempfile.TemporaryDirectory() as tmp: + path = _write(os.path.join(tmp, "names.tsv"), "0x80000010\trpc_ping\tghidra\n") + self.assertFalse(looks_like_three_column_export(path)) + + @unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)") + def test_sources_merge_on_demo(self): + loaded = load_any_target(DEMO_ELF) + self.assertTrue(loaded.ok) + db, sources, _ = load_symbol_sources(loaded) + self.assertTrue(any(source.startswith("elf:") for source in sources)) + self.assertTrue(len(inventory_records(db)) > 0) + + +class TargetShapeTests(unittest.TestCase): + def test_missing_and_incompatible(self): + missing = load_any_target("/nonexistent/nope.elf") + self.assertEqual(missing.kind, "missing") + self.assertFalse(missing.ok) + with tempfile.TemporaryDirectory() as tmp: + bad = os.path.join(tmp, "bad.elf") + with open(bad, "wb") as fh: + fh.write(b"\x7fELF" + b"\x00" * 48) + loaded = load_any_target(bad) + self.assertEqual(loaded.kind, "unsupported") + self.assertIn("EI_CLASS", loaded.reason or "") + + def test_raw_target_shape(self): + with tempfile.TemporaryDirectory() as tmp: + path = os.path.join(tmp, "raw.bin") + with open(path, "wb") as fh: + fh.write(NOP * 16) + loaded = load_any_target(path) + self.assertEqual(loaded.kind, "raw") + self.assertEqual(loaded.bias, LOAD_ADDR) + payload = shape_payload(loaded) + self.assertEqual(payload["span"], 64) + self.assertEqual(payload["sections"], []) + self.assertEqual(payload["loads"][0]["vaddr"], 0) + + @unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)") + def test_self_shape_and_inventory_zero(self): + old, new = load_any_target(DEMO_ELF), load_any_target(DEMO_ELF) + image = image_diff(old, new, max_rows=40) + self.assertEqual(image["status"], "ok") + self.assertTrue(image["same"]) + old_db, _, _ = load_symbol_sources(old) + new_db, _, _ = load_symbol_sources(new) + diff = inventory_diff(inventory_records(old_db), inventory_records(new_db)) + self.assertEqual(diff["shift"], 0) + self.assertEqual(diff["totals"]["added"], 0) + self.assertEqual(diff["totals"]["removed"], 0) + self.assertEqual(diff["totals"]["moved"], 0) + self.assertEqual(diff["totals"]["resized_named"], 0) + + +class CorpusTests(unittest.TestCase): + def test_corpus_diff(self): + with tempfile.TemporaryDirectory() as tmp: + a = _write( + os.path.join(tmp, "a.tsv"), + "addr\tfile_addr\tsize\tname\tbucket\tline\tstatus\n" + "0x1000\t0x80000000\t16\trpc_ping\tb\t0\tok\n", + ) + b = _write( + os.path.join(tmp, "b.tsv"), + "addr\tfile_addr\tsize\tname\tbucket\tline\tstatus\n" + "0x1000\t0x80000010\t16\trpc_ping\tb\t0\tok\n", + ) + diff = corpus_diff(a, b) + self.assertEqual(diff["shift"], 0x10) + self.assertEqual(diff["matched_named"], 1) + + def test_not_supplied(self): + options = VersionDiffOptions(old="old.elf", new="new.elf", out="/tmp/unused") + section = corpus_section(options, options.out) + self.assertEqual(section["status"], "skipped") + self.assertIn("not supplied", section["reason"]) + + +class RunVersionDiffTests(unittest.TestCase): + def test_missing_target_skips_cleanly(self): + with tempfile.TemporaryDirectory() as tmp: + new = os.path.join(tmp, "new.bin") + with open(new, "wb") as fh: + fh.write(NOP * 16) + out = os.path.join(tmp, "out") + options = VersionDiffOptions(old=os.path.join(tmp, "missing.elf"), new=new, out=out, + probe_instructions=8) + report = run_version_diff(options) + for name in ("image", "symbols", "probe", "behavior"): + self.assertEqual(report["sections"][name]["status"], "skipped", name) + self.assertTrue(report["sections"][name]["reason"], name) + self.assertIn("not supplied", report["sections"]["corpus"]["reason"]) + self.assertTrue(os.path.exists(os.path.join(out, "version-diff.json"))) + self.assertTrue(os.path.exists(os.path.join(out, "version-diff.md"))) + + def test_gate_refused_before_any_file_open(self): + with tempfile.TemporaryDirectory() as tmp: + out = os.path.join(tmp, "out") + rc, _, err = _run_main( + version_diff_main, + ["--old", os.path.join(tmp, "rm610.elf"), "--new", os.path.join(tmp, "new.elf"), + "--out", out], + ) + self.assertEqual(rc, 3) + self.assertIn("docs/23", err) + self.assertFalse(os.path.exists(out), "the gate must fire before --out is created") + + def test_gate_passes_with_token(self): + with tempfile.TemporaryDirectory() as tmp: + new = os.path.join(tmp, "new.bin") + with open(new, "wb") as fh: + fh.write(NOP * 16) + out = os.path.join(tmp, "out") + rc, _, _ = _run_main( + version_diff_main, + ["--old", os.path.join(tmp, "rm610.bin"), "--new", new, "--release", "rm610", + "--out", out, "--approval-token", "RM610-APPROVED", "--probe-instructions", "4"], + ) + self.assertEqual(rc, 0) + self.assertTrue(os.path.exists(os.path.join(out, "version-diff.json"))) + + +class CiReleaseTests(unittest.TestCase): + def test_dry_run_exits_zero_and_touches_nothing(self): + with tempfile.TemporaryDirectory() as tmp: + out = os.path.join(tmp, "bundle") + rc, stdout, _ = _run_main( + ci_release_main, + ["--old", os.path.join(tmp, "old.elf"), "--new", os.path.join(tmp, "new.elf"), + "--out", out, "--dry-run"], + ) + self.assertEqual(rc, 0) + self.assertIn("version_diff", stdout) + self.assertIn("portability_matrix", stdout) + self.assertIn("mutation_smoke", stdout) + self.assertFalse(os.path.exists(out)) + + def test_dry_run_rm610_refused(self): + rc, _, err = _run_main( + ci_release_main, + ["--old", "rm610.elf", "--new", "new.elf", "--dry-run"], + ) + self.assertEqual(rc, 3) + self.assertIn("docs/23", err) + + def test_help_is_clean(self): + for main_fn in (ci_release_main, version_diff_main): + with contextlib.redirect_stdout(io.StringIO()) as buf, self.assertRaises(SystemExit) as ctx: + main_fn(["--help"]) + self.assertEqual(ctx.exception.code, 0) + self.assertIn("usage", buf.getvalue()) + + +@unittest.skipUnless(qemu_available(), "qemu-system-riscv64 not installed") +@unittest.skipUnless(os.path.exists(DEMO_ELF), "demo firmware not built (make demo)") +class VersionDiffQemuTests(unittest.TestCase): + def test_self_diff_zero_inventory_and_behavior(self): + with tempfile.TemporaryDirectory(prefix="vdiff-self-") as tmp: + options = VersionDiffOptions( + old=DEMO_ELF, + new=DEMO_ELF, + out=tmp, + probe_instructions=200, + behavior_duration=0.2, + max_rows=20, + ) + report = run_version_diff(options) + self.assertTrue(report["sections"]["image"]["same"]) + symbols = report["sections"]["symbols"] + self.assertEqual(symbols["status"], "ok") + self.assertEqual(symbols["shift"], 0) + self.assertEqual(symbols["totals"]["added"], 0) + self.assertEqual(symbols["totals"]["removed"], 0) + self.assertEqual(symbols["totals"]["moved"], 0) + self.assertEqual(symbols["totals"]["resized_named"], 0) + probe = report["sections"]["probe"] + self.assertEqual(probe["status"], "ok") + self.assertFalse(probe["changed"]) + behavior = report["sections"]["behavior"] + self.assertEqual(behavior["status"], "ok") + self.assertTrue(behavior["all_zero"], behavior["deltas"]) + for rows in behavior["deltas"].values(): + for row in rows: + self.assertEqual(row["delta"], 0) + self.assertTrue(os.path.exists(os.path.join(tmp, "version-diff.md"))) + with open(os.path.join(tmp, "version-diff.json"), encoding="utf-8") as fh: + self.assertEqual(json.load(fh)["format"], "greeneye-version-diff/1") + + +if __name__ == "__main__": + unittest.main() diff --git a/docs/07-developers/02-development-and-analysis-environment.md b/docs/07-developers/02-development-and-analysis-environment.md new file mode 100644 index 0000000..370cefc --- /dev/null +++ b/docs/07-developers/02-development-and-analysis-environment.md @@ -0,0 +1,363 @@ +# Development and analysis environment: Ghidra, green-eye and the Open-IOV toolchain + +> The single front door to the environment an Open-IOV developer works in: +> the host stack, the guest diagnostics, the CUDA userspace and GSP firmware +> Ghidra corpora, the host RM tooling, green-eye, CI and MCP. Each component +> links to the document that specifies it; this page is the map, not a +> duplicate of the manuals. + +## 1. Scope and benefit + +Open-IOV spans a host kernel module, a QEMU/KVM guest stack, a CUDA userspace +binary, GSP firmware, a host resource manager and a growing analysis toolchain. +The pieces were built by different workstreams and each has its own manual; the +cost of a new contributor (or a returning one) has been re-learning where to +start for a given question. This document exists to remove that cost: it is the +**one map** of the environment — what every tool is for, when to use it, where +it lives, and which document explains it — plus the two reverse-engineering +loops that tie static and dynamic analysis together. + +In scope: the developer-facing analysis environment. Out of scope: end-user +installation (see [`02-getting-started/01-install-and-first-run.md`](../02-getting-started/01-install-and-first-run.md)), +the emulation architecture itself (see +[`03-architecture/01-system-overview.md`](../03-architecture/01-system-overview.md)) +and product roadmaps. The green-eye toolchain is **in development and not +ready yet** — status, limits and flag-removal criteria are in +[`05-operations/03-risc-v-gsp-rm-vm.md`](../05-operations/03-risc-v-gsp-rm-vm.md) +and the tool's own +[`docs/00-OVERVIEW.md`](../../debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md). + +## 2. The environment at a glance + +```mermaid +flowchart TB + subgraph HOST["Lab host - x86_64 or aarch64"] + SM["scripts/stack_manager.sh
build / load / status / test / unload"] + MOD["open-iov-vfio.ko
virtual device + GSP-RM emulation + backend"] + QEMU["QEMU / KVM
scripts/vgpu_vm.py, launch_qemu_*.sh"] + VFIO["VFIO mdev + IOMMU isolation"] + RMTOOLS["tools/rmtool, rmtop, rmq
read-only host RM oracle"] + subgraph GUEST["Guest VM - stock driver, no Open-IOV code"] + WORK["CUDA workload + tools/cuda-ab"] + GDIAG["tools/guest-diag
ioctl tracer, engines, pmap, probes"] + end + subgraph STATIC["Static analysis"] + GHFW["tools/ghidra + firmware/57X-GSP/<ver>
GSP firmware Ghidra projects"] + GHCUDA["userspace/cuda Ghidra corpus
+ analysis tools"] + end + subgraph DYN["Dynamic firmware analysis - green-eye"] + GE["Session: hooks, watchpoints, memory,
call, run_until, record"] + GEB["Backends: QEMU-RSP + native RV64IMAC"] + GEH["harness/, injection SDK,
Ghidra bridges, device maps"] + RMH["rmharness host captures"] + end + subgraph AUTO["Automation"] + CI[".github/workflows/ci.yml
.github/workflows/greeneye.yml"] + MCP["plugins/mcp_server.py
agent tool surface"] + end + end + SM --> MOD --> QEMU --> VFIO --> GUEST + MOD --> RMTOOLS + WORK --> GDIAG + RMTOOLS --> RMH + GHFW --> GE + GHCUDA --> GE + GE --> RMH + GE --> CI + GE --> MCP + GE -->|"export_hits + symbol DB"| GHFW + GHFW -->|"names + addresses"| GE +``` + +| Layer | What it answers | Entry point | +|---|---|---| +| Host stack | does the device load, boot and serve the guest? | `scripts/stack_manager.sh`, `scripts/vgpu_vm.py` | +| Guest diagnostics | what did the stock driver request and receive? | `tools/guest-diag/` | +| CUDA static corpus | what is `libcuda` doing, and what changed? | `userspace/cuda/READ-ME-FIRST.md` | +| GSP firmware static | what does the firmware image contain? | `tools/ghidra/`, `firmware/57X-GSP//` | +| Host RM oracle | what does real RM report on this GPU? | `tools/rmtool`, `tools/rmtop`, `rmharness/` | +| Dynamic firmware | what did the firmware server actually do? | `debug/risc-v-gsp-rm-vm/greeneye.sh` | +| CI / agent | did the broad suite pass; can an agent drive it? | `.github/workflows/`, `plugins/mcp_server.py` | + +## 3. Host stack: KVM, QEMU, VFIO and the module + +The reference path is KVM + QEMU with the VFIO mdev transport. `open-iov-vfio.ko` +emulates the device interface and GSP-RM behaviour (or drives the physical +backend, per the chosen execution mode); the guest runs stock drivers only. + +```bash +./install.sh deps nvidia firmware build # full host setup (stages selectable) +make kernel # or: ./scripts/stack_manager.sh build-kernel +./scripts/stack_manager.sh load-open-iov # load open-iov-vfio.ko +./scripts/stack_manager.sh mdev-create # create the mediated device +./scripts/vgpu_vm.py start vgpu-linux # guest lifecycle + guest-agent exec/shell +./scripts/stack_manager.sh test # host test suite +./scripts/stack_manager.sh dmesg # live filtered trace +``` + +Module load order matters on the optional OpenRM path: build `kernel/` first +(it generates `kernel/Module.symvers`), then build the OpenRM modules with +`KBUILD_EXTRA_SYMBOLS`, then load Open-IOV, then OpenRM, then firmware. On a +typical single-GPU developer host the distribution driver owns the GPU and the +emulated QEMU path runs without the OpenRM modules. The full contract is +[`03-architecture/06-host-module-and-hybrid-model.md` §7](../03-architecture/06-host-module-and-hybrid-model.md). + +Guest virtual-memory inspection uses the QEMU gdbstub +(`scripts/qemu_gdb_attach.sh`, `-gdb tcp::1234`); host-side guest-RAM scans and +pokes use the module debug parameters (`scan_guest_ram`, `scan_seq`, +`poke_guest`) documented in +[`05-operations/02-debugging-and-maintenance-workflows.md`](../05-operations/02-debugging-and-maintenance-workflows.md) §3.1–3.3. +VM access from tooling goes through `scripts/vgpu_vm.py`; scripts never reboot +or change system state without explicit operator action (project hard rule). + +## 4. Guest diagnostics + +The stock guest driver is the best oracle for driver-side behaviour. These +tools read it out without modifying it: + +| Tool | Purpose | Location | +|---|---|---| +| `ioctl_trace.c` | ptrace-based syscall/ioctl tracer (`LD_PRELOAD` cannot see `libcuda`'s raw syscalls) | `tools/guest-diag/ioctl_trace.c` | +| `guest_engines.c` | guest engine/class inventory, virtualization mode, CE caps | `tools/guest-diag/guest_engines.c` | +| `guest_pmap*.py`, `pmap_va.sh` | guest page-map walks (Linux and Windows samples) | `tools/guest-diag/` | +| `push_guest_tools.sh` | stage diagnostics into the guest | `tools/guest-diag/push_guest_tools.sh` | +| UVM debug prints | `echo 1 > /sys/module/nvidia_uvm/parameters/uvm_debug_prints` | guest sysfs | + +Standard captures for a bug report (host log, status, guest dmesg, `lspci -vvv`, +`nvidia-smi -q`) are listed in +[`05-operations/02` §2](../05-operations/02-debugging-and-maintenance-workflows.md). + +## 5. CUDA userspace: the Ghidra corpus and its tools + +`userspace/cuda/` holds the decompiled corpus and name map for the guest +`libcuda` (570.144) plus reusable tooling. Start at +[`userspace/cuda/READ-ME-FIRST.md`](../../userspace/cuda/READ-ME-FIRST.md); the +five core documents are `docs/01-architecture.md` through +`docs/05-570.144-decompilation.md`, with +`docs/07-version-upgrade-playbook.md` for version bumps and +`docs/06-tooling.md` for recipes. + +**Address convention (the one rule that bites):** public offsets are file +virtual addresses (`libcuda+0x...`); Ghidra loads the library at image base +`0x100000`, so decompilations produced without translation point 0x100000 too +low. Current tooling translates; re-derive old conclusions +([`docs/05` §1.1](../../userspace/cuda/docs/05-570.144-decompilation.md)). + +| Tool | Purpose | +|---|---| +| `tools/cudatool.py` | exports/FDEs/wrappers, `info`, `nearest`, `crash`, `delta`, `fdematch` | +| `tools/callwalk.py` | callers/callees/chains over `callgraph.tsv` | +| `tools/diff_corpus.py` | corpus diff across driver versions (match discipline reused by green-eye version diffs) | +| `tools/name_candidates.py`, `subsystem_digest.py` | name candidates and per-subsystem call-tree digests | +| `tools/ghidra_*.sh`, `tools/ghidra/` | headless analysis, decompile-at, index/name-map export, merge | +| `tools/annotate_corpus.py` | apply the name map to the corpus | +| `570.144/annotations/names.tsv`, `subsystems.tsv` | tracked name map (659 exports, verified internals) and subsystem index | +| `tools/cuda-ab/ab.sh` + `vector_add.cu` | host-vs-guest A/B workload; `RESULT`/`STATUS` lines must match | +| `tools/test/cuda_drv_test.c` | driver-API smoke test (prints `ALL OK`) | + +## 6. GSP firmware: Ghidra projects and images + +Firmware images are versioned and never overwritten: +`firmware/-GSP//gsp-.fwimage.bin` (currently 570.144). The +firmware analysis pipeline is driven from `tools/ghidra/`: + +| Tool / artifact | Purpose | +|---|---| +| `tools/ghidra/analyze_fwimage.py` | headless project creation + analysis for a `.fwimage` | +| `tools/ghidra/create_gsp_project.py` | build the GSP Ghidra project layout | +| `tools/ghidra/diff_gsp_firmware.py`, `analyze_gsp_firmware.sh` | cross-version firmware diff / batch analysis | +| `firmware/57X-GSP/570.144/analyze_firmware.py` | version-local extraction and reporting scripts | +| `firmware/57X-GSP/570.144/decompiled*`, `RPC_HANDLER_MAP.md`, `FINAL_REPORT.md` | per-version decompiled handlers and maps | +| `kernel/firmware_analyzer.c` (`known_signatures[]`) | runtime firmware identification used by the module | + +The upgrade procedure — acquire, version, decompile, diff the RPC dispatcher, +update the version surface, validate, roll back safely — is +[`05-operations/02` §4.1](../05-operations/02-debugging-and-maintenance-workflows.md); +green-eye adds a runtime layer on top in +[`debug/risc-v-gsp-rm-vm/docs/22-PORTING-CUDA-AND-DEVICES.md`](../../debug/risc-v-gsp-rm-vm/docs/22-PORTING-CUDA-AND-DEVICES.md). + +## 7. Host RM oracle: rmtool, rmtop, rmq and rmharness + +`tools/rmtool` and `tools/rmtop` are new, read-only host-RM clients sharing one +ioctl layer (`tools/rmtool/src/rm_api.c`, `query.c`); `tools/rmq` is the +pre-existing device-info oracle. They allocate only an RM client/device/ +subdevice handle and never create engine, channel or memory objects; write +paths are refused in code. Green-eye's `rmharness/` wrappers locate, build and +capture them into sessions. + +```bash +make -C tools/rmtool && make -C tools/rmtop +tools/rmtool/rmtool gpu list --format=json +tools/rmtool/rmtool engine list && tools/rmtool/rmtool mem stats +./rmharness/harness.sh --session dumps/real-rm -- rmtool engine list --format=json +``` + +Details: `tools/rmtool/` sources, [`rmharness/README.md`](../../debug/risc-v-gsp-rm-vm/rmharness/README.md), +the tool doc [`17-RM-HARNESS.md`](../../debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md), +and the metric use (M1/M6) in `docs/06-ACCURACY.md`. + +## 8. green-eye and its subsystems + +`debug/risc-v-gsp-rm-vm/` is the dynamic firmware environment. Its subsystem +map: `greeneye/` (Session, memory, symbols, structs, probes, record, analyze, +plugins, Ghidra bridges, injection, device map, native backend), `sim/` +(deterministic RV64IMAC interpreter + trace), `sdk/` (injection ABI), `qemu/` +(launch helper + TCG trace plugin), `ghidra/` (Java bridge scripts), +`plugins/` (example, rmharness, MCP), `harness/` (automation), `rmharness/`, +`firmware/uproc_demo/` and `tests/`. + +```bash +cd debug/risc-v-gsp-rm-vm +make demo sdk +./greeneye.sh info && ./greeneye.sh demo +./greeneye.sh probe # real fwimage: first divergence, honestly bounded +./greeneye.sh analyze dumps/ +``` + +The full architecture and API surfaces are +[`26-ARCHITECTURE.md`](../../debug/risc-v-gsp-rm-vm/docs/26-ARCHITECTURE.md); +purpose/status is +[`05-operations/03-risc-v-gsp-rm-vm.md`](../05-operations/03-risc-v-gsp-rm-vm.md); +the long-term program is +[`GREEN-EYE-ROADMAP.md`](../../debug/risc-v-gsp-rm-vm/GREEN-EYE-ROADMAP.md). + +## 9. CI and MCP + +* **Project CI**: `.github/workflows/ci.yml` builds the kernel module, + userspace and host tests on the main tree. +* **Tool CI**: `.github/workflows/greeneye.yml` installs + `qemu-system-misc clang lld-18`, builds `sdk`/`demo`, and runs the tool unit + tests (`python3 -m unittest discover -s tests -t .`) on pushes/PRs touching + `debug/risc-v-gsp-rm-vm/**` or `tools/rm*/**`. No secrets, hardware or guest. +* **Release pipelines**: `harness/version_diff.py` and `harness/ci_release.py` + produce a hashed evidence bundle; the RM 610 gate is enforced before any + target file is opened (`docs/21`, `docs/23`). +* **MCP**: `plugins/mcp_server.py` exposes the active session as 24 JSON-RPC + tools over stdio for Claude Code/opencode; read/observe by default, writes + and injection explicitly gated, sessions as the audit trail (`docs/20`). + +## 10. Tool table + +| Tool | Purpose | When to use it | Where it lives | Doc | +|---|---|---|---|---| +| `stack_manager.sh` | build/load/status/test/unload with correct ordering | every host-stack change or bring-up | `scripts/stack_manager.sh` | [`03-architecture/06` §7](../03-architecture/06-host-module-and-hybrid-model.md) | +| `vgpu_vm.py` | VM lifecycle + guest-agent exec/shell | booting/inspecting the guest | `scripts/vgpu_vm.py` | [`05-operations/02`](../05-operations/02-debugging-and-maintenance-workflows.md) | +| QEMU launch/gdb helpers | direct QEMU launches, QMP/gdbstub access | gdbstub triage, direct QEMU runs | `scripts/launch_qemu_*.sh`, `qemu_*.sh/py` | [`05-operations/02` §3.3/§3.7](../05-operations/02-debugging-and-maintenance-workflows.md) | +| module debug parameters | guest-RAM scan/poke, live trace | memory-state debugging in kernel context | `/sys/module/open_iov_vfio/parameters/` | [`05-operations/02` §3.1–3.2](../05-operations/02-debugging-and-maintenance-workflows.md) | +| `guest-diag` | ioctl trace, engine list, pmap | driver-side oracle for a guest failure | `tools/guest-diag/` | [`05-operations/02` §3.4](../05-operations/02-debugging-and-maintenance-workflows.md) | +| `cudatool.py` | CUDA binary info/delta/crash/fdematch | every `libcuda` version bump or crash | `userspace/cuda/tools/` | [`READ-ME-FIRST` §3](../../userspace/cuda/READ-ME-FIRST.md) | +| `callwalk.py` | caller/callee/path walks | tracing a crash path through the corpus | `userspace/cuda/tools/` | [`READ-ME-FIRST` §3](../../userspace/cuda/READ-ME-FIRST.md) | +| `diff_corpus.py` | corpus cross-version diff | deciding port class (patch/feature/reshuffle) | `userspace/cuda/tools/` | [`docs/07`](../../userspace/cuda/docs/07-version-upgrade-playbook.md) | +| CUDA Ghidra scripts | headless analysis, decompile-at, name map | building/extending the corpus | `userspace/cuda/tools/ghidra*` | [`docs/06-tooling.md`](../../userspace/cuda/docs/06-tooling.md) | +| `cuda-ab/ab.sh` | deterministic host-vs-guest workload A/B | validating an emulation change end-to-end | `tools/cuda-ab/` | [`05-operations/01`](../05-operations/01-performance-correctness-security.md) | +| GSP Ghidra pipeline | firmware project creation + diff | new GSP version or new firmware question | `tools/ghidra/` | [`05-operations/02` §4.1](../05-operations/02-debugging-and-maintenance-workflows.md) | +| `firmware_analyzer.c` | runtime firmware signatures | registering a new image for identification | `kernel/firmware_analyzer.c` | [`05-operations/02` §4.1](../05-operations/02-debugging-and-maintenance-workflows.md) | +| `rmtool` | read-only RM inventory/mem/timer oracle | ground truth for M1/M6 metrics | `tools/rmtool/` | [`17`](../../debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md) | +| `rmtop` | ncurses dashboard; `--once` for capture | human overview / session capture | `tools/rmtop/` | [`17`](../../debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md) | +| `rmq` | device-info tables (unmodified) | TU104 static data queries | `tools/rmq/` | [`17`](../../debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md) | +| `rmharness` | capture host oracle runs into sessions | pairing real RM data with a sim session | `debug/risc-v-gsp-rm-vm/rmharness/` | [`rmharness/README`](../../debug/risc-v-gsp-rm-vm/rmharness/README.md) | +| green-eye CLI | session lifecycle, probe, record, analyze, compare, inject, Ghidra | all dynamic firmware questions | `debug/risc-v-gsp-rm-vm/greeneye.sh` | [`00`](../../debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md), [`02`](../../debug/risc-v-gsp-rm-vm/docs/02-USAGE.md) | +| green-eye Python API | scripted instrumentation and injection | repeatable/CI-able experiments | `greeneye/`, `examples/` | [`03`](../../debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md) | +| green-eye Ghidra bridge | run-until/breakpoints from the decompiler | static↔dynamic address joins | `ghidra/`, `greeneye/gdbridge.py` | [`05`](../../debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md), [`13`](../../debug/risc-v-gsp-rm-vm/docs/13-GHIDRA-CONTROL.md) | +| injection SDK | PIC RV64 service blobs + ABI | prototyping a service or engine | `sdk/`, `greeneye/inject.py` | [`12`](../../debug/risc-v-gsp-rm-vm/docs/12-INJECTION-SDK.md) | +| green-eye harness | matrix, mutation, discovery, reach, version diff, release bundle, demos | porting, regression, evidence packs | `debug/risc-v-gsp-rm-vm/harness/` | [`18`](../../debug/risc-v-gsp-rm-vm/docs/18-HARNESS-AUTOMATION.md), [`19`](../../debug/risc-v-gsp-rm-vm/docs/19-CONTROL-DISCOVERY.md), [`21`](../../debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md) | +| native simulator + trace | deterministic RV64IMAC + exec/access trace | speed/determinism experiments | `sim/`, `greeneye/sim_backend.py` | [`16`](../../debug/risc-v-gsp-rm-vm/docs/16-CUSTOM-SIMULATOR.md) | +| device map | first-touch MMIO data + lenient mode | device-model bring-up | `greeneye/device_map.py` | [`24`](../../debug/risc-v-gsp-rm-vm/docs/24-DEVICE-MAP.md) | +| TCG trace plugin | full instruction/memory JSONL trace | coverage map when hooks are not enough | `qemu/greeneye_trace.c` | [`qemu/README.md`](../../debug/risc-v-gsp-rm-vm/qemu/README.md) | +| MCP server | agent tool surface over a live session | agentic analysis/monitoring | `plugins/mcp_server.py` | [`20`](../../debug/risc-v-gsp-rm-vm/docs/20-MCP-SERVER.md) | +| CI workflows | project and tool suites | every PR touching the covered paths | `.github/workflows/` | [`21` §5](../../debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md) | + +## 11. The two reverse-engineering loops + +Static analysis answers *what is the code*; dynamic analysis answers *what did +it do*. They interlock through addresses, symbols and recorded evidence. + +```mermaid +flowchart LR + subgraph STATIC["Static loop (Ghidra)"] + A1["CUDA corpus + GSP firmware projects"] --> A2["name transfer, corpus diff,
symbol inventory diff"] + A2 --> A3["hypothesis: dispatch tables,
call paths, data layouts"] + end + subgraph DYN["Dynamic loop (green-eye)"] + B1["Session + injection on the firmware"] --> B2["observe, record, compare
sessions"] + B2 --> B3["evidence: hits, watch PCs,
written pages, reply values"] + end + A2 -->|"symbol DB with bias
(symbols export)"| B1 + B3 -->|"export_hits, fault PC,
corpus bucket:line"| A1 + A3 --> C["emulation change in open-iov-vfio"] + B3 --> C + C --> D["physical confirmation +
regression with the same stimulus"] +``` + +* **Static loop**: import the binary/version into Ghidra, transfer the name + map (`cudatool.py delta`/`fdematch` for CUDA; `diff_gsp_firmware.py` and the + RPC map for firmware), diff the corpus, and read the changed functions. +* **Dynamic loop**: boot the firmware in green-eye, instrument it, record a + session, compare against a reference, and attach the session as evidence. +* **Interlock**: a Ghidra symbol/address is resolved into the runtime through a + symbol DB with the right bias; a runtime hit or fault PC is exported back + (`export_hits`) and annotated into the project + (`MarkGreenEyeHits.java`); `corpus_location()` maps a hit to its decompiled + body when the index carried `bucket:line`. See the green-eye + [`05`](../../debug/risc-v-gsp-rm-vm/docs/05-GHIDRA-BRIDGE.md), + [`13`](../../debug/risc-v-gsp-rm-vm/docs/13-GHIDRA-CONTROL.md) and + [`26` §7](../../debug/risc-v-gsp-rm-vm/docs/26-ARCHITECTURE.md). + +## 12. Porting workflows at a map level + +Three independent churn axes; a port is a claim with evidence, not a patch. +Detailed procedures are not duplicated here. + +| Axis | Static triage | Dynamic confirmation | Full procedure | +|---|---|---|---| +| New CUDA userspace (`libcuda` bump) | `cudatool.py info/delta`, `fdematch`, `diff_corpus.py`, name-map apply | guest smoke (`cuda_drv_test.c`), `cuda-ab`, symbol spot-checks | [`userspace/cuda/docs/07`](../../userspace/cuda/docs/07-version-upgrade-playbook.md); green-eye [`22`](../../debug/risc-v-gsp-rm-vm/docs/22-PORTING-CUDA-AND-DEVICES.md) §2 | +| New GSP/RM firmware version | decompile + diff RPC dispatcher/controls; Ghidra symbol inventory | `greeneye probe` first-touch, scripted/re-hosted `record`, `compare`, `version_diff.py` | [`05-operations/02` §4.1](../05-operations/02-debugging-and-maintenance-workflows.md) (upgrade runbook); green-eye [`07 P1`](../../debug/risc-v-gsp-rm-vm/docs/07-PORTABILITY-PLAYBOOKS.md), [`21`](../../debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md), [`22`](../../debug/risc-v-gsp-rm-vm/docs/22-PORTING-CUDA-AND-DEVICES.md) §3 | +| New device / architecture | device profile data, ISA/image shape, PMU/SOE deltas | portability matrix, probe first-touch on the new image | [`06-portability/hardware-software-matrix.md`](../06-portability/hardware-software-matrix.md); green-eye [`07 P2/P3`](../../debug/risc-v-gsp-rm-vm/docs/07-PORTABILITY-PLAYBOOKS.md), [`18`](../../debug/risc-v-gsp-rm-vm/docs/18-HARNESS-AUTOMATION.md) | +| A/B accuracy ritual (real vs emulated) | M1–M6 metric definition | hardware/VFIO reference capture vs green-eye session | [`05-operations/01`](../05-operations/01-performance-correctness-security.md); green-eye [`06`](../../debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md), [`07 P4`](../../debug/risc-v-gsp-rm-vm/docs/07-PORTABILITY-PLAYBOOKS.md), [`25`](../../debug/risc-v-gsp-rm-vm/docs/25-VALUE-VALIDATION.md) | +| RM 610 family | — | gate first; nothing opened before approval | green-eye [`23`](../../debug/risc-v-gsp-rm-vm/docs/23-RM610-READINESS.md) (hard gate), [`21` §3](../../debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md) | + +The fast pipeline for a GSP version is: `harness/version_diff.py` → +`harness/portability_matrix.py` → `harness/mutation_harness.py`, wrapped by +`harness/ci_release.py --dry-run` first, with the resulting bundle attached to +the change. The diagonal rule holds everywhere: static analysis proposes, the +dynamic session disposes, and physical confirmation gates any product change. + +## 13. Environment invariants + +1. **The guest is stock**: no Open-IOV code, agent, driver patch or extra + kernel parameters in the guest, ever. +2. **Tools are in-tree**: no tools in `/tmp`; artifacts survive reboots and are + reviewable. +3. **Read-only host tools stay read-only**: `rmtool`/`rmtop`/`rmq`/`rmharness` + refuse write operations in code; no module load/unload, mdev churn or + `/sys/fs/pstore` access. +4. **No unattended physical-GPU cycles**; physical confirmation is supervised + and explicit. +5. **Simulator/re-hosted code is a study environment**: never shipped, never a + dependency, no confidentiality/isolation/timing claims. +6. **Evidence or it did not happen**: sessions, hashes and the M1–M6 table + accompany any portability or accuracy claim. +7. **Versioned, parallel directories**: firmware, CUDA versions and profiles + are added, never overwritten; rollback never destroys a working anchor. +8. **Provenance discipline**: tracked docs describe components and + behaviour; conclusions cite recorded evidence. + +## 14. Reading paths + +* **New contributor**: [`onboarding-and-standards.md`](onboarding-and-standards.md) + → this document → the subsystem doc for what you will touch. +* **CUDA bring-up**: [`../../userspace/cuda/READ-ME-FIRST.md`](../../userspace/cuda/READ-ME-FIRST.md) + → `docs/01`, `docs/03`, `docs/05` → `docs/22` for porting. +* **Firmware/green-eye**: [`05-operations/03-risc-v-gsp-rm-vm.md`](../05-operations/03-risc-v-gsp-rm-vm.md) + → tool [`00`](../../debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md), + [`26`](../../debug/risc-v-gsp-rm-vm/docs/26-ARCHITECTURE.md), then + [`03`](../../debug/risc-v-gsp-rm-vm/docs/03-SCRIPTING.md), + [`19`](../../debug/risc-v-gsp-rm-vm/docs/19-CONTROL-DISCOVERY.md) and + [`21`](../../debug/risc-v-gsp-rm-vm/docs/21-CI-RELEASE-PIPELINE.md) as needed. +* **Host stack / VM**: [`03-architecture/06-host-module-and-hybrid-model.md`](../03-architecture/06-host-module-and-hybrid-model.md) + and [`05-operations/02-debugging-and-maintenance-workflows.md`](../05-operations/02-debugging-and-maintenance-workflows.md). +* **Portability**: [`06-portability/hardware-software-matrix.md`](../06-portability/hardware-software-matrix.md) + plus green-eye `docs/07` and `docs/22`. + +Every path in this document was verified at the time of writing; when a tool +moves, update this table first — it is the front door. diff --git a/tools/rmtool/Makefile b/tools/rmtool/Makefile new file mode 100644 index 0000000..a66b7de --- /dev/null +++ b/tools/rmtool/Makefile @@ -0,0 +1,35 @@ +# rmtool - read-only NVIDIA RM host oracle (Open-IOV). +# +# Builds src/*.c into ./rmtool and a reusable lib/librmapi.a that rmtop links +# against. No external SDK or library dependency. +CC ?= gcc +CFLAGS ?= -O2 -Wall -Wextra +CPPFLAGS += -Iinclude +PREFIX ?= /usr/local + +OBJS = src/main.o src/rm_api.o src/query.o src/output.o src/gpu.o \ + src/engine.o src/mem.o src/nv2080.o +LIBOBJS = src/rm_api.o src/query.o src/output.o +LIB = lib/librmapi.a + +all: rmtool $(LIB) + +rmtool: $(OBJS) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $(OBJS) + +$(LIB): $(LIBOBJS) + @mkdir -p lib + ar rcs $@ $(LIBOBJS) + +src/%.o: src/%.c include/rm_api.h include/rmtool.h + $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< + +install: rmtool + install -d $(DESTDIR)$(PREFIX)/bin + install -m 0755 rmtool $(DESTDIR)$(PREFIX)/bin/rmtool + +clean: + rm -f $(OBJS) rmtool $(LIB) + rmdir lib 2>/dev/null || true + +.PHONY: all install clean diff --git a/tools/rmtool/include/rm_api.h b/tools/rmtool/include/rm_api.h new file mode 100644 index 0000000..5d7f8cf --- /dev/null +++ b/tools/rmtool/include/rm_api.h @@ -0,0 +1,297 @@ +/* rm_api.h - minimal self-contained NVIDIA RM userspace client. + * + * ABI and control IDs mirror the NV570 SDK (nvos.h, ctrl2080*.h) but the + * definitions live here so the tools build with no external SDK dependency. + * This is the shared layer used by rmtool and rmtop; it is read-only by + * design: it only allocates an RM client/device/subdevice and issues + * read-only NV2080/NV0080 control queries. + */ +#ifndef RM_API_H +#define RM_API_H + +#include + +typedef uint8_t NvU8; +typedef uint16_t NvU16; +typedef uint32_t NvU32; +typedef uint64_t NvU64; +typedef uint32_t NvHandle; +typedef uint64_t NvP64; +typedef uint32_t NvV32; + +/* NVIDIA escape encoding: _IOWR('F', nr, type) */ +#define NV_IOCTL_MAGIC 'F' +#define NV_IOCTL_BASE 200 +#define NV_ESC_RM_ALLOC 0x2B +#define NV_ESC_RM_CONTROL 0x2A +#define NV_ESC_CHECK_VERSION_STR (NV_IOCTL_BASE + 10) +#define NV_ESC_WAIT_OPEN_COMPLETE (NV_IOCTL_BASE + 18) + +/* RM classes used by the client ladder */ +#define NV01_ROOT 0x00000000U +#define NV01_ROOT_CLIENT 0x00000041U +#define NV01_DEVICE_0 0x00000080U +#define NV20_SUBDEVICE_0 0x00002080U + +/* NVOS21/NVOS64 match the running driver ABI (NV570 kernel nvos.h): both + * carry paramsSize. The userspace SDK snapshot in-tree has an older layout; + * using it results in the kernel rejecting the ioctl with EINVAL. + */ +typedef struct { + NvHandle hRoot; + NvHandle hObjectParent; + NvHandle hObjectNew; + NvV32 hClass; + NvP64 pAllocParms __attribute__((aligned(8))); + NvU32 paramsSize; + NvV32 status; +} NVOS21_PARAMETERS; + +#define NVOS64_FLAGS_NONE 0x00000000U +#define NVOS64_FLAGS_FINN_SERIALIZED 0x00000001U + +typedef struct { + NvHandle hRoot; + NvHandle hObjectParent; + NvHandle hObjectNew; + NvV32 hClass; + NvP64 pAllocParms __attribute__((aligned(8))); + NvP64 pRightsRequested __attribute__((aligned(8))); + NvU32 paramsSize; + NvU32 flags; + NvV32 status; +} NVOS64_PARAMETERS; + +/* NVOS54 (NV04_CONTROL), standard nvos.h layout: sizeof = 32 */ +typedef struct { + NvHandle hClient; + NvHandle hObject; + NvV32 cmd; + NvU32 flags; + NvP64 params __attribute__((aligned(8))); + NvU32 paramsSize; + NvV32 status; +} NVOS54_PARAMETERS; + +#define NV_RM_API_VERSION_STRING_LENGTH 64 +#define NV_RM_API_VERSION_CMD_QUERY '2' +#define NV_RM_API_VERSION_REPLY_RECOGNIZED 1U + +typedef struct { + NvU32 cmd; + NvU32 reply; + char versionString[NV_RM_API_VERSION_STRING_LENGTH]; +} nv_ioctl_rm_api_version_t; + +typedef struct { + int rc; + NvU32 adapterStatus; +} nv_ioctl_wait_open_complete_t; + +typedef struct { + NvU32 deviceId; + NvHandle hClientShare; + NvHandle hTargetClient; + NvHandle hTargetDevice; + NvV32 flags; + NvU64 vaSpaceSize __attribute__((aligned(8))); + NvU64 vaStartInternal __attribute__((aligned(8))); + NvU64 vaLimitInternal __attribute__((aligned(8))); + NvV32 vaMode; +} NV0080_ALLOC_PARAMETERS; + +typedef struct { + NvU32 subDeviceId; +} NV2080_ALLOC_PARAMETERS; + +#define NV_IOCTL_RM_ALLOC21 \ + _IOWR(NV_IOCTL_MAGIC, NV_ESC_RM_ALLOC, NVOS21_PARAMETERS) +#define NV_IOCTL_RM_ALLOC64 \ + _IOWR(NV_IOCTL_MAGIC, NV_ESC_RM_ALLOC, NVOS64_PARAMETERS) +#define NV_IOCTL_RM_CONTROL \ + _IOWR(NV_IOCTL_MAGIC, NV_ESC_RM_CONTROL, NVOS54_PARAMETERS) +#define NV_IOCTL_CHECK_VERSION \ + _IOWR(NV_IOCTL_MAGIC, NV_ESC_CHECK_VERSION_STR, \ + nv_ioctl_rm_api_version_t) +#define NV_IOCTL_WAIT_OPEN_COMPLETE \ + _IOWR(NV_IOCTL_MAGIC, NV_ESC_WAIT_OPEN_COMPLETE, \ + nv_ioctl_wait_open_complete_t) + +/* ------------------------------------------------------------------ */ +/* Read-only NV2080/NV0080 controls used by the tools. */ +/* ------------------------------------------------------------------ */ + +/* NV2080_CTRL_CMD_GPU_GET_INFO_V2 (subdevice, no embedded pointers) */ +#define NV2080_CTRL_CMD_GPU_GET_INFO_V2 0x20800102U +#define NV2080_CTRL_CMD_GPU_GET_NAME_STRING 0x20800110U +#define NV2080_CTRL_CMD_GPU_QUERY_MODE 0x20800128U +#define NV2080_CTRL_CMD_GPU_GET_ID 0x20800142U +#define NV2080_CTRL_CMD_GPU_GET_ENGINES_V2 0x20800170U +#define NV2080_CTRL_CMD_MC_GET_ARCH_INFO 0x20801701U +#define NV2080_CTRL_CMD_FB_GET_INFO_V2 0x20801303U +#define NV2080_CTRL_CMD_BUS_GET_PCI_INFO 0x20801801U +#define NV2080_CTRL_CMD_TIMER_GET_TIME 0x20800403U +#define NV2080_CTRL_CMD_FIFO_GET_DEVICE_INFO_TABLE 0x20801112U +#define NV0080_CTRL_CMD_GPU_GET_CLASSLIST_V2 0x00000292U + +#define NV2080_CTRL_GPU_INFO_MAX_LIST_SIZE 0x41U +#define NV2080_CTRL_GPU_INFO_INDEX_SYSMEM_ACCESS 0x1fU +#define NV2080_CTRL_GPU_INFO_INDEX_GPU_SM_VERSION 0x2cU +#define NV2080_CTRL_GPU_INFO_INDEX_GPU_FLA_CAPABILITY 0x2dU +#define NV2080_CTRL_GPU_INFO_INDEX_4K_PAGE_ISOLATION_REQUIRED 0x33U +#define NV2080_CTRL_GPU_INFO_INDEX_DISPLAY_ENABLED 0x34U +#define NV2080_CTRL_GPU_INFO_INDEX_GPU_PROFILING_CAPABILITY 0x36U +#define NV2080_CTRL_GPU_INFO_INDEX_GPU_DEBUGGING_CAPABILITY 0x37U +#define NV2080_CTRL_GPU_INFO_INDEX_CMP_SKU 0x3cU +#define NV2080_CTRL_GPU_INFO_INDEX_DMABUF_CAPABILITY 0x3dU + +typedef struct { + NvU32 index; + NvU32 data; +} NV2080_CTRL_GPU_INFO; + +typedef struct { + NvU32 gpuInfoListSize; + NV2080_CTRL_GPU_INFO gpuInfoList[NV2080_CTRL_GPU_INFO_MAX_LIST_SIZE]; +} NV2080_CTRL_GPU_GET_INFO_V2_PARAMS; + +#define NV2080_GPU_MAX_NAME_STRING_LENGTH 0x40U + +typedef struct { + NvU32 gpuNameStringFlags; + union { + NvU8 ascii[NV2080_GPU_MAX_NAME_STRING_LENGTH]; + NvU16 unicode[NV2080_GPU_MAX_NAME_STRING_LENGTH]; + } gpuNameString; +} NV2080_CTRL_GPU_GET_NAME_STRING_PARAMS; + +typedef struct { + NvU32 gpuId; +} NV2080_CTRL_GPU_GET_ID_PARAMS; + +typedef struct { + NvU32 mode; +} NV2080_CTRL_GPU_QUERY_MODE_PARAMS; + +#define NV2080_GPU_MAX_ENGINES_LIST_SIZE 0x54U + +typedef struct { + NvU32 engineCount; + NvU32 engineList[NV2080_GPU_MAX_ENGINES_LIST_SIZE]; +} NV2080_CTRL_GPU_GET_ENGINES_V2_PARAMS; + +typedef struct { + NvU32 architecture; + NvU32 implementation; + NvU32 revision; + NvU8 subRevision; +} NV2080_CTRL_MC_ARCH_INFO_PARAMS; + +#define NV2080_CTRL_FB_INFO_MAX_LIST_SIZE 0x39U +#define NV2080_CTRL_FB_INFO_INDEX_TOTAL_RAM_SIZE 0x08U +#define NV2080_CTRL_FB_INFO_INDEX_HEAP_SIZE 0x09U +#define NV2080_CTRL_FB_INFO_INDEX_LARGEST_FREE_REGION_SIZE_KB 0x12U +#define NV2080_CTRL_FB_INFO_INDEX_HEAP_FREE 0x16U +#define NV2080_CTRL_FB_INFO_INDEX_BAR1_SIZE 0x05U +#define NV2080_CTRL_FB_INFO_INDEX_BAR1_AVAIL_SIZE 0x1dU +#define NV2080_CTRL_FB_INFO_INDEX_USABLE_RAM_SIZE 0x20U + +typedef struct { + NvU32 index; + NvU32 data; +} NV2080_CTRL_FB_INFO; + +typedef struct { + NvU32 fbInfoListSize; + NV2080_CTRL_FB_INFO fbInfoList[NV2080_CTRL_FB_INFO_MAX_LIST_SIZE]; +} NV2080_CTRL_FB_GET_INFO_V2_PARAMS; + +typedef struct { + NvU32 pciDeviceId; + NvU32 pciSubSystemId; + NvU32 pciRevisionId; + NvU32 pciExtDeviceId; +} NV2080_CTRL_BUS_GET_PCI_INFO_PARAMS; + +typedef struct { + NvU64 time_nsec __attribute__((aligned(8))); +} NV2080_CTRL_TIMER_GET_TIME_PARAMS; + +/* NV2080_CTRL_FIFO_GET_DEVICE_INFO_TABLE: static engine table with names. + * Sizes match tools/rmq/rmq.c, which has produced ground-truth on TU104. + */ +typedef struct { + NvU32 engineData[16]; + NvU32 pbdmaIds[2]; + NvU32 pbdmaFaultIds[2]; + NvU32 numPbdmas; + char engineName[16]; +} NV2080_FIFO_DEVICE_ENTRY; + +typedef struct { + NvU32 baseIndex; + NvU32 numEntries; + NvU32 bMore; + NV2080_FIFO_DEVICE_ENTRY entries[32]; +} NV2080_CTRL_FIFO_GET_DEVICE_INFO_TABLE_PARAMS; + +#define NV0080_CTRL_GPU_CLASSLIST_MAX_SIZE 100U + +typedef struct { + NvU32 numClasses; + NvU32 classList[NV0080_CTRL_GPU_CLASSLIST_MAX_SIZE]; +} NV0080_CTRL_GPU_GET_CLASSLIST_V2_PARAMS; + +/* ------------------------------------------------------------------ */ +/* Client + GPU discovery */ +/* ------------------------------------------------------------------ */ + +struct rm_client { + int fd; /* /dev/nvidiactl control fd */ + int gpu_fd; /* /dev/nvidiaN device fd (open-complete) */ + NvU32 hClient; + NvU32 hDevice; + NvU32 hSubdevice; + int gpu_minor; +}; + +#define RM_MAX_GPUS 32 +#define RM_BDF_LEN 32 +#define RM_NAME_LEN 128 +#define RM_UUID_LEN 96 +#define RM_VBIOS_LEN 64 + +struct rm_gpu_desc { + int minor; + char bdf[RM_BDF_LEN]; + char model[RM_NAME_LEN]; + char uuid[RM_UUID_LEN]; + char vbios[RM_VBIOS_LEN]; + int irq; +}; + +/* Open /dev/nvidiactl, bind a GPU (gpu_minor; <0 means 0), alloc the + * root-client -> device -> subdevice ladder, and keep the matching + * /dev/nvidiaN device fd open for the lifetime of the client. + * dev_path (may be NULL) overrides the GPU device node. + * Returns 0 on success, negative errno, or an RM status. + */ +int rm_client_open(struct rm_client *c, int gpu_minor, const char *dev_path); +void rm_client_close(struct rm_client *c); + +/* Issue a control on hObject; returns the raw NV_STATUS (0 = NV_OK) and + * stores -1 in *ioctl_ret on transport failure (NULL allowed). */ +NvU32 rm_control(struct rm_client *c, NvU32 hObject, NvU32 cmd, + void *params, NvU32 size, int *ioctl_ret); + +/* Scan the per-GPU information files under /proc/driver/nvidia (read-only, + * no device access). + */ +int rm_scan_gpus(struct rm_gpu_desc *out, int max); + +/* True if cmd is on the read-only allowlist (see nv2080.c). */ +int rm_control_is_readonly(NvU32 cmd); + +const char *rm_status_str(NvU32 status); + +#endif /* RM_API_H */ diff --git a/tools/rmtool/include/rmtool.h b/tools/rmtool/include/rmtool.h new file mode 100644 index 0000000..55166f8 --- /dev/null +++ b/tools/rmtool/include/rmtool.h @@ -0,0 +1,71 @@ +/* rmtool.h - internal shared declarations for rmtool commands and output. */ +#ifndef RMTOOL_H +#define RMTOOL_H + +#include +#include +#include "rm_api.h" + +#define RMTOOL_VERSION "0.1.0" + +enum rm_format { + RM_FMT_TABLE = 0, + RM_FMT_JSON, + RM_FMT_CSV, +}; + +struct rm_kv { + const char *key; + const char *value; +}; + +struct rm_ctx { + int gpu_minor; /* -1 = first/default GPU */ + const char *dev_path; /* NULL = /dev/nvidiactl */ + enum rm_format format; + struct rm_client rm; + int have_rm; +}; + +/* output.c */ +enum rm_format rm_format_parse(const char *name); +const char *rm_human_kib(NvU64 kib, char *buf, size_t len); +void rm_print_kv(const char *title, const struct rm_kv *kv, int n, + enum rm_format format); +void rm_print_table(const char *title, const char *const *headers, + const char *cells, size_t stride, int rows, int cols, + enum rm_format format); +void rm_hexdump(const void *data, size_t len); + +/* query.c */ +int rm_ctx_open(struct rm_ctx *ctx); +int rm_apply_common_opt(struct rm_ctx *ctx, int opt, const char *arg); +NvU32 rm_query_name(struct rm_ctx *ctx, char *buf, size_t len); +NvU32 rm_query_mc_arch(struct rm_ctx *ctx, NV2080_CTRL_MC_ARCH_INFO_PARAMS *p); +NvU32 rm_query_pci(struct rm_ctx *ctx, NV2080_CTRL_BUS_GET_PCI_INFO_PARAMS *p); +NvU32 rm_query_fb(struct rm_ctx *ctx, const NvU32 *indices, NvU32 *values, + NvU32 n); +NvU32 rm_query_engines(struct rm_ctx *ctx, + NV2080_CTRL_GPU_GET_ENGINES_V2_PARAMS *p); +NvU32 rm_query_fifo_table(struct rm_ctx *ctx, + NV2080_FIFO_DEVICE_ENTRY *entries, + NvU32 max_entries, NvU32 *n_entries); +const char *rm_arch_str(NvU32 architecture); +const char *rm_engine_type_name(NvU32 engine_type); +const char *rm_engine_name(const NV2080_FIFO_DEVICE_ENTRY *entries, + NvU32 n_entries, NvU32 engine_type); + +/* gpu.c */ +int cmd_gpu(int argc, char **argv, struct rm_ctx *ctx); + +/* engine.c */ +int cmd_engine(int argc, char **argv, struct rm_ctx *ctx); + +/* mem.c */ +int cmd_mem(int argc, char **argv, struct rm_ctx *ctx); + +/* nv2080.c (includes sample) */ +int cmd_nv2080(int argc, char **argv, struct rm_ctx *ctx); +int cmd_sample(int argc, char **argv, struct rm_ctx *ctx); + +#endif /* RMTOOL_H */ diff --git a/tools/rmtool/rmtool b/tools/rmtool/rmtool new file mode 100755 index 0000000..a0c6bea Binary files /dev/null and b/tools/rmtool/rmtool differ diff --git a/tools/rmtool/src/engine.c b/tools/rmtool/src/engine.c new file mode 100644 index 0000000..c247e47 --- /dev/null +++ b/tools/rmtool/src/engine.c @@ -0,0 +1,252 @@ +/* engine.c - rmtool engine list/info via read-only GPU/engine controls. */ +#include +#include +#include +#include +#include + +#include "rmtool.h" + +static void usage(void) +{ + printf("usage: rmtool engine list [-d N|BDF] [--format=...]\n" + " rmtool engine info [-d N|BDF] -e ENGINE\n" + "ENGINE is a FIFO table name (gr, ce0, nvdec0, ...).\n"); +} + +static int parse_list(int argc, char **argv, struct rm_ctx *ctx) +{ + static const struct option long_opts[] = { + { "device-spec", required_argument, NULL, 'd' }, + { "format", required_argument, NULL, 'f' }, + { "device", required_argument, NULL, 1000 }, + { "help", no_argument, NULL, 'h' }, + { NULL, 0, NULL, 0 }, + }; + int opt; + + optind = 1; + opterr = 0; + while ((opt = getopt_long(argc, argv, "d:f:h", long_opts, NULL)) != -1) { + int rc; + + if (opt == 'h') + return 1; + rc = rm_apply_common_opt(ctx, opt, optarg); + if (rc < 0) + return -1; + } + if (optind < argc) { + fprintf(stderr, "rmtool: unexpected argument '%s'\n", + argv[optind]); + return -1; + } + return 0; +} + +static int engine_list(struct rm_ctx *ctx) +{ + NV2080_CTRL_GPU_GET_ENGINES_V2_PARAMS engines; + NV2080_FIFO_DEVICE_ENTRY *fifo; + const char *headers[] = { "IDX", "TYPE", "NAME", "PBDMAS", + "PBDMA IDS" }; + char (*cells)[64]; + int rc; + NvU32 status, n_fifo = 0; + unsigned int i; + + fifo = calloc(1, sizeof(*fifo) * 512); + cells = calloc(1, sizeof(*cells) * (NV2080_GPU_MAX_ENGINES_LIST_SIZE * + 5)); + if (fifo == NULL || cells == NULL) { + free(fifo); + free(cells); + return 2; + } + rc = rm_ctx_open(ctx); + if (rc != 0) { + fprintf(stderr, "rmtool: cannot open RM client: rc=%d\n", rc); + free(fifo); + free(cells); + return 2; + } + status = rm_query_engines(ctx, &engines); + if (status != 0) { + fprintf(stderr, "rmtool: GPU_GET_ENGINES_V2: %s\n", + rm_status_str(status)); + free(fifo); + free(cells); + return 2; + } + status = rm_query_fifo_table(ctx, fifo, 512, &n_fifo); + + for (i = 0; i < engines.engineCount; i++) { + char type[24]; + + snprintf(type, sizeof(type), "0x%x", engines.engineList[i]); + snprintf(cells[i * 5 + 0], sizeof(cells[0]), "%u", i); + snprintf(cells[i * 5 + 1], sizeof(cells[0]), "%s", type); + snprintf(cells[i * 5 + 2], sizeof(cells[0]), "%s", + rm_engine_name(fifo, n_fifo, engines.engineList[i])); + snprintf(cells[i * 5 + 3], sizeof(cells[0]), "-"); + snprintf(cells[i * 5 + 4], sizeof(cells[0]), "-"); + } + if (status == 0) { + NvU32 j; + + for (j = 0; j < n_fifo && j < engines.engineCount; j++) { + snprintf(cells[j * 5 + 2], sizeof(cells[0]), "%s", + fifo[j].engineName); + snprintf(cells[j * 5 + 3], sizeof(cells[0]), "%u", + fifo[j].numPbdmas); + snprintf(cells[j * 5 + 4], sizeof(cells[0]), + "%u,%u", fifo[j].pbdmaIds[0], + fifo[j].pbdmaIds[1]); + } + } + if (ctx->format == RM_FMT_TABLE) + printf("%u engine(s); FIFO table %s\n", engines.engineCount, + status == 0 ? "read" : "unavailable"); + rm_print_table("engines", headers, &cells[0][0], sizeof(cells[0]), + (int)engines.engineCount, 5, ctx->format); + free(fifo); + free(cells); + return 0; +} + +static int engine_info(struct rm_ctx *ctx, const char *want) +{ + NV2080_FIFO_DEVICE_ENTRY *fifo; + struct rm_kv kv[8]; + char v[8][256]; + NvU32 n_fifo = 0, i; + int rc, n = 0; + NvU32 status; + + fifo = calloc(1, sizeof(*fifo) * 512); + if (fifo == NULL) + return 2; + rc = rm_ctx_open(ctx); + if (rc != 0) { + fprintf(stderr, "rmtool: cannot open RM client: rc=%d\n", rc); + free(fifo); + return 2; + } + status = rm_query_fifo_table(ctx, fifo, 512, &n_fifo); + if (status != 0) { + fprintf(stderr, "rmtool: FIFO_GET_DEVICE_INFO_TABLE: %s\n", + rm_status_str(status)); + free(fifo); + return 2; + } + for (i = 0; i < n_fifo; i++) { + if (strcasecmp(fifo[i].engineName, want) != 0) + continue; + snprintf(v[0], sizeof(v[0]), "%s", fifo[i].engineName); + kv[n].key = "Engine"; + kv[n++].value = v[0]; + snprintf(v[1], sizeof(v[1]), "%u", i); + kv[n].key = "Table index"; + kv[n++].value = v[1]; + snprintf(v[2], sizeof(v[2]), "%u", fifo[i].numPbdmas); + kv[n].key = "PBDMA count"; + kv[n++].value = v[2]; + snprintf(v[3], sizeof(v[3]), "%u,%u", fifo[i].pbdmaIds[0], + fifo[i].pbdmaIds[1]); + kv[n].key = "PBDMA ids"; + kv[n++].value = v[3]; + snprintf(v[4], sizeof(v[4]), "%u,%u", + fifo[i].pbdmaFaultIds[0], fifo[i].pbdmaFaultIds[1]); + kv[n].key = "PBDMA fault ids"; + kv[n++].value = v[4]; + snprintf(v[5], sizeof(v[5]),"%u", fifo[i].engineData[0]); + kv[n].key = "engineData[0] (type)"; + kv[n++].value = v[5]; + { + size_t off = 0; + unsigned int j; + + for (j = 0; j < 16 && off < sizeof(v[6]); j++) + off += (size_t)snprintf(v[6] + off, + sizeof(v[6]) - off, + "%s%u", j ? "," : "", + fifo[i].engineData[j]); + } + kv[n].key = "engineData[0..15]"; + kv[n++].value = v[6]; + rm_print_kv("engine info (FIFO device table)", kv, n, + ctx->format); + free(fifo); + return 0; + } + fprintf(stderr, "rmtool: engine '%s' not found in FIFO table " + "(%u entries)\n", want, n_fifo); + free(fifo); + return 2; +} + +int cmd_engine(int argc, char **argv, struct rm_ctx *ctx) +{ + const char *want = NULL; + int rc; + + if (argc < 2) { + usage(); + return 2; + } + if (strcmp(argv[1], "list") == 0) { + rc = parse_list(argc - 1, argv + 1, ctx); + if (rc < 0) + return 2; + if (rc == 1) { + usage(); + return 0; + } + return engine_list(ctx); + } + if (strcmp(argv[1], "info") == 0) { + int opt; + static const struct option long_opts[] = { + { "device-spec", required_argument, NULL, 'd' }, + { "format", required_argument, NULL, 'f' }, + { "engine", required_argument, NULL, 'e' }, + { "device", required_argument, NULL, 1000 }, + { "help", no_argument, NULL, 'h' }, + { NULL, 0, NULL, 0 }, + }; + + optind = 1; + opterr = 0; + while ((opt = getopt_long(argc - 1, argv + 1, "d:f:e:h", + long_opts, NULL)) != -1) { + if (opt == 'h') { + usage(); + return 0; + } + if (opt == 'e') { + want = optarg; + continue; + } + if (rm_apply_common_opt(ctx, opt, optarg) < 0) + return 2; + } + if (want == NULL) { + usage(); + return 2; + } + return engine_info(ctx, want); + } + if (strcmp(argv[1], "allocate") == 0 || + strcmp(argv[1], "start") == 0 || + strcmp(argv[1], "stop") == 0 || + strcmp(argv[1], "config") == 0) { + fprintf(stderr, + "rmtool: 'engine %s' is a write/ownership operation " + "and is refused by policy (read-only host oracle). " + "See debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md.\n", + argv[1]); + return 4; + } + usage(); + return 2; +} diff --git a/tools/rmtool/src/gpu.c b/tools/rmtool/src/gpu.c new file mode 100644 index 0000000..ff9b26c --- /dev/null +++ b/tools/rmtool/src/gpu.c @@ -0,0 +1,283 @@ +/* gpu.c - rmtool gpu list/topology/info: enumeration and identity queries. */ +#include +#include +#include +#include + +#include "rmtool.h" + +static int read_sysfs(const char *bdf, const char *file, char *buf, size_t len) +{ + char path[512]; + FILE *fh; + + snprintf(path, sizeof(path), "/sys/bus/pci/devices/%s/%s", bdf, file); + fh = fopen(path, "r"); + if (fh == NULL) { + buf[0] = '\0'; + return -1; + } + if (fgets(buf, (int)len, fh) == NULL) + buf[0] = '\0'; + fclose(fh); + buf[strcspn(buf, "\r\n")] = '\0'; + return 0; +} + +static int parse_common(int argc, char **argv, struct rm_ctx *ctx) +{ + static const struct option long_opts[] = { + { "device-spec", required_argument, NULL, 'd' }, + { "format", required_argument, NULL, 'f' }, + { "device", required_argument, NULL, 1000 }, + { "help", no_argument, NULL, 'h' }, + { NULL, 0, NULL, 0 }, + }; + int opt; + + optind = 1; + opterr = 0; + while ((opt = getopt_long(argc, argv, "d:f:h", long_opts, NULL)) != -1) { + int rc; + + if (opt == 'h') + return 1; + rc = rm_apply_common_opt(ctx, opt, optarg); + if (rc < 0) + return -1; + if (rc == 0) { + fprintf(stderr, "rmtool: unknown option\n"); + return -1; + } + } + if (optind < argc) { + fprintf(stderr, "rmtool: unexpected argument '%s'\n", + argv[optind]); + return -1; + } + return 0; +} + +static void list_usage(void) +{ + printf("usage: rmtool gpu list [--format=table|json|csv]\n" + " rmtool gpu topology [--format=...]\n" + " rmtool gpu info [-d N|BDF] [--format=...]\n"); +} + +static int gpu_list(struct rm_ctx *ctx) +{ + struct rm_gpu_desc gpus[RM_MAX_GPUS]; + const char *headers[] = { "MINOR", "BUS", "MODEL", "UUID", "VBIOS" }; + char cells[RM_MAX_GPUS * 5][RM_UUID_LEN + 8]; + int n; + int i; + + n = rm_scan_gpus(gpus, RM_MAX_GPUS); + if (n < 0) { + fprintf(stderr, "rmtool: cannot read /proc/driver/nvidia/gpus: " + "%s\n", strerror(-n)); + return 2; + } + for (i = 0; i < n; i++) { + snprintf(cells[i * 5 + 0], sizeof(cells[0]), "%d", + gpus[i].minor); + snprintf(cells[i * 5 + 1], sizeof(cells[0]), "%s", + gpus[i].bdf); + snprintf(cells[i * 5 + 2], sizeof(cells[0]), "%s", + gpus[i].model); + snprintf(cells[i * 5 + 3], sizeof(cells[0]), "%s", + gpus[i].uuid); + snprintf(cells[i * 5 + 4], sizeof(cells[0]), "%s", + gpus[i].vbios); + } + if (ctx->format == RM_FMT_TABLE) + printf("%d GPU(s) on this host\n", n); + rm_print_table("gpus", headers, &cells[0][0], sizeof(cells[0]), n, 5, + ctx->format); + return 0; +} + +static int gpu_topology(struct rm_ctx *ctx) +{ + struct rm_gpu_desc gpus[RM_MAX_GPUS]; + const char *headers[] = { "MINOR", "BUS", "LINK", "MAX LINK", "NUMA", + "VENDOR:DEVICE" }; + char cells[RM_MAX_GPUS * 6][RM_UUID_LEN]; + int n; + int i; + + n = rm_scan_gpus(gpus, RM_MAX_GPUS); + if (n < 0) { + fprintf(stderr, "rmtool: cannot read /proc/driver/nvidia/gpus: " + "%s\n", strerror(-n)); + return 2; + } + for (i = 0; i < n; i++) { + char speed[32], width[32], max_speed[32], max_width[32]; + char numa[16], vendor[32], device[32]; + + read_sysfs(gpus[i].bdf, "current_link_speed", speed, + sizeof(speed)); + read_sysfs(gpus[i].bdf, "current_link_width", width, + sizeof(width)); + read_sysfs(gpus[i].bdf, "max_link_speed", max_speed, + sizeof(max_speed)); + read_sysfs(gpus[i].bdf, "max_link_width", max_width, + sizeof(max_width)); + read_sysfs(gpus[i].bdf, "numa_node", numa, sizeof(numa)); + read_sysfs(gpus[i].bdf, "vendor", vendor, sizeof(vendor)); + read_sysfs(gpus[i].bdf, "device", device, sizeof(device)); + snprintf(cells[i * 6 + 0], sizeof(cells[0]), "%d", + gpus[i].minor); + snprintf(cells[i * 6 + 1], sizeof(cells[0]), "%s", + gpus[i].bdf); + snprintf(cells[i * 6 + 2], sizeof(cells[0]), "%s x%s", + speed, width); + snprintf(cells[i * 6 + 3], sizeof(cells[0]), "%s x%s", + max_speed, max_width); + snprintf(cells[i * 6 + 4], sizeof(cells[0]), "%s", numa); + snprintf(cells[i * 6 + 5], sizeof(cells[0]), "%s:%s", vendor, + device); + } + rm_print_table("gpu topology (sysfs, read-only)", headers, + &cells[0][0], sizeof(cells[0]), n, 6, ctx->format); + return 0; +} + +static int gpu_info(struct rm_ctx *ctx) +{ + struct rm_gpu_desc gpus[RM_MAX_GPUS]; + NV2080_CTRL_MC_ARCH_INFO_PARAMS arch; + NV2080_CTRL_BUS_GET_PCI_INFO_PARAMS pci; + NV2080_CTRL_GPU_GET_ENGINES_V2_PARAMS engines; + struct rm_kv kv[20]; + char v[20][96]; + int n = 0; + int i, n_gpus, rc; + NvU32 status; + + n_gpus = rm_scan_gpus(gpus, RM_MAX_GPUS); + rc = rm_ctx_open(ctx); + if (rc != 0) { + fprintf(stderr, "rmtool: cannot open RM client " + "(need the driver bound and read access): rc=%d\n", rc); + return 2; + } + + status = rm_query_name(ctx, v[0], sizeof(v[0])); + kv[n].key = "Name"; + kv[n++].value = status == 0 ? v[0] : "query failed"; + + memset(v[1], 0, sizeof(v[1])); + snprintf(v[1], sizeof(v[1]), "%d", ctx->rm.gpu_minor); + kv[n].key = "GPU minor"; + kv[n++].value = v[1]; + + if (n_gpus > 0) { + const struct rm_gpu_desc *g = NULL; + + for (i = 0; i < n_gpus; i++) { + if (gpus[i].minor == ctx->rm.gpu_minor) + g = &gpus[i]; + } + if (g == NULL) + g = &gpus[0]; + kv[n].key = "Model (proc)"; + kv[n++].value = g->model; + kv[n].key = "Bus location"; + kv[n++].value = g->bdf; + kv[n].key = "GPU UUID"; + kv[n++].value = g->uuid; + kv[n].key = "Video BIOS"; + kv[n++].value = g->vbios; + } + + status = rm_query_mc_arch(ctx, &arch); + if (status == 0) { + snprintf(v[2], sizeof(v[2]), "%s (0x%x/0x%x rev %u)", + rm_arch_str(arch.architecture), arch.architecture, + arch.implementation, arch.revision); + kv[n].key = "Architecture"; + kv[n++].value = v[2]; + } else { + snprintf(v[2], sizeof(v[2]), "MC_GET_ARCH_INFO: %s", + rm_status_str(status)); + kv[n].key = "Architecture"; + kv[n++].value = v[2]; + } + + status = rm_query_pci(ctx, &pci); + if (status == 0) { + snprintf(v[3], sizeof(v[3]), "%04x:%04x (subsystem %04x)", + pci.pciDeviceId & 0xffff, + (pci.pciDeviceId >> 16) & 0xffff, + pci.pciSubSystemId); + kv[n].key = "PCI device"; + kv[n++].value = v[3]; + snprintf(v[4], sizeof(v[4]), "%u", pci.pciRevisionId); + kv[n].key = "PCI revision"; + kv[n++].value = v[4]; + } + + status = rm_query_engines(ctx, &engines); + snprintf(v[5], sizeof(v[5]), "%s", + status == 0 ? "ok" : rm_status_str(status)); + kv[n].key = "Engine inventory"; + kv[n++].value = v[5]; + if (status == 0) { + snprintf(v[6], sizeof(v[6]), "%u", engines.engineCount); + kv[n].key = "Engine count"; + kv[n++].value = v[6]; + } + + rm_print_kv("rmtool gpu info (read-only RM queries)", kv, n, + ctx->format); + return 0; +} + +int cmd_gpu(int argc, char **argv, struct rm_ctx *ctx) +{ + const char *sub; + + if (argc < 2) { + list_usage(); + return 2; + } + sub = argv[1]; + if (strcmp(sub, "list") == 0) { + int rc = parse_common(argc - 1, argv + 1, ctx); + + if (rc < 0) + return 2; + if (rc == 1) { + list_usage(); + return 0; + } + return gpu_list(ctx); + } + if (strcmp(sub, "topology") == 0) { + int rc = parse_common(argc - 1, argv + 1, ctx); + + if (rc < 0) + return 2; + if (rc == 1) { + list_usage(); + return 0; + } + return gpu_topology(ctx); + } + if (strcmp(sub, "info") == 0) { + int rc = parse_common(argc - 1, argv + 1, ctx); + + if (rc < 0) + return 2; + if (rc == 1) { + list_usage(); + return 0; + } + return gpu_info(ctx); + } + list_usage(); + return 2; +} diff --git a/tools/rmtool/src/main.c b/tools/rmtool/src/main.c new file mode 100644 index 0000000..4e4b440 --- /dev/null +++ b/tools/rmtool/src/main.c @@ -0,0 +1,95 @@ +/* main.c - rmtool entry point: command dispatch and global help/version. + * + * rmtool is a read-only host RM oracle for the Open-IOV accuracy loop. It + * never allocates engine/channel/memory objects and never touches VFIO, + * mdevs or kernel modules; write commands are refused by policy. + */ +#include +#include +#include + +#include "rmtool.h" + +static void usage(void) +{ + printf("rmtool %s - read-only NVIDIA RM host oracle\n\n", + RMTOOL_VERSION); + printf("usage: rmtool [subcommand] [options]\n\n" + "commands:\n" + " gpu list|topology|info enumeration/identity\n" + " engine list|info engine inventory (read-only)\n" + " mem stats VRAM/heap/BAR1 totals\n" + " nv2080 class list device class list\n" + " nv2080 method call allowlisted read-only controls\n" + " sample timer GPU timer sampling\n" + " version print version\n\n" + "options:\n" + " -d N|BDF select GPU (minor or bus id)\n" + " -f FMT output format table|json|csv\n" + " --device PATH use an explicit GPU device node\n\n" + "safety: read-only queries only. alloc/map/pin, engine/channel\n" + "control and any write control are refused. Never touches\n" + "modules, VFIO, mdevs, sysfs bindings or /sys/fs/pstore.\n\n" + "Not implemented (documented commands): channel, context,\n" + "resserve, cudasw, resman, gsp. See docs/17-RM-HARNESS.md.\n"); +} + +int main(int argc, char **argv) +{ + struct rm_ctx ctx; + const char *command; + int rc; + + ctx.gpu_minor = -1; + ctx.dev_path = NULL; + ctx.format = RM_FMT_TABLE; + ctx.have_rm = 0; + ctx.rm.fd = -1; + + if (argc < 2) { + usage(); + return 2; + } + command = argv[1]; + if (strcmp(command, "--help") == 0 || strcmp(command, "-h") == 0 || + strcmp(command, "help") == 0) { + usage(); + return 0; + } + if (strcmp(command, "--version") == 0 || + strcmp(command, "version") == 0) { + printf("rmtool %s (read-only; Open-IOV RM oracle)\n", + RMTOOL_VERSION); + return 0; + } + if (strcmp(command, "gpu") == 0) { + rc = cmd_gpu(argc - 1, argv + 1, &ctx); + } else if (strcmp(command, "engine") == 0) { + rc = cmd_engine(argc - 1, argv + 1, &ctx); + } else if (strcmp(command, "mem") == 0) { + rc = cmd_mem(argc - 1, argv + 1, &ctx); + } else if (strcmp(command, "nv2080") == 0) { + rc = cmd_nv2080(argc - 1, argv + 1, &ctx); + } else if (strcmp(command, "sample") == 0) { + rc = cmd_sample(argc - 1, argv + 1, &ctx); + } else if (strcmp(command, "channel") == 0 || + strcmp(command, "context") == 0 || + strcmp(command, "resserve") == 0 || + strcmp(command, "cudasw") == 0 || + strcmp(command, "resman") == 0 || + strcmp(command, "gsp") == 0) { + fprintf(stderr, "rmtool: '%s' is a documented but not-yet-" + "implemented surface in this minimal build; it is a " + "write/ownership path and is refused. See " + "docs/17-RM-HARNESS.md.\n", command); + rc = 3; + } else { + fprintf(stderr, "rmtool: unknown command '%s'\n", command); + usage(); + rc = 2; + } + + if (ctx.have_rm) + rm_client_close(&ctx.rm); + return rc; +} diff --git a/tools/rmtool/src/mem.c b/tools/rmtool/src/mem.c new file mode 100644 index 0000000..a85bd70 --- /dev/null +++ b/tools/rmtool/src/mem.c @@ -0,0 +1,146 @@ +/* mem.c - rmtool mem stats: read-only FB/heap/BAR1 information. + * + * Allocation, mapping, pinning and arbitrary-handle inspection are + * deliberately not implemented: they are write operations against the live + * driver and are out of scope for this read-only oracle. + */ +#include +#include +#include +#include + +#include "rmtool.h" + +static const NvU32 fb_indices[] = { + NV2080_CTRL_FB_INFO_INDEX_TOTAL_RAM_SIZE, + NV2080_CTRL_FB_INFO_INDEX_USABLE_RAM_SIZE, + NV2080_CTRL_FB_INFO_INDEX_HEAP_SIZE, + NV2080_CTRL_FB_INFO_INDEX_HEAP_FREE, + NV2080_CTRL_FB_INFO_INDEX_LARGEST_FREE_REGION_SIZE_KB, + NV2080_CTRL_FB_INFO_INDEX_BAR1_SIZE, + NV2080_CTRL_FB_INFO_INDEX_BAR1_AVAIL_SIZE, +}; + +static const char *fb_names[] = { + "vram_total_kib", + "vram_usable_kib", + "heap_size_kib", + "heap_free_kib", + "largest_free_region_kib", + "bar1_size_kib", + "bar1_avail_kib", +}; + +#define FB_N (sizeof(fb_indices) / sizeof(fb_indices[0])) + +static void usage(void) +{ + printf("usage: rmtool mem stats [-d N|BDF] [--format=...]\n" + " rmtool mem info -h HANDLE (not implemented: write " + "path)\n" + "rmtool is read-only; alloc/map/unmap/pin are refused.\n"); +} + +static int mem_stats(struct rm_ctx *ctx) +{ + NvU32 values[FB_N]; + struct rm_kv kv[FB_N + 4]; + char v[FB_N + 4][128]; + NvU32 status; + int rc, n = 0; + unsigned int i; + + rc = rm_ctx_open(ctx); + if (rc != 0) { + fprintf(stderr, "rmtool: cannot open RM client: rc=%d\n", rc); + return 2; + } + memset(values, 0, sizeof(values)); + status = rm_query_fb(ctx, fb_indices, values, FB_N); + if (status != 0) { + fprintf(stderr, "rmtool: FB_GET_INFO_V2: %s\n", + rm_status_str(status)); + return 2; + } + for (i = 0; i < FB_N; i++) { + char human[32]; + + rm_human_kib(values[i], human, sizeof(human)); + snprintf(v[n], sizeof(v[n]), "%u (%s)", values[i], human); + kv[n].key = fb_names[i]; + kv[n].value = v[n]; + n++; + } + if (values[2] >= values[3]) { + unsigned int used = values[2] - values[3]; + char human[32]; + + rm_human_kib(used, human, sizeof(human)); + snprintf(v[n], sizeof(v[n]), "%u (%s)", used, human); + kv[n].key = "heap_used_kib (derived)"; + kv[n].value = v[n]; + n++; + } + rm_print_kv("VRAM / heap (FB_GET_INFO_V2, KiB units)", kv, n, + ctx->format); + return 0; +} + +static int parse_common(int argc, char **argv, struct rm_ctx *ctx) +{ + static const struct option long_opts[] = { + { "device-spec", required_argument, NULL, 'd' }, + { "format", required_argument, NULL, 'f' }, + { "device", required_argument, NULL, 1000 }, + { "help", no_argument, NULL, 'h' }, + { NULL, 0, NULL, 0 }, + }; + int opt; + + optind = 1; + opterr = 0; + while ((opt = getopt_long(argc, argv, "d:f:h", long_opts, NULL)) != -1) { + int rc; + + if (opt == 'h') + return 1; + rc = rm_apply_common_opt(ctx, opt, optarg); + if (rc < 0) + return -1; + } + return 0; +} + +int cmd_mem(int argc, char **argv, struct rm_ctx *ctx) +{ + int rc; + + if (argc < 2) { + usage(); + return 2; + } + if (strcmp(argv[1], "stats") == 0) { + rc = parse_common(argc - 1, argv + 1, ctx); + if (rc < 0) + return 2; + if (rc == 1) { + usage(); + return 0; + } + return mem_stats(ctx); + } + if (strcmp(argv[1], "alloc") == 0 || + strcmp(argv[1], "map") == 0 || + strcmp(argv[1], "unmap") == 0 || + strcmp(argv[1], "pin") == 0 || + strcmp(argv[1], "info") == 0) { + fprintf(stderr, + "rmtool: 'mem %s' is a write/ownership operation and " + "is refused by policy (read-only host oracle). " + "See debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md.\n", + argv[1]); + return 4; + } + usage(); + return 2; +} diff --git a/tools/rmtool/src/nv2080.c b/tools/rmtool/src/nv2080.c new file mode 100644 index 0000000..5dd808e --- /dev/null +++ b/tools/rmtool/src/nv2080.c @@ -0,0 +1,398 @@ +/* nv2080.c - rmtool nv2080 class list / method call and sample. + * + * The generic control entry point is restricted to a read-only allowlist so + * the tools can never mutate the live GPU or driver state. Anything else is + * refused before the ioctl is made. + */ +#include +#include +#include +#include +#include +#include + +#include "rmtool.h" + +static const NvU32 readonly_cmds[] = { + NV2080_CTRL_CMD_GPU_GET_INFO_V2, + NV2080_CTRL_CMD_GPU_GET_NAME_STRING, + NV2080_CTRL_CMD_GPU_QUERY_MODE, + NV2080_CTRL_CMD_GPU_GET_ID, + NV2080_CTRL_CMD_GPU_GET_ENGINES_V2, + NV2080_CTRL_CMD_MC_GET_ARCH_INFO, + NV2080_CTRL_CMD_FB_GET_INFO_V2, + NV2080_CTRL_CMD_BUS_GET_PCI_INFO, + NV2080_CTRL_CMD_TIMER_GET_TIME, + NV2080_CTRL_CMD_FIFO_GET_DEVICE_INFO_TABLE, + NV0080_CTRL_CMD_GPU_GET_CLASSLIST_V2, +}; + +int rm_control_is_readonly(NvU32 cmd) +{ + unsigned int i; + + for (i = 0; i < sizeof(readonly_cmds) / sizeof(readonly_cmds[0]); i++) { + if (readonly_cmds[i] == cmd) + return 1; + } + return 0; +} + +static const char *class_name(NvU32 klass) +{ + switch (klass) { + case NV01_DEVICE_0: + return "NV01_DEVICE_0"; + case NV20_SUBDEVICE_0: + return "NV20_SUBDEVICE_0"; + default: + return "-"; + } +} + +static void usage(void) +{ + printf("usage: rmtool nv2080 class list [-d N|BDF] [--format=...]\n" + " rmtool nv2080 method call -m CMD [--object=OBJ] " + "[--params=HEX]\n" + "OBJ is subdevice (default), device or client. CMD must be on " + "the\nread-only allowlist; write controls are refused.\n" + " rmtool sample timer [-d N|BDF] [--interval=100ms] " + "[--count=N]\n"); +} + +static int class_list(struct rm_ctx *ctx) +{ + NV0080_CTRL_GPU_GET_CLASSLIST_V2_PARAMS classes; + const char *headers[] = { "IDX", "CLASS", "NAME" }; + char cells[NV0080_CTRL_GPU_CLASSLIST_MAX_SIZE * 3][32]; + unsigned int i; + int rc, ioctl_ret; + NvU32 status; + + rc = rm_ctx_open(ctx); + if (rc != 0) { + fprintf(stderr, "rmtool: cannot open RM client: rc=%d\n", rc); + return 2; + } + memset(&classes, 0, sizeof(classes)); + status = rm_control(&ctx->rm, ctx->rm.hDevice, + NV0080_CTRL_CMD_GPU_GET_CLASSLIST_V2, &classes, + sizeof(classes), &ioctl_ret); + if (status != 0) { + fprintf(stderr, "rmtool: GPU_GET_CLASSLIST_V2: %s\n", + rm_status_str(status)); + return 2; + } + if (classes.numClasses > NV0080_CTRL_GPU_CLASSLIST_MAX_SIZE) + classes.numClasses = NV0080_CTRL_GPU_CLASSLIST_MAX_SIZE; + for (i = 0; i < classes.numClasses; i++) { + snprintf(cells[i * 3 + 0], sizeof(cells[0]), "%u", i); + snprintf(cells[i * 3 + 1], sizeof(cells[0]), "0x%x", + classes.classList[i]); + snprintf(cells[i * 3 + 2], sizeof(cells[0]), "%s", + class_name(classes.classList[i])); + } + rm_print_table("device classes (NV0080_CTRL_GPU_GET_CLASSLIST_V2)", + headers, &cells[0][0], sizeof(cells[0]), + (int)classes.numClasses, 3, ctx->format); + return 0; +} + +static int parse_hex(const char *hex, unsigned char **out, size_t *len) +{ + size_t n, i; + unsigned char *buf; + + if (hex == NULL) { + *out = NULL; + *len = 0; + return 0; + } + if (hex[0] == '0' && (hex[1] == 'x' || hex[1] == 'X')) + hex += 2; + n = strlen(hex); + if (n % 2 != 0) { + fprintf(stderr, "rmtool: --params must be whole hex bytes\n"); + return -1; + } + buf = malloc(n / 2 + 1); + if (buf == NULL) + return -1; + for (i = 0; i < n / 2; i++) { + unsigned int byte; + + if (sscanf(hex + i * 2, "%2x", &byte) != 1) { + fprintf(stderr, "rmtool: bad hex byte '%c%c'\n", + hex[i * 2], hex[i * 2 + 1]); + free(buf); + return -1; + } + buf[i] = (unsigned char)byte; + } + *out = buf; + *len = n / 2; + return 0; +} + +static int method_call(struct rm_ctx *ctx, NvU32 cmd, const char *object, + const char *hex) +{ + unsigned char *params; + size_t len; + NvU32 hObject; + int rc, ioctl_ret; + NvU32 status; + + if (!rm_control_is_readonly(cmd)) { + fprintf(stderr, "rmtool: control 0x%x is not on the read-only " + "allowlist; refused (see docs/17-RM-HARNESS.md)\n", + cmd); + return 4; + } + if (parse_hex(hex, ¶ms, &len) < 0) + return 2; + rc = rm_ctx_open(ctx); + if (rc != 0) { + fprintf(stderr, "rmtool: cannot open RM client: rc=%d\n", rc); + free(params); + return 2; + } + if (object == NULL || strcmp(object, "subdevice") == 0) + hObject = ctx->rm.hSubdevice; + else if (strcmp(object, "device") == 0) + hObject = ctx->rm.hDevice; + else if (strcmp(object, "client") == 0) + hObject = ctx->rm.hClient; + else { + fprintf(stderr, "rmtool: unknown --object '%s'\n", object); + free(params); + return 2; + } + status = rm_control(&ctx->rm, hObject, cmd, params, (NvU32)len, + &ioctl_ret); + printf("cmd=0x%x object=0x%x params=%zu bytes\n", cmd, hObject, len); + if (ioctl_ret != 0) { + fprintf(stderr, "rmtool: control ioctl failed: %s\n", + strerror(-ioctl_ret)); + free(params); + return 2; + } + printf("status=%s\n", rm_status_str(status)); + if (status == 0 && len > 0) + rm_hexdump(params, len); + free(params); + return status == 0 ? 0 : 2; +} + +static double parse_interval_ms(const char *s) +{ + char *end; + double v = strtod(s, &end); + + if (end != NULL && strcmp(end, "ms") == 0) + return v; + if (end != NULL && strcmp(end, "s") == 0) + return v * 1000.0; + if (end != NULL && strcmp(end, "us") == 0) + return v / 1000.0; + return v; +} + +static int sample_timer(struct rm_ctx *ctx, double interval_ms, int count) +{ + int rc, i; + + rc = rm_ctx_open(ctx); + if (rc != 0) { + fprintf(stderr, "rmtool: cannot open RM client: rc=%d\n", rc); + return 2; + } + for (i = 0; i < count; i++) { + NV2080_CTRL_TIMER_GET_TIME_PARAMS p; + struct tm tm; + char when[32]; + time_t secs; + int ioctl_ret; + NvU32 status; + + memset(&p, 0, sizeof(p)); + status = rm_control(&ctx->rm, ctx->rm.hSubdevice, + NV2080_CTRL_CMD_TIMER_GET_TIME, &p, + sizeof(p), &ioctl_ret); + if (status != 0 || ioctl_ret != 0) { + fprintf(stderr, "rmtool: TIMER_GET_TIME: %s\n", + rm_status_str(status)); + return 2; + } + secs = (time_t)(p.time_nsec / 1000000000ULL); + gmtime_r(&secs, &tm); + strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%S", &tm); + printf("gpu_time_ns=%llu (%s.%09lluZ)\n", + (unsigned long long)p.time_nsec, when, + (unsigned long long)(p.time_nsec % 1000000000ULL)); + fflush(stdout); + if (i + 1 < count) { + struct timespec req; + + req.tv_sec = (time_t)(interval_ms / 1000.0); + req.tv_nsec = (long)((interval_ms - + (double)req.tv_sec * 1000.0) * + 1000000.0); + nanosleep(&req, NULL); + } + } + return 0; +} + +static int unsupported_sample(const char *kind) +{ + fprintf(stderr, + "rmtool: 'sample %s' is not implemented in this minimal build: " + "it needs NVML-class perfmon/thermal/power controls whose " + "structs are not exposed in the in-tree SDK. Use nvidia-smi " + "for now; see debug/risc-v-gsp-rm-vm/docs/17-RM-HARNESS.md.\n", + kind); + return 3; +} + +int cmd_nv2080(int argc, char **argv, struct rm_ctx *ctx) +{ + int opt; + + if (argc < 3) { + usage(); + return 2; + } + if (strcmp(argv[1], "class") == 0 && strcmp(argv[2], "list") == 0) { + static const struct option long_opts[] = { + { "device-spec", required_argument, NULL, 'd' }, + { "format", required_argument, NULL, 'f' }, + { "device", required_argument, NULL, 1000 }, + { "help", no_argument, NULL, 'h' }, + { NULL, 0, NULL, 0 }, + }; + + optind = 1; + opterr = 0; + while ((opt = getopt_long(argc - 2, argv + 2, "d:f:h", + long_opts, NULL)) != -1) { + if (opt == 'h') { + usage(); + return 0; + } + if (rm_apply_common_opt(ctx, opt, optarg) < 0) + return 2; + } + return class_list(ctx); + } + if (strcmp(argv[1], "method") == 0 && strcmp(argv[2], "call") == 0) { + static const struct option long_opts[] = { + { "cmd", required_argument, NULL, 'm' }, + { "method", required_argument, NULL, 'm' }, + { "class", required_argument, NULL, 'c' }, + { "object", required_argument, NULL, 'o' }, + { "params", required_argument, NULL, 'p' }, + { "format", required_argument, NULL, 'f' }, + { "device-spec", required_argument, NULL, 'd' }, + { "device", required_argument, NULL, 1000 }, + { "help", no_argument, NULL, 'h' }, + { NULL, 0, NULL, 0 }, + }; + const char *object = NULL; + const char *params = NULL; + const char *klass = NULL; + NvU32 cmd = 0; + int have_cmd = 0; + + optind = 1; + opterr = 0; + while ((opt = getopt_long(argc - 2, argv + 2, "m:c:o:p:f:d:h", + long_opts, NULL)) != -1) { + if (opt == 'h') { + usage(); + return 0; + } + if (opt == 'm') { + cmd = (NvU32)strtoul(optarg, NULL, 0); + have_cmd = 1; + continue; + } + if (opt == 'o') { + object = optarg; + continue; + } + if (opt == 'p') { + params = optarg; + continue; + } + if (opt == 'c') { + klass = optarg; + continue; + } + if (rm_apply_common_opt(ctx, opt, optarg) < 0) + return 2; + } + if (!have_cmd) { + usage(); + return 2; + } + if (klass != NULL) + fprintf(stderr, "rmtool: note: --class %s is accepted " + "for documentation parity; the RM control " + "command is sent as given with -m.\n", klass); + return method_call(ctx, cmd, object, params); + } + if (strcmp(argv[1], "event") == 0) { + fprintf(stderr, "rmtool: nv2080 event subscribe/wait is not " + "implemented (event handling is a write path).\n"); + return 3; + } + usage(); + return 2; +} + +int cmd_sample(int argc, char **argv, struct rm_ctx *ctx) +{ + static const struct option long_opts[] = { + { "device-spec", required_argument, NULL, 'd' }, + { "format", required_argument, NULL, 'f' }, + { "interval", required_argument, NULL, 'i' }, + { "count", required_argument, NULL, 'n' }, + { "device", required_argument, NULL, 1000 }, + { "help", no_argument, NULL, 'h' }, + { NULL, 0, NULL, 0 }, + }; + double interval_ms = 1000.0; + int count = 1; + int opt; + + if (argc < 2) { + usage(); + return 2; + } + if (strcmp(argv[1], "timer") != 0) + return unsupported_sample(argv[1]); + optind = 1; + opterr = 0; + while ((opt = getopt_long(argc - 1, argv + 1, "d:f:i:n:h", + long_opts, NULL)) != -1) { + if (opt == 'h') { + usage(); + return 0; + } + if (opt == 'i') { + interval_ms = parse_interval_ms(optarg); + continue; + } + if (opt == 'n') { + count = atoi(optarg); + continue; + } + if (rm_apply_common_opt(ctx, opt, optarg) < 0) + return 2; + } + if (count <= 0) + count = 1; + return sample_timer(ctx, interval_ms, count); +} diff --git a/tools/rmtool/src/output.c b/tools/rmtool/src/output.c new file mode 100644 index 0000000..6de5d02 --- /dev/null +++ b/tools/rmtool/src/output.c @@ -0,0 +1,251 @@ +/* output.c - table/JSON/CSV formatting helpers shared by rmtool commands. */ +#include +#include +#include +#include + +#include "rmtool.h" + +enum rm_format rm_format_parse(const char *name) +{ + if (name == NULL || strcmp(name, "table") == 0) + return RM_FMT_TABLE; + if (strcmp(name, "json") == 0) + return RM_FMT_JSON; + if (strcmp(name, "csv") == 0) + return RM_FMT_CSV; + return (enum rm_format)-1; +} + +const char *rm_human_kib(NvU64 kib, char *buf, size_t len) +{ + if (kib >= 1024ULL * 1024ULL) + snprintf(buf, len, "%.2f GiB", (double)kib / (1024.0 * 1024.0)); + else if (kib >= 1024ULL) + snprintf(buf, len, "%.1f MiB", (double)kib / 1024.0); + else + snprintf(buf, len, "%llu KiB", (unsigned long long)kib); + return buf; +} + +static int json_plain_number(const char *value) +{ + char *end; + double d; + + if (value == NULL || *value == '\0') + return 0; + d = strtod(value, &end); + if (end == value || *end != '\0') + return 0; + (void)d; + return 1; +} + +static void json_string(FILE *out, const char *value) +{ + const unsigned char *p = (const unsigned char *)value; + + fputc('"', out); + for (; p != NULL && *p != '\0'; p++) { + switch (*p) { + case '"': + fputs("\\\"", out); + break; + case '\\': + fputs("\\\\", out); + break; + case '\n': + fputs("\\n", out); + break; + case '\r': + fputs("\\r", out); + break; + case '\t': + fputs("\\t", out); + break; + default: + if (*p < 0x20) + fprintf(out, "\\u%04x", *p); + else + fputc(*p, out); + break; + } + } + fputc('"', out); +} + +static void json_value(FILE *out, const char *value) +{ + if (value == NULL) { + fputs("null", out); + } else if (json_plain_number(value)) { + fputs(value, out); + } else { + json_string(out, value); + } +} + +static void csv_value(FILE *out, const char *value) +{ + const char *p; + int needs_quote = 0; + + if (value == NULL) + value = ""; + for (p = value; *p != '\0'; p++) { + if (*p == ',' || *p == '"' || *p == '\n' || *p == '\r') { + needs_quote = 1; + break; + } + } + if (!needs_quote) { + fputs(value, out); + return; + } + fputc('"', out); + for (p = value; *p != '\0'; p++) { + if (*p == '"') + fputc('"', out); + fputc(*p, out); + } + fputc('"', out); +} + +void rm_print_kv(const char *title, const struct rm_kv *kv, int n, + enum rm_format format) +{ + int i; + + if (format == RM_FMT_JSON) { + printf("{\"title\": "); + json_string(stdout, title); + printf(", \"values\": {"); + for (i = 0; i < n; i++) { + if (i) + fputs(", ", stdout); + json_string(stdout, kv[i].key); + fputs(": ", stdout); + json_value(stdout, kv[i].value); + } + printf("}}\n"); + return; + } + if (format == RM_FMT_CSV) { + for (i = 0; i < n; i++) { + csv_value(stdout, kv[i].key); + fputc(',', stdout); + csv_value(stdout, kv[i].value); + fputc('\n', stdout); + } + return; + } + printf("%s\n", title); + for (i = 0; i < n; i++) + printf(" %-28s %s\n", kv[i].key, + kv[i].value ? kv[i].value : "-"); +} + +static const char *cell_at(const char *cells, size_t stride, int row, + int col, int cols) +{ + return cells + (size_t)(row * cols + col) * stride; +} + +void rm_print_table(const char *title, const char *const *headers, + const char *cells, size_t stride, int rows, int cols, + enum rm_format format) +{ + int *width; + int r, c; + + if (format == RM_FMT_JSON) { + printf("["); + for (r = 0; r < rows; r++) { + if (r) + fputs(", ", stdout); + fputc('{', stdout); + for (c = 0; c < cols; c++) { + if (c) + fputs(", ", stdout); + json_string(stdout, headers[c]); + fputs(": ", stdout); + json_value(stdout, cell_at(cells, stride, r, + c, cols)); + } + fputc('}', stdout); + } + printf("]\n"); + return; + } + if (format == RM_FMT_CSV) { + for (c = 0; c < cols; c++) { + if (c) + fputc(',', stdout); + csv_value(stdout, headers[c]); + } + fputc('\n', stdout); + for (r = 0; r < rows; r++) { + for (c = 0; c < cols; c++) { + if (c) + fputc(',', stdout); + csv_value(stdout, cell_at(cells, stride, r, + c, cols)); + } + fputc('\n', stdout); + } + return; + } + width = calloc((size_t)cols, sizeof(*width)); + if (width == NULL) + return; + for (c = 0; c < cols; c++) + width[c] = (int)strlen(headers[c]); + for (r = 0; r < rows; r++) { + for (c = 0; c < cols; c++) { + const char *v = cell_at(cells, stride, r, c, cols); + int len = v ? (int)strlen(v) : 0; + + if (len > width[c]) + width[c] = len; + } + } + printf("%s\n", title); + for (c = 0; c < cols; c++) + printf("%-*s%s", width[c], headers[c], + c + 1 < cols ? " " : "\n"); + for (r = 0; r < rows; r++) { + for (c = 0; c < cols; c++) + printf("%-*s%s", width[c], + cell_at(cells, stride, r, c, cols), + c + 1 < cols ? " " : "\n"); + } + free(width); +} + +void rm_hexdump(const void *data, size_t len) +{ + const unsigned char *p = data; + size_t off; + + for (off = 0; off < len; off += 16) { + size_t i; + + printf(" %04zx ", off); + for (i = 0; i < 16; i++) { + if (off + i < len) + printf("%02x ", p[off + i]); + else + fputs(" ", stdout); + if (i == 7) + fputc(' ', stdout); + } + fputs(" |", stdout); + for (i = 0; i < 16 && off + i < len; i++) { + unsigned char ch = p[off + i]; + + fputc(isprint(ch) ? ch : '.', stdout); + } + puts("|"); + } +} diff --git a/tools/rmtool/src/query.c b/tools/rmtool/src/query.c new file mode 100644 index 0000000..c9a7460 --- /dev/null +++ b/tools/rmtool/src/query.c @@ -0,0 +1,256 @@ +/* query.c - shared read-only RM queries used by rmtool and rmtop. */ +#include +#include +#include + +#include "rmtool.h" + +int rm_ctx_open(struct rm_ctx *ctx) +{ + int rc; + + if (ctx->have_rm) + return 0; + rc = rm_client_open(&ctx->rm, ctx->gpu_minor, ctx->dev_path); + if (rc != 0) + return rc; + ctx->have_rm = 1; + return 0; +} + +NvU32 rm_query_name(struct rm_ctx *ctx, char *buf, size_t len) +{ + NV2080_CTRL_GPU_GET_NAME_STRING_PARAMS p; + NvU32 status; + int ioctl_ret; + + memset(&p, 0, sizeof(p)); + p.gpuNameStringFlags = 0; + status = rm_control(&ctx->rm, ctx->rm.hSubdevice, + NV2080_CTRL_CMD_GPU_GET_NAME_STRING, &p, + sizeof(p), &ioctl_ret); + if (status != 0) + return status; + p.gpuNameString.ascii[sizeof(p.gpuNameString.ascii) - 1] = '\0'; + snprintf(buf, len, "%s", p.gpuNameString.ascii); + return 0; +} + +NvU32 rm_query_mc_arch(struct rm_ctx *ctx, NV2080_CTRL_MC_ARCH_INFO_PARAMS *p) +{ + int ioctl_ret; + + memset(p, 0, sizeof(*p)); + return rm_control(&ctx->rm, ctx->rm.hSubdevice, + NV2080_CTRL_CMD_MC_GET_ARCH_INFO, p, sizeof(*p), + &ioctl_ret); +} + +NvU32 rm_query_pci(struct rm_ctx *ctx, NV2080_CTRL_BUS_GET_PCI_INFO_PARAMS *p) +{ + int ioctl_ret; + + memset(p, 0, sizeof(*p)); + return rm_control(&ctx->rm, ctx->rm.hSubdevice, + NV2080_CTRL_CMD_BUS_GET_PCI_INFO, p, sizeof(*p), + &ioctl_ret); +} + +NvU32 rm_query_fb(struct rm_ctx *ctx, const NvU32 *indices, NvU32 *values, + NvU32 n) +{ + NV2080_CTRL_FB_GET_INFO_V2_PARAMS p; + NvU32 status; + NvU32 i; + int ioctl_ret; + + if (n > NV2080_CTRL_FB_INFO_MAX_LIST_SIZE) + return (NvU32)-1; + memset(&p, 0, sizeof(p)); + p.fbInfoListSize = n; + for (i = 0; i < n; i++) { + p.fbInfoList[i].index = indices[i]; + values[i] = 0; + } + status = rm_control(&ctx->rm, ctx->rm.hSubdevice, + NV2080_CTRL_CMD_FB_GET_INFO_V2, &p, sizeof(p), + &ioctl_ret); + if (status != 0) + return status; + for (i = 0; i < n; i++) { + NvU32 j; + + for (j = 0; j < p.fbInfoListSize && j < n; j++) { + if (p.fbInfoList[j].index == indices[i]) { + values[i] = p.fbInfoList[j].data; + break; + } + } + } + return 0; +} + +NvU32 rm_query_engines(struct rm_ctx *ctx, + NV2080_CTRL_GPU_GET_ENGINES_V2_PARAMS *p) +{ + int ioctl_ret; + + memset(p, 0, sizeof(*p)); + return rm_control(&ctx->rm, ctx->rm.hSubdevice, + NV2080_CTRL_CMD_GPU_GET_ENGINES_V2, p, sizeof(*p), + &ioctl_ret); +} + +NvU32 rm_query_fifo_table(struct rm_ctx *ctx, + NV2080_FIFO_DEVICE_ENTRY *entries, + NvU32 max_entries, NvU32 *n_entries) +{ + NV2080_CTRL_FIFO_GET_DEVICE_INFO_TABLE_PARAMS t; + NvU32 base = 0; + NvU32 total = 0; + + *n_entries = 0; + do { + NvU32 status; + NvU32 i; + int ioctl_ret; + + memset(&t, 0, sizeof(t)); + t.baseIndex = base; + status = rm_control(&ctx->rm, ctx->rm.hSubdevice, + NV2080_CTRL_CMD_FIFO_GET_DEVICE_INFO_TABLE, + &t, sizeof(t), &ioctl_ret); + if (status != 0) + return status; + for (i = 0; i < t.numEntries && total < max_entries; i++) + entries[total++] = t.entries[i]; + base += t.numEntries; + if (!t.bMore || t.numEntries == 0) + break; + } while (1); + *n_entries = total; + return 0; +} + +const char *rm_arch_str(NvU32 architecture) +{ + switch (architecture) { + case 0xE0000023U: + return "T23X"; + case 0x00000160U: + return "TU100"; + case 0x00000170U: + return "GA100"; + case 0x00000180U: + return "GH100"; + case 0x00000190U: + return "AD100"; + case 0x000001A0U: + return "GB100"; + case 0x000001B0U: + return "GB200"; + default: + return "unknown"; + } +} + +const char *rm_engine_type_name(NvU32 engine_type) +{ + static const struct { + NvU32 type; + const char *name; + } table[] = { + { 0x01, "gr" }, { 0x09, "ce0" }, { 0x0a, "ce1" }, + { 0x0b, "ce2" }, { 0x0c, "ce3" }, { 0x0d, "ce4" }, + { 0x0e, "ce5" }, { 0x0f, "ce6" }, { 0x10, "ce7" }, + { 0x11, "ce8" }, { 0x12, "ce9" }, { 0x34, "ce10" }, + { 0x35, "ce11" }, { 0x36, "ce12" }, { 0x37, "ce13" }, + { 0x38, "ce14" }, { 0x39, "ce15" }, + { 0x13, "nvdec0" }, { 0x14, "nvdec1" }, { 0x15, "nvdec2" }, + { 0x16, "nvdec3" }, { 0x17, "nvdec4" }, { 0x18, "nvdec5" }, + { 0x19, "nvdec6" }, { 0x1a, "nvdec7" }, + { 0x1b, "nvenc0" }, { 0x1c, "nvenc1" }, { 0x1d, "nvenc2" }, + { 0x3f, "nvenc3" }, + { 0x2b, "nvjpg0" }, { 0x2c, "nvjpg1" }, { 0x2d, "nvjpg2" }, + { 0x2e, "nvjpg3" }, { 0x2f, "nvjpg4" }, { 0x30, "nvjpg5" }, + { 0x31, "nvjpg6" }, { 0x32, "nvjpg7" }, + { 0x33, "ofa0" }, { 0x3e, "ofa1" }, + { 0x24, "vic" }, { 0x26, "sec2" }, { 0x22, "sw" }, + { 0x27, "host" }, { 0x29, "pmu" }, { 0x2a, "fbflcn" }, + { 0x1e, "vp" }, { 0x1f, "me" }, { 0x20, "ppp" }, + { 0x21, "mpeg" }, { 0x23, "cipher" }, { 0x25, "mp" }, + { 0x28, "dpu" }, + }; + unsigned int i; + + for (i = 0; i < sizeof(table) / sizeof(table[0]); i++) { + if (table[i].type == engine_type) + return table[i].name; + } + return NULL; +} + +const char *rm_engine_name(const NV2080_FIFO_DEVICE_ENTRY *entries, + NvU32 n_entries, NvU32 engine_type) +{ + static char fallback[24]; + const char *name; + NvU32 i; + + for (i = 0; i < n_entries; i++) { + if (entries[i].engineName[0] == '\0') + continue; + if (entries[i].engineData[0] == engine_type) + return entries[i].engineName; + } + name = rm_engine_type_name(engine_type); + if (name != NULL) + return name; + snprintf(fallback, sizeof(fallback), "engine[0x%x]", engine_type); + return fallback; +} + +int rm_apply_common_opt(struct rm_ctx *ctx, int opt, const char *arg) +{ + if (opt == 'd') { + char *end; + long minor = strtol(arg, &end, 0); + + if (arg[0] != '\0' && *end == '\0') { + ctx->gpu_minor = (int)minor; + return 1; + } + if (strchr(arg, ':') != NULL) { + struct rm_gpu_desc gpus[RM_MAX_GPUS]; + int n = rm_scan_gpus(gpus, RM_MAX_GPUS); + int i; + + for (i = 0; i < n; i++) { + if (strcmp(gpus[i].bdf, arg) == 0) { + ctx->gpu_minor = gpus[i].minor; + return 1; + } + } + fprintf(stderr, "rmtool: no GPU with bus id %s\n", arg); + return -1; + } + fprintf(stderr, "rmtool: bad GPU selector '%s'\n", arg); + return -1; + } + if (opt == 'f') { + enum rm_format fmt = rm_format_parse(arg); + + if ((int)fmt < 0) { + fprintf(stderr, "rmtool: bad format '%s' " + "(table|json|csv)\n", arg); + return -1; + } + ctx->format = fmt; + return 1; + } + if (opt == 1000) { + ctx->dev_path = arg; + return 1; + } + return 0; +} diff --git a/tools/rmtool/src/rm_api.c b/tools/rmtool/src/rm_api.c new file mode 100644 index 0000000..17fb48b --- /dev/null +++ b/tools/rmtool/src/rm_api.c @@ -0,0 +1,291 @@ +/* rm_api.c - RM ioctl client, GPU discovery and control plumbing. + * + * The ioctl ladder (root -> device -> subdevice) and NV_ESC_RM_CONTROL + * pattern follow tools/rmq/rmq.c and tools/guest-diag/guest_engines.c. + * All operations here are control-path only; no kernel module, VFIO or + * device state is modified. + */ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "rm_api.h" + +#define CONTROL_NODE "/dev/nvidiactl" + +static NvU32 rm_alloc_root(int fd, NvHandle *out) +{ + NVOS21_PARAMETERS p = { 0 }; + + p.hRoot = 0; + p.hObjectParent = 0; + p.hClass = NV01_ROOT_CLIENT; + if (ioctl(fd, NV_IOCTL_RM_ALLOC21, &p) < 0) + return (NvU32)-1; + if (p.status != 0) + return p.status; + *out = p.hObjectNew; + return 0; +} + +static NvU32 rm_alloc64(int fd, NvHandle hRoot, NvHandle hParent, + NvU32 hClass, void *params, NvU32 params_size, + NvHandle *out) +{ + NVOS64_PARAMETERS p = { 0 }; + + p.hRoot = hRoot; + p.hObjectParent = hParent; + p.hClass = hClass; + p.pAllocParms = (NvP64)params; + p.paramsSize = params_size; + p.flags = NVOS64_FLAGS_NONE; + if (ioctl(fd, NV_IOCTL_RM_ALLOC64, &p) < 0) + return (NvU32)-1; + if (p.status != 0) + return p.status; + *out = p.hObjectNew; + return 0; +} + +static NvU32 rm_version_check(int fd) +{ + nv_ioctl_rm_api_version_t v = { 0 }; + + v.cmd = NV_RM_API_VERSION_CMD_QUERY; + if (ioctl(fd, NV_IOCTL_CHECK_VERSION, &v) < 0) + return (NvU32)-1; + if (v.reply != NV_RM_API_VERSION_REPLY_RECOGNIZED) + return (NvU32)-2; + return 0; +} + +/* The open module registers a GPU once its device node open completes; a + * NV01_DEVICE_0 alloc issued before that fails with NV_ERR_INVALID_STATE. + */ +static int rm_wait_gpu_open(int gpu_minor, const char *dev_path, int *gpu_fd) +{ + nv_ioctl_wait_open_complete_t w = { 0 }; + char path[64]; + int fd; + + if (dev_path != NULL) { + snprintf(path, sizeof(path), "%s", dev_path); + } else if (gpu_minor >= 0) { + snprintf(path, sizeof(path), "/dev/nvidia%d", gpu_minor); + } else { + snprintf(path, sizeof(path), "/dev/nvidia0"); + } + fd = open(path, O_RDWR | O_NONBLOCK | O_CLOEXEC); + if (fd < 0) + return -errno; + if (ioctl(fd, NV_IOCTL_WAIT_OPEN_COMPLETE, &w) < 0) { + int err = -errno; + + close(fd); + return err; + } + if (w.rc != 0) { + close(fd); + return w.rc; + } + *gpu_fd = fd; + return 0; +} + +int rm_client_open(struct rm_client *c, int gpu_minor, const char *dev_path) +{ + NV0080_ALLOC_PARAMETERS dev_params; + NV2080_ALLOC_PARAMETERS sub_params; + NvU32 status; + int rc; + + memset(c, 0, sizeof(*c)); + c->fd = -1; + c->gpu_fd = -1; + c->gpu_minor = gpu_minor >= 0 ? gpu_minor : 0; + c->fd = open(CONTROL_NODE, O_RDWR | O_CLOEXEC); + if (c->fd < 0) + return -errno; + + status = rm_version_check(c->fd); + if (status != 0) { + fprintf(stderr, "rmtool: RM version check failed: %s\n", + rm_status_str(status)); + rm_client_close(c); + return (int)status; + } + + rc = rm_wait_gpu_open(gpu_minor, dev_path, &c->gpu_fd); + if (rc != 0) { + fprintf(stderr, "rmtool: GPU open/complete failed: %s\n", + strerror(-rc)); + rm_client_close(c); + return rc; + } + + status = rm_alloc_root(c->fd, &c->hClient); + if (status != 0) { + fprintf(stderr, "rmtool: NV01_ROOT_CLIENT alloc failed: " + "0x%x\n", status); + rm_client_close(c); + return (int)status; + } + + memset(&dev_params, 0, sizeof(dev_params)); + dev_params.deviceId = (NvU32)c->gpu_minor; + status = rm_alloc64(c->fd, c->hClient, c->hClient, NV01_DEVICE_0, + &dev_params, sizeof(dev_params), &c->hDevice); + if (status != 0) { + fprintf(stderr, "rmtool: NV01_DEVICE_0 alloc failed: 0x%x\n", + status); + rm_client_close(c); + return (int)status; + } + + memset(&sub_params, 0, sizeof(sub_params)); + sub_params.subDeviceId = 0; + status = rm_alloc64(c->fd, c->hClient, c->hDevice, NV20_SUBDEVICE_0, + &sub_params, sizeof(sub_params), &c->hSubdevice); + if (status != 0) { + fprintf(stderr, "rmtool: NV20_SUBDEVICE_0 alloc failed: " + "0x%x\n", status); + rm_client_close(c); + return (int)status; + } + return 0; +} + +void rm_client_close(struct rm_client *c) +{ + if (c->gpu_fd >= 0) + close(c->gpu_fd); + if (c->fd >= 0) + close(c->fd); + c->fd = -1; + c->gpu_fd = -1; + c->hClient = 0; + c->hDevice = 0; + c->hSubdevice = 0; +} + +NvU32 rm_control(struct rm_client *c, NvU32 hObject, NvU32 cmd, + void *params, NvU32 size, int *ioctl_ret) +{ + NVOS54_PARAMETERS p = { 0 }; + + p.hClient = c->hClient; + p.hObject = hObject; + p.cmd = cmd; + p.params = (NvP64)params; + p.paramsSize = size; + if (ioctl(c->fd, NV_IOCTL_RM_CONTROL, &p) < 0) { + if (ioctl_ret) + *ioctl_ret = -errno; + return (NvU32)-1; + } + if (ioctl_ret) + *ioctl_ret = 0; + return p.status; +} + +const char *rm_status_str(NvU32 status) +{ + static char buf[32]; + + if (status == 0) + return "NV_OK"; + snprintf(buf, sizeof(buf), "NV_STATUS(0x%x)", status); + return buf; +} + +/* ------------------------------------------------------------------ */ +/* /proc/driver/nvidia/gpus discovery */ +/* ------------------------------------------------------------------ */ + +static void copy_trim(char *dst, size_t len, const char *src) +{ + size_t n; + + while (*src == ' ' || *src == '\t') + src++; + n = strcspn(src, "\r\n"); + if (n >= len) + n = len - 1; + memcpy(dst, src, n); + dst[n] = '\0'; + while (n > 0 && (dst[n - 1] == ' ' || dst[n - 1] == '\t')) + dst[--n] = '\0'; +} + +static void parse_information(const char *path, struct rm_gpu_desc *gpu) +{ + FILE *fh; + char line[512]; + + fh = fopen(path, "r"); + if (fh == NULL) + return; + while (fgets(line, sizeof(line), fh) != NULL) { + char *colon = strchr(line, ':'); + + if (colon == NULL) + continue; + *colon = '\0'; + if (strcmp(line, "Model") == 0) + copy_trim(gpu->model, sizeof(gpu->model), colon + 1); + else if (strcmp(line, "GPU UUID") == 0) + copy_trim(gpu->uuid, sizeof(gpu->uuid), colon + 1); + else if (strcmp(line, "Video BIOS") == 0) + copy_trim(gpu->vbios, sizeof(gpu->vbios), colon + 1); + else if (strcmp(line, "Bus Location") == 0) + copy_trim(gpu->bdf, sizeof(gpu->bdf), colon + 1); + else if (strcmp(line, "IRQ") == 0) + gpu->irq = atoi(colon + 1); + else if (strcmp(line, "Device Minor") == 0) + gpu->minor = atoi(colon + 1); + } + fclose(fh); +} + +static int gpu_compare(const void *a, const void *b) +{ + const struct rm_gpu_desc *ga = a; + const struct rm_gpu_desc *gb = b; + + return ga->minor - gb->minor; +} + +int rm_scan_gpus(struct rm_gpu_desc *out, int max) +{ + const char *root = "/proc/driver/nvidia/gpus"; + DIR *dir; + struct dirent *ent; + int n = 0; + + dir = opendir(root); + if (dir == NULL) + return -errno; + while ((ent = readdir(dir)) != NULL && n < max) { + char info[512]; + struct rm_gpu_desc *gpu = &out[n]; + + if (ent->d_name[0] == '.') + continue; + memset(gpu, 0, sizeof(*gpu)); + gpu->minor = -1; + copy_trim(gpu->bdf, sizeof(gpu->bdf), ent->d_name); + snprintf(info, sizeof(info), "%s/%s/information", root, + ent->d_name); + parse_information(info, gpu); + n++; + } + closedir(dir); + qsort(out, n, sizeof(*out), gpu_compare); + return n; +} diff --git a/tools/rmtop/Makefile b/tools/rmtop/Makefile new file mode 100644 index 0000000..3a5cd67 --- /dev/null +++ b/tools/rmtop/Makefile @@ -0,0 +1,46 @@ +# rmtop - read-only ncurses GPU dashboard (Open-IOV). +# +# Reuses rmtool's RM query layer (../rmtool/src) so there is exactly one +# ioctl implementation. Requires ncursesw; falls back to -lncursesw when +# pkg-config is unavailable. +CC ?= gcc +CFLAGS ?= -O2 -Wall -Wextra +RMTOOL = ../rmtool +CPPFLAGS += -Iinclude -I$(RMTOOL)/include +NCURSES_CFLAGS := $(shell pkg-config --cflags ncursesw 2>/dev/null) +NCURSES_LIBS := $(shell pkg-config --libs ncursesw 2>/dev/null || echo -lncursesw) +CFLAGS += $(NCURSES_CFLAGS) + +OBJS = src/main.o src/panel.o +SHARED = build/rm_api.o build/query.o build/output.o +PREFIX ?= /usr/local + +all: rmtop + +rmtop: $(OBJS) $(SHARED) + $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $^ $(NCURSES_LIBS) + +build: + @mkdir -p build + +build/rm_api.o: $(RMTOOL)/src/rm_api.c $(RMTOOL)/include/rm_api.h | build + $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< + +build/query.o: $(RMTOOL)/src/query.c $(RMTOOL)/include/rm_api.h $(RMTOOL)/include/rmtool.h | build + $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< + +build/output.o: $(RMTOOL)/src/output.c $(RMTOOL)/include/rmtool.h | build + $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< + +src/%.o: src/%.c include/rmtop.h $(RMTOOL)/include/rm_api.h $(RMTOOL)/include/rmtool.h | build + $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< + +install: rmtop + install -d $(DESTDIR)$(PREFIX)/bin + install -m 0755 rmtop $(DESTDIR)$(PREFIX)/bin/rmtop + +clean: + rm -f $(OBJS) $(SHARED) rmtop + rmdir build 2>/dev/null || true + +.PHONY: all install clean diff --git a/tools/rmtop/include/rmtop.h b/tools/rmtop/include/rmtop.h new file mode 100644 index 0000000..9335e89 --- /dev/null +++ b/tools/rmtop/include/rmtop.h @@ -0,0 +1,49 @@ +/* rmtop.h - state and rendering declarations for the rmtop dashboard. */ +#ifndef RMTOP_H +#define RMTOP_H + +#include +#include "rmtool.h" + +#define RMTOP_VERSION "0.1.0" +#define RMTOP_MAX_ENGINES NV2080_GPU_MAX_ENGINES_LIST_SIZE + +struct rmtop_state { + int gpu_minor; + char model[RM_NAME_LEN]; + char bdf[RM_BDF_LEN]; + char uuid[RM_UUID_LEN]; + char driver[80]; + + int rm_rc; + int have_rm; + char rm_name[RM_NAME_LEN]; + NvU32 mc_arch; + NvU32 mc_impl; + NvU32 mc_rev; + NvU8 mc_subrev; + + NvU32 vram_total_kib; + NvU32 vram_usable_kib; + NvU32 heap_size_kib; + NvU32 heap_free_kib; + NvU32 bar1_size_kib; + NvU32 bar1_avail_kib; + + int have_timer; + NvU64 timer_nsec; + + NvU32 engine_count; + NvU32 engine_list[RMTOP_MAX_ENGINES]; + char engine_names[RMTOP_MAX_ENGINES][24]; + int engine_names_ok; + + time_t sampled_at; +}; + +int rmtop_collect(struct rm_ctx *ctx, struct rmtop_state *st); +void rmtop_render_plain(const struct rmtop_state *st, enum rm_format format); +void rmtop_render_curses(const struct rmtop_state *st, int interval_ms, + int paused); + +#endif /* RMTOP_H */ diff --git a/tools/rmtop/rmtop b/tools/rmtop/rmtop new file mode 100755 index 0000000..08b0664 Binary files /dev/null and b/tools/rmtop/rmtop differ diff --git a/tools/rmtop/src/main.c b/tools/rmtop/src/main.c new file mode 100644 index 0000000..b7985df --- /dev/null +++ b/tools/rmtop/src/main.c @@ -0,0 +1,323 @@ +/* main.c - rmtop: ncurses real-time read-only GPU dashboard. + * + * Interactive mode needs a terminal; --once renders the same panels as plain + * table/json/csv text for scripting and harness capture. All sampling is + * read-only (RM control queries and /proc/sysfs reads); no write path exists. + */ +#include +#include +#include +#include +#include +#include +#include + +#include "rmtop.h" + +static void usage(void) +{ + printf("rmtop %s - read-only NVIDIA GPU dashboard (Open-IOV)\n\n", + RMTOP_VERSION); + printf("usage: rmtop [-g N|BDF] [--interval=MS] [--once] " + "[--format=...]\n\n" + " -g N|BDF select GPU (default: first)\n" + " --interval=MS refresh interval in ms (default 500)\n" + " --once render one snapshot as plain text and exit\n" + " --format=FMT table|json|csv (with --once)\n" + " --help, --version\n\n" + "keys: q quit r refresh s interval p pause h help\n" + " g/G next/previous GPU\n\n" + "Live engine utilization, power and thermals are marked n/a in\n" + "this read-only minimal build; see docs/17-RM-HARNESS.md.\n"); +} + +static int parse_common(struct rm_ctx *ctx, int *interval_ms, int *once, + int argc, char **argv) +{ + static const struct option long_opts[] = { + { "device-spec", required_argument, NULL, 'g' }, + { "interval", required_argument, NULL, 'i' }, + { "once", no_argument, NULL, 'o' }, + { "format", required_argument, NULL, 'f' }, + { "device", required_argument, NULL, 1000 }, + { "help", no_argument, NULL, 'h' }, + { "version", no_argument, NULL, 1001 }, + { NULL, 0, NULL, 0 }, + }; + int opt; + + optind = 1; + opterr = 0; + while ((opt = getopt_long(argc, argv, "g:i:of:h", long_opts, + NULL)) != -1) { + if (opt == 'g' || opt == 'f') { + if (rm_apply_common_opt(ctx, opt == 'g' ? 'd' : 'f', + optarg) < 0) + return -1; + continue; + } + if (opt == 'i') { + *interval_ms = atoi(optarg); + if (*interval_ms < 100) + *interval_ms = 100; + continue; + } + if (opt == 'o') { + *once = 1; + continue; + } + if (opt == 'h') { + usage(); + exit(0); + } + if (opt == 1000) { + ctx->dev_path = optarg; + continue; + } + if (opt == 1001) { + printf("rmtop %s (read-only; Open-IOV)\n", + RMTOP_VERSION); + exit(0); + } + return -1; + } + return 0; +} + +static void draw_bar(WINDOW *win, int y, int x, int width, double ratio, + const char *label) +{ + int filled = (int)(ratio * width); + int i; + + if (filled < 0) + filled = 0; + if (filled > width) + filled = width; + mvwprintw(win, y, x, "%-10s [", label); + for (i = 0; i < width; i++) { + if (i < filled) + waddch(win, ACS_CKBOARD); + else + waddch(win, '-'); + } + wprintw(win, "] %5.1f%%", ratio * 100.0); +} + +void rmtop_render_curses(const struct rmtop_state *st, int interval_ms, + int paused) +{ + char when[32]; + struct tm tm; + struct timespec ts; + int row = 1; + + clock_gettime(CLOCK_REALTIME, &ts); + localtime_r(&ts.tv_sec, &tm); + strftime(when, sizeof(when), "%H:%M:%S", &tm); + + erase(); + attron(A_REVERSE); + mvprintw(0, 0, " rmtop %s | GPU %d: %.40s | %.50s | %s%s ", + RMTOP_VERSION, st->gpu_minor, + st->model[0] ? st->model : "-", + st->driver[0] ? st->driver : "driver unknown", when, + paused ? " | PAUSED" : ""); + clrtoeol(); + attroff(A_REVERSE); + + mvprintw(row++, 1, "Bus %-14s UUID %-40s", + st->bdf[0] ? st->bdf : "-", + st->uuid[0] ? st->uuid : "-"); + mvprintw(row++, 1, "RM client: %s%s", + st->have_rm ? "ok" : "unavailable", + st->have_rm && st->rm_name[0] ? " " : ""); + if (st->have_rm && st->rm_name[0]) + printw("%s", st->rm_name); + mvprintw(row++, 1, "Arch: %s (0x%x/0x%x rev %u)", + st->have_rm ? rm_arch_str(st->mc_arch) : "-", + st->mc_arch, st->mc_impl, st->mc_rev); + mvprintw(row++, 1, "VRAM: %u MiB total, %u MiB free, %u MiB used", + st->vram_total_kib / 1024, st->heap_free_kib / 1024, + (st->vram_total_kib - st->heap_free_kib) / 1024); + mvprintw(row++, 1, "BAR1: %u MiB size, %u MiB available", + st->bar1_size_kib / 1024, st->bar1_avail_kib / 1024); + row++; + mvprintw(row++, 1, "Engines (%u)%s:", st->engine_count, + st->engine_names_ok ? "" : " [FIFO names unavailable]"); + if (st->engine_count > 0) { + unsigned int i; + + move(row, 3); + for (i = 0; i < st->engine_count; i++) { + const char *name = st->engine_names_ok ? + st->engine_names[i] : "-"; + + printw("%s ", name); + if ((i + 1) % 8 == 0 && i + 1 < st->engine_count) + mvprintw(++row, 3, " "); + } + row++; + } + row++; + if (st->have_timer) { + time_t secs = (time_t)(st->timer_nsec / 1000000000ULL); + + gmtime_r(&secs, &tm); + strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%S", &tm); + mvprintw(row++, 1, "GPU timer: %llu ns (%sZ)", + (unsigned long long)st->timer_nsec, when); + } + row++; + { + double total = st->vram_total_kib ? st->vram_total_kib : 1.0; + + draw_bar(stdscr, row++, 1, 40, + (double)(st->vram_total_kib - st->heap_free_kib) / + total, "VRAM"); + } + draw_bar(stdscr, row++, 1, 40, + st->bar1_size_kib ? + (double)(st->bar1_size_kib - st->bar1_avail_kib) / + st->bar1_size_kib : 0.0, "BAR1"); + row++; + attron(A_DIM); + mvprintw(row++, 1, "Live util/power/thermal: n/a in this read-only " + "minimal build (see docs/17-RM-HARNESS.md)"); + attroff(A_DIM); + + attron(A_REVERSE); + mvprintw(LINES - 1, 0, " q quit | r refresh | s %dms interval | " + "p pause | g/G GPU | h help ", interval_ms); + clrtoeol(); + attroff(A_REVERSE); + refresh(); +} + +static void help_screen(void) +{ + erase(); + mvprintw(0, 1, "rmtop help"); + mvprintw(2, 1, "This dashboard samples read-only RM controls and " + "/proc/sysfs."); + mvprintw(3, 1, "It never allocates, maps, resets or claims the GPU."); + mvprintw(5, 1, "q / F10 quit"); + mvprintw(6, 1, "r refresh now"); + mvprintw(7, 1, "s cycle sample interval (250/500/1000/2000 ms)"); + mvprintw(8, 1, "p pause/resume sampling"); + mvprintw(9, 1, "g / G next / previous GPU"); + mvprintw(10, 1, "h this help"); + mvprintw(12, 1, "Press any key to return."); + refresh(); + timeout(-1); + getch(); + timeout(0); +} + +int main(int argc, char **argv) +{ + struct rm_ctx ctx; + struct rmtop_state st; + int interval_ms = 500; + int once = 0; + int paused = 0; + int rc; + + ctx.gpu_minor = -1; + ctx.dev_path = NULL; + ctx.format = RM_FMT_TABLE; + ctx.have_rm = 0; + ctx.rm.fd = -1; + + if (parse_common(&ctx, &interval_ms, &once, argc, argv) < 0) { + usage(); + return 2; + } + + if (once) { + rmtop_collect(&ctx, &st); + rmtop_render_plain(&st, ctx.format); + if (ctx.have_rm) + rm_client_close(&ctx.rm); + return 0; + } + + initscr(); + cbreak(); + noecho(); + keypad(stdscr, TRUE); + curs_set(0); + if (has_colors()) { + start_color(); + use_default_colors(); + init_pair(1, COLOR_GREEN, -1); + init_pair(2, COLOR_YELLOW, -1); + init_pair(3, COLOR_RED, -1); + } + + rc = 0; + while (1) { + int ch; + + if (!paused) { + rmtop_collect(&ctx, &st); + rmtop_render_curses(&st, interval_ms, paused); + timeout(interval_ms); + } else { + timeout(-1); + } + ch = getch(); + switch (ch) { + case 'q': + case 'Q': + case KEY_F(10): + goto done; + case 'r': + break; + case 's': + if (interval_ms == 250) + interval_ms = 500; + else if (interval_ms == 500) + interval_ms = 1000; + else if (interval_ms == 1000) + interval_ms = 2000; + else + interval_ms = 250; + break; + case 'p': + paused = !paused; + break; + case 'g': + case 'G': { + struct rm_gpu_desc gpus[RM_MAX_GPUS]; + int n = rm_scan_gpus(gpus, RM_MAX_GPUS); + int i; + + for (i = 0; i < n; i++) { + if (gpus[i].minor == ctx.gpu_minor) { + int next = (ch == 'g') ? + (i + 1) % n : (i + n - 1) % n; + + rm_client_close(&ctx.rm); + ctx.have_rm = 0; + ctx.gpu_minor = gpus[next].minor; + break; + } + } + break; + } + case 'h': + help_screen(); + break; + case KEY_RESIZE: + break; + default: + break; + } + } +done: + endwin(); + if (ctx.have_rm) + rm_client_close(&ctx.rm); + return rc; +} diff --git a/tools/rmtop/src/panel.c b/tools/rmtop/src/panel.c new file mode 100644 index 0000000..9ad5ac8 --- /dev/null +++ b/tools/rmtop/src/panel.c @@ -0,0 +1,232 @@ +/* panel.c - rmtop sampling (read-only RM + /proc) and plain rendering. */ +#include +#include +#include + +#include "rmtop.h" + +static int pick_gpu(struct rm_ctx *ctx, struct rmtop_state *st) +{ + struct rm_gpu_desc gpus[RM_MAX_GPUS]; + const struct rm_gpu_desc *g = NULL; + int n, i; + + n = rm_scan_gpus(gpus, RM_MAX_GPUS); + if (n <= 0) { + st->gpu_minor = ctx->gpu_minor; + return n; + } + for (i = 0; i < n; i++) { + if (gpus[i].minor == ctx->gpu_minor) + g = &gpus[i]; + } + if (g == NULL) + g = &gpus[0]; + st->gpu_minor = g->minor; + snprintf(st->model, sizeof(st->model), "%s", g->model); + snprintf(st->bdf, sizeof(st->bdf), "%s", g->bdf); + snprintf(st->uuid, sizeof(st->uuid), "%s", g->uuid); + return n; +} + +static void read_driver_version(struct rmtop_state *st) +{ + FILE *fh = fopen("/proc/driver/nvidia/version", "r"); + + st->driver[0] = '\0'; + if (fh == NULL) + return; + if (fgets(st->driver, sizeof(st->driver), fh) != NULL) + st->driver[strcspn(st->driver, "\r\n")] = '\0'; + fclose(fh); +} + +int rmtop_collect(struct rm_ctx *ctx, struct rmtop_state *st) +{ + NV2080_CTRL_MC_ARCH_INFO_PARAMS arch; + NV2080_CTRL_GPU_GET_ENGINES_V2_PARAMS engines; + NV2080_CTRL_TIMER_GET_TIME_PARAMS timer; + NV2080_FIFO_DEVICE_ENTRY *fifo; + NvU32 fb_indices[6] = { + NV2080_CTRL_FB_INFO_INDEX_TOTAL_RAM_SIZE, + NV2080_CTRL_FB_INFO_INDEX_USABLE_RAM_SIZE, + NV2080_CTRL_FB_INFO_INDEX_HEAP_SIZE, + NV2080_CTRL_FB_INFO_INDEX_HEAP_FREE, + NV2080_CTRL_FB_INFO_INDEX_BAR1_SIZE, + NV2080_CTRL_FB_INFO_INDEX_BAR1_AVAIL_SIZE, + }; + NvU32 fb_values[6] = { 0 }; + NvU32 n_fifo = 0; + unsigned int i; + + memset(st, 0, sizeof(*st)); + st->gpu_minor = ctx->gpu_minor; + st->sampled_at = time(NULL); + read_driver_version(st); + pick_gpu(ctx, st); + + st->rm_rc = rm_ctx_open(ctx); + if (st->rm_rc != 0) + return 0; + st->have_rm = 1; + st->gpu_minor = ctx->rm.gpu_minor; + + if (rm_query_name(ctx, st->rm_name, sizeof(st->rm_name)) != 0) + snprintf(st->rm_name, sizeof(st->rm_name), "%s", "-"); + + memset(&arch, 0, sizeof(arch)); + if (rm_query_mc_arch(ctx, &arch) == 0) { + st->mc_arch = arch.architecture; + st->mc_impl = arch.implementation; + st->mc_rev = arch.revision; + st->mc_subrev = arch.subRevision; + } + + if (rm_query_fb(ctx, fb_indices, fb_values, 6) == 0) { + st->vram_total_kib = fb_values[0]; + st->vram_usable_kib = fb_values[1]; + st->heap_size_kib = fb_values[2]; + st->heap_free_kib = fb_values[3]; + st->bar1_size_kib = fb_values[4]; + st->bar1_avail_kib = fb_values[5]; + } + + memset(&timer, 0, sizeof(timer)); + { + int ioctl_ret; + + if (rm_control(&ctx->rm, ctx->rm.hSubdevice, + NV2080_CTRL_CMD_TIMER_GET_TIME, &timer, + sizeof(timer), &ioctl_ret) == 0 && + ioctl_ret == 0) { + st->timer_nsec = timer.time_nsec; + st->have_timer = 1; + } + } + + memset(&engines, 0, sizeof(engines)); + if (rm_query_engines(ctx, &engines) == 0) { + st->engine_count = engines.engineCount; + if (st->engine_count > RMTOP_MAX_ENGINES) + st->engine_count = RMTOP_MAX_ENGINES; + for (i = 0; i < st->engine_count; i++) + st->engine_list[i] = engines.engineList[i]; + } + fifo = calloc(1, sizeof(*fifo) * 512); + if (fifo != NULL) { + rm_query_fifo_table(ctx, fifo, 512, &n_fifo); + for (i = 0; i < st->engine_count; i++) { + snprintf(st->engine_names[i], + sizeof(st->engine_names[i]), "%s", + rm_engine_name(fifo, n_fifo, + st->engine_list[i])); + } + st->engine_names_ok = st->engine_count > 0; + free(fifo); + } + return 0; +} + +void rmtop_render_plain(const struct rmtop_state *st, enum rm_format format) +{ + struct rm_kv kv[16]; + char v[16][160]; + char human[32], human2[32]; + char when[32]; + struct tm tm; + int n = 0; + + if (format != RM_FMT_TABLE) + goto build; + printf("rmtop %s - read-only GPU dashboard (one-shot)\n", + RMTOP_VERSION); + printf("sampled %s", ctime(&st->sampled_at)); +build: + kv[n].key = "gpu_minor"; + snprintf(v[n], sizeof(v[n]), "%d", st->gpu_minor); + kv[n].value = v[n]; + n++; + kv[n].key = "model"; + kv[n].value = st->model[0] ? st->model : "-"; + n++; + kv[n].key = "driver"; + kv[n].value = st->driver[0] ? st->driver : "-"; + n++; + kv[n].key = "bus"; + kv[n].value = st->bdf[0] ? st->bdf : "-"; + n++; + kv[n].key = "uuid"; + kv[n].value = st->uuid[0] ? st->uuid : "-"; + n++; + kv[n].key = "rm_name"; + kv[n].value = st->have_rm && st->rm_name[0] ? st->rm_name : "-"; + n++; + kv[n].key = "rm_client"; + snprintf(v[n], sizeof(v[n]), "%s", + st->have_rm ? "ok" : "unavailable"); + kv[n].value = v[n]; + n++; + if (st->have_rm) { + snprintf(v[n], sizeof(v[n]), "%s (0x%x/0x%x rev %u)", + rm_arch_str(st->mc_arch), st->mc_arch, + st->mc_impl, st->mc_rev); + kv[n].key = "arch"; + kv[n].value = v[n]; + n++; + } + rm_human_kib(st->vram_total_kib, human, sizeof(human)); + rm_human_kib(st->vram_usable_kib, human2, sizeof(human2)); + snprintf(v[n], sizeof(v[n]), "%u KiB (%s) / usable %u KiB (%s)", + st->vram_total_kib, human, st->vram_usable_kib, human2); + kv[n].key = "vram"; + kv[n].value = v[n]; + n++; + rm_human_kib(st->heap_free_kib, human, sizeof(human)); + rm_human_kib(st->heap_size_kib, human2, sizeof(human2)); + snprintf(v[n], sizeof(v[n]), "free %u KiB (%s) / heap %u KiB (%s)", + st->heap_free_kib, human, st->heap_size_kib, human2); + kv[n].key = "vram_free"; + kv[n].value = v[n]; + n++; + rm_human_kib(st->bar1_size_kib, human, sizeof(human)); + rm_human_kib(st->bar1_avail_kib, human2, sizeof(human2)); + snprintf(v[n], sizeof(v[n]), "size %u KiB (%s), avail %u KiB (%s)", + st->bar1_size_kib, human, st->bar1_avail_kib, human2); + kv[n].key = "bar1"; + kv[n].value = v[n]; + n++; + if (st->have_timer) { + time_t secs = (time_t)(st->timer_nsec / 1000000000ULL); + + gmtime_r(&secs, &tm); + strftime(when, sizeof(when), "%Y-%m-%dT%H:%M:%S", &tm); + snprintf(v[n], sizeof(v[n]), "%llu ns (%s.%09lluZ)", + (unsigned long long)st->timer_nsec, when, + (unsigned long long)(st->timer_nsec % 1000000000ULL)); + kv[n].key = "gpu_timer"; + kv[n].value = v[n]; + n++; + } + { + size_t off = 0; + unsigned int i; + + v[n][0] = '\0'; + for (i = 0; i < st->engine_count && off < sizeof(v[n]) - 2; + i++) { + const char *name = st->engine_names_ok ? + st->engine_names[i] : "engine"; + + off += (size_t)snprintf(v[n] + off, + sizeof(v[n]) - off, "%s%s", + i ? " " : "", name); + } + kv[n].key = "engines"; + kv[n].value = v[n][0] ? v[n] : "-"; + n++; + } + kv[n].key = "live_util_power_thermal"; + kv[n].value = "n/a (read-only minimal build; see docs/17)"; + n++; + rm_print_kv("rmtop", kv, n, format); +} -- cgit v1.2.3