summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm/harness
diff options
context:
space:
mode:
authorArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-04 18:42:36 -0400
committerArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-04 18:42:36 -0400
commit1f2073d4fbb1d412d9bbf373312e3c51b83db92a (patch)
treea661537d7ec9510324e0a40a5a65e4b8c853f01b /debug/risc-v-gsp-rm-vm/harness
parentc1adf2727983f5cace8ca0f5d1119ac2256c348e (diff)
downloadopen-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')
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py924
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py585
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json80
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md46
4 files changed, 1572 insertions, 63 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())
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 <bdf> # config space readback
+ cat /sys/bus/pci/devices/<bdf>/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": "<date>"},
+ {"name": "BOOT_42", "window": "pmc", "offset": "0xa00",
+ "value": "0x164", "source": "sysfs-readback", "captured_at": "<date>"}
+ ]
+ }
+ 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