diff options
| author | Arthur Hanson Rasmusson <arthur@vgpu.io> | 2026-10-03 14:05:38 -0400 |
|---|---|---|
| committer | Arthur Hanson Rasmusson <arthur@vgpu.io> | 2026-10-03 14:05:38 -0400 |
| commit | cc79b478843f0230fe6aa8ecd822de7ba71c891e (patch) | |
| tree | 5de760ddec4e45cc23d39ce7502635f5c0711217 /debug/risc-v-gsp-rm-vm/harness | |
| parent | 40351167dd209012ab485e6164ce94f0b8965422 (diff) | |
| download | open-iov-cc79b478843f0230fe6aa8ecd822de7ba71c891e.tar.gz open-iov-cc79b478843f0230fe6aa8ecd822de7ba71c891e.zip | |
vgsp: host CUDA bridge + sema release recovery; greeneye simulator wave
- kernel: gsp_cuda_bridge (QMD launch capture, debugfs job/out/done, param+output resolution cached from inline writes), sema drain fixes (never hold/starve a channel), GR ring autopin + counter autoreg, per-page push reads, counter-derive gate; module builds clean
- tools/cuda-bridge: CUDA driver-API daemon executing mail/vadd kernels on the physical GPU, CUBB2/CBD2 v2 protocol, layout check + self-tests
- tools/guest-diag + tools/cuda-ab: scanner/pin fixes, victory push-only mode
- debug/risc-v-gsp-rm-vm: Phase S simulator wave (GSP device model, boot ladder, triage/re-demo harnesses, docs 30-41), GPGPU-sim scaffolding
- STATE-CURRENT/docs updates
Diffstat (limited to 'debug/risc-v-gsp-rm-vm/harness')
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json | 289 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md | 102 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/harness/re-demo-report.md | 116 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/harness/re_demo.py | 832 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/harness/re_demo/result.json | 454 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/harness/sim_qemu_line.py | 1247 | ||||
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/harness/triage.py | 875 |
7 files changed, 3915 insertions, 0 deletions
diff --git a/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json b/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json new file mode 100644 index 0000000..4df3593 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json @@ -0,0 +1,289 @@ +{ + "above_qemu_line": true, + "features": { + "features": { + "analyze_compare": { + "detail": { + "analyze_chars": 444, + "analyze_session": "harness/qemu_line/sessions/native-1", + "compare_chars": 526, + "compare_headers": [ + "Breakpoint profile delta (A vs B)", + "Watchpoint delta", + "Written pages delta" + ], + "compare_pair": [ + "harness/qemu_line/sessions/native-1", + "harness/qemu_line/sessions/native-2" + ] + }, + "status": "pass" + }, + "breakpoints": { + "detail": { + "attached_then_removed": true, + "kind": "sw", + "run_until_hit": 2147483912 + }, + "status": "pass" + }, + "hooks": { + "detail": { + "cmds": [ + 1, + 2 + ], + "rpc_dispatch_hits": 1 + }, + "status": "pass" + }, + "injection": { + "detail": { + "blob": "sdk/build/injected_demo.elf", + "dir": "harness/qemu_line/sessions/features-inject", + "expected": 48814, + "hashes": { + "events.jsonl": "08ab0874cfad71e0e4565b569d9b0ea5251bf158eaab3cdf17798182e4c66a95", + "session.json": "f34b677be0b0b453cdade4487118e059c3dfb5a1010d4e6c2fc8b37a867fb25e", + "summary.md": "59a1aa4f2b616d5266d565c4df4b0257d11aab95b09cec80f380e9aca8bfc22f" + }, + "ping_result": 48814, + "slot": 6 + }, + "status": "pass" + }, + "mcp": { + "reason": "native MCP surface is S3's deliverable (plugins/mcp_server.py); S4 must not race that work. Re-run after S3 lands (docs/37).", + "status": "not_run" + }, + "memory": { + "detail": { + "ctrl": 2147485760, + "magic": 1196642353 + }, + "status": "pass" + }, + "sessions": { + "detail": { + "dir": "harness/qemu_line/sessions/features", + "event_counts": { + "bp_hit": 2, + "hook": 1, + "image_loaded": 1, + "mem_read": 2, + "mem_write": 4, + "session_end": 1, + "session_start": 1, + "stop": 2, + "watch": 1, + "watch_hit": 1 + }, + "hashes": { + "events.jsonl": "a6328cb00287bdf678d9b62d496929fc59190a08af3910b059c840c6bc98462e", + "session.json": "2372eea627f31d11f114019759d8d6dc665683bbc88e834f158f56b16fa5c0d4", + "summary.md": "6dfca9e61a4f23374b90dbef995d86771130fcf4c2b61dc40aac6d379d4b8706" + } + }, + "status": "pass" + }, + "struct": { + "detail": { + "fields": 14, + "schema": "gsp_ctrl.schema.json" + }, + "status": "pass" + }, + "trace": { + "detail": { + "blob": "harness/qemu_line/sessions/features-trace/blobs/trace.jsonl", + "dir": "harness/qemu_line/sessions/features-trace", + "events": 4001, + "hashes": { + "events.jsonl": "36b80ed88205daa8e1bd58536eb274ed4849c3d4b1a05daa0f2c44382c7b764a", + "session.json": "a09ec38202c291a5b7a3719434ea785725e291cba2dc877d94cd0caf7fcd1a4e", + "summary.md": "10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44" + }, + "max_events": 4000 + }, + "status": "pass" + }, + "watch": { + "detail": { + "pcs": [ + 2147484066 + ], + "result_field_stores": 1 + }, + "status": "pass" + } + }, + "ran": true, + "reason": "ok" + }, + "firmware": { + "name": "gsp-570.144.fwimage.bin", + "sha256": "2d1bb5c60b208cfd115f015b17dd04a6fece05fd2c0dcba3625d9d48cce3ceea" + }, + "harness": "harness/sim_qemu_line.py", + "line_definition": "docs/35-GREENEYE-SIMULATOR-DESIGN.md 1.1 + 3.3", + "native": { + "backend": "native", + "compare": { + "chars": 526, + "headers": [ + "Breakpoint profile delta (A vs B)", + "Watchpoint delta", + "Written pages delta" + ], + "ran": true + }, + "device_model_used": true, + "identical": true, + "instructions_budget": 200000, + "pair_wall_s": 0.3093, + "profile": "sim/profiles/tu104-570.144.json", + "ran": true, + "reason": "ok", + "repeat": 2, + "rung": "B2", + "rung_basis": "observed S1 boot-ladder rung from sim.gsp_boot (milestones=['bootargs_consumer', 'relocation_copy'], mmio_unknown=0, device_unhandled=0)", + "runs": [ + { + "cycle_model": "rv64imac-modeled/1", + "cycles": 27294, + "device_accesses": 1, + "device_state_sha256": "3fa264b9f81081e8455a9cd26c0c2a721e3ac272e1079105a0eb191b1497f83d", + "device_unhandled": 0, + "first_unknown_mmio": null, + "instructions": 14682, + "milestones": [ + "bootargs_consumer", + "relocation_copy" + ], + "mmio_unknown": 0, + "rung": "B2", + "rung_basis": "observed S1 boot-ladder rung from sim.gsp_boot (milestones=['bootargs_consumer', 'relocation_copy'], mmio_unknown=0, device_unhandled=0)", + "session": "harness/qemu_line/sessions/native-1", + "session_hashes": { + "events.jsonl": "f1f336af84b750b0c8bf8f8e9a7a3952161eb884418f5580f08cb3e26a070713", + "session.json": "c756f8078812aa1ed03628e097ee517320d7de3664089f1133861c96130d5b5b", + "summary.md": "c0aa5d5b2cb306fda703a7144af455c36fe72b9d1e2c73ddcdab905d36cdc8a7" + }, + "stop": { + "pc": 67168780, + "pc_fw": 67168780, + "reason": "fault" + }, + "wall_s": 0.1561 + }, + { + "cycle_model": "rv64imac-modeled/1", + "cycles": 27294, + "device_accesses": 1, + "device_state_sha256": "3fa264b9f81081e8455a9cd26c0c2a721e3ac272e1079105a0eb191b1497f83d", + "device_unhandled": 0, + "first_unknown_mmio": null, + "instructions": 14682, + "milestones": [ + "bootargs_consumer", + "relocation_copy" + ], + "mmio_unknown": 0, + "rung": "B2", + "rung_basis": "observed S1 boot-ladder rung from sim.gsp_boot (milestones=['bootargs_consumer', 'relocation_copy'], mmio_unknown=0, device_unhandled=0)", + "session": "harness/qemu_line/sessions/native-2", + "session_hashes": { + "events.jsonl": "d0d15816dbde8c85c30ba2576f97c9d5e3f470e6c38d9b4762d266a612cafe12", + "session.json": "8c7f0ea958a930edb814690bc0b233be01fcab3f747332abff39989e66642c2e", + "summary.md": "d2d6154ec22f4ee0d942543a32c29699ccb8031188df955c415d836462d04fd3" + }, + "stop": { + "pc": 67168780, + "pc_fw": 67168780, + "reason": "fault" + }, + "wall_s": 0.1524 + } + ] + }, + "not_verified": [ + "lenient MMIO (B0) is observation, not device semantics", + "B3/B4 are not reached: the S1 device model stops at the B2 DMEM-runspace unknown", + "the boot shim values (bootargs/WPR meta/DMEM mirror) are declared assumptions", + "MCP on native is S3-owned and not exercised here", + "L2 timing: cycles are modeled (docs/32), never silicon claims", + "no hardware/GPU/guest/module involvement by design" + ], + "qemu": { + "backend": "qemu", + "device_map_entries": 2, + "fault_count_window": 5269, + "first_fault": { + "access_addr": 2305843009213696512, + "cause": 5, + "desc": "fault_load", + "epc": 2214629396, + "epc_fw": 67145748, + "reason": "rejected", + "rw": "read", + "size": 4 + }, + "first_touch_match": true, + "log_window_capped": true, + "ran": true, + "reason": "ok", + "rung": "pre-B0", + "session": "harness/qemu_line/sessions/qemu-probe", + "session_hashes": { + "events.jsonl": "cdba69c63e306f4c3de220b171e2ea7c1de4bd4abf18cdba1fb51bc681d5d172", + "session.json": "7e3f0f8b24c0403d114e59f68ed1b923e44abbfbf19f4865bd566a1b631c64ac", + "summary.md": "d141fa06d3e2d0603f3b99ad7603758e1fc14627aed74c6fe8a6ad22c56eb14b" + }, + "stopped_pc": 0, + "wall_s": 0.9519 + }, + "rerun_when_s1_lands": [ + "python3 harness/sim_qemu_line.py", + "S1 landed (2026-10-03): sim/profiles/*.json is discovered and the native side runs sim.gsp_boot", + "sim/devices/*.json with greeneye-device-map/1 (or a device_map key) is still picked up as an observational map" + ], + "rung_order": [ + "pre-B0", + "B0", + "B1", + "B2", + "B3", + "B4", + "B5" + ], + "rung_progress": true, + "s1_integration": { + "declared_rung": null, + "notes": [ + "using sim/profiles/tu104-570.144.json (greeneye-sim-profile/1, firmware_sha256 matched)" + ], + "profile": "sim/profiles/tu104-570.144.json", + "source": "sim/profiles/tu104-570.144.json" + }, + "schema": "greeneye-sim-qemu-line/1", + "threshold_checks": { + "all": true, + "device_model_documented": true, + "identical_runs": true, + "native_above_qemu": true, + "native_pair_wall_s": true, + "qemu_first_fault_reproduced": true + }, + "thresholds": { + "device_model_required": true, + "frozen_on": "2026-10-03", + "identical_runs": 2, + "native_pair_wall_s": 20.0, + "native_rungs_above_qemu": 1, + "qemu_first_fault_fw": 67145748, + "qemu_first_touch": 2305843009213696512 + }, + "verdict": "above", + "verdict_explanation": "all frozen checks passed: QEMU fault reproduced, native >= QEMU + 1 rung, identical runs, wall bound, documented device model", + "x86_vms_running": 0 +} diff --git a/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md b/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md new file mode 100644 index 0000000..4e546b3 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md @@ -0,0 +1,102 @@ +# Simulator vs the QEMU line - measured report + +* schema: `greeneye-sim-qemu-line/1` +* harness: `harness/sim_qemu_line.py` +* line: docs/35-GREENEYE-SIMULATOR-DESIGN.md 1.1 + 3.3 +* firmware: `gsp-570.144.fwimage.bin` sha256 `2d1bb5c60b208cfd...` +* verdict: **above** (above_qemu_line=True) +* verdict meaning: all frozen checks passed: QEMU fault reproduced, native >= QEMU + 1 rung, identical runs, wall bound, documented device model + +## Rung table + +| side | ran | rung | instructions | cycles (modeled) | stop | wall s | evidence | +|---|---|---|---:|---:|---|---:|---| +| QEMU probe | True | pre-B0 | - | - | fault | 0.9519 | `harness/qemu_line/sessions/qemu-probe` | +| native run 1 | True | B2 | 14682 | 27294 | fault@0x400ea0c | 0.1561 | `harness/qemu_line/sessions/native-1` | +| native run 2 | True | B2 | 14682 | 27294 | fault@0x400ea0c | 0.1524 | `harness/qemu_line/sessions/native-2` | + +### QEMU first fault + +* fault_load at fw vaddr `0x4009014` accessing `0x2000000000000a00` (4 bytes, read) +* first-touch matches the documented reference: True +* fault records in the capped log window: 5269 + +## Frozen thresholds + +| check | pass | +|---|---| +| qemu_first_fault_reproduced | True | +| native_above_qemu | True | +| identical_runs | True | +| native_pair_wall_s | True | +| device_model_documented | True | +| all | True | + +Thresholds (frozen 2026-10-03): native >= QEMU + 1 rung(s); 2 identical runs; pair wall <= 20.0 s. + +## Determinism + +* identical across 2 native runs: True +* session compare ran: True (['Breakpoint profile delta (A vs B)', 'Watchpoint delta', 'Written pages delta']) + +## Session hashes (sha256) + +| session | file | sha256 | +|---|---|---| +| qemu-probe | events.jsonl | `cdba69c63e306f4c3de220b171e2ea7c1de4bd4abf18cdba1fb51bc681d5d172` | +| qemu-probe | session.json | `7e3f0f8b24c0403d114e59f68ed1b923e44abbfbf19f4865bd566a1b631c64ac` | +| qemu-probe | summary.md | `d141fa06d3e2d0603f3b99ad7603758e1fc14627aed74c6fe8a6ad22c56eb14b` | +| native-1 | events.jsonl | `f1f336af84b750b0c8bf8f8e9a7a3952161eb884418f5580f08cb3e26a070713` | +| native-1 | session.json | `c756f8078812aa1ed03628e097ee517320d7de3664089f1133861c96130d5b5b` | +| native-1 | summary.md | `c0aa5d5b2cb306fda703a7144af455c36fe72b9d1e2c73ddcdab905d36cdc8a7` | +| native-2 | events.jsonl | `d0d15816dbde8c85c30ba2576f97c9d5e3f470e6c38d9b4762d266a612cafe12` | +| native-2 | session.json | `8c7f0ea958a930edb814690bc0b233be01fcab3f747332abff39989e66642c2e` | +| native-2 | summary.md | `d2d6154ec22f4ee0d942543a32c29699ccb8031188df955c415d836462d04fd3` | +| sessions | events.jsonl | `a6328cb00287bdf678d9b62d496929fc59190a08af3910b059c840c6bc98462e` | +| sessions | session.json | `2372eea627f31d11f114019759d8d6dc665683bbc88e834f158f56b16fa5c0d4` | +| sessions | summary.md | `6dfca9e61a4f23374b90dbef995d86771130fcf4c2b61dc40aac6d379d4b8706` | +| trace | events.jsonl | `36b80ed88205daa8e1bd58536eb274ed4849c3d4b1a05daa0f2c44382c7b764a` | +| trace | session.json | `a09ec38202c291a5b7a3719434ea785725e291cba2dc877d94cd0caf7fcd1a4e` | +| trace | summary.md | `10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44` | +| injection | events.jsonl | `08ab0874cfad71e0e4565b569d9b0ea5251bf158eaab3cdf17798182e4c66a95` | +| injection | session.json | `f34b677be0b0b453cdade4487118e059c3dfb5a1010d4e6c2fc8b37a867fb25e` | +| injection | summary.md | `59a1aa4f2b616d5266d565c4df4b0257d11aab95b09cec80f380e9aca8bfc22f` | + +## Native feature matrix (demo firmware) + +| feature | status | detail | +|---|---|---| +| analyze_compare | pass | {'analyze_session': 'harness/qemu_line/sessions/native-1', 'analyze_chars': 444, 'compare_pair': ['harness/qemu_line/sessions/native-1', 'harness/qemu_line/sessions/native-2'], 'compare_chars': 526, 'compare_headers': ['Breakpoint profile delta (A vs B)', 'Watchpoint delta', 'Written pages delta']} | +| breakpoints | pass | {'run_until_hit': 2147483912, 'attached_then_removed': True, 'kind': 'sw'} | +| hooks | pass | {'rpc_dispatch_hits': 1, 'cmds': [1, 2]} | +| injection | pass | {'dir': 'harness/qemu_line/sessions/features-inject', 'blob': 'sdk/build/injected_demo.elf', 'slot': 6, 'ping_result': 48814, 'expected': 48814, 'hashes': {'session.json': 'f34b677be0b0b453cdade4487118e059c3dfb5a1010d4e6c2fc8b37a867fb25e', 'events.jsonl': '08ab0874cfad71e0e4565b569d9b0ea5251bf158eaab3cdf17798182e4c66a95', 'summary.md': '59a1aa4f2b616d5266d565c4df4b0257d11aab95b09cec80f380e9aca8bfc22f'}} | +| mcp | not_run | native MCP surface is S3's deliverable (plugins/mcp_server.py); S4 must not race that work. Re-run after S3 lands (docs/37). | +| memory | pass | {'ctrl': 2147485760, 'magic': 1196642353} | +| sessions | pass | {'dir': 'harness/qemu_line/sessions/features', 'event_counts': {'bp_hit': 2, 'hook': 1, 'image_loaded': 1, 'mem_read': 2, 'mem_write': 4, 'session_end': 1, 'session_start': 1, 'stop': 2, 'watch': 1, 'watch_hit': 1}, 'hashes': {'session.json': '2372eea627f31d11f114019759d8d6dc665683bbc88e834f158f56b16fa5c0d4', 'events.jsonl': 'a6328cb00287bdf678d9b62d496929fc59190a08af3910b059c840c6bc98462e', 'summary.md': '6dfca9e61a4f23374b90dbef995d86771130fcf4c2b61dc40aac6d379d4b8706'}} | +| struct | pass | {'schema': 'gsp_ctrl.schema.json', 'fields': 14} | +| trace | pass | {'dir': 'harness/qemu_line/sessions/features-trace', 'blob': 'harness/qemu_line/sessions/features-trace/blobs/trace.jsonl', 'events': 4001, 'max_events': 4000, 'hashes': {'session.json': 'a09ec38202c291a5b7a3719434ea785725e291cba2dc877d94cd0caf7fcd1a4e', 'events.jsonl': '36b80ed88205daa8e1bd58536eb274ed4849c3d4b1a05daa0f2c44382c7b764a', 'summary.md': '10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44'}} | +| watch | pass | {'result_field_stores': 1, 'pcs': [2147484066]} | + +## S1 integration + +* device source: sim/profiles/tu104-570.144.json +* profile: sim/profiles/tu104-570.144.json +* declared rung: None +* note: using sim/profiles/tu104-570.144.json (greeneye-sim-profile/1, firmware_sha256 matched) + +## Not verified + +* lenient MMIO (B0) is observation, not device semantics +* B3/B4 are not reached: the S1 device model stops at the B2 DMEM-runspace unknown +* the boot shim values (bootargs/WPR meta/DMEM mirror) are declared assumptions +* MCP on native is S3-owned and not exercised here +* L2 timing: cycles are modeled (docs/32), never silicon claims +* no hardware/GPU/guest/module involvement by design + +## S1 integration notes + +* `python3 harness/sim_qemu_line.py` +* S1 landed (2026-10-03): sim/profiles/*.json is discovered and the native side runs sim.gsp_boot +* sim/devices/*.json with greeneye-device-map/1 (or a device_map key) is still picked up as an observational map + +* other agents' x86 QEMU VMs observed (never touched): 0 diff --git a/debug/risc-v-gsp-rm-vm/harness/re-demo-report.md b/debug/risc-v-gsp-rm-vm/harness/re-demo-report.md new file mode 100644 index 0000000..aa7a116 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/re-demo-report.md @@ -0,0 +1,116 @@ +# Real RE demo - what the GSP does between entry and the first fault + +* schema: `greeneye-re-demo/1` +* script: `harness/re_demo.py` (stdlib-only, no hardware/guest/module) +* firmware: `gsp-570.144.fwimage.bin` sha256 `2d1bb5c60b208cfd...` +* verdict: **PASS** + +## Scenario + +> What happens on the real GSP image between entry and the first fault/milestone, and what does the state tell us about boot? + +## Answer + +**q1_first_touch: the first MMIO is a 4-byte read of PA 0x2000000000000a00 at fw PC 0x4009014; it is the QEMU-documented first fault** + +**q2_path: entry state is all-zero (no bootloader handoff modelled); the bootstrap builds the device PA with 5 arithmetic instructions (li/slli/addi/slli/addi), reads it, then runs the chip-ID comparison chain: 25 instructions recorded from entry until the halt at fw 0x4009070** + +**q3_id_field: the read value's bits [28:20] are extracted (srli 20, andi 0x1ff) and compared against 354..360 (Turing) and 368 (Ampere); the image itself defines this as a chip-ID field** + +**q4_dispatch: ID 354..360 dispatches to .tu10x_text at 0x4007000; ID 368 dispatches to .ga100_text at 0x4005000; any other value (including the lenient zero) falls to the fail-closed `j .` loop at 0x4009070** + +**q5_next_blocker: with a Turing ID served, .tu10x_text programs CSRs 0x7c8-0x7cc (region descriptors) and enables mstatus FP/SIMD state, then builds the absolute link-time address 0x40070f8 and jumps to it; because the loader relocates segments but not absolute immediates, that jump lands outside RAM and faults - a relocation artifact, so the next rung needs a chip-ID device model plus load-at-link-address/alias translation** + +## Baseline vs emulator (measured) + +| side | method | wall s | what it answers | +|---|---|---:|---| +| baseline | `python3 -m greeneye.cli probe <image> --duration 1.0 (here run via harness.run_qemu_baseline, 0.5 s bound)` | 0.9495 | first fault only | +| baseline | manual static reading (estimate, floor estimate: ~90 instructions across .bootstrap + .tu10x_text + .ga100_text read by hand, dispatch table identified and correlated with the QEMU first-fault report; not measured in this run) | 600.0 | entry->fault path, by hand | +| baseline | total (conservative) | 600.9495 | full question | +| emulator | three recorded native sessions | 0.1251 | full question + evidence | + +Per-session `wall_s` in the milestone table is the recorded run only; the emulator total includes session launch, repeat and report writing. + +## Rung / milestone table + +| run | first touch | stop/milestone | instructions | wall s | evidence | +|---|---|---|---:|---:|---| +| QEMU probe | 0x2000000000000a00 (fault) | fault, then mtvec=0 loop | - | 0.9495 | `harness/re_demo/sessions/qemu-probe` | +| native lenient (B0) | 0x2000000000000a00 | halt loop fw 0x4009070 | 25 | 0.0006 | `harness/re_demo/sessions/native-lenient` | +| native modeled chip-ID (B1 candidate) | 0x2000000000000a00 | instruction fault at fw 0x40070f8 (outside RAM) | 74 | 0.0004 | `harness/re_demo/sessions/native-modeled-id` | + +## Frozen thresholds + +| check | pass | +|---|---| +| answers_complete | True | +| deterministic_lenient_runs | True | +| emulator_wall_s | True | +| milestone_past_qemu | True | +| baseline_beat | True | +| pass | True | + +Thresholds frozen 2026-10-03 before the run: emulator wall <= 120.0 s; 2 identical lenient runs; all 5 answers present; milestone past the QEMU point. Failure signal: any check false -> the run is a failed experiment and the feature is not claimed. + +## Sessions (evidence) + +| session | file | sha256 | +|---|---|---| +| qemu-probe | events.jsonl | `8c4dba8006a496d853c81ce9e2b16944cd27b8d6171c01777daa6e5738be02bc` | +| qemu-probe | session.json | `c7bf5e4d29f534160d660dadb81f81942faa17cbfb1941587c60dcf78180f56c` | +| qemu-probe | summary.md | `d141fa06d3e2d0603f3b99ad7603758e1fc14627aed74c6fe8a6ad22c56eb14b` | +| native-lenient | events.jsonl | `ccb568588738c0609071ee542c9b04f7bb2868ee8fae12c28b2bfc5d9343721b` | +| native-lenient | session.json | `ec5944adce4b823a3d6fd7d5aa6385fb5b61c2f9542c7221f46f328601c12c66` | +| native-lenient | summary.md | `b50820afa60c35da0d4701e54f9907d4b7090c52721e1ecf656942a41bcf44db` | +| native-lenient-repeat | events.jsonl | `2cd28b4da009447d70505cfbba59e9c1bf8bd277e3a7ea696e2690f54f66f756` | +| native-lenient-repeat | session.json | `fe21ed52c05436d0f7886dfeb0886ec1a11b25678e4d827db3b9e4009d2187e4` | +| native-lenient-repeat | summary.md | `357d95a664e33128a2a48da7ecfc058bdbbea56fae130557593fd59ff2efa597` | +| native-modeled-id | events.jsonl | `fdf0756c3543741b74c9267dc9e4d22156f43a4b54463ffbfb1671c327444515` | +| native-modeled-id | session.json | `5ae7eeff56a19ed7eea093a164a3a3738965b56c39f35a563db3ee93a2c90d39` | +| native-modeled-id | summary.md | `bd670d207d23079173b494a9be9f9a0e7d31a10a11f9dc48fb8a134888442d0b` | + +## Disassembly of the entry prologue (from the lenient session memory read) + +``` +0x84009000: 0010029b addiw t0,zero,1 +0x84009004: 03129293 slli t0,t0,49 +0x84009008: 00128293 addi t0,t0,1 +0x8400900c: 00c29293 slli t0,t0,12 +0x84009010: a0028293 addi t0,t0,-1536 +0x84009014: 0002a283 lw t0,0(t0) +0x84009018: 0142d293 srli t0,t0,20 +0x8400901c: 1ff2f293 andi t0,t0,511 +``` + +### Turing dispatch tail (from the modeled session memory read) + +``` +0x840070e4: 1e0002b7 lui t0,0x1e000 +0x840070e8: 3002a073 csrs mstatus,t0 +0x840070ec: 040072b7 lui t0,0x4007 +0x840070f0: 0f828293 addi t0,t0,248 +0x840070f4: 00028067 jalr zero,0(t0) +``` + +## Not verified (honest limits) + +* the modeled chip-ID value is an assumption (the QEMU baseline faults before reading it); only the field position and the compared IDs are firmware evidence +* no timing/cycle claim: cycles are modeled (L2 not modelled, docs/06) and none are used in this answer +* L3 device environment is absent: the register's on-silicon semantics are inferred from firmware comparisons, not from a device model +* load-at-link-address and absolute-immediate relocation behaviour are not implemented; the B1 candidate fault is a relocation artifact of this harness +* watchpoints are not exercised on this path: the bootstrap performs no RAM data access before the fault (RAM watchpoints are exercised by harness/sim_qemu_line.py on the demo firmware) +* no hardware, GPU, mdev, module, guest or network involvement by design + +## Reproduce + +```bash +cd open-iov/debug/risc-v-gsp-rm-vm +python3 harness/re_demo.py +``` + +## Re-run contract + +* thresholds live in harness/re_demo.py (FROZEN_THRESHOLDS) and are not moved after the run +* the QEMU baseline needs qemu-system-riscv64; without it the emulator columns still reproduce and the baseline is marked unverified +* sessions are under harness/re_demo/sessions/ (hash-stamped); the report regenerates with the same command diff --git a/debug/risc-v-gsp-rm-vm/harness/re_demo.py b/debug/risc-v-gsp-rm-vm/harness/re_demo.py new file mode 100644 index 0000000..3f9c317 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/re_demo.py @@ -0,0 +1,832 @@ +#!/usr/bin/env python3 +"""Real reverse-engineering demo: GSP entry -> first fault/milestone (U51). + +Answers one concrete question on the real TU104 570.144 image with the +simulator's own primitives (``launch_native`` session, hooks, memory reads, +snapshots, exec trace, recorded sessions): + + What happens on the real GSP image between entry and the first + fault/milestone, and what does the state tell us about boot? + +Method (both sides measured, thresholds frozen in this file before any run): + +* **baseline** - the QEMU ``probe`` path (bounded 0.5 s, measured) plus a + conservative manual static-reading estimate (``MANUAL_ESTIMATE_S``; the + method note says how the estimate is derived). QEMU reports the first + fault; the "what happens between entry and the fault" part is hand work + in a disassembler. +* **emulator** - three recorded native sessions: (A) lenient MMIO (B0), which + reproduces the QEMU first touch and follows the firmware to its fail-closed + halt; (A') the same run repeated for determinism; (B) the same run with the + chip-ID register served a Turing value from a one-entry device map, which + advances past the QEMU point to the next (relocation) blocker. + +Everything is stdlib-only, bounded (<= 2M instructions, <= 10 s QEMU), and +touches no hardware, GPU, guest, module or network. This is simulator +evidence at L0/L1 (docs/06): no timing or silicon claim. + +CLI:: + + python3 harness/re_demo.py + python3 harness/re_demo.py --json + python3 harness/re_demo.py --out harness/re_demo --report harness/re-demo-report.md +""" +from __future__ import annotations + +import argparse +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(HERE) +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from greeneye.device_map import DeviceEntry, DeviceMap, QEMU_REFERENCE # noqa: E402 +from greeneye.sim_backend import launch_native # noqa: E402 +from harness.sim_qemu_line import run_qemu_baseline, session_hashes # noqa: E402 +from sim.decode import ( # noqa: E402 + K_AUIPC, + K_BRANCH, + K_CSR, + K_JAL, + K_JALR, + K_LOAD, + K_LUI, + K_OP, + K_OP32, + K_OPI, + K_OPI32, + decode16, + decode32, +) + +DEFAULT_IMAGE = os.path.normpath( + os.path.join( + TOOL_ROOT, "..", "..", "firmware", "57X-GSP", "570.144", "gsp-570.144.fwimage.bin" + ) +) +DEFAULT_OUT = os.path.join(HERE, "re_demo") +DEFAULT_REPORT = os.path.join(HERE, "re-demo-report.md") + +#: GPU physical addresses are relocated into simulator RAM at this bias +#: (same convention as the probe/device-map/gsp-profile paths). +BIAS = 0x80000000 + +ENTRY = 0x4009000 +FIRST_TOUCH_PC = 0x4009014 +HALT_PC = 0x4009070 +TU10X_ENTRY = 0x4007000 +GA100_ENTRY = 0x4005000 +TU10X_ABS_TARGET = 0x40070F8 + +#: The chip-ID field is bits [28:20] of the register read at the first touch; +#: these are the comparison values the image itself uses (session A trace). +TU10X_IDS = (354, 355, 356, 357, 358, 359, 360) +GA100_ID = 368 +#: Exploratory modeled register value: Turing TU10x ID in the field position. +#: This is an *assumption* chosen to exercise the dispatch path, not an +#: observed hardware value (the QEMU baseline faults before reading it). +MODELED_ID = TU10X_IDS[0] << 20 + +#: Frozen thresholds - recorded 2026-10-03, before the first measurement. +#: Never move silently; if a threshold changes, date the change in docs/37. +FROZEN_THRESHOLDS = { + "frozen_on": "2026-10-03", + "emulator_wall_s_max": 120.0, + "manual_static_estimate_s": 600.0, + "answers_required": 5, + "identical_lenient_runs": 2, + "milestone_past_qemu": True, +} + +#: How the manual estimate is derived (a floor, not a ceiling): read the +#: 6-instruction .bootstrap prologue + the two per-architecture text sections +#: (~90 instructions), identify the pointer construction, the chip-ID bit +#: field, the two dispatch targets and the halt path, then correlate against +#: the QEMU fault report. No human fast path is measured; 10 minutes is the +#: conservative floor for one engineer with the artifacts already open. +MANUAL_METHOD_NOTE = ( + "floor estimate: ~90 instructions across .bootstrap + .tu10x_text + " + ".ga100_text read by hand, dispatch table identified and correlated " + "with the QEMU first-fault report; not measured in this run" +) + + +def _relpath(path: str | None) -> str | None: + if path is None: + return None + return os.path.relpath(os.path.abspath(path), TOOL_ROOT) + + +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 _read_json_lines(path: str) -> list[dict]: + rows: list[dict] = [] + try: + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if line: + rows.append(json.loads(line)) + except (OSError, ValueError): + pass + return rows + + +def _trace_exec_pcs(session_dir: str) -> list[int]: + rows = _read_json_lines(os.path.join(session_dir, "blobs", "trace.jsonl")) + return [int(row["pc"], 16) for row in rows if row.get("t") == "exec"] + + +def _fw(pc: int) -> int: + return pc - BIAS if pc >= BIAS else pc + + +# -- ELF section names (stdlib) ---------------------------------------------- + + +def elf_progbits_sections(path: str) -> dict: + """{name: (addr, size)} for PROGBITS sections, parsed directly from ELF.""" + with open(path, "rb") as fh: + data = fh.read() + e_shoff = int.from_bytes(data[0x28:0x30], "little") + e_shentsize = int.from_bytes(data[0x3A:0x3C], "little") + e_shnum = int.from_bytes(data[0x3C:0x3E], "little") + e_shstrndx = int.from_bytes(data[0x3E:0x40], "little") + + def header(index: int): + off = e_shoff + index * e_shentsize + return ( + int.from_bytes(data[off : off + 4], "little"), # sh_name + int.from_bytes(data[off + 4 : off + 8], "little"), # sh_type + int.from_bytes(data[off + 16 : off + 24], "little"), # sh_addr + int.from_bytes(data[off + 24 : off + 32], "little"), # sh_offset + int.from_bytes(data[off + 32 : off + 40], "little"), # sh_size + ) + + _, _, _, strtab_off, strtab_size = header(e_shstrndx) + strtab = data[strtab_off : strtab_off + strtab_size] + sections: dict = {} + for index in range(e_shnum): + name_off, sh_type, addr, _offset, size = header(index) + end = strtab.find(b"\0", name_off) + name = strtab[name_off:end].decode("utf-8", "replace") if end >= 0 else "" + if sh_type == 1 and size: # SHT_PROGBITS + sections[name] = (addr, size) + return sections + + +def section_for(sections: dict, fw_addr: int) -> str | None: + for name, (base, size) in sections.items(): + if base <= fw_addr < base + size: + return name + return None + + +# -- minimal disassembly (decode tuples -> mnemonics) ------------------------ + +_LOAD_NAMES = ("lb", "lh", "lw", "ld", "lbu", "lhu", "lwu") +_BRANCH_NAMES = ("beq", "bne", "blt", "bge", "bltu", "bgeu") +_OPI_NAMES = {0: "addi", 1: "slli", 2: "slti", 3: "sltiu", 4: "xori", 5: "srli", 6: "ori", 7: "andi", 0x15: "srai"} +_OPI32_NAMES = {0: "addiw", 1: "slliw", 5: "srliw", 0x15: "sraiw"} +_OP_NAMES = { + 0: "add", 1: "sll", 2: "slt", 3: "sltu", 4: "xor", 5: "srl", 6: "or", 7: "and", + 8: "sub", 0x15: "sra", +} +_OP32_NAMES = {0: "addw", 1: "sllw", 5: "srlw", 8: "subw", 0x15: "sraw"} +_CSR_NAMES = {0x300: "mstatus"} +_CSR_OPS = {1: "csrrw", 2: "csrs", 3: "csrc", 5: "csrrwi", 6: "csrsi", 7: "csrci"} + + +def _abi(index: int) -> str: + return ( + "zero", "ra", "sp", "gp", "tp", "t0", "t1", "t2", "s0", "s1", + "a0", "a1", "a2", "a3", "a4", "a5", "a6", "a7", + "s2", "s3", "s4", "s5", "s6", "s7", "s8", "s9", "s10", "s11", + "t3", "t4", "t5", "t6", + )[index] + + +def disasm(pc: int, raw: int, decoded: tuple) -> str: + kind, sub, rd, rs1, rs2, imm, _next = decoded + if kind == K_OPI: + name = _OPI_NAMES.get(sub, f"opi:{sub}") + text = f"{name} {_abi(rd)},{_abi(rs1)},{imm}" + elif kind == K_OP: + name = _OP_NAMES.get(sub, f"op:{sub}") + text = f"{name} {_abi(rd)},{_abi(rs1)},{_abi(rs2)}" + elif kind == K_OPI32: + name = _OPI32_NAMES.get(sub, f"opi32:{sub}") + text = f"{name} {_abi(rd)},{_abi(rs1)},{imm}" + elif kind == K_OP32: + name = _OP32_NAMES.get(sub, f"op32:{sub}") + text = f"{name} {_abi(rd)},{_abi(rs1)},{_abi(rs2)}" + elif kind == K_LOAD: + name = _LOAD_NAMES[sub] if sub < len(_LOAD_NAMES) else f"load:{sub}" + text = f"{name} {_abi(rd)},{imm}({_abi(rs1)})" + elif kind == K_BRANCH: + name = _BRANCH_NAMES[sub] if sub < len(_BRANCH_NAMES) else f"branch:{sub}" + text = f"{name} {_abi(rs1)},{_abi(rs2)},{_next:#x}" + elif kind == K_LUI: + text = f"lui {_abi(rd)},{imm >> 12:#x}" + elif kind == K_AUIPC: + text = f"auipc {_abi(rd)},{imm >> 12:#x}" + elif kind == K_JALR: + text = f"jalr {_abi(rd)},{imm}({_abi(rs1)})" + elif kind == K_JAL: + text = f"jal {_abi(rd)},{_next:#x}" + elif kind == K_CSR: + csr = _CSR_NAMES.get(imm, f"0x{imm:x}") + name = _CSR_OPS.get(sub, f"csr:{imm:#x}") + text = f"{name} {csr},{_abi(rs1)}" + else: + text = f".raw 0x{raw:08x}" + return f"{pc:#x}: {raw:08x} {text}" + + +def read_disasm(session, addr: int, count: int) -> list[str]: + lines: list[str] = [] + for _ in range(count): + raw16 = int.from_bytes(session.memory.read(addr, 2), "little") + if raw16 & 3 == 3: + raw = int.from_bytes(session.memory.read(addr, 4), "little") + decoded = decode32(addr, raw) + step = 4 + else: + raw = raw16 + decoded = decode16(addr, raw16) + step = 2 + lines.append(disasm(addr, raw, decoded)) + addr += step + return lines + + +# -- sessions ---------------------------------------------------------------- + + +def _launch(image: str, out_dir: str, name: str, device_map, trace_spec): + session_dir = os.path.join(out_dir, "sessions", name) + shutil.rmtree(session_dir, ignore_errors=True) + session = launch_native( + kernel=image, + record=session_dir, + name=name, + memory_mb=512, + bias=BIAS, + device_map=device_map, + unknown_mmio="lenient", + learn_mmio=device_map is not None, + trace=trace_spec, + ) + return session, session_dir + + +def run_lenient_session(image: str, out_dir: str, tag: str = "native-lenient") -> dict: + """Session A: lenient MMIO; reproduce the QEMU first touch, follow to halt.""" + dm = DeviceMap(meta={"source": "re-demo-lenient", "bias": BIAS}) + session, session_dir = _launch( + image, + out_dir, + tag, + dm, + {"exec": True, "start": ENTRY + BIAS, "end": HALT_PC + BIAS, "max_events": 256}, + ) + start = time.monotonic() + entry_regs = session.read_regs() + snapshot = session.snapshot(ENTRY + BIAS, 0x78, name="bootstrap") + stops: list[dict] = [] + + def record(label): + def callback(hit): + regs = hit.regs + stops.append( + { + "label": label, + "pc": hit.pc, + "pc_fw": _fw(hit.pc), + "t0": regs.get("t0", 0), + "t1": regs.get("t1", 0), + "t2": regs.get("t2", 0), + } + ) + return callback + + session.hook(FIRST_TOUCH_PC + BIAS, on_enter=record("first_touch_pc")) + session.hook(HALT_PC + BIAS, on_enter=record("halt")) + session.run(duration=0.05, max_stops=2) + wall = time.monotonic() - start + machine = session.target.machine + halt_pc = session.pc + disassembly = read_disasm(session, ENTRY + BIAS, 8) + session.note( + "re_demo_lenient", + first_unknown_mmio=machine.mmio_log[0] if machine.mmio_log else None, + stop_pc_fw=_fw(halt_pc), + instructions=machine.insns, + hook_stops=stops, + ) + session.close() + trace_pcs = _trace_exec_pcs(session_dir) + first_touch = machine.mmio_log[0] if machine.mmio_log else None + return { + "session": _relpath(session_dir), + "session_hashes": session_hashes(session_dir), + "wall_s": round(wall, 4), + "entry_regs_zero": all(value == 0 for value in entry_regs.values()), + "snapshot_bytes": len(snapshot), + "first_unknown_mmio": first_touch, + "first_touch_fw_pc": _fw(first_touch["pc"]) if first_touch else None, + "first_touch_matches_qemu": bool( + first_touch and first_touch["addr"] == QEMU_REFERENCE["access_addr"] + ), + "hook_stops": stops, + "instructions": machine.insns, + "stop_pc": halt_pc, + "stop_pc_fw": _fw(halt_pc), + "trace_exec_count": len(trace_pcs), + "trace_path_fw": [_fw(pc) for pc in trace_pcs], + "disassembly": disassembly, + } + + +def run_modeled_session(image: str, out_dir: str) -> dict: + """Session B: serve the chip-ID register and follow the Turing dispatch.""" + dm = DeviceMap( + meta={ + "source": "re-demo-modeled-chip-id", + "bias": BIAS, + "note": ( + "exploratory assumption: register value carries the chip-ID field " + f"{TU10X_IDS[0]} in bits[28:20]; not observed (QEMU faults before reading)" + ), + } + ) + dm.add( + DeviceEntry( + address=QEMU_REFERENCE["access_addr"], + width=4, + kind="r", + values=(MODELED_ID,), + hits=1, + first_epc=FIRST_TOUCH_PC + BIAS, + first_epc_fw=FIRST_TOUCH_PC, + source="manual", + notes="assumed Turing TU10x chip ID", + ) + ) + session, session_dir = _launch( + image, + out_dir, + "native-modeled-id", + dm, + {"exec": True, "start": 0x84000000, "end": 0x84010000, "max_events": 1024}, + ) + start = time.monotonic() + jumps: list[dict] = [] + + def on_jr(hit): + jumps.append( + { + "pc_fw": _fw(hit.pc), + "t0_absolute_target": hit.regs.get("t0", 0), + "target_fw": _fw(hit.regs.get("t0", 0)), + } + ) + + stops: list[dict] = [] + session.on_event( + "stop", + lambda hit: stops.append( + { + "pc_fw": _fw(hit.pc), + "signal": hit.stop.signal if hit.stop is not None else None, + } + ), + ) + session.hook(TU10X_ABS_TARGET - 4 + BIAS, on_enter=on_jr) + session.run(duration=0.05, max_stops=1) + after_jump_pc = session.pc + session.run(duration=0.05, max_stops=1) + stop_pc = session.pc + fault_stop = stops[-1] if stops else None + wall = time.monotonic() - start + machine = session.target.machine + tail_disasm = read_disasm(session, TU10X_ABS_TARGET - 0x14 + BIAS, 5) + session.note( + "re_demo_modeled", + jumps=jumps, + after_jump_pc_fw=_fw(after_jump_pc), + stop_pc_fw=_fw(stop_pc), + instructions=machine.insns, + fault_stops=stops, + ) + session.close() + trace_pcs = _trace_exec_pcs(session_dir) + return { + "session": _relpath(session_dir), + "session_hashes": session_hashes(session_dir), + "wall_s": round(wall, 4), + "modeled_id": MODELED_ID, + "assumed_chip_id": TU10X_IDS[0], + "jumps": jumps, + "instructions": machine.insns, + "stop_pc": stop_pc, + "stop_pc_fw": _fw(stop_pc), + "stop_outside_ram": stop_pc < BIAS, + "fault_signal": fault_stop.get("signal") if fault_stop else None, + "fault_stops": stops, + "disassembly_tail": tail_disasm, + "trace_exec_count": len(trace_pcs), + "trace_path_fw": [_fw(pc) for pc in trace_pcs], + } + + +# -- answers / evaluation / rendering ---------------------------------------- + + +def build_answers(lenient: dict, modeled: dict) -> dict: + first = lenient.get("first_unknown_mmio") or {} + path = lenient.get("trace_path_fw") or [] + halt_seen = any( + stop.get("label") == "halt" for stop in (lenient.get("hook_stops") or []) + ) + answers = { + "q1_first_touch": { + "answer": ( + f"the first MMIO is a 4-byte read of PA {first.get('addr'):#x} at fw " + f"PC {lenient.get('first_touch_fw_pc'):#x}; it is the QEMU-documented " + "first fault" + ), + "addr": first.get("addr"), + "size": first.get("size"), + "rw": first.get("rw"), + "pc_fw": lenient.get("first_touch_fw_pc"), + "matches_qemu_reference": lenient.get("first_touch_matches_qemu"), + }, + "q2_path": { + "answer": ( + f"entry state is all-zero (no bootloader handoff modelled); the " + f"bootstrap builds the device PA with 5 arithmetic instructions " + f"(li/slli/addi/slli/addi), reads it, then runs the chip-ID " + f"comparison chain: {lenient.get('trace_exec_count')} instructions " + f"recorded from entry until the halt at fw " + f"{lenient.get('stop_pc_fw'):#x}" + ), + "entry_regs_zero": lenient.get("entry_regs_zero"), + "instructions_to_halt": lenient.get("instructions"), + "halt_pc_fw": lenient.get("stop_pc_fw"), + "halt_seen": halt_seen, + "trace_path_fw": path, + }, + "q3_id_field": { + "answer": ( + "the read value's bits [28:20] are extracted (srli 20, andi 0x1ff) " + f"and compared against {TU10X_IDS[0]}..{TU10X_IDS[-1]} (Turing) and " + f"{GA100_ID} (Ampere); the image itself defines this as a chip-ID " + "field" + ), + "shift": 20, + "mask": 0x1FF, + "turing_ids": list(TU10X_IDS), + "ampere_id": GA100_ID, + "disassembly": lenient.get("disassembly"), + }, + "q4_dispatch": { + "answer": ( + f"ID {TU10X_IDS[0]}..{TU10X_IDS[-1]} dispatches to .tu10x_text at " + f"{TU10X_ENTRY:#x}; ID {GA100_ID} dispatches to .ga100_text at " + f"{GA100_ENTRY:#x}; any other value (including the lenient zero) " + f"falls to the fail-closed `j .` loop at {HALT_PC:#x}" + ), + "tu10x_target": TU10X_ENTRY, + "ga100_target": GA100_ENTRY, + "halt_pc": HALT_PC, + "sections": None, # filled by the caller (needs the image path) + }, + "q5_next_blocker": { + "answer": ( + "with a Turing ID served, .tu10x_text programs CSRs 0x7c8-0x7cc " + "(region descriptors) and enables mstatus FP/SIMD state, then " + f"builds the absolute link-time address {TU10X_ABS_TARGET:#x} and " + "jumps to it; because the loader relocates segments but not " + "absolute immediates, that jump lands outside RAM and faults - a " + "relocation artifact, so the next rung needs a chip-ID device " + "model plus load-at-link-address/alias translation" + ), + "modeled_run": { + "jumps": modeled.get("jumps"), + "instructions": modeled.get("instructions"), + "stop_pc_fw": modeled.get("stop_pc_fw"), + "stop_outside_ram": modeled.get("stop_outside_ram"), + "fault_signal": modeled.get("fault_signal"), + }, + "disassembly_tail": modeled.get("disassembly_tail"), + }, + } + return answers + + +def evaluate( + *, + answers: dict, + lenient: dict, + repeat: dict, + emulator_wall_s: float, + baseline_total_s: float, + thresholds: dict, +) -> dict: + keys = ("instructions", "stop_pc_fw", "first_unknown_mmio", "trace_exec_count") + identical = all(lenient.get(key) == repeat.get(key) for key in keys) + checks = { + "answers_complete": all( + isinstance(answer, dict) and bool(answer.get("answer")) + for answer in answers.values() + ) + and len(answers) >= int(thresholds["answers_required"]), + "deterministic_lenient_runs": identical, + "emulator_wall_s": emulator_wall_s <= float(thresholds["emulator_wall_s_max"]), + "milestone_past_qemu": bool( + lenient.get("first_touch_matches_qemu") + and lenient.get("stop_pc_fw") == HALT_PC + and (repeat.get("stop_pc_fw") == HALT_PC) + ), + "baseline_beat": emulator_wall_s < baseline_total_s, + } + checks["pass"] = all(checks.values()) + return checks + + +def render_markdown(report: dict) -> str: + answers = report["answers"] + baseline = report["baseline"] + emulator = report["emulator"] + lenient = emulator["lenient"] + modeled = emulator["modeled"] + checks = report["threshold_checks"] + first = lenient.get("first_unknown_mmio") or {} + lines = [ + "# Real RE demo - what the GSP does between entry and the first fault", + "", + f"* schema: `{report['schema']}`", + f"* script: `{report['script']}` (stdlib-only, no hardware/guest/module)", + f"* firmware: `{report['firmware']['name']}` sha256 " + f"`{report['firmware']['sha256'][:16]}...`", + f"* verdict: **{'PASS' if checks.get('pass') else 'FAIL'}**", + "", + "## Scenario", + "", + f"> {report['scenario']}", + "", + "## Answer", + "", + ] + for index in sorted(answers): + lines.append(f"**{index}: {answers[index]['answer']}**") + lines.append("") + lines += [ + "## Baseline vs emulator (measured)", + "", + "| side | method | wall s | what it answers |", + "|---|---|---:|---|", + f"| baseline | `{baseline['qemu_method']}` | {baseline['qemu_probe_wall_s']} | first fault only |", + f"| baseline | manual static reading (estimate, {MANUAL_METHOD_NOTE}) | " + f"{baseline['manual_estimate_s']} | entry->fault path, by hand |", + f"| baseline | total (conservative) | {baseline['total_s']} | full question |", + f"| emulator | three recorded native sessions | {emulator['wall_s']} | full question + evidence |", + "", + "Per-session `wall_s` in the milestone table is the recorded run only; the " + "emulator total includes session launch, repeat and report writing.", + "", + "## Rung / milestone table", + "", + "| run | first touch | stop/milestone | instructions | wall s | evidence |", + "|---|---|---|---:|---:|---|", + f"| QEMU probe | {QEMU_REFERENCE['access_addr']:#x} (fault) | fault, then mtvec=0 loop | - | " + f"{baseline['qemu_probe_wall_s']} | `{baseline['qemu_session']}` |", + f"| native lenient (B0) | {first.get('addr'):#x} | halt loop fw " + f"{lenient['stop_pc_fw']:#x} | {lenient['instructions']} | {lenient['wall_s']} | " + f"`{lenient['session']}` |", + f"| native modeled chip-ID (B1 candidate) | {first.get('addr'):#x} | instruction " + f"fault at fw {modeled['stop_pc_fw']:#x} (outside RAM) | {modeled['instructions']} | " + f"{modeled['wall_s']} | `{modeled['session']}` |", + "", + "## Frozen thresholds", + "", + "| check | pass |", + "|---|---|", + ] + for name, value in checks.items(): + lines.append(f"| {name} | {value} |") + lines += [ + "", + f"Thresholds frozen {report['thresholds']['frozen_on']} before the run: " + f"emulator wall <= {report['thresholds']['emulator_wall_s_max']} s; " + f"{report['thresholds']['identical_lenient_runs']} identical lenient runs; " + f"all {report['thresholds']['answers_required']} answers present; milestone past " + "the QEMU point. Failure signal: any check false -> the run is a failed " + "experiment and the feature is not claimed.", + "", + "## Sessions (evidence)", + "", + "| session | file | sha256 |", + "|---|---|---|", + ] + for label, session in ( + ("qemu-probe", baseline.get("qemu_session_hashes")), + ("native-lenient", lenient.get("session_hashes")), + ("native-lenient-repeat", report.get("lenient_repeat_hashes")), + ("native-modeled-id", modeled.get("session_hashes")), + ): + if not session: + continue + for name, digest in sorted(session.items()): + lines.append(f"| {label} | {name} | `{digest}` |") + lines += [ + "", + "## Disassembly of the entry prologue (from the lenient session memory read)", + "", + "```", + *lenient.get("disassembly", []), + "```", + "", + "### Turing dispatch tail (from the modeled session memory read)", + "", + "```", + *(modeled.get("disassembly_tail") or []), + "```", + "", + "## Not verified (honest limits)", + "", + ] + for item in report.get("not_verified") or []: + lines.append(f"* {item}") + lines += [ + "", + "## Reproduce", + "", + "```bash", + f"cd {report['rerun']['cwd']}", + report["rerun"]["command"], + "```", + "", + "## Re-run contract", + "", + ] + for item in report.get("rerun_notes") or []: + lines.append(f"* {item}") + lines.append("") + return "\n".join(lines) + + +# -- main -------------------------------------------------------------------- + + +def run_demo(image: str = DEFAULT_IMAGE, out_dir: str = DEFAULT_OUT, + report_path: str = DEFAULT_REPORT, run_qemu: bool = True) -> dict: + os.makedirs(out_dir, exist_ok=True) + if not os.path.isfile(image): + return { + "schema": "greeneye-re-demo/1", + "ran": False, + "reason": f"firmware absent: {image}", + } + + baseline_start = time.monotonic() + qemu = ( + run_qemu_baseline(image=image, out_dir=out_dir, duration=0.5) + if run_qemu + else {"ran": False, "reason": "qemu disabled", "wall_s": 0.0} + ) + baseline_wall = time.monotonic() - baseline_start + baseline_total = ( + (qemu.get("wall_s") or 0.0 if qemu.get("ran") else baseline_wall) + + FROZEN_THRESHOLDS["manual_static_estimate_s"] + ) + + emulator_start = time.monotonic() + lenient = run_lenient_session(image, out_dir, tag="native-lenient") + repeat = run_lenient_session(image, out_dir, tag="native-lenient-repeat") + modeled = run_modeled_session(image, out_dir) + emulator_wall = time.monotonic() - emulator_start + + sections = elf_progbits_sections(image) + answers = build_answers(lenient, modeled) + answers["q4_dispatch"]["sections"] = { + "tu10x_target": section_for(sections, TU10X_ENTRY), + "ga100_target": section_for(sections, GA100_ENTRY), + "bootstrap": section_for(sections, ENTRY), + } + checks = evaluate( + answers=answers, + lenient=lenient, + repeat=repeat, + emulator_wall_s=emulator_wall, + baseline_total_s=baseline_total, + thresholds=FROZEN_THRESHOLDS, + ) + report = { + "schema": "greeneye-re-demo/1", + "ran": True, + "script": "harness/re_demo.py", + "scenario": ( + "What happens on the real GSP image between entry and the first " + "fault/milestone, and what does the state tell us about boot?" + ), + "firmware": {"name": os.path.basename(image), "sha256": _sha256_file(image)}, + "qemu_reference_first_fault": QEMU_REFERENCE, + "thresholds": FROZEN_THRESHOLDS, + "threshold_checks": checks, + "baseline": { + "qemu_method": "python3 -m greeneye.cli probe <image> --duration 1.0 (here run via harness.run_qemu_baseline, 0.5 s bound)", + "qemu_probe_wall_s": qemu.get("wall_s"), + "qemu_session": qemu.get("session"), + "qemu_session_hashes": qemu.get("session_hashes"), + "qemu_first_fault": qemu.get("first_fault"), + "manual_estimate_s": FROZEN_THRESHOLDS["manual_static_estimate_s"], + "manual_method": MANUAL_METHOD_NOTE, + "total_s": round(baseline_total, 4), + }, + "emulator": { + "wall_s": round(emulator_wall, 4), + "lenient": lenient, + "modeled": modeled, + }, + "lenient_repeat": repeat, + "lenient_repeat_hashes": repeat.get("session_hashes"), + "answers": answers, + "not_verified": [ + "the modeled chip-ID value is an assumption (the QEMU baseline faults before reading it); only the field position and the compared IDs are firmware evidence", + "no timing/cycle claim: cycles are modeled (L2 not modelled, docs/06) and none are used in this answer", + "L3 device environment is absent: the register's on-silicon semantics are inferred from firmware comparisons, not from a device model", + "load-at-link-address and absolute-immediate relocation behaviour are not implemented; the B1 candidate fault is a relocation artifact of this harness", + "watchpoints are not exercised on this path: the bootstrap performs no RAM data access before the fault (RAM watchpoints are exercised by harness/sim_qemu_line.py on the demo firmware)", + "no hardware, GPU, mdev, module, guest or network involvement by design", + ], + "rerun": { + "cwd": "open-iov/debug/risc-v-gsp-rm-vm", + "command": "python3 harness/re_demo.py", + }, + "rerun_notes": [ + "thresholds live in harness/re_demo.py (FROZEN_THRESHOLDS) and are not moved after the run", + "the QEMU baseline needs qemu-system-riscv64; without it the emulator columns still reproduce and the baseline is marked unverified", + "sessions are under harness/re_demo/sessions/ (hash-stamped); the report regenerates with the same command", + ], + } + json_path = os.path.join(out_dir, "result.json") + with open(json_path, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, sort_keys=True) + fh.write("\n") + markdown = render_markdown(report) + os.makedirs(os.path.dirname(report_path) or ".", exist_ok=True) + with open(report_path, "w", encoding="utf-8") as fh: + fh.write(markdown) + report["result_json"] = _relpath(json_path) + report["report_md"] = _relpath(report_path) + return report + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python3 harness/re_demo.py", + description=( + "U51 real RE demo: entry->first fault on the real GSP image, with " + "frozen thresholds and recorded sessions. No hardware." + ), + ) + parser.add_argument("--image", default=DEFAULT_IMAGE) + parser.add_argument("--out", default=DEFAULT_OUT) + parser.add_argument("--report", default=DEFAULT_REPORT) + parser.add_argument("--no-qemu", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + report = run_demo( + image=args.image, out_dir=args.out, report_path=args.report, run_qemu=not args.no_qemu + ) + if not report.get("ran"): + print(f"[re-demo] skipped: {report.get('reason')}", file=sys.stderr) + return 2 + checks = report["threshold_checks"] + if args.json: + print(json.dumps(report, indent=1, sort_keys=True, default=str)) + print(f"[re-demo] answers: {len(report['answers'])}") + print(f"[re-demo] emulator wall: {report['emulator']['wall_s']} s") + print(f"[re-demo] baseline (qemu+manual): {report['baseline']['total_s']} s") + print(f"[re-demo] verdict: {'PASS' if checks.get('pass') else 'FAIL'} ({checks})") + print(f"[re-demo] report: {report.get('report_md')} + {report.get('result_json')}") + return 0 if checks.get("pass") else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/re_demo/result.json b/debug/risc-v-gsp-rm-vm/harness/re_demo/result.json new file mode 100644 index 0000000..4de85de --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/re_demo/result.json @@ -0,0 +1,454 @@ +{ + "answers": { + "q1_first_touch": { + "addr": 2305843009213696512, + "answer": "the first MMIO is a 4-byte read of PA 0x2000000000000a00 at fw PC 0x4009014; it is the QEMU-documented first fault", + "matches_qemu_reference": true, + "pc_fw": 67145748, + "rw": "r", + "size": 4 + }, + "q2_path": { + "answer": "entry state is all-zero (no bootloader handoff modelled); the bootstrap builds the device PA with 5 arithmetic instructions (li/slli/addi/slli/addi), reads it, then runs the chip-ID comparison chain: 25 instructions recorded from entry until the halt at fw 0x4009070", + "entry_regs_zero": false, + "halt_pc_fw": 67145840, + "halt_seen": true, + "instructions_to_halt": 25, + "trace_path_fw": [ + 67145728, + 67145732, + 67145736, + 67145740, + 67145744, + 67145748, + 67145752, + 67145756, + 67145760, + 67145764, + 67145768, + 67145772, + 67145776, + 67145780, + 67145784, + 67145788, + 67145792, + 67145796, + 67145800, + 67145804, + 67145808, + 67145812, + 67145824, + 67145828, + 67145840 + ] + }, + "q3_id_field": { + "ampere_id": 368, + "answer": "the read value's bits [28:20] are extracted (srli 20, andi 0x1ff) and compared against 354..360 (Turing) and 368 (Ampere); the image itself defines this as a chip-ID field", + "disassembly": [ + "0x84009000: 0010029b addiw t0,zero,1", + "0x84009004: 03129293 slli t0,t0,49", + "0x84009008: 00128293 addi t0,t0,1", + "0x8400900c: 00c29293 slli t0,t0,12", + "0x84009010: a0028293 addi t0,t0,-1536", + "0x84009014: 0002a283 lw t0,0(t0)", + "0x84009018: 0142d293 srli t0,t0,20", + "0x8400901c: 1ff2f293 andi t0,t0,511" + ], + "mask": 511, + "shift": 20, + "turing_ids": [ + 354, + 355, + 356, + 357, + 358, + 359, + 360 + ] + }, + "q4_dispatch": { + "answer": "ID 354..360 dispatches to .tu10x_text at 0x4007000; ID 368 dispatches to .ga100_text at 0x4005000; any other value (including the lenient zero) falls to the fail-closed `j .` loop at 0x4009070", + "ga100_target": 67129344, + "halt_pc": 67145840, + "sections": { + "bootstrap": ".bootstrap", + "ga100_target": ".ga100_text", + "tu10x_target": ".tu10x_text" + }, + "tu10x_target": 67137536 + }, + "q5_next_blocker": { + "answer": "with a Turing ID served, .tu10x_text programs CSRs 0x7c8-0x7cc (region descriptors) and enables mstatus FP/SIMD state, then builds the absolute link-time address 0x40070f8 and jumps to it; because the loader relocates segments but not absolute immediates, that jump lands outside RAM and faults - a relocation artifact, so the next rung needs a chip-ID device model plus load-at-link-address/alias translation", + "disassembly_tail": [ + "0x840070e4: 1e0002b7 lui t0,0x1e000", + "0x840070e8: 3002a073 csrs mstatus,t0", + "0x840070ec: 040072b7 lui t0,0x4007", + "0x840070f0: 0f828293 addi t0,t0,248", + "0x840070f4: 00028067 jalr zero,0(t0)" + ], + "modeled_run": { + "fault_signal": 11, + "instructions": 74, + "jumps": [ + { + "pc_fw": 67137780, + "t0_absolute_target": 67137784, + "target_fw": 67137784 + } + ], + "stop_outside_ram": true, + "stop_pc_fw": 67137784 + } + } + }, + "baseline": { + "manual_estimate_s": 600.0, + "manual_method": "floor estimate: ~90 instructions across .bootstrap + .tu10x_text + .ga100_text read by hand, dispatch table identified and correlated with the QEMU first-fault report; not measured in this run", + "qemu_first_fault": { + "access_addr": 2305843009213696512, + "cause": 5, + "desc": "fault_load", + "epc": 2214629396, + "epc_fw": 67145748, + "reason": "rejected", + "rw": "read", + "size": 4 + }, + "qemu_method": "python3 -m greeneye.cli probe <image> --duration 1.0 (here run via harness.run_qemu_baseline, 0.5 s bound)", + "qemu_probe_wall_s": 0.9495, + "qemu_session": "harness/re_demo/sessions/qemu-probe", + "qemu_session_hashes": { + "events.jsonl": "8c4dba8006a496d853c81ce9e2b16944cd27b8d6171c01777daa6e5738be02bc", + "session.json": "c7bf5e4d29f534160d660dadb81f81942faa17cbfb1941587c60dcf78180f56c", + "summary.md": "d141fa06d3e2d0603f3b99ad7603758e1fc14627aed74c6fe8a6ad22c56eb14b" + }, + "total_s": 600.9495 + }, + "emulator": { + "lenient": { + "disassembly": [ + "0x84009000: 0010029b addiw t0,zero,1", + "0x84009004: 03129293 slli t0,t0,49", + "0x84009008: 00128293 addi t0,t0,1", + "0x8400900c: 00c29293 slli t0,t0,12", + "0x84009010: a0028293 addi t0,t0,-1536", + "0x84009014: 0002a283 lw t0,0(t0)", + "0x84009018: 0142d293 srli t0,t0,20", + "0x8400901c: 1ff2f293 andi t0,t0,511" + ], + "entry_regs_zero": false, + "first_touch_fw_pc": 67145748, + "first_touch_matches_qemu": true, + "first_unknown_mmio": { + "addr": 2305843009213696512, + "pc": 2214629396, + "rw": "r", + "size": 4 + }, + "hook_stops": [ + { + "label": "first_touch_pc", + "pc": 2214629396, + "pc_fw": 67145748, + "t0": 2305843009213696512, + "t1": 0, + "t2": 0 + }, + { + "label": "halt", + "pc": 2214629488, + "pc_fw": 67145840, + "t0": 0, + "t1": 359, + "t2": 368 + } + ], + "instructions": 25, + "session": "harness/re_demo/sessions/native-lenient", + "session_hashes": { + "events.jsonl": "ccb568588738c0609071ee542c9b04f7bb2868ee8fae12c28b2bfc5d9343721b", + "session.json": "ec5944adce4b823a3d6fd7d5aa6385fb5b61c2f9542c7221f46f328601c12c66", + "summary.md": "b50820afa60c35da0d4701e54f9907d4b7090c52721e1ecf656942a41bcf44db" + }, + "snapshot_bytes": 120, + "stop_pc": 2214629488, + "stop_pc_fw": 67145840, + "trace_exec_count": 25, + "trace_path_fw": [ + 67145728, + 67145732, + 67145736, + 67145740, + 67145744, + 67145748, + 67145752, + 67145756, + 67145760, + 67145764, + 67145768, + 67145772, + 67145776, + 67145780, + 67145784, + 67145788, + 67145792, + 67145796, + 67145800, + 67145804, + 67145808, + 67145812, + 67145824, + 67145828, + 67145840 + ], + "wall_s": 0.0006 + }, + "modeled": { + "assumed_chip_id": 354, + "disassembly_tail": [ + "0x840070e4: 1e0002b7 lui t0,0x1e000", + "0x840070e8: 3002a073 csrs mstatus,t0", + "0x840070ec: 040072b7 lui t0,0x4007", + "0x840070f0: 0f828293 addi t0,t0,248", + "0x840070f4: 00028067 jalr zero,0(t0)" + ], + "fault_signal": 11, + "fault_stops": [ + { + "pc_fw": 67137784, + "signal": 11 + } + ], + "instructions": 74, + "jumps": [ + { + "pc_fw": 67137780, + "t0_absolute_target": 67137784, + "target_fw": 67137784 + } + ], + "modeled_id": 371195904, + "session": "harness/re_demo/sessions/native-modeled-id", + "session_hashes": { + "events.jsonl": "fdf0756c3543741b74c9267dc9e4d22156f43a4b54463ffbfb1671c327444515", + "session.json": "5ae7eeff56a19ed7eea093a164a3a3738965b56c39f35a563db3ee93a2c90d39", + "summary.md": "bd670d207d23079173b494a9be9f9a0e7d31a10a11f9dc48fb8a134888442d0b" + }, + "stop_outside_ram": true, + "stop_pc": 67137784, + "stop_pc_fw": 67137784, + "trace_exec_count": 74, + "trace_path_fw": [ + 67145728, + 67145732, + 67145736, + 67145740, + 67145744, + 67145748, + 67145752, + 67145756, + 67145760, + 67145764, + 67145816, + 67145820, + 67137536, + 67137540, + 67137544, + 67137548, + 67137552, + 67137556, + 67137560, + 67137564, + 67137568, + 67137572, + 67137576, + 67137580, + 67137584, + 67137588, + 67137592, + 67137596, + 67137600, + 67137604, + 67137608, + 67137612, + 67137616, + 67137620, + 67137624, + 67137628, + 67137632, + 67137636, + 67137640, + 67137644, + 67137648, + 67137652, + 67137656, + 67137660, + 67137664, + 67137668, + 67137672, + 67137676, + 67137680, + 67137684, + 67137688, + 67137692, + 67137696, + 67137700, + 67137704, + 67137708, + 67137712, + 67137716, + 67137720, + 67137724, + 67137728, + 67137732, + 67137736, + 67137740, + 67137744, + 67137748, + 67137752, + 67137756, + 67137760, + 67137764, + 67137768, + 67137772, + 67137776, + 67137780 + ], + "wall_s": 0.0004 + }, + "wall_s": 0.1251 + }, + "firmware": { + "name": "gsp-570.144.fwimage.bin", + "sha256": "2d1bb5c60b208cfd115f015b17dd04a6fece05fd2c0dcba3625d9d48cce3ceea" + }, + "lenient_repeat": { + "disassembly": [ + "0x84009000: 0010029b addiw t0,zero,1", + "0x84009004: 03129293 slli t0,t0,49", + "0x84009008: 00128293 addi t0,t0,1", + "0x8400900c: 00c29293 slli t0,t0,12", + "0x84009010: a0028293 addi t0,t0,-1536", + "0x84009014: 0002a283 lw t0,0(t0)", + "0x84009018: 0142d293 srli t0,t0,20", + "0x8400901c: 1ff2f293 andi t0,t0,511" + ], + "entry_regs_zero": false, + "first_touch_fw_pc": 67145748, + "first_touch_matches_qemu": true, + "first_unknown_mmio": { + "addr": 2305843009213696512, + "pc": 2214629396, + "rw": "r", + "size": 4 + }, + "hook_stops": [ + { + "label": "first_touch_pc", + "pc": 2214629396, + "pc_fw": 67145748, + "t0": 2305843009213696512, + "t1": 0, + "t2": 0 + }, + { + "label": "halt", + "pc": 2214629488, + "pc_fw": 67145840, + "t0": 0, + "t1": 359, + "t2": 368 + } + ], + "instructions": 25, + "session": "harness/re_demo/sessions/native-lenient-repeat", + "session_hashes": { + "events.jsonl": "2cd28b4da009447d70505cfbba59e9c1bf8bd277e3a7ea696e2690f54f66f756", + "session.json": "fe21ed52c05436d0f7886dfeb0886ec1a11b25678e4d827db3b9e4009d2187e4", + "summary.md": "357d95a664e33128a2a48da7ecfc058bdbbea56fae130557593fd59ff2efa597" + }, + "snapshot_bytes": 120, + "stop_pc": 2214629488, + "stop_pc_fw": 67145840, + "trace_exec_count": 25, + "trace_path_fw": [ + 67145728, + 67145732, + 67145736, + 67145740, + 67145744, + 67145748, + 67145752, + 67145756, + 67145760, + 67145764, + 67145768, + 67145772, + 67145776, + 67145780, + 67145784, + 67145788, + 67145792, + 67145796, + 67145800, + 67145804, + 67145808, + 67145812, + 67145824, + 67145828, + 67145840 + ], + "wall_s": 0.0005 + }, + "lenient_repeat_hashes": { + "events.jsonl": "2cd28b4da009447d70505cfbba59e9c1bf8bd277e3a7ea696e2690f54f66f756", + "session.json": "fe21ed52c05436d0f7886dfeb0886ec1a11b25678e4d827db3b9e4009d2187e4", + "summary.md": "357d95a664e33128a2a48da7ecfc058bdbbea56fae130557593fd59ff2efa597" + }, + "not_verified": [ + "the modeled chip-ID value is an assumption (the QEMU baseline faults before reading it); only the field position and the compared IDs are firmware evidence", + "no timing/cycle claim: cycles are modeled (L2 not modelled, docs/06) and none are used in this answer", + "L3 device environment is absent: the register's on-silicon semantics are inferred from firmware comparisons, not from a device model", + "load-at-link-address and absolute-immediate relocation behaviour are not implemented; the B1 candidate fault is a relocation artifact of this harness", + "watchpoints are not exercised on this path: the bootstrap performs no RAM data access before the fault (RAM watchpoints are exercised by harness/sim_qemu_line.py on the demo firmware)", + "no hardware, GPU, mdev, module, guest or network involvement by design" + ], + "qemu_reference_first_fault": { + "access_addr": 2305843009213696512, + "cause": 5, + "desc": "load access fault", + "firmware_vaddr": 67145748, + "rw": "read", + "size": 4 + }, + "ran": true, + "rerun": { + "command": "python3 harness/re_demo.py", + "cwd": "open-iov/debug/risc-v-gsp-rm-vm" + }, + "rerun_notes": [ + "thresholds live in harness/re_demo.py (FROZEN_THRESHOLDS) and are not moved after the run", + "the QEMU baseline needs qemu-system-riscv64; without it the emulator columns still reproduce and the baseline is marked unverified", + "sessions are under harness/re_demo/sessions/ (hash-stamped); the report regenerates with the same command" + ], + "scenario": "What happens on the real GSP image between entry and the first fault/milestone, and what does the state tell us about boot?", + "schema": "greeneye-re-demo/1", + "script": "harness/re_demo.py", + "threshold_checks": { + "answers_complete": true, + "baseline_beat": true, + "deterministic_lenient_runs": true, + "emulator_wall_s": true, + "milestone_past_qemu": true, + "pass": true + }, + "thresholds": { + "answers_required": 5, + "emulator_wall_s_max": 120.0, + "frozen_on": "2026-10-03", + "identical_lenient_runs": 2, + "manual_static_estimate_s": 600.0, + "milestone_past_qemu": true + } +} diff --git a/debug/risc-v-gsp-rm-vm/harness/sim_qemu_line.py b/debug/risc-v-gsp-rm-vm/harness/sim_qemu_line.py new file mode 100644 index 0000000..b47a028 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/sim_qemu_line.py @@ -0,0 +1,1247 @@ +#!/usr/bin/env python3 +"""S4 evidence harness: is the native GSP simulator above the QEMU line? + +Runs one A/B pair on the real TU104 570.144 GSP image: + +* **QEMU baseline** - the v0.1/v0.2 probe path (flat relocated load, bounded + wall-clock run, ``guest_errors,unimp,int`` log): first exception, address and + ladder rung, recorded as a normal ``greeneye-session/1``. +* **Native baseline** - ``sim.gsp_profile`` (deterministic RV64IMAC, modeled + cycles): instructions, cycles, first modeled MMIO, stop reason and rung. + If S1's device model/profile is present (``sim/profiles/``, ``sim/devices/``, + or a Python ``sim.devices`` entry point) it is discovered defensively and + used as the MMIO map; otherwise the run is the lenient B0 baseline. + +The rung ladder is docs/35-GREENEYE-SIMULATOR-DESIGN.md section 3.3 +(``pre-B0`` < B0 < B1 < ... < B5). "Above the QEMU line" means the native +rung ordinal is at least one past QEMU's, twice identically, within the frozen +wall-clock threshold, and with a discovered S1 device model (docs/35 1.1); +requiring the device model means a lenient run reports ``above_lenient_only``, +not ``above``. The frozen record and the first measured results live in +``docs/37-SIM-QEMU-LINE.md``. + +The harness also exercises the native feature matrix (hooks/breakpoints/ +watchpoints, memory/struct, sessions, compare, trace, injection) on the bundled +demo firmware. MCP is deliberately ``not_run``: the native MCP surface is S3's +deliverable (see docs/37 section "Not verified"). + +Everything is stdlib-only, bounded (``<= 2M`` instructions per native run, +``<= 10 s`` of QEMU) and touches no hardware, GPU, guest, module or network. + +CLI:: + + python3 harness/sim_qemu_line.py + python3 harness/sim_qemu_line.py --no-qemu --instructions 50000 + python3 harness/sim_qemu_line.py --device-map sim/profiles/tu104-570.144.json +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import time + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.dirname(HERE) +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from greeneye.analyze import compare_sessions, parse_qemu_faults # noqa: E402 +from greeneye.device_map import ( # noqa: E402 + QEMU_REFERENCE, + DeviceEntry, + DeviceMap, + firmware_sha256, + harvest_from_text, +) +from greeneye.errors import GreenEyeError # noqa: E402 +from harness.common import ( # noqa: E402 + load_target, + open_flat_session, + qemu_available, + read_log_window, +) +from sim.gsp_profile import DEFAULT_IMAGE, run_profile # noqa: E402 + +SCHEMA = "greeneye-sim-qemu-line/1" + +#: Boot-ladder rungs (docs/35 section 3.3), lowest first. ``pre-B0`` is the +#: QEMU fault position: image loaded, first MMIO observed *as a fault*. +RUNG_ORDER = ("pre-B0", "B0", "B1", "B2", "B3", "B4", "B5") +RUNG_INDEX = {name: index for index, name in enumerate(RUNG_ORDER)} +QEMU_RUNG = "pre-B0" +NATIVE_LENIENT_RUNG = "B0" + +#: Default frozen thresholds (recorded 2026-10-03, before the first +#: measurement). The measured run and rationale live in +#: docs/37-SIM-QEMU-LINE.md section 3; the CLI uses these values when no +#: override is passed. Never move silently. +FROZEN_THRESHOLDS = { + "frozen_on": "2026-10-03", + "native_rungs_above_qemu": 1, + "identical_runs": 2, + "native_pair_wall_s": 20.0, + "qemu_first_fault_fw": QEMU_REFERENCE["firmware_vaddr"], + "qemu_first_touch": QEMU_REFERENCE["access_addr"], + # docs/35 1.1 requires the milestone to be reached "with the device model + # and shims documented": lenient MMIO is observation, not a device model, + # so it cannot satisfy the line contract on its own. + "device_model_required": True, +} + +#: Bounds (eng-ops / task discipline). +MAX_INSTRUCTIONS = 2_000_000 +MAX_QEMU_DURATION_S = 10.0 +DEFAULT_INSTRUCTIONS = 200_000 +DEFAULT_QEMU_DURATION_S = 0.5 + +#: Default report directory (new, S4-owned). +DEFAULT_OUT = os.path.join(HERE, "qemu_line") + +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") +SDK_ELF = os.path.join(TOOL_ROOT, "sdk", "build", "injected_demo.elf") + +#: S1 device-model discovery: JSON conventions first, then a duck-typed Python +#: API. See docs/37 section "Re-running when S1 lands". +S1_JSON_DIRS = ("sim/profiles", "sim/devices") +S1_PY_API_NAMES = ( + "device_map_for_firmware", + "profile_for_firmware", + "load_device_map", + "load_profile", + "profile_for", +) +_RUNG_KEYS = ("rung", "highest_rung", "boot_ladder_rung", "milestone") + + +class HarnessError(RuntimeError): + """A harness precondition failed in a way the caller should report.""" + + +# -- small helpers --------------------------------------------------------- + + +def _relpath(path: str | None) -> str | None: + if path is None: + return None + return os.path.relpath(os.path.abspath(path), TOOL_ROOT) + + +def _rm_tree(path: str) -> None: + shutil.rmtree(path, ignore_errors=True) + + +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 _write_json(path: str, payload: dict) -> str: + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = f"{path}.tmp-{os.getpid()}" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.write("\n") + os.replace(tmp, path) + return path + + +def session_hashes(session_dir: str | None) -> dict: + """sha256 of a session's core evidence files ({} when the dir is absent).""" + if not session_dir or not os.path.isdir(session_dir): + return {} + hashes: dict = {} + for name in ("session.json", "events.jsonl", "summary.md"): + path = os.path.join(session_dir, name) + if not os.path.isfile(path): + continue + digest = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + digest.update(chunk) + hashes[name] = digest.hexdigest() + return hashes + + +def _x86_vms_running() -> int | None: + """Count other agents' x86 QEMU VMs (never disturbed; recorded only).""" + try: + proc = subprocess.run( + ["pgrep", "-f", "qemu-system-x86_64"], + capture_output=True, + text=True, + timeout=5.0, + ) + except (OSError, subprocess.SubprocessError): + return None + if proc.returncode not in (0, 1): + return None + pids = [line for line in proc.stdout.splitlines() if line.strip().isdigit()] + return len(pids) + + +def rung_ordinal(rung: str | None) -> int | None: + if rung in RUNG_INDEX: + return RUNG_INDEX[rung] + return None + + +def above_qemu_line(qemu_rung: str | None, native_rung: str | None, minimum: int = 1) -> bool: + """True when ``native_rung`` is at least ``minimum`` rungs past QEMU's.""" + q = rung_ordinal(qemu_rung) + n = rung_ordinal(native_rung) + if q is None or n is None: + return False + return n >= q + minimum + + +def _summary_fault(fault: dict, bias: int) -> dict: + access = fault.get("access") or {} + epc = int(fault.get("epc", 0) or 0) + return { + "desc": fault.get("desc"), + "cause": fault.get("cause"), + "epc": epc, + "epc_fw": (epc - bias) if (bias and epc and epc >= bias) else epc, + "access_addr": access.get("access_addr"), + "size": access.get("size"), + "rw": access.get("rw"), + "reason": access.get("reason"), + } + + +# -- QEMU baseline --------------------------------------------------------- + + +def run_qemu_baseline( + image: str = DEFAULT_IMAGE, + out_dir: str = DEFAULT_OUT, + duration: float = DEFAULT_QEMU_DURATION_S, +) -> dict: + """Probe the real image under QEMU; skip cleanly when unavailable/absent.""" + result: dict = { + "backend": "qemu", + "ran": False, + "reason": None, + "rung": None, + "session": None, + "wall_s": 0.0, + } + if duration <= 0 or duration > MAX_QEMU_DURATION_S: + raise HarnessError(f"qemu duration must be in (0, {MAX_QEMU_DURATION_S}], got {duration}") + if not os.path.isfile(image): + result["reason"] = f"firmware absent: {image}" + return result + if not qemu_available(): + result["reason"] = "qemu-system-riscv64 not installed" + return result + + start = time.monotonic() + image_obj = load_target(image) + session_dir = os.path.join(out_dir, "sessions", "qemu-probe") + _rm_tree(session_dir) + with open_flat_session(image_obj, image, record=session_dir, name="s4-qemu-probe") as flat: + flat.session.run(duration=duration) + pc = flat.session.pc + bias = flat.bias + # Read the log window once: `-d int` trap loops flood the file, so the + # first exception has to be parsed from the bounded head window. + log_text, capped = read_log_window(flat.target.qemu_log) + faults = parse_qemu_faults(log_text) + first = faults[0] if faults else None + device_map = harvest_from_text(log_text, bias=bias) + meta = { + "s4_qemu_rung": QEMU_RUNG, + "qemu_fault_count_window": len(faults), + "qemu_log_window_capped": capped, + "qemu_first_fault": first, + } + recorder = getattr(flat.session, "recorder", None) + if recorder is not None and hasattr(recorder, "meta"): + recorder.meta.update(meta) + if log_text and hasattr(recorder, "blob"): + recorder.meta["qemu_log_blob"] = recorder.blob( + "qemu.log", log_text.encode("utf-8") + ) + result.update( + { + "ran": True, + "reason": "ok", + "rung": QEMU_RUNG, + "session": _relpath(session_dir), + "session_hashes": session_hashes(session_dir), + "stopped_pc": pc, + "fault_count_window": len(faults), + "log_window_capped": capped, + "first_fault": _summary_fault(first, bias) if first else None, + "first_touch_match": bool( + first + and (first.get("access") or {}).get("access_addr") == QEMU_REFERENCE["access_addr"] + ), + "device_map_entries": len(device_map), + "device_map": device_map, + } + ) + result["wall_s"] = round(time.monotonic() - start, 4) + return result + + +# -- S1 integration (defensive) ------------------------------------------- + + +def _coerce_device_map(obj) -> DeviceMap | None: + """Accept DeviceMap objects, paths, or device-map/profile dicts; else None.""" + if isinstance(obj, DeviceMap): + return obj + if isinstance(obj, (str, os.PathLike)): + path = os.fspath(obj) + if os.path.isfile(path): + try: + return DeviceMap.load(path) + except GreenEyeError: + return None + return None + if isinstance(obj, dict): + if obj.get("schema") == "greeneye-device-map/1": + try: + return DeviceMap.from_dict(obj) + except (KeyError, TypeError, ValueError, GreenEyeError): + return None + if "device_map" in obj: + return _coerce_device_map(obj["device_map"]) + entries = obj.get("entries") + if isinstance(entries, list): + dm = DeviceMap(meta=dict(obj.get("meta") or {})) + try: + for raw in entries: + dm.add(DeviceEntry.from_dict(raw)) + except (KeyError, TypeError, ValueError): + return None + return dm + return None + + +def _json_rung(data) -> str | None: + if not isinstance(data, dict): + return None + for key in _RUNG_KEYS: + value = data.get(key) + if isinstance(value, str) and value in RUNG_INDEX: + return value + if isinstance(value, dict): + inner = value.get("highest") or value.get("rung") + if isinstance(inner, str) and inner in RUNG_INDEX: + return inner + return None + + +def discover_s1_device_model(image: str, sha: str | None = None) -> dict: + """Find S1's device model/profile without assuming it exists. + + Returns ``{"device_map": DeviceMap|None, "profile": path|None, + "declared_rung": str|None, "source": str|None, "notes": [...]}``. + Discovery order: an explicit ``sim.devices`` Python entry point, then JSON + files under ``sim/profiles/`` and ``sim/devices/`` (a + ``greeneye-sim-profile/1`` profile, a ``greeneye-device-map/1`` file, a + profile with a ``device_map`` key, or a raw ``entries`` list). A profile + is the semantic device model (``sim.gsp_boot`` runs it); a device map only + replays observed accesses. A candidate is preferred when its firmware + hash matches the image; otherwise a single candidate is used and noted as + unverified pairing. + """ + notes: list[str] = [] + declared_rung: str | None = None + + try: + import sim.devices as devices # type: ignore + + for name in S1_PY_API_NAMES: + fn = getattr(devices, name, None) + if not callable(fn): + continue + for args in ((sha,), (image,), ()): + if sha is None and args == (sha,): + continue + try: + candidate = fn(*args) + except TypeError: + continue + except (OSError, UnicodeError): + # A path-taking helper handed a digest (or a helper handed + # the binary firmware) simply does not apply; not an error. + break + except Exception as exc: # discovery must never break the run + notes.append(f"sim.devices.{name}{args!r} failed: {exc}") + break + if candidate is None: + continue + dm = _coerce_device_map(candidate) + rung = getattr(candidate, "rung", None) + if isinstance(rung, str) and rung in RUNG_INDEX: + declared_rung = rung + if dm is not None: + notes.append(f"device map via sim.devices.{name}{args!r}") + return { + "device_map": dm, + "profile": None, + "declared_rung": declared_rung, + "source": f"sim.devices.{name}", + "notes": notes, + } + except ImportError: + notes.append("sim.devices package absent") + except Exception as exc: + notes.append(f"sim.devices import failed: {exc}") + + candidates: list[tuple[str, DeviceMap, str | None, str | None]] = [] + profiles: list[tuple[str, str, str | None]] = [] + for rel in S1_JSON_DIRS: + root = os.path.join(TOOL_ROOT, rel) + if not os.path.isdir(root): + notes.append(f"{rel}/ absent") + continue + for name in sorted(os.listdir(root)): + if not name.endswith(".json"): + continue + path = os.path.join(root, name) + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError) as exc: + notes.append(f"{rel}/{name}: unreadable ({exc})") + continue + if isinstance(data, dict) and data.get("schema") == "greeneye-sim-profile/1": + firmware = data.get("firmware") or {} + profiles.append((f"{rel}/{name}", path, + str(firmware.get("sha256") or "") or None)) + continue + dm = _coerce_device_map(data) + rung = _json_rung(data) + if rung and declared_rung is None: + declared_rung = rung + if dm is not None: + candidates.append((f"{rel}/{name}", dm, rung, None)) + + exact_profiles = [p for p in profiles if sha and p[2] == sha] + if exact_profiles: + rel, path, _digest = exact_profiles[0] + notes.append(f"using {rel} (greeneye-sim-profile/1, firmware_sha256 matched)") + return { + "device_map": None, + "profile": path, + "declared_rung": declared_rung, + "source": rel, + "notes": notes, + } + + if not candidates: + if len(profiles) == 1: + rel, path, _digest = profiles[0] + notes.append(f"using {rel} (single profile, pairing unverified)") + return { + "device_map": None, + "profile": path, + "declared_rung": declared_rung, + "source": rel, + "notes": notes, + } + notes.append("no S1 device-map/profile JSON found") + return {"device_map": None, "profile": None, "declared_rung": declared_rung, + "source": None, "notes": notes} + + exact = [ + c for c in candidates + if sha and str((c[1].meta or {}).get("firmware_sha256", "")) == sha + ] + chosen = exact[0] if exact else candidates[0] + note = "firmware_sha256 matched" if exact else "single candidate, pairing unverified" + notes.append(f"using {chosen[0]} ({note})") + return { + "device_map": chosen[1], + "profile": None, + "declared_rung": chosen[2] or declared_rung, + "source": chosen[0], + "notes": notes, + } + + +# -- native baseline ------------------------------------------------------- + + +def estimate_native_rung(run: dict, *, device_model_used: bool, + declared_rung: str | None = None) -> tuple[str, str]: + """Conservative rung estimate from a bounded native run. + + * matching lenient first touch -> B0 (observed, no device semantics); + * a device model that serves the QEMU first-touch and lets execution + continue -> B1 (frontier moved past the QEMU fault); + * a higher rung only when S1 declares it in profile data (honoured as + ``rung_source: declared``). + + B2+ need boot-stage milestones, not just progress; absence of a fault is + not evidence of a boot stage. See docs/37 sections 3-4. + """ + stop = run.get("stop") or {} + stop_reason = stop.get("reason") + first = run.get("first_unknown_mmio") + reached_b0 = bool( + (first and first.get("addr") == QEMU_REFERENCE["access_addr"]) + or run.get("instructions", 0) > 0 + ) + + inferred = None + basis = None + if device_model_used and reached_b0: + unmodelled_at_qemu = bool(first and first.get("addr") == QEMU_REFERENCE["access_addr"]) + if not unmodelled_at_qemu: + inferred = "B1" + basis = ( + "device model served the QEMU first-touch and execution continued " + "past the QEMU fault to the run bound" + ) + else: + inferred = "B0" + basis = "device model present but the QEMU first-touch was still served leniently" + elif reached_b0: + inferred = NATIVE_LENIENT_RUNG + basis = "lenient first-touch observation; execution continued past the QEMU fault" + + if inferred is None: + return "pre-B0", "no native progress observed" + + rung, source = inferred, "inferred" + declared_index = rung_ordinal(declared_rung) + if declared_index is not None and declared_index > RUNG_INDEX[inferred]: + rung, source = declared_rung, "declared" + basis = f"S1 profile declares {declared_rung}; inference was {inferred} ({basis})" + return rung, f"{basis} [rung_source={source}, stop={stop_reason}]" + + +def run_native_baseline( + image: str = DEFAULT_IMAGE, + out_dir: str = DEFAULT_OUT, + instructions: int = DEFAULT_INSTRUCTIONS, + repeat: int = 2, + device_map=None, + declared_rung: str | None = None, + profile: str | None = None, + use_shim: bool = True, +) -> dict: + """Run the bounded deterministic native baseline ``repeat`` times. + + ``profile`` selects the S1 semantic device model (``sim.gsp_boot`` with + the versioned profile and the recorded boot shim); without it the run is + the lenient ``sim.gsp_profile`` baseline. ``device_map`` remains a + legacy/observational MMIO map for the lenient path. + """ + if instructions <= 0 or instructions > MAX_INSTRUCTIONS: + raise HarnessError(f"instructions must be in (0, {MAX_INSTRUCTIONS}], got {instructions}") + if repeat < 1: + raise HarnessError(f"repeat must be >= 1, got {repeat}") + if not os.path.isfile(image): + return { + "backend": "native", + "ran": False, + "reason": f"firmware absent: {image}", + "runs": [], + } + + s1_model = bool(profile) or device_map is not None + runs: list[dict] = [] + start_all = time.monotonic() + for index in range(repeat): + session_dir = os.path.join(out_dir, "sessions", f"native-{index + 1}") + _rm_tree(session_dir) + start = time.monotonic() + if profile: + from sim.gsp_boot import run_boot # deferred: only the S1 path needs it + + boot = run_boot( + image=image, + profile_path=profile, + instructions=instructions, + record=session_dir, + name=f"s4-native-{index + 1}", + use_shim=use_shim, + ) + wall = time.monotonic() - start + if not boot.ran: + raise HarnessError(f"native boot refused to run: {boot.reason}") + run = { + "instructions": boot.instructions, + "cycles": boot.cycles, + "cycle_model": boot.cycle_model, + "stop": { + "reason": boot.stop_reason, + "pc": boot.stop_pc, + "pc_fw": boot.stop_pc_fw, + }, + "first_unknown_mmio": boot.first_unknown_mmio, + "mmio_unknown": boot.mmio_unknown, + "device_unhandled": boot.device_unhandled, + "device_accesses": boot.device_accesses, + "device_state_sha256": boot.device_state_sha256, + "milestones": list(boot.milestones), + "session": _relpath(session_dir), + "session_hashes": session_hashes(session_dir), + "wall_s": round(wall, 4), + } + run["rung"] = boot.rung + run["rung_basis"] = ( + "observed S1 boot-ladder rung from sim.gsp_boot " + f"(milestones={list(boot.milestones)}, mmio_unknown={boot.mmio_unknown}, " + f"device_unhandled={boot.device_unhandled})" + ) + else: + result = run_profile( + image=image, + instructions=instructions, + record=session_dir, + device_map=device_map, + name=f"s4-native-{index + 1}", + ) + wall = time.monotonic() - start + if not result.ran: + raise HarnessError(f"native profile refused to run: {result.reason}") + run = { + "instructions": result.instructions, + "cycles": result.cycles, + "cycle_model": result.cycle_model, + "stop": { + "reason": result.stop_reason, + "pc": result.stop_pc, + "pc_fw": result.stop_pc_fw, + }, + "first_unknown_mmio": result.first_unknown_mmio, + "mmio_unknown": result.mmio_unknown, + "session": _relpath(session_dir), + "session_hashes": session_hashes(session_dir), + "wall_s": round(wall, 4), + } + run["rung"], run["rung_basis"] = estimate_native_rung( + run, device_model_used=s1_model, declared_rung=declared_rung + ) + runs.append(run) + + keys = ( + "instructions", + "cycles", + "stop", + "first_unknown_mmio", + "mmio_unknown", + "rung", + "device_state_sha256", + "milestones", + ) + identical = all( + all(a.get(key) == runs[0].get(key) for key in keys) for a in runs[1:] + ) + compare = {"ran": False} + if repeat >= 2: + try: + text = compare_sessions( + os.path.join(out_dir, "sessions", "native-1"), + os.path.join(out_dir, "sessions", "native-2"), + ) + compare = { + "ran": True, + "chars": len(text), + "headers": [ + line[3:].strip() + for line in text.splitlines() + if line.startswith("## ") + ], + } + except Exception as exc: # compare is evidence, never a fatal step + compare = {"ran": False, "error": f"{type(exc).__name__}: {exc}"} + + return { + "backend": "native", + "ran": True, + "reason": "ok", + "instructions_budget": instructions, + "repeat": repeat, + "identical": identical, + "rung": runs[0]["rung"], + "rung_basis": runs[0]["rung_basis"], + "runs": runs, + "pair_wall_s": round(time.monotonic() - start_all, 4), + "compare": compare, + "device_model_used": s1_model, + "profile": _relpath(profile), + } + + +# -- native feature matrix ------------------------------------------------- + + +def _events_summary(session_dir: str) -> dict: + counts: dict[str, int] = {} + path = os.path.join(session_dir, "events.jsonl") + try: + with open(path, "r", encoding="utf-8") as fh: + for line in fh: + line = line.strip() + if not line: + continue + try: + kind = json.loads(line).get("type") + except ValueError: + continue + if kind: + counts[kind] = counts.get(kind, 0) + 1 + except OSError: + pass + return dict(sorted(counts.items())) + + +def run_feature_matrix(out_dir: str = DEFAULT_OUT) -> dict: + """Exercise the native debugger feature set on the bundled demo firmware.""" + import greeneye as ge + + matrix: dict = {} + if not os.path.isfile(DEMO_ELF): + return { + "ran": False, + "reason": f"demo firmware absent: {DEMO_ELF} (run make demo)", + "features": {}, + } + + session_dir = os.path.join(out_dir, "sessions", "features") + _rm_tree(session_dir) + session = ge.launch(kernel=DEMO_ELF, backend="native", record=session_dir, name="s4-features") + try: + ctrl = session.symbols.lookup("gsp_ctrl") + session.run(duration=0.2) + magic = session.memory.read_u32(ctrl) + matrix["memory"] = { + "status": "pass" if magic == 0x47535031 else "fail", + "detail": {"ctrl": ctrl, "magic": magic}, + } + + with open(DEMO_SCHEMA, "r", encoding="utf-8") as fh: + schema = ge.schema_from_json(json.load(fh)) + decoded = schema.decode(session.memory.read(ctrl, schema.size), base=0) + matrix["struct"] = { + "status": "pass" if decoded.get("magic") == 0x47535031 else "fail", + "detail": {"schema": os.path.basename(DEMO_SCHEMA), "fields": len(decoded)}, + } + + dispatches: list[int] = [] + stores: list[int] = [] + session.hook("rpc_dispatch", on_enter=lambda hit: dispatches.append(hit.arg(0))) + session.watch(ctrl + 0x40, size=8, kind="w", callback=lambda hit: stores.append(hit.pc)) + session.memory.write_u64(ctrl + 0x28, 0x1234) + session.memory.write_u64(ctrl + 0x20, 1) + session.run(duration=0.25) + matrix["hooks"] = { + "status": "pass" if dispatches else "fail", + "detail": {"rpc_dispatch_hits": len(dispatches), "cmds": dispatches}, + } + matrix["watch"] = { + "status": "pass" if stores else "fail", + "detail": {"result_field_stores": len(stores), "pcs": stores[:4]}, + } + + session.memory.write_u64(ctrl + 0x28, 0x55) + session.memory.write_u64(ctrl + 0x20, 2) + hit = session.run_until("rpc_dispatch", timeout=1.0) + bp = session.breakpoint("rpc_dispatch") + attached = bool(bp.attached) + session.remove_breakpoint(bp) + matrix["breakpoints"] = { + "status": "pass" if hit is not None and attached and not bp.attached else "fail", + "detail": { + "run_until_hit": hit.addr if hit is not None else None, + "attached_then_removed": attached and not bp.attached, + "kind": "sw", + }, + } + finally: + session.close() + + events = _events_summary(session_dir) + matrix["sessions"] = { + "status": "pass" + if os.path.isfile(os.path.join(session_dir, "session.json")) + and os.path.isfile(os.path.join(session_dir, "events.jsonl")) + else "fail", + "detail": { + "dir": _relpath(session_dir), + "event_counts": events, + "hashes": session_hashes(session_dir), + }, + } + + trace_dir = os.path.join(out_dir, "sessions", "features-trace") + _rm_tree(trace_dir) + trace_session = ge.launch( + kernel=DEMO_ELF, + backend="native", + record=trace_dir, + name="s4-features-trace", + trace={"exec": True, "max_events": 4000}, + ) + try: + trace_session.run(duration=0.1) + finally: + trace_session.close() + trace_path = os.path.join(trace_dir, "blobs", "trace.jsonl") + trace_lines = 0 + if os.path.isfile(trace_path): + with open(trace_path, "r", encoding="utf-8", errors="replace") as fh: + trace_lines = sum(1 for _ in fh) + matrix["trace"] = { + "status": "pass" if trace_lines > 0 else "fail", + "detail": { + "dir": _relpath(trace_dir), + "blob": _relpath(trace_path), + "events": trace_lines, + "max_events": 4000, + "hashes": session_hashes(trace_dir), + }, + } + + if os.path.isfile(SDK_ELF): + from greeneye.inject import invoke_remote, load_service + + inject_dir = os.path.join(out_dir, "sessions", "features-inject") + _rm_tree(inject_dir) + inject_session = ge.launch( + kernel=DEMO_ELF, backend="native", record=inject_dir, name="s4-features-inject" + ) + try: + inject_session.run(duration=0.25) + service = load_service(inject_session, SDK_ELF, slot=6) + value = invoke_remote(inject_session, service, 1, 0x41) + finally: + inject_session.close() + matrix["injection"] = { + "status": "pass" if value == (0x41 ^ 0xBEEF) else "fail", + "detail": { + "dir": _relpath(inject_dir), + "blob": _relpath(SDK_ELF), + "slot": 6, + "ping_result": value, + "expected": 0x41 ^ 0xBEEF, + "hashes": session_hashes(inject_dir), + }, + } + else: + matrix["injection"] = { + "status": "not_run", + "reason": "sdk/build/injected_demo.elf not built (run make sdk)", + } + + matrix["mcp"] = { + "status": "not_run", + "reason": ( + "native MCP surface is S3's deliverable (plugins/mcp_server.py); " + "S4 must not race that work. Re-run after S3 lands (docs/37)." + ), + } + analyze_dir = os.path.join(out_dir, "sessions", "native-1") + compare_dir = os.path.join(out_dir, "sessions", "native-2") + if os.path.isfile(os.path.join(analyze_dir, "session.json")) and os.path.isfile( + os.path.join(compare_dir, "session.json") + ): + try: + from greeneye.analyze import render_summary + + summary = render_summary(analyze_dir) + delta_text = compare_sessions(analyze_dir, compare_dir) + matrix["analyze_compare"] = { + "status": "pass" if summary and delta_text else "fail", + "detail": { + "analyze_session": _relpath(analyze_dir), + "analyze_chars": len(summary), + "compare_pair": [_relpath(analyze_dir), _relpath(compare_dir)], + "compare_chars": len(delta_text), + "compare_headers": [ + line[3:].strip() + for line in delta_text.splitlines() + if line.startswith("## ") + ], + }, + } + except Exception as exc: # evidence step, never fatal to the matrix + matrix["analyze_compare"] = { + "status": "fail", + "reason": f"{type(exc).__name__}: {exc}", + } + else: + matrix["analyze_compare"] = { + "status": "not_run", + "reason": "native-1/native-2 sessions absent", + } + return {"ran": True, "reason": "ok", "features": matrix} + + +# -- report ---------------------------------------------------------------- + + +def threshold_checks(report: dict, thresholds: dict | None = None) -> dict: + """Evaluate the frozen thresholds against a report; returns check -> bool.""" + th = dict(FROZEN_THRESHOLDS) + th.update(thresholds or {}) + qemu = report.get("qemu") or {} + native = report.get("native") or {} + checks: dict = { + "qemu_first_fault_reproduced": bool( + qemu.get("ran") and qemu.get("first_touch_match") + ), + "native_above_qemu": above_qemu_line( + qemu.get("rung"), native.get("rung"), int(th["native_rungs_above_qemu"]) + ), + "identical_runs": bool( + native.get("ran") + and native.get("repeat", 0) >= int(th["identical_runs"]) + and native.get("identical") + ), + "native_pair_wall_s": bool( + native.get("ran") + and float(native.get("pair_wall_s", 1e9)) <= float(th["native_pair_wall_s"]) + ), + "device_model_documented": bool( + not th.get("device_model_required", True) or native.get("device_model_used") + ), + } + checks["all"] = all(checks.values()) + return checks + + +def build_report( + *, + image: str, + qemu: dict, + native: dict, + features: dict | None, + s1: dict, + thresholds: dict, +) -> dict: + checks = threshold_checks({"qemu": qemu, "native": native}, thresholds) + rung_progress = bool(checks["native_above_qemu"] and checks["identical_runs"]) + if not qemu.get("ran"): + verdict = "unverified_qemu_absent" + elif not checks["qemu_first_fault_reproduced"]: + verdict = "unverified_qemu_mismatch" + elif checks["all"]: + verdict = "above" + elif rung_progress and not checks["device_model_documented"]: + # Rung progress exists, but docs/35 1.1 also requires working device + # semantics; lenient MMIO cannot satisfy the line contract. Underclaim. + verdict = "above_lenient_only" + else: + verdict = "not_above" + return { + "schema": SCHEMA, + "harness": "harness/sim_qemu_line.py", + "line_definition": "docs/35-GREENEYE-SIMULATOR-DESIGN.md 1.1 + 3.3", + "firmware": { + "name": os.path.basename(image), + "sha256": firmware_sha256(image) if os.path.isfile(image) else None, + }, + "rung_order": list(RUNG_ORDER), + "thresholds": thresholds, + "threshold_checks": checks, + "verdict": verdict, + "verdict_explanation": { + "above": "all frozen checks passed: QEMU fault reproduced, native >= QEMU + 1 rung, identical runs, wall bound, documented device model", + "above_lenient_only": "native progressed past the QEMU fault, but lenient MMIO is not a documented device model (docs/35 1.1) - contract NOT satisfied", + "not_above": "one or more frozen checks failed and no rung-progress excuse applies", + "unverified_qemu_absent": "QEMU baseline unavailable; no A/B comparison is possible", + "unverified_qemu_mismatch": "QEMU ran but did not reproduce its documented first fault; the rungs are not comparable", + }.get(verdict, ""), + "rung_progress": rung_progress, + "above_qemu_line": verdict == "above", + "qemu": {k: v for k, v in qemu.items() if k != "device_map"}, + "native": native, + "features": features, + "s1_integration": { + "source": s1.get("source"), + "profile": _relpath(s1.get("profile")), + "declared_rung": s1.get("declared_rung"), + "notes": s1.get("notes", []), + }, + "x86_vms_running": _x86_vms_running(), + "not_verified": [ + "lenient MMIO (B0) is observation, not device semantics", + "B3/B4 are not reached: the S1 device model stops at the B2 DMEM-runspace unknown", + "the boot shim values (bootargs/WPR meta/DMEM mirror) are declared assumptions", + "MCP on native is S3-owned and not exercised here", + "L2 timing: cycles are modeled (docs/32), never silicon claims", + "no hardware/GPU/guest/module involvement by design", + ], + "rerun_when_s1_lands": [ + "python3 harness/sim_qemu_line.py", + "S1 landed (2026-10-03): sim/profiles/*.json is discovered and the native side runs sim.gsp_boot", + "sim/devices/*.json with greeneye-device-map/1 (or a device_map key) is still picked up as an observational map", + ], + } + + +def render_markdown(report: dict) -> str: + qemu = report.get("qemu") or {} + native = report.get("native") or {} + lines = [ + "# Simulator vs the QEMU line - measured report", + "", + f"* schema: `{report.get('schema')}`", + f"* harness: `{report.get('harness')}`", + f"* line: {report.get('line_definition')}", + f"* firmware: `{(report.get('firmware') or {}).get('name')}` " + f"sha256 `{((report.get('firmware') or {}).get('sha256') or '')[:16]}...`", + f"* verdict: **{report.get('verdict')}** " + f"(above_qemu_line={report.get('above_qemu_line')})", + f"* verdict meaning: {report.get('verdict_explanation') or '-'}", + "", + "## Rung table", + "", + "| side | ran | rung | instructions | cycles (modeled) | stop | wall s | evidence |", + "|---|---|---|---:|---:|---|---:|---|", + ] + runs = native.get("runs") or [] + lines.append( + f"| QEMU probe | {qemu.get('ran')} | {qemu.get('rung')} | - | - | " + f"{'fault' if qemu.get('first_fault') else '-'} | {qemu.get('wall_s')} | " + f"`{qemu.get('session') or qemu.get('reason')}` |" + ) + for index, run in enumerate(runs, start=1): + stop = run.get("stop") or {} + pc_fw = stop.get("pc_fw") + pc_fw_text = f"{pc_fw:#x}" if pc_fw is not None else "-" + lines.append( + f"| native run {index} | True | {run.get('rung')} | {run.get('instructions')} | " + f"{run.get('cycles')} | {stop.get('reason')}@{pc_fw_text} | " + f"{run.get('wall_s')} | `{run.get('session')}` |" + ) + if not runs: + lines.append(f"| native | {native.get('ran')} | - | - | - | - | - | {native.get('reason')} |") + + first = qemu.get("first_fault") or {} + lines += [ + "", + "### QEMU first fault", + "", + f"* {first.get('desc')} at fw vaddr `{first.get('epc_fw') if first.get('epc_fw') is not None else 0:#x}` " + f"accessing `{(first.get('access_addr') or 0):#x}` ({first.get('size')} bytes, {first.get('rw')})", + f"* first-touch matches the documented reference: {qemu.get('first_touch_match')}", + f"* fault records in the capped log window: {qemu.get('fault_count_window')}", + "", + "## Frozen thresholds", + "", + "| check | pass |", + "|---|---|", + ] + for name, value in (report.get("threshold_checks") or {}).items(): + lines.append(f"| {name} | {value} |") + lines += [ + "", + f"Thresholds (frozen {report['thresholds'].get('frozen_on')}): " + f"native >= QEMU + {report['thresholds'].get('native_rungs_above_qemu')} rung(s); " + f"{report['thresholds'].get('identical_runs')} identical runs; " + f"pair wall <= {report['thresholds'].get('native_pair_wall_s')} s.", + "", + "## Determinism", + "", + f"* identical across {native.get('repeat')} native runs: {native.get('identical')}", + f"* session compare ran: {(native.get('compare') or {}).get('ran')} " + f"({(native.get('compare') or {}).get('headers')})", + "", + "## Session hashes (sha256)", + "", + "| session | file | sha256 |", + "|---|---|---|", + ] + hash_rows = [("qemu-probe", qemu.get("session"), qemu.get("session_hashes") or {})] + for index, run in enumerate(runs, start=1): + hash_rows.append((f"native-{index}", run.get("session"), run.get("session_hashes") or {})) + features_detail = ((report.get("features") or {}).get("features") or {}) + for feature in ("sessions", "trace", "injection"): + detail = (features_detail.get(feature) or {}).get("detail") or {} + if isinstance(detail, dict): + hashes = detail.get("hashes") or {} + if hashes: + hash_rows.append((feature, detail.get("dir") or detail.get("blob"), hashes)) + for label, session, hashes in hash_rows: + if not hashes: + lines.append(f"| {label} | - | (no hashes recorded) |") + continue + for name in sorted(hashes): + lines.append(f"| {label} | {name} | `{hashes[name]}` |") + lines += [ + "", + "## Native feature matrix (demo firmware)", + "", + "| feature | status | detail |", + "|---|---|---|", + ] + feature_block = (report.get("features") or {}).get("features") or {} + for name in sorted(feature_block): + entry = feature_block[name] + detail = entry.get("detail") or entry.get("reason") or "" + lines.append(f"| {name} | {entry.get('status')} | {detail} |") + if not feature_block: + lines.append(f"| - | not_run | {(report.get('features') or {}).get('reason')} |") + lines += [ + "", + "## S1 integration", + "", + f"* device source: {report['s1_integration'].get('source') or 'lenient (no S1 device model found)'}", + f"* profile: {report['s1_integration'].get('profile') or '-'}", + f"* declared rung: {report['s1_integration'].get('declared_rung')}", + ] + for note in report["s1_integration"].get("notes") or []: + lines.append(f"* note: {note}") + lines += [ + "", + "## Not verified", + "", + ] + for item in report.get("not_verified") or []: + lines.append(f"* {item}") + lines += [ + "", + "## S1 integration notes", + "", + ] + for item in report.get("rerun_when_s1_lands") or []: + lines.append(f"* `{item}`" if item.startswith("python3") else f"* {item}") + lines += [ + "", + f"* other agents' x86 QEMU VMs observed (never touched): {report.get('x86_vms_running')}", + "", + ] + return "\n".join(lines) + + +def run_harness( + *, + image: str = DEFAULT_IMAGE, + out_dir: str = DEFAULT_OUT, + instructions: int = DEFAULT_INSTRUCTIONS, + qemu_duration: float = DEFAULT_QEMU_DURATION_S, + repeat: int = 2, + device_map: str | None = None, + features: bool = True, + thresholds: dict | None = None, + run_qemu: bool = True, +) -> dict: + """Run the full A/B harness and write ``sim-qemu-line.json`` + ``.md``.""" + os.makedirs(out_dir, exist_ok=True) + frozen = dict(FROZEN_THRESHOLDS) + frozen.update(thresholds or {}) + + if device_map is not None: + dm = DeviceMap.load(device_map) + s1 = { + "device_map": dm, + "profile": None, + "declared_rung": None, + "source": _relpath(device_map), + "notes": ["explicit --device-map override"], + } + else: + sha = firmware_sha256(image) if os.path.isfile(image) else None + s1 = discover_s1_device_model(image, sha) + + qemu = ( + run_qemu_baseline(image=image, out_dir=out_dir, duration=qemu_duration) + if run_qemu + else {"backend": "qemu", "ran": False, "reason": "qemu=False", "rung": None} + ) + + native = run_native_baseline( + image=image, + out_dir=out_dir, + instructions=instructions, + repeat=repeat, + device_map=s1.get("device_map"), + declared_rung=s1.get("declared_rung"), + profile=s1.get("profile"), + ) + if not native.get("ran"): + report = build_report( + image=image, qemu=qemu, native=native, features=None, s1=s1, thresholds=frozen + ) + else: + feature_result = run_feature_matrix(out_dir) if features else None + report = build_report( + image=image, + qemu=qemu, + native=native, + features=feature_result, + s1=s1, + thresholds=frozen, + ) + + json_path = _write_json(os.path.join(out_dir, "sim-qemu-line.json"), report) + md_path = _write_text(os.path.join(out_dir, "sim-qemu-line.md"), render_markdown(report)) + report["report_json"] = _relpath(json_path) + report["report_md"] = _relpath(md_path) + return report + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="python3 harness/sim_qemu_line.py", + description=( + "S4 A/B: QEMU probe vs native GSP simulator on the real 570.144 image; " + "emits sim-qemu-line.json + .md. Bounded, no hardware." + ), + ) + parser.add_argument("--image", default=DEFAULT_IMAGE, help="flat GSP firmware image") + parser.add_argument("--out", default=DEFAULT_OUT, help="report directory") + parser.add_argument( + "--instructions", type=int, default=DEFAULT_INSTRUCTIONS, + help=f"native instruction budget per run (<= {MAX_INSTRUCTIONS})", + ) + parser.add_argument( + "--qemu-duration", type=float, default=DEFAULT_QEMU_DURATION_S, + help=f"QEMU wall-clock probe bound (<= {MAX_QEMU_DURATION_S}s)", + ) + parser.add_argument("--repeat", type=int, default=2, help="native runs for determinism") + parser.add_argument("--device-map", help="explicit greeneye-device-map/1 file (skips discovery)") + parser.add_argument("--no-qemu", action="store_true", help="native only (no baseline claim)") + parser.add_argument("--no-features", action="store_true", help="skip the feature matrix") + parser.add_argument("--json", action="store_true", help="print the report JSON") + args = parser.parse_args(argv) + + try: + report = run_harness( + image=args.image, + out_dir=args.out, + instructions=args.instructions, + qemu_duration=args.qemu_duration, + repeat=args.repeat, + device_map=args.device_map, + features=not args.no_features, + run_qemu=not args.no_qemu, + ) + except (HarnessError, GreenEyeError) as exc: + print(f"[sim-qemu-line] error: {exc}", file=sys.stderr) + return 1 + + if not os.path.isfile(args.image): + print(f"[sim-qemu-line] skipped: firmware absent: {args.image}", file=sys.stderr) + return 2 + if args.json: + print(json.dumps(report, indent=1, sort_keys=True, default=str)) + native = report.get("native") or {} + print(f"[sim-qemu-line] qemu rung: {(report.get('qemu') or {}).get('rung')}") + print(f"[sim-qemu-line] native rung: {native.get('rung')} (identical={native.get('identical')})") + print(f"[sim-qemu-line] verdict: {report.get('verdict')}") + print(f"[sim-qemu-line] thresholds: {report.get('threshold_checks')}") + print(f"[sim-qemu-line] report: {report.get('report_json')} + {report.get('report_md')}") + return 0 if report.get("above_qemu_line") else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/triage.py b/debug/risc-v-gsp-rm-vm/harness/triage.py new file mode 100644 index 0000000..2175e48 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/triage.py @@ -0,0 +1,875 @@ +#!/usr/bin/env python3 +"""One-command new-version GSP firmware triage (docs/40, goal U46). + +For a NEW GSP firmware image (optionally against a ``--baseline``) this tool +answers, in one bounded run and with one report: + +* **what it is** - sha256, size, ELF shape and version label (explicit + ``--version``, the ``gsp_version.txt`` sidecar convention, parent directory, + file stem, in that order); +* **where it boots to** - the existing QEMU probe path (first exception / + first touch) and the S1 native boot ladder (``python3 -m sim.gsp_boot``, + rungs B0-B4); +* **how the native backend compares** - the existing ``harness/sim_qemu_line`` + A/B harness (QEMU vs native, deterministic repeat, frozen thresholds); +* **what changed vs the baseline** - the existing ``harness/version_diff`` + (image/symbol/corpus/probe/behavior sections), when ``--baseline`` is given; +* **what capability surface is present** - hooks/watch/memory/session API, + native simulator, TCG trace plugin, MCP, demo, boot ladder; +* **what work ``vgsp`` support needs** - a ``support_readiness`` verdict with + blockers and ordered next actions. + +Every step is wrapped: a failing step is recorded (``status: error``) and the +others still run. Every session a step uses is listed with sha256 hashes. +Nothing is downloaded; no hardware, GPU, mdev, kernel module or guest is +touched; QEMU is the only process launched besides the bounded native boot. + +This is the flagship workflow of the simulator program (handoff +``U46``) and remains **in development** (docs/40). The frozen value +thresholds below were recorded on 2026-10-03 *before* the first measurement +(docs/40 section 5); never move them silently. + +Run from the tool root:: + + python3 harness/triage.py --firmware ../../firmware/57X-GSP/570.144/gsp-570.144.fwimage.bin --version 570.144 --out dumps/triage-570.144 + python3 harness/triage.py --firmware <new> --baseline <old> --out dumps/triage-570.211 --dry-run +""" +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import re +import shutil +import subprocess +import sys +import time +from dataclasses import dataclass + +HERE = os.path.dirname(os.path.abspath(__file__)) +TOOL_ROOT = os.path.dirname(HERE) +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from harness.common import ( # noqa: E402 + DEMO_ELF, + ensure_dir, + qemu_available, + sha256_file, + write_json, + write_text, +) +from harness.sim_qemu_line import ( # noqa: E402 + MAX_INSTRUCTIONS, + run_harness, + run_qemu_baseline, + rung_ordinal, + session_hashes, +) +from harness.version_diff import ( # noqa: E402 + RM610_TOKEN, + Rm610GateError, + VersionDiffOptions, + check_rm610_gate, + run_version_diff, +) + +FORMAT = "greeneye-new-version-triage/1" +PLAN_FORMAT = "greeneye-new-version-triage-plan/1" + +#: Instruction budget bounds (task rule: <= 2M per attempt). +DEFAULT_BUDGET = 200_000 +#: QEMU probe wall-clock bound (matches the S4 harness default). +DEFAULT_QEMU_DURATION_S = 0.5 +#: Per-step wall-clock net (subprocess wedge protection; no unbounded runs). +DEFAULT_STEP_TIMEOUT_S = 300.0 +#: Native rung at which runtime observation (not just first touch) is possible. +OBSERVABLE_RUNG = "B2" + +#: Frozen 2026-10-03, before the first measured triage run (docs/40 section 5). +#: ``manual_baseline_s`` is the *conservative understatement* of the manual +#: steps this one command replaces (run+interpret probe, native boot ladder, +#: A/B, session hash collection and write-up for one new image). +FROZEN_THRESHOLDS = { + "frozen_on": "2026-10-03", + "manual_baseline_s": 1800.0, + "triage_wall_s_max": 600.0, + "required_steps": ("identity", "probe", "native_boot", "ab_eval", "features"), +} + +VERSION_RE = re.compile(r"v?(\d{3}\.\d+)([A-Za-z0-9._-]*)?") +_UNAVAILABLE_MARKERS = ( + "no module named", + "modulenotfounderror", + "importerror", + "cannot import", + "profile absent", + "image absent", + "unavailable", +) + + +# --------------------------------------------------------------------------- +# options and helpers +# --------------------------------------------------------------------------- + + +@dataclass +class TriageOptions: + firmware: str + baseline: str | None = None + version: str | None = None + baseline_label: str | None = None + out: str | None = None + budget: int = DEFAULT_BUDGET + qemu_duration: float = DEFAULT_QEMU_DURATION_S + step_timeout: float = DEFAULT_STEP_TIMEOUT_S + release: str | None = None + approval_token: str | None = None + dry_run: bool = False + + def labels(self) -> list[str]: + return [ + value + for value in ( + self.release, + self.version, + self.baseline_label, + os.path.basename(self.firmware) if self.firmware else None, + os.path.basename(self.baseline) if self.baseline else None, + ) + if value + ] + + +def _rm_tree(path: str) -> None: + if path and os.path.isdir(path): + shutil.rmtree(path, ignore_errors=True) + + +def _rel(path: str, root: str) -> str: + try: + return os.path.relpath(path, root) + except ValueError: + return path + + +def _abs(path: str | None) -> str | None: + """Absolute, normalised path; session dirs from the harness may be tool-root relative.""" + if not path: + return None + return os.path.normpath(path if os.path.isabs(path) else os.path.join(TOOL_ROOT, path)) + + +def _first_line(text: str | None, limit: int = 300) -> str | None: + if not text: + return None + line = text.strip().splitlines()[-1].strip() if text.strip() else "" + return line[:limit] or None + + +def _looks_unavailable(text: str) -> bool: + lowered = (text or "").lower() + return any(marker in lowered for marker in _UNAVAILABLE_MARKERS) + + +def extract_version(firmware: str, explicit: str | None = None) -> tuple[str | None, str]: + """Version label: explicit, ``gsp_version.txt``, parent dir, file stem. + + The sidecar convention mirrors the existing probe/device-map helper + (``greeneye/device_map.py``); no firmware content is guessed. + """ + if explicit: + return explicit, "explicit --version" + sidecar = os.path.join(os.path.dirname(os.path.abspath(firmware)), "gsp_version.txt") + if os.path.isfile(sidecar): + try: + with open(sidecar, "r", encoding="utf-8", errors="replace") as fh: + text = "".join(ch for ch in fh.read() if ch.isprintable()).strip() + if text: + return text, "gsp_version.txt sidecar" + except OSError: + pass + parent = os.path.basename(os.path.dirname(os.path.abspath(firmware))) + if parent and VERSION_RE.fullmatch(parent): + return parent, "parent directory name" + stem = os.path.splitext(os.path.basename(firmware))[0] + return (stem or None), "file stem" + + +def _target_brief(path: str, label: str | None = None) -> dict: + info: dict = {"path": path, "name": os.path.basename(path)} + version, version_source = extract_version(path, label) + info["version"] = version + info["version_source"] = version_source + if os.path.isfile(path): + info["sha256"] = sha256_file(path) + info["size"] = os.path.getsize(path) + else: + info["error"] = "file not found" + return info + + +# --------------------------------------------------------------------------- +# steps +# --------------------------------------------------------------------------- + + +def step_identity(options: TriageOptions, out_dir: str) -> dict: + firmware = _target_brief(options.firmware, None) + version, version_source = extract_version(options.firmware, options.version) + firmware["version"] = version + firmware["version_source"] = version_source + if not os.path.isfile(options.firmware): + return { + "status": "error", + "reason": f"firmware not found: {options.firmware}", + "firmware": firmware, + } + result: dict = {"status": "ok", "firmware": firmware} + try: + from greeneye.elf import load_elf + + image = load_elf(options.firmware) + result["elf"] = { + "is_riscv": bool(image.is_riscv), + "entry": image.entry, + "min_vaddr": image.min_vaddr, + "max_vaddr": image.max_vaddr, + "segments": len(getattr(image, "segments", ()) or ()), + } + except Exception as exc: + result["elf"] = {"unreadable": f"{type(exc).__name__}: {exc}"} + if options.baseline: + result["baseline"] = _target_brief(options.baseline, options.baseline_label) + return result + + +def step_probe(options: TriageOptions, out_dir: str) -> dict: + result = run_qemu_baseline( + image=options.firmware, out_dir=out_dir, duration=options.qemu_duration + ) + detail: dict = { + "status": "ok" if result.get("ran") else "skipped", + "reason": result.get("reason"), + "rung": result.get("rung"), + "stopped_pc": result.get("stopped_pc"), + "first_fault": result.get("first_fault"), + "first_touch_match": result.get("first_touch_match"), + "fault_count_window": result.get("fault_count_window"), + "device_map_entries": result.get("device_map_entries"), + "wall_s": result.get("wall_s"), + "sessions": [], + } + session = _abs(result.get("session")) + if session: + detail["sessions"].append({"name": "qemu-probe", "dir": session}) + detail["session_hashes"] = result.get("session_hashes") or session_hashes(session) + return detail + + +def step_native_boot(options: TriageOptions, out_dir: str) -> dict: + session_dir = os.path.join(out_dir, "sessions", "native-boot") + _rm_tree(session_dir) + argv = [ + sys.executable, + "-m", + "sim.gsp_boot", + "--image", + options.firmware, + "--instructions", + str(options.budget), + "--record", + session_dir, + "--name", + "triage-native-boot", + "--json", + ] + try: + proc = subprocess.run( + argv, + cwd=TOOL_ROOT, + capture_output=True, + text=True, + timeout=options.step_timeout, + ) + except subprocess.TimeoutExpired: + return {"status": "error", "reason": f"native boot timed out after {options.step_timeout}s"} + except OSError as exc: + return {"status": "skipped", "reason": f"native boot ladder unavailable: {exc}"} + + stdout, stderr = proc.stdout or "", proc.stderr or "" + payload = None + if stdout.strip(): + try: + payload = json.loads(stdout) + except ValueError: + payload = None + if proc.returncode == 0 and isinstance(payload, dict): + session = _abs(payload.get("session")) or session_dir + return { + "status": "ok", + "rung": payload.get("rung"), + "instructions_executed": payload.get("instructions_executed"), + "cycles": payload.get("cycles"), + "stop": payload.get("stop"), + "first_unknown_mmio": payload.get("first_unknown_mmio"), + "mmio_unknown": payload.get("mmio_unknown"), + "device_accesses": payload.get("device_accesses"), + "milestones": payload.get("milestones"), + "sessions": [{"name": "native-boot", "dir": session}], + "session_hashes": session_hashes(session), + } + reason = _first_line(stderr) or _first_line(stdout) or f"exit {proc.returncode}" + status = "skipped" if _looks_unavailable(stderr + stdout) or proc.returncode == 2 else "error" + if os.path.isdir(session_dir) and os.listdir(session_dir): + return { + "status": status, + "reason": reason, + "sessions": [{"name": "native-boot", "dir": session_dir}], + "session_hashes": session_hashes(session_dir), + } + return {"status": status, "reason": reason} + + +def step_ab_eval(options: TriageOptions, out_dir: str) -> dict: + if not os.path.isfile(options.firmware): + return {"status": "skipped", "reason": f"firmware absent: {options.firmware}"} + ab_dir = os.path.join(out_dir, "ab-eval") + report = run_harness( + image=options.firmware, + out_dir=ab_dir, + instructions=options.budget, + qemu_duration=options.qemu_duration, + repeat=2, + features=False, + run_qemu=True, + ) + qemu = report.get("qemu") or {} + native = report.get("native") or {} + detail: dict = { + "status": "ok" if native.get("ran") else "skipped", + "reason": native.get("reason") if not native.get("ran") else None, + "qemu_rung": qemu.get("rung"), + "native_rung": native.get("rung"), + "native_identical": native.get("identical"), + "native_repeat": native.get("repeat"), + "above_qemu_line": report.get("above_qemu_line"), + "verdict": report.get("verdict"), + "threshold_checks": report.get("threshold_checks"), + "report_json": _abs(report.get("report_json")), + "report_md": _abs(report.get("report_md")), + "sessions": [], + } + qemu_session = _abs(qemu.get("session")) + if qemu_session: + detail["sessions"].append({"name": "ab-qemu", "dir": qemu_session}) + for index, run in enumerate(native.get("runs") or [], start=1): + session = _abs(run.get("session")) or os.path.join(ab_dir, "sessions", f"native-{index}") + detail["sessions"].append({"name": f"ab-native-{index}", "dir": session}) + return detail + + +def step_version_diff(options: TriageOptions, out_dir: str) -> dict: + if not options.baseline: + return {"status": "skipped", "reason": "no --baseline supplied"} + if not os.path.isfile(options.baseline): + return {"status": "skipped", "reason": f"baseline absent: {options.baseline}"} + vd_dir = os.path.join(out_dir, "version-diff") + old_version, _ = extract_version(options.baseline, options.baseline_label) + report = run_version_diff( + VersionDiffOptions( + old=options.baseline, + new=options.firmware, + out=vd_dir, + old_label=options.baseline_label or old_version, + new_label=options.version, + release=options.release, + probe_instructions=max(1, min(options.budget, 2000)), + behavior_duration=0.4, + approval_token=options.approval_token, + ) + ) + sections = {name: section.get("status") for name, section in report["sections"].items()} + symbols = report["sections"].get("symbols") or {} + detail: dict = { + "status": "error" if "error" in sections.values() else "ok", + "reason": None, + "old_label": report.get("old", {}).get("label"), + "new_label": report.get("new", {}).get("label"), + "sections": sections, + "sections_skipped": report.get("summary", {}).get("sections_skipped"), + "sections_error": report.get("summary", {}).get("sections_error"), + "report_json": os.path.join(vd_dir, "version-diff.json"), + "report_md": os.path.join(vd_dir, "version-diff.md"), + "image_changes": _image_changes(report), + "symbol_changes": _symbol_changes(symbols), + "sessions": [], + } + sessions_root = os.path.join(vd_dir, "sessions") + if os.path.isdir(sessions_root): + for name in sorted(os.listdir(sessions_root)): + path = os.path.join(sessions_root, name) + if os.path.isdir(path): + detail["sessions"].append({"name": f"version-diff/{name}", "dir": path}) + return detail + + +def _image_changes(report: dict) -> dict: + section = (report.get("sections") or {}).get("image") or {} + return { + "status": section.get("status"), + "same": section.get("same"), + "deltas": section.get("deltas"), + } + + +def _symbol_changes(symbols: dict) -> dict: + totals = symbols.get("totals") or {} + return { + "status": symbols.get("status"), + "reason": symbols.get("reason"), + "shift": symbols.get("shift"), + "matched_named": symbols.get("matched_named"), + "matched_shifted": symbols.get("matched_shifted"), + "added": totals.get("added"), + "removed": totals.get("removed"), + "moved": totals.get("moved"), + "resized_named": totals.get("resized_named"), + } + + +def _spec_available(module: str) -> bool: + try: + return importlib.util.find_spec(module) is not None + except (ImportError, ValueError): + return False + + +def step_features(options: TriageOptions, out_dir: str) -> dict: + checks: dict = {} + notes: list[str] = [] + try: + import greeneye as ge + + checks["greeneye_version"] = ge.__version__ + checks["hooks_watch_step"] = all( + hasattr(ge.Session, name) for name in ("hook", "watch", "run", "step", "close") + ) + checks["memory_struct"] = hasattr(ge, "Memory") + checks["native_backend"] = _spec_available("greeneye.sim_backend") + except Exception as exc: + notes.append(f"greeneye import failed: {type(exc).__name__}: {exc}") + checks.update({"greeneye_version": None, "hooks_watch_step": False, "memory_struct": False, "native_backend": False}) + checks["native_boot_ladder"] = os.path.isfile(os.path.join(TOOL_ROOT, "sim", "gsp_boot.py")) and os.path.isdir( + os.path.join(TOOL_ROOT, "sim", "profiles") + ) + checks["ab_harness"] = os.path.isfile(os.path.join(TOOL_ROOT, "harness", "sim_qemu_line.py")) + checks["version_diff"] = os.path.isfile(os.path.join(TOOL_ROOT, "harness", "version_diff.py")) + checks["tcg_trace_plugin"] = os.path.isfile(os.path.join(TOOL_ROOT, "qemu", "greeneye-trace.so")) + checks["mcp_server"] = os.path.isfile(os.path.join(TOOL_ROOT, "plugins", "mcp_server.py")) + checks["demo_firmware"] = os.path.isfile(DEMO_ELF) + checks["qemu"] = qemu_available() + unavailable = sorted(name for name, value in checks.items() if value is False) + if unavailable: + notes.append("unavailable: " + ", ".join(unavailable)) + return {"status": "ok", "checks": checks, "unavailable": unavailable, "notes": notes} + + +DEFAULT_RUNNERS = ( + ("identity", step_identity), + ("probe", step_probe), + ("native_boot", step_native_boot), + ("ab_eval", step_ab_eval), + ("version_diff", step_version_diff), + ("features", step_features), +) + + +# --------------------------------------------------------------------------- +# readiness and report assembly +# --------------------------------------------------------------------------- + + +def support_readiness(report: dict) -> dict: + """Blockers and next actions derived from the recorded step results.""" + steps = {step["name"]: step for step in report["steps"]} + blockers: list[str] = [] + next_actions: list[str] = [] + identity = steps.get("identity", {}) + if identity.get("status") != "ok": + blockers.append(f"firmware identity failed: {identity.get('reason') or 'unreadable'}") + probe = steps.get("probe", {}) + if probe.get("status") == "error": + blockers.append(f"QEMU probe failed: {probe.get('reason')}") + native = steps.get("native_boot", {}) + native_rung = native.get("rung") + native_ok = native.get("status") == "ok" + if not native_ok: + next_actions.append( + "native boot ladder unavailable/again incomplete " + f"({native.get('reason') or native.get('status')}): the S1 device model/profile is the enabler" + ) + elif rung_ordinal(native_rung) is not None and rung_ordinal(native_rung) >= rung_ordinal(OBSERVABLE_RUNG): + next_actions.append( + f"native ladder is at {native_rung} (runtime-observable): inspect the milestone/stop with " + "hooks and watchpoints, then route the finding through the accuracy loop" + ) + else: + next_actions.append( + f"native ladder stops at {native_rung}: extend the device model past the next unknown MMIO " + "before claiming runtime observation" + ) + ab = steps.get("ab_eval", {}) + if ab.get("status") == "ok" and not ab.get("above_qemu_line"): + next_actions.append( + f"native A/B is not yet above the QEMU line (verdict={ab.get('verdict')}): " + "device model and documented shims gate the claim" + ) + vd = steps.get("version_diff", {}) + baseline_supplied = bool(report.get("input", {}).get("baseline_supplied")) + if vd.get("status") == "error": + blockers.append(f"version diff errored: {vd.get('sections_error') or vd.get('reason')}") + elif vd.get("status") == "ok" and baseline_supplied: + skipped = vd.get("sections_skipped") or {} + if "symbols" in skipped: + next_actions.append( + "symbol inventory skipped: supply Ghidra exports / symbol DBs so the handler " + "inventory delta can gate the vgsp support scope" + ) + if "corpus" in skipped: + next_actions.append( + "corpus diff skipped: supply --corpus-diff indexes for decompiled-change hints" + ) + else: + next_actions.append( + "re-run with --baseline <previous.fwimage> for the symbol/behavior delta " + "(the release-to-support gate needs the A/B)" + ) + features = steps.get("features", {}) + for name in features.get("unavailable") or []: + if name in ("qemu", "native_boot_ladder", "hooks_watch_step", "memory_struct"): + next_actions.append(f"capability unavailable: {name}") + if not blockers and native_ok and rung_ordinal(native_rung) is not None and rung_ordinal(native_rung) >= rung_ordinal(OBSERVABLE_RUNG): + verdict = "sim-observable" + elif not blockers: + verdict = "first-touch-only" + else: + verdict = "blocked" + return { + "verdict": verdict, + "baseline_compared": bool(baseline_supplied and vd.get("status") == "ok"), + "observable_rung": OBSERVABLE_RUNG, + "blockers": blockers, + "next_actions": next_actions, + } + + +def _collect_sessions(steps: list[dict], out_dir: str) -> list[dict]: + rows: list[dict] = [] + seen: set[str] = set() + for step in steps: + for entry in step.get("sessions") or []: + path = _abs(entry.get("dir")) + if not path or path in seen: + continue + seen.add(path) + hashes = step.get("session_hashes") or {} + rows.append( + { + "step": step["name"], + "name": entry.get("name") or step["name"], + "dir": _rel(path, out_dir), + "present": os.path.isdir(path), + "hashes": hashes or session_hashes(path), + } + ) + return rows + + +def render_markdown(report: dict) -> str: + firmware = report["input"].get("firmware") or {} + steps = report["steps"] + readiness = report["support_readiness"] + lines = [ + f"# New-version triage — {firmware.get('version') or firmware.get('name')}", + "", + "> Status: in development (docs/40). Simulator-only: no hardware, GPU, mdev,", + "> kernel module or guest was used. Frozen thresholds were recorded before the run.", + "", + f"* format: `{report['format']}`", + f"* generated: {report['generated_at']}", + f"* wall: {report['wall_s']}s (budget {report['input'].get('budget')} instructions/attempt)", + f"* RM610 gate: {report['rm610_gate']['status']}", + "", + "## Firmware", + "", + "| field | value |", + "|---|---|", + f"| path | `{firmware.get('path')}` |", + f"| sha256 | `{firmware.get('sha256')}` |", + f"| size | {firmware.get('size')} |", + f"| version | {firmware.get('version')} ({firmware.get('version_source')}) |", + f"| ELF | {json.dumps(report['input'].get('elf') or {}, sort_keys=True)} |", + ] + baseline = report["input"].get("baseline") + if baseline: + lines += [ + f"| baseline | `{baseline.get('name')}` {baseline.get('sha256')} |", + f"| baseline version | {baseline.get('version')} ({baseline.get('version_source')}) |", + ] + lines += ["", "## Steps", "", "| step | status | wall_s | key result |", "|---|---|---|---|"] + for step in steps: + key = ( + step.get("reason") + or step.get("verdict") + or step.get("rung") + or step.get("status") + ) + lines.append(f"| {step['name']} | {step['status']} | {step.get('wall_s')} | {str(key)[:120]} |") + lines += [ + "", + "## Support readiness", + "", + f"* verdict: **{readiness['verdict']}**", + f"* baseline compared: {readiness['baseline_compared']}", + ] + for label, items in (("blockers", readiness["blockers"]), ("next actions", readiness["next_actions"])): + lines.append(f"* {label}:") + if not items: + lines.append(" * none") + for item in items: + lines.append(f" * {item}") + sessions = report.get("sessions") or [] + lines += ["", "## Sessions used", "", "| step | name | dir | session.json sha256 |", "|---|---|---|---|"] + if not sessions: + lines.append("| - | - | - | (none recorded) |") + for row in sessions: + sha = (row.get("hashes") or {}).get("session.json") or "-" + lines.append(f"| {row['step']} | {row['name']} | `{row['dir']}` | `{sha}` |") + checks = report["threshold_checks"] + lines += [ + "", + "## Value (frozen thresholds, docs/40 section 5)", + "", + "| metric | value |", + "|---|---|", + f"| triage wall_s | {checks['triage_wall_s']} |", + f"| wall_s max | {checks['triage_wall_s_max']} |", + f"| within max | {checks['wall_within_max']} |", + f"| manual baseline_s | {checks['manual_baseline_s']} |", + f"| speedup_x | {checks['speedup_x']} |", + f"| beats manual baseline | {checks['beats_manual_baseline']} |", + "", + "## Artifacts", + "", + ] + for key in ("report_json", "report_md"): + lines.append(f"* {key}: `{report['artifacts'][key]}`") + lines.append("") + return "\n".join(lines) + + +def run_triage(options: TriageOptions, runners=None) -> dict: + """Run every step (wrapped), write ``triage-report.json|.md``, return it.""" + check_rm610_gate(options.labels(), approval_token=options.approval_token) + if not options.out: + raise ValueError("--out is required unless --dry-run is used") + if options.budget <= 0 or options.budget > MAX_INSTRUCTIONS: + raise ValueError(f"--budget must be in 1..{MAX_INSTRUCTIONS}, got {options.budget}") + out = os.path.abspath(options.out) + ensure_dir(out) + started = time.monotonic() + steps: list[dict] = [] + for name, fn in runners or DEFAULT_RUNNERS: + step_started = time.monotonic() + try: + detail = fn(options, out) + if not isinstance(detail, dict): + detail = {"status": "ok", "result": detail} + except Exception as exc: + detail = {"status": "error", "reason": f"{type(exc).__name__}: {exc}"} + entry = {"name": name, "wall_s": round(time.monotonic() - step_started, 3)} + entry.update(detail) + steps.append(entry) + wall_s = round(time.monotonic() - started, 3) + report: dict = { + "format": FORMAT, + "tool": "harness/triage.py", + "dry_run": False, + "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "rm610_gate": {"status": "passed", "labels": sorted(set(options.labels()))}, + "input": { + "firmware": _target_brief(options.firmware, options.version), + "baseline": _target_brief(options.baseline, options.baseline_label) if options.baseline else None, + "baseline_supplied": options.baseline is not None, + "budget": options.budget, + "qemu_duration": options.qemu_duration, + "triage_command": ( + "python3 harness/triage.py --firmware <firmware>" + + (" --baseline <baseline>" if options.baseline else "") + + (f" --version {options.version}" if options.version else "") + + f" --budget {options.budget} --out <out>" + ), + }, + "thresholds": dict(FROZEN_THRESHOLDS), + "steps": steps, + "support_readiness": {}, + "wall_s": wall_s, + "artifacts": {}, + } + firmware = report["input"]["firmware"] + if firmware.get("error"): + report["input"]["firmware"]["status"] = "error" + report["input"]["elf"] = next((s.get("elf") for s in steps if s["name"] == "identity"), None) + report["errors"] = [step["name"] for step in steps if step["status"] == "error"] + report["sessions"] = _collect_sessions(steps, out) + report["support_readiness"] = support_readiness(report) + checks = { + "triage_wall_s": wall_s, + "triage_wall_s_max": FROZEN_THRESHOLDS["triage_wall_s_max"], + "wall_within_max": wall_s <= FROZEN_THRESHOLDS["triage_wall_s_max"], + "manual_baseline_s": FROZEN_THRESHOLDS["manual_baseline_s"], + "speedup_x": round(FROZEN_THRESHOLDS["manual_baseline_s"] / wall_s, 1) if wall_s > 0 else None, + "beats_manual_baseline": wall_s < FROZEN_THRESHOLDS["manual_baseline_s"], + "required_steps_missing": [ + name + for name in FROZEN_THRESHOLDS["required_steps"] + if steps_by_name(steps, name).get("status") not in ("ok", "skipped") + ], + } + report["threshold_checks"] = checks + artifacts = { + "report_json": os.path.join(out, "triage-report.json"), + "report_md": os.path.join(out, "triage-report.md"), + } + report["artifacts"] = artifacts + write_json(artifacts["report_json"], report) + write_text(artifacts["report_md"], render_markdown(report)) + return report + + +def steps_by_name(steps: list[dict], name: str) -> dict: + return next((step for step in steps if step["name"] == name), {}) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def build_plan(options: TriageOptions) -> dict: + out = os.path.abspath(options.out) if options.out else "<out>" + return { + "format": PLAN_FORMAT, + "tool": "harness/triage.py", + "dry_run": True, + "status": "planned", + "rm610_gate": {"status": "passed", "labels": sorted(set(options.labels()))}, + "firmware": {"path": options.firmware, "version": options.version or "auto"}, + "baseline": options.baseline, + "out": out, + "budget": options.budget, + "steps": [ + {"name": name, "budget": options.budget if name in ("native_boot", "ab_eval") else None} + for name, _fn in DEFAULT_RUNNERS + ], + "side_effects": { + "writes": [ + os.path.join(out, "triage-report.json"), + os.path.join(out, "triage-report.md"), + os.path.join(out, "sessions/"), + os.path.join(out, "ab-eval/"), + os.path.join(out, "version-diff/ (only with --baseline)"), + ], + "hardware": "none", + "network": "none", + "max_instructions_per_attempt": MAX_INSTRUCTIONS, + }, + } + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="risc-v-gsp-rm-debugger triage", description=__doc__.splitlines()[0]) + parser.add_argument("--firmware", required=True, help="new GSP firmware image (read-only)") + parser.add_argument("--baseline", help="previous firmware image for the version diff (optional)") + parser.add_argument("--version", help="version label (default: gsp_version.txt / dir / stem)") + parser.add_argument("--baseline-label", help="version label recorded for the baseline") + parser.add_argument("--out", help="report directory (required unless --dry-run)") + parser.add_argument("--budget", type=int, default=DEFAULT_BUDGET, + help=f"instruction budget per native attempt (1..{MAX_INSTRUCTIONS})") + parser.add_argument("--qemu-duration", type=float, default=DEFAULT_QEMU_DURATION_S, + help="QEMU probe wall-clock bound in seconds") + parser.add_argument("--step-timeout", type=float, default=DEFAULT_STEP_TIMEOUT_S, + help="per-step wall-clock net in seconds") + 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("--dry-run", action="store_true", + help="print the plan and exit without opening targets or creating --out") + return parser + + +def options_from_args(args: argparse.Namespace) -> TriageOptions: + return TriageOptions( + firmware=args.firmware, + baseline=args.baseline, + version=args.version, + baseline_label=args.baseline_label, + out=args.out, + budget=args.budget, + qemu_duration=args.qemu_duration, + step_timeout=args.step_timeout, + release=args.release, + approval_token=args.approval_token, + 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"[triage] 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"[triage] dry-run: {len(plan['steps'])} steps planned; no targets opened, no --out created") + return 0 + if not options.out: + parser.error("--out is required unless --dry-run is used") + if options.budget <= 0 or options.budget > MAX_INSTRUCTIONS: + parser.error(f"--budget must be in 1..{MAX_INSTRUCTIONS}") + if not os.path.isfile(options.firmware): + print(f"[triage] skipped: firmware absent: {options.firmware}", file=sys.stderr) + return 2 + try: + report = run_triage(options) + except Rm610GateError as exc: + print(f"[triage] RM610 gate refused: {exc}", file=sys.stderr) + return 3 + except ValueError as exc: + parser.error(str(exc)) + except Exception as exc: + print(f"[triage] 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"[triage] {step['name']}: status={step['status']}{reason}") + readiness = report["support_readiness"] + print(f"[triage] readiness: {readiness['verdict']} (baseline_compared={readiness['baseline_compared']})") + for action in readiness["next_actions"][:6]: + print(f"[triage] next: {action}") + checks = report["threshold_checks"] + print(f"[triage] wall: {checks['triage_wall_s']}s vs manual baseline {checks['manual_baseline_s']}s " + f"(speedup {checks['speedup_x']}x)") + print(f"[triage] wrote {report['artifacts']['report_json']} + {report['artifacts']['report_md']}") + return 1 if report["errors"] else 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) |
