diff options
| author | Arthur Hanson Rasmusson <arthur@vgpu.io> | 2026-10-04 18:42:36 -0400 |
|---|---|---|
| committer | Arthur Hanson Rasmusson <arthur@vgpu.io> | 2026-10-04 18:42:36 -0400 |
| commit | 1f2073d4fbb1d412d9bbf373312e3c51b83db92a (patch) | |
| tree | a661537d7ec9510324e0a40a5a65e4b8c853f01b /debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py | |
| parent | c1adf2727983f5cace8ca0f5d1119ac2256c348e (diff) | |
| download | open-iov-1f2073d4fbb1d412d9bbf373312e3c51b83db92a.tar.gz open-iov-1f2073d4fbb1d412d9bbf373312e3c51b83db92a.zip | |
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.
Diffstat (limited to 'debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py')
| -rw-r--r-- | debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py | 924 |
1 files changed, 924 insertions, 0 deletions
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 ``addr<TAB>name[...]`` 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``: ``addr<TAB>hits<TAB>symbol<TAB>annotation``, 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 (``addr<TAB>name``).""" + 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_<pc>``. + """ + 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( + "<ghidra-project-dir>", "<project>", "<program>", 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_<pc>``) 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()) |
