From 1f2073d4fbb1d412d9bbf373312e3c51b83db92a Mon Sep 17 00:00:00 2001 From: Arthur Hanson Rasmusson Date: Sun, 4 Oct 2026 18:42:36 -0400 Subject: greeneye: GMMU/FBIF observability, PTE decoder and oracle, MPU/libos simulator wave Simulator development wave (docs 42-44): GMMU page-table entry decode with its oracle/workflow harness, FBIF observability, and MPU/LIBOS device surfaces in the device model, plus refreshed boot/machine/profile data (tu104-570.144), the qemu-line results, and the new unit tests. Docs 00/06/35-41 updated to match; harness helpers and .gitignore included. --- debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py | 924 +++++++++++++++++++++ debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py | 585 +++++++++++++ .../harness/qemu_line/sim-qemu-line.json | 80 +- .../harness/qemu_line/sim-qemu-line.md | 46 +- 4 files changed, 1572 insertions(+), 63 deletions(-) create mode 100644 debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py create mode 100644 debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py (limited to 'debug/risc-v-gsp-rm-vm/harness') diff --git a/debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py b/debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py new file mode 100644 index 0000000..794bb1f --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py @@ -0,0 +1,924 @@ +#!/usr/bin/env python3 +"""GMMU/FBIF static<->dynamic join (harness/gmmu_debug.py). + +Answers "which firmware function programs which register, in what order" +from a recorded simulator trace, then writes a Ghidra-ready hit file. + +Inputs +------ + +* A G1 trace: ``greeneye-gmmu-fbif-trace/1``. The producer's document is + nested (``windows[].registers[].accesses[]`` plus + ``windows[].unclassified.accesses[]``); a flat ``events`` list and field + aliases are also accepted so the harness survives producer naming choices + (see :func:`load_sim_trace`). Nested documents are flattened per + window/register: per-register order is preserved, global interleaving + across registers is not recoverable from that shape. +* A session directory containing such a trace under ``blobs/`` or named by + ``session.json`` (``gmmu_fbif_trace`` / ``gmmu_fbif_trace_blob``). +* A symbol DB/map: ``greeneye-symbols/1`` JSON, a plain ``{addr: name}`` + JSON map, or a Ghidra ``addrname[...]`` TSV. + +Outputs +------- + +* ``gmmu-debug-report.json`` / ``.md``: per-firmware-function register rows + (function, register, window, offset, sequence of written values, + first/last pc, write and read counts). +* ``greeneye_hits.tsv``: ``addrhitssymbolannotation``, the + exact shape ``ghidra/MarkGreenEyeHits.java`` consumes. + +The ``demo`` subcommand builds a small synthetic fixture, runs the whole +loop, and compares the measured wall time against thresholds frozen in this +file before the first measurement. The manual baseline is a conservative +estimate, not a measurement on a human. + +Development-only: this module only reads files and writes reports. It never +touches a GPU, module, mdev, guest or network. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import time +from dataclasses import dataclass, field +from typing import Any + +TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from greeneye.errors import GreenEyeError # noqa: E402 +from greeneye.ghidra import render_ghidra_command # noqa: E402 +from greeneye.symbols import Symbol, SymbolDB # noqa: E402 + +#: Schema tag of the G1 simulator observation file. +TRACE_FORMAT = "greeneye-gmmu-fbif-trace/1" +#: Schema tag of the report this module emits. +DEBUG_REPORT_FORMAT = "greeneye-gmmu-debug-report/1" +#: Schema tag of the demo result line. +DEMO_RESULT_FORMAT = "greeneye-gmmu-debug-demo/1" +#: Exact header expected by ``ghidra/MarkGreenEyeHits.java``. +HITS_HEADER = "addr\thits\tsymbol\tannotation" +#: Cap on the per-register value sequence kept in the JSON report. +MAX_SEQUENCE_VALUES = 32 + +# FROZEN BEFORE THE FIRST MEASUREMENT (2026-10-03). Do not move silently: +# a threshold change requires a new recorded run and a note in docs/44. +#: Conservative ESTIMATE (not measured on a human) of the static path for +#: the demo's handful of functions: open the Ghidra project, locate each +#: function, read every register-write site by hand against the register +#: header. ~20 minutes is deliberately generous to the manual baseline. +MANUAL_STATIC_BASELINE_SECONDS = 1200 +MANUAL_BASELINE_LABEL = "conservative estimate (not measured on a human)" +#: The tool path must stay at or below this fraction of the estimate. +WORKFLOW_THRESHOLD_FRACTION = 0.05 + + +class GmmuInputError(GreenEyeError): + """A GMMU input file is absent, unreadable or not in a supported schema.""" + + +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 parse_int(value: Any, default: int | None = None) -> int | None: + """Parse ints, decimal strings and ``0x`` strings; ``None`` on failure.""" + if value is None: + return default + if isinstance(value, bool): + return int(value) + if isinstance(value, int): + return value + if isinstance(value, float): + return int(value) + if isinstance(value, str): + text = value.strip() + if not text: + return default + try: + return int(text, 0) + except ValueError: + try: + return int(text, 16) + except ValueError: + return default + return default + + +def _first_str(event: dict, keys: tuple[str, ...]) -> str: + for key in keys: + value = event.get(key) + if isinstance(value, str) and value: + return value + if isinstance(value, int) and not isinstance(value, bool): + return str(value) + return "" + + +def _first_int(event: dict, keys: tuple[str, ...]) -> int | None: + for key in keys: + if key in event: + parsed = parse_int(event.get(key)) + if parsed is not None: + return parsed + return None + + +def _first_bool(event: dict, keys: tuple[str, ...]) -> bool | None: + for key in keys: + value = event.get(key) + if isinstance(value, bool): + return value + return None + + +_WRITE_WORDS = {"w", "write", "wr", "store", "st", "out"} +_READ_WORDS = {"r", "read", "rd", "load", "ld", "in"} +_OTHER_WORDS = {"exec", "summary", "note", "event"} + + +def _normalise_kind(event: dict) -> str: + for key in ("kind", "rw", "direction", "access", "op", "type", "t"): + value = event.get(key) + if isinstance(value, str): + low = value.strip().lower() + if low in _WRITE_WORDS: + return "write" + if low in _READ_WORDS: + return "read" + if low in _OTHER_WORDS: + return "other" + write_flag = event.get("write") + if isinstance(write_flag, bool): + return "write" if write_flag else "read" + return "other" + + +@dataclass +class TraceObservation: + """One register access from a simulator trace.""" + + seq: int + kind: str + name: str = "" + window: str = "" + offset: int | None = None + addr: int | None = None + size: int | None = None + value: int | None = None + pc: int | None = None + function: str = "" + handled: bool | None = None + vt: int | None = None + + def to_json(self) -> dict: + out: dict[str, Any] = {"seq": self.seq, "kind": self.kind} + if self.name: + out["name"] = self.name + if self.window: + out["window"] = self.window + if self.offset is not None: + out["offset"] = self.offset + if self.addr is not None: + out["addr"] = self.addr + if self.size is not None: + out["size"] = self.size + if self.value is not None: + out["value"] = self.value + if self.pc is not None: + out["pc"] = self.pc + if self.function: + out["function"] = self.function + if self.handled is not None: + out["handled"] = self.handled + if self.vt is not None: + out["vt"] = self.vt + return out + + +@dataclass +class SimTrace: + path: str + format: str + sha256: str + observations: list[TraceObservation] = field(default_factory=list) + source: dict = field(default_factory=dict) + + +def _observation_from_event(event: dict, seq: int) -> TraceObservation: + return TraceObservation( + seq=seq, + kind=_normalise_kind(event), + name=_first_str(event, ("name", "register", "reg", "signal")), + window=_first_str(event, ("window", "block", "device", "bar", "region")), + offset=_first_int(event, ("offset", "reg_offset", "index")), + addr=_first_int(event, ("addr", "address", "pa", "guest_addr", "gpa")), + size=_first_int(event, ("size", "width", "bytes")), + value=_first_int(event, ("value", "data", "val")), + pc=_first_int(event, ("pc", "pc_addr")), + function=_first_str(event, ("function", "symbol", "func", "pc_symbol")), + handled=_first_bool(event, ("handled", "served")), + vt=_first_int(event, ("vt", "virtual_time")), + ) + + +_EVENT_KEYS = ("events", "trace", "accesses", "items", "observations") + + +def _flatten_windows(payload: dict) -> list[dict]: + """Flatten the G1 nested document (windows -> registers -> accesses).""" + events: list[dict] = [] + for window in payload.get("windows", []): + if not isinstance(window, dict): + continue + label = window.get("label") or window.get("window") or "" + for register in window.get("registers", []) or []: + if not isinstance(register, dict): + continue + name = register.get("name") + for access in register.get("accesses", []) or []: + if not isinstance(access, dict): + continue + event = dict(access) + if label: + event.setdefault("window", label) + if name: + event.setdefault("register", name) + events.append(event) + unclassified = window.get("unclassified") + if isinstance(unclassified, dict): + for access in unclassified.get("accesses", []) or []: + if not isinstance(access, dict): + continue + event = dict(access) + if label: + event.setdefault("window", label) + events.append(event) + return events + + +def _source_metadata(payload: dict) -> dict: + source: dict = {} + if isinstance(payload.get("source"), dict): + source.update(payload["source"]) + for key in ("image_sha256", "profile", "profile_name", "symbols"): + if payload.get(key) is not None: + source.setdefault(key, payload[key]) + summary = payload.get("summary") + if isinstance(summary, dict): + source.setdefault("summary", summary) + return source + + +def _events_from_payload(payload: Any) -> tuple[str, list | None, dict]: + if isinstance(payload, list): + return "unlabeled-list", payload, {} + if not isinstance(payload, dict): + raise GmmuInputError("trace JSON must be an object or a list of events") + fmt = str(payload.get("format") or payload.get("schema") or "") + source = _source_metadata(payload) + for key in _EVENT_KEYS: + if isinstance(payload.get(key), list): + return fmt, payload[key], source + if isinstance(payload.get("windows"), list): + return fmt, _flatten_windows(payload), source + if fmt == TRACE_FORMAT: + return fmt, [], source + return fmt, None, source + + +def _parse_jsonl(text: str) -> tuple[str, list[dict], dict]: + fmt = "" + events: list[dict] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError: + continue + if not isinstance(item, dict): + continue + if item.get("format") or item.get("schema"): + fmt = str(item.get("format") or item.get("schema")) + if not any(k in item for k in ("addr", "pc", "offset", "name", "register")): + continue + if item.get("t") == "summary": + continue + events.append(item) + return fmt, events, {} + + +def _find_session_trace(session_dir: str) -> str: + session_json = os.path.join(session_dir, "session.json") + explicit: list[str] = [] + if os.path.isfile(session_json): + try: + with open(session_json, "r", encoding="utf-8") as fh: + meta = json.load(fh) + except (OSError, json.JSONDecodeError): + meta = {} + if isinstance(meta, dict): + for key in ("gmmu_fbif_trace", "gmmu_fbif_trace_blob", "trace_blob"): + value = meta.get(key) + if isinstance(value, str) and value: + explicit.append(os.path.join(session_dir, value)) + for candidate in explicit: + if os.path.isfile(candidate): + return candidate + blobs = os.path.join(session_dir, "blobs") + if os.path.isdir(blobs): + for name in sorted(os.listdir(blobs)): + if not name.endswith((".json", ".jsonl")): + continue + candidate = os.path.join(blobs, name) + try: + with open(candidate, "r", encoding="utf-8", errors="replace") as fh: + head = fh.read(1 << 16) + except OSError: + continue + if TRACE_FORMAT in head: + return candidate + for name in sorted(os.listdir(blobs)): + if name.endswith(".jsonl"): + return os.path.join(blobs, name) + raise GmmuInputError( + f"{session_dir}: no {TRACE_FORMAT} trace found (looked for session.json " + "pointers and blobs/*.json[l])" + ) + + +def load_sim_trace(path: str) -> SimTrace: + """Load a G1 trace file, or a session directory containing one. + + Accepted shapes: the G1 nested document (``schema`` + + ``windows[].registers[].accesses[]``), an object with a flat + ``events``/``trace``/``accesses`` list, a bare list, and JSONL. Event + aliases: ``kind``/``rw``/``direction``/``type``/``t``; + ``name``/``register``/``reg``; ``window``/``block``/``device``; + ``offset``/``reg_offset``; ``addr``/``address``/``pa``; + ``value``/``data``; ``pc``; ``function``/``symbol``/``pc_symbol``; + ``handled``/``served``. Unknown fields are ignored; an event with no + recognisable direction is kept as ``other``. + """ + if os.path.isdir(path): + path = _find_session_trace(path) + if not os.path.isfile(path): + raise GmmuInputError(f"trace not found: {path}") + digest = sha256_file(path) + with open(path, "r", encoding="utf-8", errors="replace") as fh: + text = fh.read() + try: + payload = json.loads(text) + except json.JSONDecodeError: + payload = None + if payload is not None: + fmt, events, source = _events_from_payload(payload) + if events is None: + raise GmmuInputError( + f"{path}: no event list (expected {TRACE_FORMAT} with an 'events' key)" + ) + else: + fmt, events, source = _parse_jsonl(text) + observations = [ + _observation_from_event(event, seq) + for seq, event in enumerate(events) + if isinstance(event, dict) + ] + return SimTrace( + path=os.path.abspath(path), + format=fmt or "unlabeled", + sha256=digest, + observations=observations, + source=source, + ) + + +def load_symbols(path: str | None) -> SymbolDB: + """Load a symbol DB (JSON) or a Ghidra-style TSV/map (``addrname``).""" + db = SymbolDB() + if not path: + return db + if not os.path.isfile(path): + raise GmmuInputError(f"symbol map not found: {path}") + if path.endswith(".json"): + try: + with open(path, "r", encoding="utf-8") as fh: + payload = json.load(fh) + except (OSError, json.JSONDecodeError) as exc: + raise GmmuInputError(f"cannot read symbol map {path}: {exc}") from exc + if isinstance(payload, dict) and ( + "symbols" in payload or payload.get("format") == "greeneye-symbols/1" + ): + db.load_json(path) + return db + if isinstance(payload, list): + db.load_json(path) + return db + if isinstance(payload, dict): + for key, value in payload.items(): + addr = parse_int(key) + if addr is None: + continue + if isinstance(value, str): + db.add(Symbol(addr=addr, name=value, source="map")) + elif isinstance(value, dict) and value.get("name"): + db.add( + Symbol( + addr=addr, + name=str(value["name"]), + size=parse_int(value.get("size"), 0) or 0, + source="map", + ) + ) + return db + raise GmmuInputError(f"{path}: unsupported symbol JSON shape") + db.load_index_tsv(path) + if len(db) == 0: + db.load_names_tsv(path) + if len(db) == 0: + raise GmmuInputError(f"{path}: no symbols parsed") + return db + + +def _function_identity( + obs: TraceObservation, symbols: SymbolDB | None +) -> tuple[tuple | None, int | None, str]: + """Resolve an observation to ``(group_key, entry_addr, display_name)``. + + An explicit ``function`` field groups by name (all events of one named + function stay together even without a symbol DB); a resolved symbol + groups by its entry address; an unresolved pc becomes ``sub_``. + """ + if obs.function: + symbol = symbols.get(obs.function) if symbols is not None else None + return ( + ("name", obs.function), + symbol.addr if symbol is not None else None, + obs.function, + ) + if obs.pc is None: + return None, None, "" + symbol = symbols.resolve(obs.pc) if symbols is not None else None + if symbol is not None: + return ("addr", symbol.addr), symbol.addr, symbol.name + return ("sub", obs.pc), obs.pc, f"sub_{obs.pc:#x}" + + +def _register_identity(obs: TraceObservation) -> tuple: + if obs.offset is not None: + return (obs.window, obs.offset, obs.name) + if obs.name: + return (obs.window, None, obs.name) + return (obs.window, obs.addr, "") + + +def _register_label(obs: TraceObservation) -> str: + if obs.name: + return obs.name + if obs.offset is not None: + return f"0x{obs.offset:x}" + if obs.addr is not None: + return f"0x{obs.addr:x}" + return "?" + + +@dataclass +class RegisterRow: + function: str + function_addr: int | None + register: str + window: str + offset: int | None + addr: int | None + write_count: int = 0 + read_count: int = 0 + first_pc: int | None = None + last_pc: int | None = None + first_seq: int | None = None + last_seq: int | None = None + values: list[int] = field(default_factory=list) + values_truncated: bool = False + + def observe(self, obs: TraceObservation) -> None: + if obs.kind == "write": + self.write_count += 1 + if obs.value is not None: + if len(self.values) < MAX_SEQUENCE_VALUES: + self.values.append(obs.value) + else: + self.values_truncated = True + elif obs.kind == "read": + self.read_count += 1 + if obs.pc is not None: + if self.first_pc is None: + self.first_pc = obs.pc + self.last_pc = obs.pc + if self.first_seq is None: + self.first_seq = obs.seq + self.last_seq = obs.seq + + def to_json(self) -> dict: + out: dict[str, Any] = { + "register": self.register, + "write_count": self.write_count, + "read_count": self.read_count, + "first_seq": self.first_seq, + "last_seq": self.last_seq, + } + if self.window: + out["window"] = self.window + if self.offset is not None: + out["offset"] = self.offset + if self.addr is not None: + out["addr"] = self.addr + if self.first_pc is not None: + out["first_pc"] = self.first_pc + if self.last_pc is not None: + out["last_pc"] = self.last_pc + out["values"] = self.values + out["values_truncated"] = self.values_truncated + return out + + +def build_debug_report(trace: SimTrace, symbols: SymbolDB | None) -> dict: + """Group trace observations by firmware function and register.""" + functions: dict[tuple, dict] = {} + order: list[tuple] = [] + unknown_pc = 0 + for obs in trace.observations: + fkey, addr, function = _function_identity(obs, symbols) + if fkey is None: + unknown_pc += 1 + continue + entry = functions.get(fkey) + if entry is None: + entry = { + "function": function, + "addr": addr, + "first_pc": None, + "writes": 0, + "reads": 0, + "registers": {}, + "order": [], + } + functions[fkey] = entry + order.append(fkey) + rkey = _register_identity(obs) + row = entry["registers"].get(rkey) + if row is None: + row = RegisterRow( + function=function, + function_addr=addr, + register=_register_label(obs), + window=obs.window, + offset=obs.offset, + addr=obs.addr, + ) + entry["registers"][rkey] = row + entry["order"].append(rkey) + row.observe(obs) + if obs.pc is not None and entry["first_pc"] is None: + entry["first_pc"] = obs.pc + if obs.kind == "write": + entry["writes"] += 1 + elif obs.kind == "read": + entry["reads"] += 1 + + def sort_key(fkey: tuple): + entry = functions[fkey] + anchor = entry["addr"] if entry["addr"] is not None else entry["first_pc"] + return (anchor is None, anchor or 0, entry["function"]) + + function_rows = [] + for fkey in sorted(order, key=sort_key): + entry = functions[fkey] + register_rows = [entry["registers"][rkey] for rkey in entry["order"]] + register_rows.sort( + key=lambda r: (r.window, r.offset is None, r.offset or 0, r.register) + ) + function_rows.append( + { + "function": entry["function"], + "addr": entry["addr"], + "first_pc": entry["first_pc"], + "writes": entry["writes"], + "reads": entry["reads"], + "registers": [r.to_json() for r in register_rows], + } + ) + + total_registers = sum(len(f["registers"]) for f in function_rows) + return { + "format": DEBUG_REPORT_FORMAT, + "inputs": { + "trace": { + "path": trace.path, + "sha256": trace.sha256, + "format": trace.format, + "events": len(trace.observations), + "source": trace.source, + } + }, + "summary": { + "functions": len(function_rows), + "registers": total_registers, + "writes": sum(f["writes"] for f in function_rows), + "reads": sum(f["reads"] for f in function_rows), + "unknown_pc_events": unknown_pc, + "trace_events": len(trace.observations), + }, + "functions": function_rows, + "limits": [ + "L0/L1 evidence only: the trace records execution and register accesses, not timing.", + "Absence of a write is not proof a register is untouched outside this run's stimulus.", + "Synthetic demo fixture values are invented; real traces must come from the simulator.", + ], + } + + +def write_hits_tsv(report: dict, path: str) -> str: + """Write a ``greeneye_hits.tsv`` compatible with MarkGreenEyeHits.java.""" + os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + fh.write(HITS_HEADER + "\n") + for function in report["functions"]: + addr = function.get("addr") + if addr is None: + addr = function.get("first_pc") + if addr is None: + for register in function["registers"]: + if register.get("first_pc") is not None: + addr = register["first_pc"] + break + if addr is None: + continue + hits = function["writes"] + function["reads"] + annotation = ( + f"gmmu: {function['writes']} write(s), {function['reads']} read(s) " + f"across {len(function['registers'])} register(s)" + ) + fh.write( + f"{addr:#x}\t{hits}\t{function['function']}\t{annotation}\n" + ) + return os.path.abspath(path) + + +def render_debug_markdown(report: dict, hits_path: str, ghidra_command: str) -> str: + summary = report["summary"] + lines = [ + "# GMMU/FBIF per-function register-write report", + "", + f"> Format `{report['format']}`. Development-only static<->dynamic join;", + "> no timing or silicon claim. Fidelity: L0/L1 (docs/06).", + "", + "## Inputs", + "", + f"* trace: `{report['inputs']['trace']['path']}` " + f"({report['inputs']['trace']['events']} events, " + f"format `{report['inputs']['trace']['format']}`, " + f"sha256 `{report['inputs']['trace']['sha256'][:16]}…`)", + "", + "## Summary", + "", + "| functions | registers | writes | reads | unknown-pc events |", + "|---|---|---|---|---|", + f"| {summary['functions']} | {summary['registers']} | {summary['writes']} | " + f"{summary['reads']} | {summary['unknown_pc_events']} |", + "", + "## Per function", + "", + ] + for function in report["functions"]: + addr = f"{function['addr']:#x}" if function["addr"] is not None else "unknown" + lines.append( + f"### `{function['function']}` @ {addr} — " + f"{function['writes']} write(s), {function['reads']} read(s)" + ) + lines.append("") + lines.append("| register | window | offset | writes | reads | first pc | last pc | value sequence |") + lines.append("|---|---|---|---|---|---|---|---|") + for register in function["registers"]: + offset = f"{register['offset']:#x}" if register.get("offset") is not None else "" + first_pc = f"{register['first_pc']:#x}" if register.get("first_pc") is not None else "" + last_pc = f"{register['last_pc']:#x}" if register.get("last_pc") is not None else "" + sequence = ", ".join(f"{v:#x}" for v in register["values"]) + if register.get("values_truncated"): + sequence += ", …" + lines.append( + f"| `{register['register']}` | {register.get('window', '')} | {offset} | " + f"{register['write_count']} | {register['read_count']} | {first_pc} | {last_pc} | " + f"{sequence or ''} |" + ) + lines.append("") + lines += [ + "## Ghidra annotation", + "", + f"Hits file: `{hits_path}`", + "", + "```bash", + ghidra_command, + "```", + "", + "## Limits", + "", + ] + for limit in report["limits"]: + lines.append(f"* {limit}") + lines.append("") + return "\n".join(lines) + + +def run_report(trace_path: str, symbols_path: str | None, out_dir: str) -> dict: + """Run the full trace -> report -> hits-TSV loop into ``out_dir``.""" + trace = load_sim_trace(trace_path) + symbols = load_symbols(symbols_path) + report = build_debug_report(trace, symbols) + os.makedirs(out_dir, exist_ok=True) + report_json = os.path.join(out_dir, "gmmu-debug-report.json") + report_md = os.path.join(out_dir, "gmmu-debug-report.md") + hits_tsv = os.path.join(out_dir, "greeneye_hits.tsv") + with open(report_json, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, sort_keys=True) + fh.write("\n") + write_hits_tsv(report, hits_tsv) + ghidra_command = render_ghidra_command( + "", "", "", os.path.abspath(hits_tsv) + ) + with open(report_md, "w", encoding="utf-8") as fh: + fh.write(render_debug_markdown(report, os.path.abspath(hits_tsv), ghidra_command)) + report["artifacts"] = { + "report_json": os.path.abspath(report_json), + "report_md": os.path.abspath(report_md), + "hits_tsv": os.path.abspath(hits_tsv), + "ghidra_command": ghidra_command, + } + return report + + +DEMO_FUNCTIONS = ( + (0x4001000, "gmmu_map_alloc", 0x120), + (0x4002000, "fbif_setup", 0x80), + (0x4003000, "gmmu_pte_write", 0x60), +) + + +def _fixture_events() -> list[dict]: + raw = [ + (0x4002010, "write", "falcon", 0x600, "FBIF_TRANSCFG0", 4, 0x00000000), + (0x4002020, "write", "falcon", 0x604, "FBIF_TRANSCFG1", 4, 0x00000001), + (0x4002030, "write", "falcon", 0x608, "FBIF_TRANSCFG2", 4, 0x00000002), + (0x4002040, "write", "falcon", 0x624, "FBIF_CTL", 4, 0x00000001), + (0x4002050, "read", "falcon", 0x008, "IRQSTAT", 4, 0x00000000), + (0x4001010, "write", "gmmu", 0x0000, "GMMU_PDB_LO", 4, 0x00400000), + (0x4001020, "write", "gmmu", 0x0004, "GMMU_PDB_HI", 4, 0x00000000), + (0x4001030, "write", "gmmu", 0x0010, "GMMU_MODE", 4, 0x00000007), + (0x4001040, "write", "gmmu", 0x0010, "GMMU_MODE", 4, 0x0000000F), + (0x4001050, "read", "gmmu", 0x0018, "GMMU_STATUS", 4, 0x00000000), + (0x4003010, "write", "gmmu", 0x0200, "PTE_DATA", 8, 0x0000000000040001), + (0x4003020, "write", "gmmu", 0x0208, "PTE_DATA", 8, 0x0000000000040001), + (0x4004010, "write", "falcon", 0x110, "DMATRFBASE", 4, 0x00000000), + ] + return [ + { + "seq": seq, + "vt": 100 + seq, + "pc": pc, + "kind": kind, + "window": window, + "offset": offset, + "name": name, + "size": size, + "value": value, + "handled": True, + } + for seq, (pc, kind, window, offset, name, size, value) in enumerate(raw) + ] + + +def build_fixture(out_dir: str) -> dict: + """Write the synthetic demo fixture (symbols + trace); return the paths. + + Symbols carry sizes so a pc past every function stays unattributed + (``sub_``) instead of being absorbed by the nearest-preceding name. + """ + os.makedirs(out_dir, exist_ok=True) + symbols_path = os.path.join(out_dir, "symbols.json") + symbols_payload = { + "format": "greeneye-symbols/1", + "count": len(DEMO_FUNCTIONS), + "symbols": [ + {"addr": addr, "name": name, "size": size, "source": "fixture"} + for addr, name, size in DEMO_FUNCTIONS + ], + } + with open(symbols_path, "w", encoding="utf-8") as fh: + json.dump(symbols_payload, fh, indent=2, sort_keys=True) + fh.write("\n") + trace_path = os.path.join(out_dir, "trace.json") + payload = { + "format": TRACE_FORMAT, + "source": { + "kind": "synthetic-fixture", + "note": "invented values; proves the join shape only, never a hardware claim", + }, + "events": _fixture_events(), + } + with open(trace_path, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.write("\n") + return { + "symbols": os.path.abspath(symbols_path), + "trace": os.path.abspath(trace_path), + "events": len(payload["events"]), + "functions": len(DEMO_FUNCTIONS), + } + + +def run_demo(out_dir: str) -> dict: + """Build the fixture, run the loop, and check the frozen threshold.""" + fixture = build_fixture(out_dir) + start = time.perf_counter() + report = run_report(fixture["trace"], fixture["symbols"], out_dir) + elapsed = time.perf_counter() - start + threshold = MANUAL_STATIC_BASELINE_SECONDS * WORKFLOW_THRESHOLD_FRACTION + result = { + "format": DEMO_RESULT_FORMAT, + "scenario": "trace -> per-function register report -> greeneye_hits.tsv -> Ghidra-ready", + "fixture": fixture, + "summary": report["summary"], + "measured_seconds": round(elapsed, 4), + "manual_baseline_seconds": MANUAL_STATIC_BASELINE_SECONDS, + "manual_baseline_label": MANUAL_BASELINE_LABEL, + "threshold_fraction": WORKFLOW_THRESHOLD_FRACTION, + "threshold_seconds": round(threshold, 4), + "pass": elapsed <= threshold, + "artifacts": report["artifacts"], + "limits": report["limits"], + } + with open(os.path.join(out_dir, "demo-result.json"), "w", encoding="utf-8") as fh: + json.dump(result, fh, indent=2, sort_keys=True) + fh.write("\n") + print("RESULT_JSON: " + json.dumps(result, sort_keys=True)) + return result + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="gmmu_debug.py", + description="GMMU/FBIF trace -> per-function register-write report -> Ghidra hits TSV.", + ) + sub = parser.add_subparsers(dest="command") + report = sub.add_parser("report", help="build the report from a trace and symbol map") + report.add_argument("--trace", required=True, help="trace file or session directory") + report.add_argument("--symbols", default=None, help="symbol DB/map (JSON or TSV)") + report.add_argument("--out", required=True, help="output directory") + demo = sub.add_parser("demo", help="run the synthetic end-to-end fixture") + demo.add_argument( + "--out", + default=os.path.join(TOOL_ROOT, "harness", "gmmu_debug_fixture"), + help="fixture/report directory (gitignored)", + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if args.command == "report": + try: + report = run_report(args.trace, args.symbols, args.out) + except GmmuInputError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + print( + json.dumps( + { + "format": report["format"], + "summary": report["summary"], + "artifacts": report["artifacts"], + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + if args.command == "demo": + result = run_demo(args.out) + return 0 if result["pass"] else 1 + parser.print_help() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py b/debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py new file mode 100644 index 0000000..c504550 --- /dev/null +++ b/debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py @@ -0,0 +1,585 @@ +#!/usr/bin/env python3 +"""Host-accuracy oracle comparison for GMMU/FBIF register observations. + +Ingests two files and compares them semantically: + +* an **oracle capture** (schema ``greeneye-gmmu-oracle/1``): a list of + ``{name, window, offset, value, source, captured_at}`` items produced by a + human from read-only RM queries or supervised captures; +* a **simulator observation** (G1's ``greeneye-gmmu-fbif-trace/1``, or a + session directory containing one), ingested by + :func:`harness.gmmu_debug.load_sim_trace`. + +The comparison reports per-register value agreement, coverage (oracle items +with no simulator observation), and simulator accesses with no oracle +counterpart, with an explicit fidelity label per row (observed vs modelled +vs unknown). It emits ``gmmu-oracle-report.json`` and ``.md``. + +``--print-capture-hints`` prints (and only prints) the supervised, read-only +commands a human would run to produce an oracle. Physical work is +supervised-only per RUNBOOK P1/P7; an agent must never run those commands. + +No cycle/timing claim is made anywhere. This tool only reads files and +writes reports; it never touches a GPU, module, mdev, guest or network. +""" +from __future__ import annotations + +import argparse +import json +import os +import sys +from dataclasses import dataclass, field +from typing import Any + +TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if TOOL_ROOT not in sys.path: + sys.path.insert(0, TOOL_ROOT) + +from harness.gmmu_debug import ( # noqa: E402 + GmmuInputError, + SimTrace, + TraceObservation, + load_sim_trace, + parse_int, + sha256_file, +) + +#: Schema tag of the human-produced oracle capture. +ORACLE_FORMAT = "greeneye-gmmu-oracle/1" +#: Schema tag of the report this module emits. +REPORT_FORMAT = "greeneye-gmmu-oracle-report/1" + +CAPTURE_HINTS = """\ +================================================================================ +GMMU/FBIF ORACLE CAPTURE HINTS — SUPERVISED, READ-ONLY, HUMAN-RUN ONLY +================================================================================ +This command prints instructions; it executes NOTHING. + +HARD RULES (RUNBOOK P1/P7, SIMULATOR-WORK-BOUNDARY.txt): + * Physical work is supervised-only: a named human present, one cycle at a + time. An agent MUST NOT run any command below. + * Read-only means exactly that: no module load/unload, no mdev create or + claim, no bind/unbind, no guest access, no /sys/fs/pstore, no reboot. + * The RM tools used here allocate only an RM client/device/subdevice handle + pair and issue allowlisted read-only controls (docs/17-RM-HARNESS.md §6). + * A simulator observation is not silicon truth; only a supervised capture + is. Product changes still need physical reproduction. + +STEP 1 — read-only RM inventory (record the session, human-run): + cd open-iov/debug/risc-v-gsp-rm-vm + ./rmharness/harness.sh --session dumps/real-rm-before --name gmmu-oracle \\ + -- rmtool gpu list --format=json + ./rmharness/harness.sh --session dumps/real-rm-before --name gmmu-oracle \\ + -- rmtool engine list --format=json + ./rmharness/harness.sh --session dumps/real-rm-before --name gmmu-oracle \\ + -- rmtool mem stats --format=json + ./rmharness/harness.sh --session dumps/real-rm-before --name gmmu-oracle \\ + -- rmtop --once + # allowlisted read-only control values (never a write control): + ./rmharness/rmtool.sh nv2080 method call --help + +STEP 2 — sysfs/proc/debugfs readbacks (read-only `cat`): + cat /proc/driver/nvidia/gpus/*/information + lspci -vv -s # config space readback + cat /sys/bus/pci/devices//resource # BAR ranges/sizes + # vgsp debugfs (host module; read-only reads): + ls /sys/kernel/debug/vgsp/ + cat /sys/kernel/debug/vgsp/gsp-phys-* # name the exact file per readback + +STEP 3 — optional supervised first-touch capture: + A simulator observation is produced without hardware (safe, agent-allowed): + python3 -m sim.gsp_boot --instructions 2000000 --record dumps/gmmu-sim + A physical first-touch capture is a supervised human step and must not be + scripted by an agent. + +STEP 4 — write the oracle file (human, from the readbacks above): + { + "format": "greeneye-gmmu-oracle/1", + "captures": [ + {"name": "FBIF_CTL", "window": "falcon", "offset": "0x624", + "value": "0x1", "source": "rm-readback", "captured_at": ""}, + {"name": "BOOT_42", "window": "pmc", "offset": "0xa00", + "value": "0x164", "source": "sysfs-readback", "captured_at": ""} + ] + } + Then compare (safe, offline): + python3 harness/gmmu_oracle.py compare \\ + --oracle oracle.json --sim dumps/gmmu-sim --out dumps/gmmu-oracle-report +================================================================================ +""" + + +@dataclass +class OracleItem: + index: int + name: str + window: str + offset: int | None + addr: int | None + value: int | None + source: str + captured_at: str + raw: dict = field(default_factory=dict) + + +@dataclass +class OracleCapture: + path: str + format: str + sha256: str + items: list[OracleItem] = field(default_factory=list) + + +def load_oracle(path: str) -> OracleCapture: + """Load a ``greeneye-gmmu-oracle/1`` capture (object wrapper or bare list). + + A missing ``format`` is tolerated and reported as ``unlabeled-list`` / + ``unlabeled-object``; a different explicit format is a clean error. + """ + if not os.path.isfile(path): + raise GmmuInputError(f"oracle not found: {path}") + digest = sha256_file(path) + try: + with open(path, "r", encoding="utf-8", errors="replace") as fh: + payload = json.load(fh) + except (OSError, json.JSONDecodeError) as exc: + raise GmmuInputError(f"cannot read oracle {path}: {exc}") from exc + if isinstance(payload, list): + fmt = "unlabeled-list" + items_payload: list = payload + elif isinstance(payload, dict): + fmt = str(payload.get("format") or payload.get("schema") or "unlabeled-object") + if fmt not in (ORACLE_FORMAT, "unlabeled-object"): + raise GmmuInputError( + f"{path}: format {fmt!r} is not {ORACLE_FORMAT!r}" + ) + found = None + for key in ("captures", "items", "registers", "observations", "oracle"): + candidate = payload.get(key) + if isinstance(candidate, list): + found = candidate + break + if found is None: + raise GmmuInputError(f"{path}: no capture list (expected 'captures')") + items_payload = found + else: + raise GmmuInputError(f"{path}: oracle must be an object or list") + items: list[OracleItem] = [] + for index, raw in enumerate(items_payload): + if not isinstance(raw, dict): + continue + name = str(raw.get("name") or raw.get("register") or "") + window = str(raw.get("window") or raw.get("block") or "") + offset = parse_int(raw.get("offset", raw.get("reg_offset"))) + addr = parse_int(raw.get("addr", raw.get("address"))) + value = parse_int(raw.get("value")) + if not name: + if offset is not None: + name = f"0x{offset:x}" + elif addr is not None: + name = f"0x{addr:x}" + else: + name = f"item_{index}" + items.append( + OracleItem( + index=index, + name=name, + window=window, + offset=offset, + addr=addr, + value=value, + source=str(raw.get("source") or "unlabelled"), + captured_at=str(raw.get("captured_at") or ""), + raw=raw, + ) + ) + return OracleCapture( + path=os.path.abspath(path), format=fmt, sha256=digest, items=items + ) + + +def _obs_keys(obs: TraceObservation) -> list[tuple]: + keys: list[tuple] = [] + if obs.offset is not None: + if obs.window: + keys.append(("window-offset", obs.window, obs.offset)) + keys.append(("offset", obs.offset)) + if obs.name and obs.window: + keys.append(("window-name", obs.window, obs.name)) + if obs.name: + keys.append(("name", obs.name)) + if obs.addr is not None: + keys.append(("addr", obs.addr)) + return keys + + +def _item_keys(item: OracleItem) -> list[tuple]: + keys: list[tuple] = [] + if item.offset is not None: + if item.window: + keys.append(("window-offset", item.window, item.offset)) + keys.append(("offset", item.offset)) + if item.name and item.window: + keys.append(("window-name", item.window, item.name)) + if item.name: + keys.append(("name", item.name)) + if item.addr is not None: + keys.append(("addr", item.addr)) + return keys + + +def _group_observations(observations: list[TraceObservation]) -> tuple[list[dict], dict]: + """Cluster trace observations into registers; return (groups, key index).""" + groups: list[dict] = [] + index: dict[tuple, int] = {} + for obs in observations: + keys = _obs_keys(obs) + gidx = None + for key in keys: + if key in index: + gidx = index[key] + break + if gidx is None: + gidx = len(groups) + groups.append({"observations": []}) + groups[gidx]["observations"].append(obs) + for key in keys: + index.setdefault(key, gidx) + return groups, index + + +def _group_descriptor(group: dict) -> dict: + observations: list[TraceObservation] = group["observations"] + first = observations[0] + last = observations[-1] + writes = [o for o in observations if o.kind == "write"] + reads = [o for o in observations if o.kind == "read"] + value_obs = writes[-1] if writes else (reads[-1] if reads else last) + out: dict[str, Any] = { + "register": first.name or ( + f"0x{first.offset:x}" if first.offset is not None + else (f"0x{first.addr:x}" if first.addr is not None else "?") + ), + "observations": len(observations), + "writes": len(writes), + "reads": len(reads), + } + if first.window: + out["window"] = first.window + if first.offset is not None: + out["offset"] = first.offset + if first.addr is not None: + out["addr"] = first.addr + if value_obs.value is not None: + out["last_value"] = value_obs.value + out["fidelity"] = "modelled (no oracle counterpart)" + else: + out["fidelity"] = "modelled (value unknown; no oracle counterpart)" + pcs = [o.pc for o in observations if o.pc is not None] + if pcs: + out["first_pc"] = pcs[0] + out["last_pc"] = pcs[-1] + return out + + +def compare(oracle: OracleCapture, trace: SimTrace) -> dict: + """Semantic oracle-vs-simulator comparison; no timing claims.""" + groups, index = _group_observations(trace.observations) + matched_groups: set[int] = set() + rows: list[dict] = [] + unmatched_oracle: list[dict] = [] + for item in oracle.items: + gidx = None + matched_by = "" + for key in _item_keys(item): + if key in index: + gidx = index[key] + matched_by = key[0] + break + if gidx is None: + unmatched_oracle.append( + { + "register": item.name, + "window": item.window, + "offset": item.offset, + "oracle_value": item.value, + "source": item.source, + "captured_at": item.captured_at, + "fidelity": "observed (no simulator counterpart)", + } + ) + rows.append( + { + "register": item.name, + "window": item.window, + "offset": item.offset, + "oracle_value": item.value, + "sim_value": None, + "status": "oracle-only", + "matched_by": "", + "fidelity": "observed (no simulator counterpart)", + "source": item.source, + "captured_at": item.captured_at, + } + ) + continue + matched_groups.add(gidx) + group = groups[gidx] + observations: list[TraceObservation] = group["observations"] + writes = [o for o in observations if o.kind == "write"] + reads = [o for o in observations if o.kind == "read"] + value_obs = writes[-1] if writes else (reads[-1] if reads else observations[-1]) + sim_value = value_obs.value + if item.value is None or sim_value is None: + status = "no-value" + fidelity = "observed vs modelled (value unknown)" + elif sim_value == item.value: + status = "agree" + fidelity = "observed vs modelled" + else: + status = "mismatch" + fidelity = "observed vs modelled" + value_ever = any(o.value == item.value for o in observations if o.value is not None) + pcs = [o.pc for o in observations if o.pc is not None] + row = { + "register": item.name, + "window": item.window, + "offset": item.offset, + "oracle_value": item.value, + "sim_value": sim_value, + "status": status, + "matched_by": matched_by, + "fidelity": fidelity, + "source": item.source, + "captured_at": item.captured_at, + "sim_observations": len(observations), + "sim_writes": len(writes), + "sim_reads": len(reads), + "sim_value_ever_agreed": value_ever, + "sim_first_pc": pcs[0] if pcs else None, + "sim_last_pc": pcs[-1] if pcs else None, + } + rows.append(row) + + unmatched_simulator = [ + _group_descriptor(group) + for gidx, group in enumerate(groups) + if gidx not in matched_groups + ] + + agreed = sum(1 for row in rows if row["status"] == "agree") + mismatched = sum(1 for row in rows if row["status"] == "mismatch") + no_value = sum(1 for row in rows if row["status"] == "no-value") + compared = agreed + mismatched + no_value + oracle_items = len(oracle.items) + denominator = agreed + mismatched + agreement_pct = round(100.0 * agreed / denominator, 2) if denominator else None + coverage_pct = round(100.0 * compared / oracle_items, 2) if oracle_items else None + sim_registers = len(groups) + simulator_match_pct = ( + round(100.0 * len(matched_groups) / sim_registers, 2) if sim_registers else None + ) + return { + "format": REPORT_FORMAT, + "inputs": { + "oracle": { + "path": oracle.path, + "format": oracle.format, + "sha256": oracle.sha256, + "items": oracle_items, + }, + "simulator": { + "path": trace.path, + "format": trace.format, + "sha256": trace.sha256, + "observations": len(trace.observations), + "registers": sim_registers, + "source": trace.source, + }, + }, + "summary": { + "oracle_items": oracle_items, + "simulator_registers": sim_registers, + "simulator_observations": len(trace.observations), + "compared": compared, + "agreed": agreed, + "mismatched": mismatched, + "no_value": no_value, + "oracle_only": len(unmatched_oracle), + "simulator_only": len(unmatched_simulator), + "agreement_pct": agreement_pct, + "coverage_pct": coverage_pct, + "simulator_match_pct": simulator_match_pct, + "claim": ( + "per-register value agreement on the oracle/simulator intersection; " + "not a timing, silicon or full-coverage claim" + ), + }, + "rows": rows, + "unmatched_oracle": unmatched_oracle, + "unmatched_simulator": unmatched_simulator, + "fidelity": { + "oracle": "observed (human read-only capture; supervised for physical work)", + "simulator": "modelled (device model / recorded trace)", + "levels": "L0/L1 only; L2 timing and L3 device environment not claimed (docs/06)", + }, + "limits": [ + "No live oracle capture was taken by this tool; it only ingests a file a human produced.", + "Agreement is per-register on the matched intersection, never a global accuracy percentage.", + "An oracle-only register means the simulator did not touch it under this stimulus; it is not proof of a device-model gap.", + "A simulator-only register means no oracle counterpart exists yet; it is not evidence of a wrong value.", + "Values are point observations, not timing or ordering claims.", + ], + } + + +def render_oracle_markdown(report: dict) -> str: + summary = report["summary"] + lines = [ + "# GMMU/FBIF oracle vs simulator report", + "", + f"> Format `{report['format']}`. Fidelity: {report['fidelity']['levels']}.", + "> No cycle/timing claim. Underclaim every number (docs/06, docs/44).", + "", + "## Inputs", + "", + f"* oracle: `{report['inputs']['oracle']['path']}` " + f"(format `{report['inputs']['oracle']['format']}`, " + f"{report['inputs']['oracle']['items']} items, " + f"sha256 `{report['inputs']['oracle']['sha256'][:16]}…`)", + f"* simulator: `{report['inputs']['simulator']['path']}` " + f"(format `{report['inputs']['simulator']['format']}`, " + f"{report['inputs']['simulator']['registers']} registers / " + f"{report['inputs']['simulator']['observations']} observations, " + f"sha256 `{report['inputs']['simulator']['sha256'][:16]}…`)", + "", + "## Summary", + "", + "| oracle items | compared | agreed | mismatched | no-value | oracle-only | sim-only | agreement % | coverage % |", + "|---|---|---|---|---|---|---|---|---|", + f"| {summary['oracle_items']} | {summary['compared']} | {summary['agreed']} | " + f"{summary['mismatched']} | {summary['no_value']} | {summary['oracle_only']} | " + f"{summary['simulator_only']} | " + f"{summary['agreement_pct'] if summary['agreement_pct'] is not None else 'n/a'} | " + f"{summary['coverage_pct'] if summary['coverage_pct'] is not None else 'n/a'} |", + "", + f"* {summary['claim']}", + "", + "## Per-register rows", + "", + "| register | window | offset | oracle | simulator | status | fidelity | matched by |", + "|---|---|---|---|---|---|---|---|", + ] + for row in report["rows"]: + offset = f"{row['offset']:#x}" if row.get("offset") is not None else "" + oracle_value = f"{row['oracle_value']:#x}" if row.get("oracle_value") is not None else "n/a" + sim_value = f"{row['sim_value']:#x}" if row.get("sim_value") is not None else "n/a" + lines.append( + f"| `{row['register']}` | {row.get('window', '')} | {offset} | {oracle_value} | " + f"{sim_value} | {row['status']} | {row['fidelity']} | {row.get('matched_by', '')} |" + ) + lines += ["", "## Oracle items with no simulator observation", ""] + if report["unmatched_oracle"]: + for item in report["unmatched_oracle"]: + offset = f"{item['offset']:#x}" if item.get("offset") is not None else "?" + value = f"{item['oracle_value']:#x}" if item.get("oracle_value") is not None else "n/a" + lines.append( + f"* `{item['register']}` ({item.get('window', '')} @ {offset}) = {value} " + f"[{item['source']}]" + ) + else: + lines.append("* none") + lines += ["", "## Simulator registers with no oracle counterpart", ""] + if report["unmatched_simulator"]: + for item in report["unmatched_simulator"]: + offset = f"{item['offset']:#x}" if item.get("offset") is not None else "?" + value = f"{item['last_value']:#x}" if item.get("last_value") is not None else "n/a" + lines.append( + f"* `{item['register']}` ({item.get('window', '')} @ {offset}) last={value} " + f"[{item['writes']}w/{item['reads']}r]" + ) + else: + lines.append("* none") + lines += ["", "## Limits", ""] + for limit in report["limits"]: + lines.append(f"* {limit}") + lines.append("") + return "\n".join(lines) + + +def run_compare(oracle_path: str, sim_path: str, out_dir: str) -> dict: + """Compare an oracle capture with a simulator trace; write JSON + MD.""" + oracle = load_oracle(oracle_path) + trace = load_sim_trace(sim_path) + report = compare(oracle, trace) + os.makedirs(out_dir, exist_ok=True) + report_json = os.path.join(out_dir, "gmmu-oracle-report.json") + report_md = os.path.join(out_dir, "gmmu-oracle-report.md") + with open(report_json, "w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, sort_keys=True) + fh.write("\n") + with open(report_md, "w", encoding="utf-8") as fh: + fh.write(render_oracle_markdown(report)) + report["artifacts"] = { + "report_json": os.path.abspath(report_json), + "report_md": os.path.abspath(report_md), + } + return report + + +def print_capture_hints(out=None) -> None: + """Print the supervised, read-only capture instructions; execute nothing.""" + print(CAPTURE_HINTS, file=out or sys.stdout) + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="gmmu_oracle.py", + description=( + "Compare a human oracle capture with a simulator GMMU/FBIF trace. " + "Offline only; never executes physical commands." + ), + ) + parser.add_argument( + "--print-capture-hints", + action="store_true", + help="print the supervised read-only commands a human would run (executes nothing)", + ) + sub = parser.add_subparsers(dest="command") + compare_cmd = sub.add_parser("compare", help="compare oracle vs simulator observation") + compare_cmd.add_argument("--oracle", required=True, help="greeneye-gmmu-oracle/1 file") + compare_cmd.add_argument("--sim", required=True, help="trace file or session directory") + compare_cmd.add_argument("--out", required=True, help="output directory") + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + if args.print_capture_hints: + print_capture_hints() + return 0 + if args.command == "compare": + try: + report = run_compare(args.oracle, args.sim, args.out) + except GmmuInputError as exc: + print(f"error: {exc}", file=sys.stderr) + return 2 + print( + json.dumps( + { + "format": report["format"], + "summary": report["summary"], + "artifacts": report["artifacts"], + }, + indent=2, + sort_keys=True, + ) + ) + return 0 + parser.print_help() + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) 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 index 4df3593..f5ccc4e 100644 --- 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 @@ -6,7 +6,7 @@ "detail": { "analyze_chars": 444, "analyze_session": "harness/qemu_line/sessions/native-1", - "compare_chars": 526, + "compare_chars": 394, "compare_headers": [ "Breakpoint profile delta (A vs B)", "Watchpoint delta", @@ -43,9 +43,9 @@ "dir": "harness/qemu_line/sessions/features-inject", "expected": 48814, "hashes": { - "events.jsonl": "08ab0874cfad71e0e4565b569d9b0ea5251bf158eaab3cdf17798182e4c66a95", - "session.json": "f34b677be0b0b453cdade4487118e059c3dfb5a1010d4e6c2fc8b37a867fb25e", - "summary.md": "59a1aa4f2b616d5266d565c4df4b0257d11aab95b09cec80f380e9aca8bfc22f" + "events.jsonl": "ce9c81fb9663813c5eefbacfb94af69dc4050ef563fd62e302869942ae207be1", + "session.json": "bb203d2e551f906cc24500c4b3f2d8263e1218267ce6aa809d99dd9c964caae2", + "summary.md": "a6a087528c23227628faa0e3ad3a646b66bf06c06489d26fda4e82091a7bb2ac" }, "ping_result": 48814, "slot": 6 @@ -79,9 +79,9 @@ "watch_hit": 1 }, "hashes": { - "events.jsonl": "a6328cb00287bdf678d9b62d496929fc59190a08af3910b059c840c6bc98462e", - "session.json": "2372eea627f31d11f114019759d8d6dc665683bbc88e834f158f56b16fa5c0d4", - "summary.md": "6dfca9e61a4f23374b90dbef995d86771130fcf4c2b61dc40aac6d379d4b8706" + "events.jsonl": "4ed32100308c465d999c78b71a232ab096e3cfcbb82561f46c5046eb34a0f462", + "session.json": "6c8c45b9b4be70b934c3a43b4bd00df4a1e875baafe7c8988b13d5a18dbc4813", + "summary.md": "9e7af234005461bd8b4395498fa3b58a3da5e589184c51e34b399f8cba37d50b" } }, "status": "pass" @@ -99,8 +99,8 @@ "dir": "harness/qemu_line/sessions/features-trace", "events": 4001, "hashes": { - "events.jsonl": "36b80ed88205daa8e1bd58536eb274ed4849c3d4b1a05daa0f2c44382c7b764a", - "session.json": "a09ec38202c291a5b7a3719434ea785725e291cba2dc877d94cd0caf7fcd1a4e", + "events.jsonl": "649ddd072446b2184427b6ea18a7e6bad6c118a0aa63c77bfff591cf9165114d", + "session.json": "c6abc0bbae7539b0c5d6685e700a6105c55d64821f42451b0f8ea386e0568020", "summary.md": "10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44" }, "max_events": 4000 @@ -129,7 +129,7 @@ "native": { "backend": "native", "compare": { - "chars": 526, + "chars": 394, "headers": [ "Breakpoint profile delta (A vs B)", "Watchpoint delta", @@ -139,70 +139,70 @@ }, "device_model_used": true, "identical": true, - "instructions_budget": 200000, - "pair_wall_s": 0.3093, + "instructions_budget": 2000000, + "pair_wall_s": 1.2722, "profile": "sim/profiles/tu104-570.144.json", "ran": true, "reason": "ok", "repeat": 2, - "rung": "B2", + "rung": "B3", "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", + "cycles": 871904, + "device_accesses": 15, + "device_state_sha256": "5b3cc7e4af933e01057cc6ab7d858ecbb11f76f2d10f3f8963a7fc974e239323", "device_unhandled": 0, "first_unknown_mmio": null, - "instructions": 14682, + "instructions": 474043, "milestones": [ "bootargs_consumer", "relocation_copy" ], "mmio_unknown": 0, - "rung": "B2", + "rung": "B3", "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" + "events.jsonl": "0501ee6c127c8acfeb06c6b4c36aecacd791e872e05bf341883df22054d34315", + "session.json": "811b076cc5d4555062ce17474f302e64d6cb29e7af9e5c05cb7d7f67825bd315", + "summary.md": "9ad8c39a95078b9aca1cf1ca0b83a1f7ce22b0dd33e679aaa5f8eb5068a97b75" }, "stop": { - "pc": 67168780, - "pc_fw": 67168780, - "reason": "fault" + "pc": 67207224, + "pc_fw": 67207224, + "reason": "ecall" }, - "wall_s": 0.1561 + "wall_s": 0.6548 }, { "cycle_model": "rv64imac-modeled/1", - "cycles": 27294, - "device_accesses": 1, - "device_state_sha256": "3fa264b9f81081e8455a9cd26c0c2a721e3ac272e1079105a0eb191b1497f83d", + "cycles": 871904, + "device_accesses": 15, + "device_state_sha256": "5b3cc7e4af933e01057cc6ab7d858ecbb11f76f2d10f3f8963a7fc974e239323", "device_unhandled": 0, "first_unknown_mmio": null, - "instructions": 14682, + "instructions": 474043, "milestones": [ "bootargs_consumer", "relocation_copy" ], "mmio_unknown": 0, - "rung": "B2", + "rung": "B3", "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" + "events.jsonl": "77b6fb63a1b0be8d2f5eef9e786795849c1ad74acab96cc8cfb7a37790bd804a", + "session.json": "626646f2bc8f8200a339d2f93a23297eaaf39160fb41de9a97867de7ad6dc131", + "summary.md": "1ad09477c1ba2611db4a6ff9e47a38441395fe3b198b407b659a8a8e4b28ab95" }, "stop": { - "pc": 67168780, - "pc_fw": 67168780, - "reason": "fault" + "pc": 67207224, + "pc_fw": 67207224, + "reason": "ecall" }, - "wall_s": 0.1524 + "wall_s": 0.6167 } ] }, @@ -235,12 +235,12 @@ "rung": "pre-B0", "session": "harness/qemu_line/sessions/qemu-probe", "session_hashes": { - "events.jsonl": "cdba69c63e306f4c3de220b171e2ea7c1de4bd4abf18cdba1fb51bc681d5d172", - "session.json": "7e3f0f8b24c0403d114e59f68ed1b923e44abbfbf19f4865bd566a1b631c64ac", + "events.jsonl": "0f59d5d26b51be185e1a38b4540dee06303f8a2c0661519d9e1a32d2e6b10062", + "session.json": "f3d1eaf6dab15c79b0f4c4019f431a8032dc2b014d3acb17f63407326eca5e26", "summary.md": "d141fa06d3e2d0603f3b99ad7603758e1fc14627aed74c6fe8a6ad22c56eb14b" }, "stopped_pc": 0, - "wall_s": 0.9519 + "wall_s": 0.9578 }, "rerun_when_s1_lands": [ "python3 harness/sim_qemu_line.py", 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 index 4e546b3..550f32a 100644 --- 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 @@ -11,9 +11,9 @@ | 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 probe | True | pre-B0 | - | - | fault | 0.9578 | `harness/qemu_line/sessions/qemu-probe` | +| native run 1 | True | B3 | 474043 | 871904 | ecall@0x4018038 | 0.6548 | `harness/qemu_line/sessions/native-1` | +| native run 2 | True | B3 | 474043 | 871904 | ecall@0x4018038 | 0.6167 | `harness/qemu_line/sessions/native-2` | ### QEMU first fault @@ -43,38 +43,38 @@ Thresholds (frozen 2026-10-03): native >= QEMU + 1 rung(s); 2 identical runs; pa | session | file | sha256 | |---|---|---| -| qemu-probe | events.jsonl | `cdba69c63e306f4c3de220b171e2ea7c1de4bd4abf18cdba1fb51bc681d5d172` | -| qemu-probe | session.json | `7e3f0f8b24c0403d114e59f68ed1b923e44abbfbf19f4865bd566a1b631c64ac` | +| qemu-probe | events.jsonl | `0f59d5d26b51be185e1a38b4540dee06303f8a2c0661519d9e1a32d2e6b10062` | +| qemu-probe | session.json | `f3d1eaf6dab15c79b0f4c4019f431a8032dc2b014d3acb17f63407326eca5e26` | | 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` | +| native-1 | events.jsonl | `0501ee6c127c8acfeb06c6b4c36aecacd791e872e05bf341883df22054d34315` | +| native-1 | session.json | `811b076cc5d4555062ce17474f302e64d6cb29e7af9e5c05cb7d7f67825bd315` | +| native-1 | summary.md | `9ad8c39a95078b9aca1cf1ca0b83a1f7ce22b0dd33e679aaa5f8eb5068a97b75` | +| native-2 | events.jsonl | `77b6fb63a1b0be8d2f5eef9e786795849c1ad74acab96cc8cfb7a37790bd804a` | +| native-2 | session.json | `626646f2bc8f8200a339d2f93a23297eaaf39160fb41de9a97867de7ad6dc131` | +| native-2 | summary.md | `1ad09477c1ba2611db4a6ff9e47a38441395fe3b198b407b659a8a8e4b28ab95` | +| sessions | events.jsonl | `4ed32100308c465d999c78b71a232ab096e3cfcbb82561f46c5046eb34a0f462` | +| sessions | session.json | `6c8c45b9b4be70b934c3a43b4bd00df4a1e875baafe7c8988b13d5a18dbc4813` | +| sessions | summary.md | `9e7af234005461bd8b4395498fa3b58a3da5e589184c51e34b399f8cba37d50b` | +| trace | events.jsonl | `649ddd072446b2184427b6ea18a7e6bad6c118a0aa63c77bfff591cf9165114d` | +| trace | session.json | `c6abc0bbae7539b0c5d6685e700a6105c55d64821f42451b0f8ea386e0568020` | | trace | summary.md | `10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44` | -| injection | events.jsonl | `08ab0874cfad71e0e4565b569d9b0ea5251bf158eaab3cdf17798182e4c66a95` | -| injection | session.json | `f34b677be0b0b453cdade4487118e059c3dfb5a1010d4e6c2fc8b37a867fb25e` | -| injection | summary.md | `59a1aa4f2b616d5266d565c4df4b0257d11aab95b09cec80f380e9aca8bfc22f` | +| injection | events.jsonl | `ce9c81fb9663813c5eefbacfb94af69dc4050ef563fd62e302869942ae207be1` | +| injection | session.json | `bb203d2e551f906cc24500c4b3f2d8263e1218267ce6aa809d99dd9c964caae2` | +| injection | summary.md | `a6a087528c23227628faa0e3ad3a646b66bf06c06489d26fda4e82091a7bb2ac` | ## 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']} | +| 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': 394, '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'}} | +| 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': 'bb203d2e551f906cc24500c4b3f2d8263e1218267ce6aa809d99dd9c964caae2', 'events.jsonl': 'ce9c81fb9663813c5eefbacfb94af69dc4050ef563fd62e302869942ae207be1', 'summary.md': 'a6a087528c23227628faa0e3ad3a646b66bf06c06489d26fda4e82091a7bb2ac'}} | | 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'}} | +| 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': '6c8c45b9b4be70b934c3a43b4bd00df4a1e875baafe7c8988b13d5a18dbc4813', 'events.jsonl': '4ed32100308c465d999c78b71a232ab096e3cfcbb82561f46c5046eb34a0f462', 'summary.md': '9e7af234005461bd8b4395498fa3b58a3da5e589184c51e34b399f8cba37d50b'}} | | 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'}} | +| 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': 'c6abc0bbae7539b0c5d6685e700a6105c55d64821f42451b0f8ea386e0568020', 'events.jsonl': '649ddd072446b2184427b6ea18a7e6bad6c118a0aa63c77bfff591cf9165114d', 'summary.md': '10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44'}} | | watch | pass | {'result_field_stores': 1, 'pcs': [2147484066]} | ## S1 integration -- cgit v1.2.3