summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm/harness/version_diff.py
diff options
context:
space:
mode:
Diffstat (limited to 'debug/risc-v-gsp-rm-vm/harness/version_diff.py')
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/version_diff.py1509
1 files changed, 1509 insertions, 0 deletions
diff --git a/debug/risc-v-gsp-rm-vm/harness/version_diff.py b/debug/risc-v-gsp-rm-vm/harness/version_diff.py
new file mode 100644
index 0000000..a95f244
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/version_diff.py
@@ -0,0 +1,1509 @@
+#!/usr/bin/env python3
+"""Version diff: old vs new firmware targets (roadmap H4.1, planned docs/21).
+
+Compares two firmware images (a built ELF or a raw ``.fwimage``) section by
+section; every section degrades to ``status: skipped`` with a reason instead
+of aborting the report:
+
+1. image/shape -- entry, PT_LOAD layout, ELF section sizes, hashes, span;
+2. symbol inventory -- ELF ``.symtab`` plus Ghidra names/index/exports TSVs
+ (including the 3-column ``ExportForGreenEye`` form) and
+ green-eye JSON DBs; added/removed/moved/resized with
+ the anonymous-shift matching discipline of
+ ``userspace/cuda/tools/diff_corpus.py``;
+3. decompiled hints -- a corpus diff over two ``decompiled_index.tsv`` files,
+ an attached corpus report, or an explicit "not
+ supplied";
+4. probe -- bounded QEMU first-touch probe of both targets with
+ ``parse_qemu_faults`` and firmware-vaddr translation of
+ the epc and access addresses;
+5. behavior -- the shared demo scenario (or a ``--stimulus`` script)
+ under QEMU, ``analyze.compare_sessions`` plus
+ hit/watch/write deltas and the docs/06 M1-M6 skeleton.
+
+Everything is local and stdlib-only: nothing is downloaded. The RM 610 gate
+(docs/23) is enforced before any target file is opened.
+
+Run from the tool root:
+
+ python3 harness/version_diff.py --old old.elf --new new.elf --out dumps/vdiff
+"""
+from __future__ import annotations
+
+import argparse
+import contextlib
+import os
+import re
+import shutil
+import statistics
+import struct
+import sys
+import tempfile
+from collections import defaultdict
+from dataclasses import dataclass
+from pathlib import Path
+
+try:
+ from .common import (
+ LOAD_ADDR,
+ MAX_FLAT_BYTES,
+ MAX_LOG_BYTES,
+ SymbolDB,
+ counter_delta,
+ ensure_dir,
+ memory_mb_for,
+ qemu_available,
+ read_log_window,
+ sha256_file,
+ write_json,
+ write_text,
+ )
+ from .portability_matrix import (
+ CTRL_RPC_COUNT,
+ METRIC_DEFS,
+ NO_REFERENCE,
+ install_scenario,
+ metric_rows,
+ profile_from_events,
+ read_ctrl,
+ )
+except ImportError: # direct script execution (python3 harness/version_diff.py)
+ from common import ( # type: ignore[no-redef]
+ LOAD_ADDR,
+ MAX_FLAT_BYTES,
+ MAX_LOG_BYTES,
+ SymbolDB,
+ counter_delta,
+ ensure_dir,
+ memory_mb_for,
+ qemu_available,
+ read_log_window,
+ sha256_file,
+ write_json,
+ write_text,
+ )
+ from portability_matrix import ( # type: ignore[no-redef]
+ CTRL_RPC_COUNT,
+ METRIC_DEFS,
+ NO_REFERENCE,
+ install_scenario,
+ metric_rows,
+ profile_from_events,
+ read_ctrl,
+ )
+
+from greeneye.analyze import compare_sessions, parse_qemu_faults # noqa: E402
+from greeneye.elf import EM_RISCV, FirmwareImage, Segment, load_elf, write_flat_kernel # noqa: E402
+from greeneye.errors import GreenEyeError # noqa: E402
+from greeneye.record import load_events # noqa: E402
+from greeneye.rsp import RspClient # noqa: E402
+from greeneye.session import Session # noqa: E402
+from greeneye.symbols import Symbol # noqa: E402
+from greeneye.target import QemuTarget # noqa: E402
+
+FORMAT = "greeneye-version-diff/1"
+RM610_DOC = "docs/23-RM610-READINESS.md"
+RM610_TOKEN = "RM610-APPROVED"
+RM610_ENV = "GREENEYE_RM610_APPROVED"
+RM610_RE = re.compile(r"rm[\s._-]*610", re.IGNORECASE)
+PT_LOAD = 1
+
+
+class Rm610GateError(GreenEyeError):
+ """A target/release carries an RM 610 label without explicit approval."""
+
+
+def is_rm610_label(value: str | None) -> bool:
+ return bool(value) and RM610_RE.search(str(value)) is not None
+
+
+def rm610_approved(token: str | None = None, environ: dict | None = None) -> bool:
+ """Explicit approval is ``--approval-token RM610-APPROVED`` or the env flag."""
+ env = os.environ if environ is None else environ
+ token_ok = (token or "").strip() == RM610_TOKEN
+ env_ok = str(env.get(RM610_ENV, "")).strip().lower() in ("1", "true", "yes", "approved")
+ return token_ok or env_ok
+
+
+def check_rm610_gate(
+ labels: list[str] | tuple[str, ...],
+ *,
+ approval_token: str | None = None,
+ environ: dict | None = None,
+) -> None:
+ """Refuse RM 610 labels before anything is read, opened or downloaded."""
+ blocked = sorted({str(label) for label in labels if is_rm610_label(label)})
+ if not blocked:
+ return
+ if rm610_approved(approval_token, environ):
+ return
+ raise Rm610GateError(
+ "target/release label(s) " + ", ".join(repr(label) for label in blocked) + " indicate RM 610. "
+ f"Refused without explicit approval: pass --approval-token {RM610_TOKEN} or set "
+ f"{RM610_ENV}=1. See {RM610_DOC}. Nothing was downloaded and no target file was opened."
+ )
+
+
+# ---------------------------------------------------------------------------
+# target loading (ELF or raw)
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class LoadedTarget:
+ path: str
+ name: str
+ kind: str # elf | raw | unsupported | missing
+ image: FirmwareImage | None = None
+ sha256: str = ""
+ size: int = 0
+ entry: int = 0
+ bias: int = 0
+ reason: str | None = None
+
+ @property
+ def ok(self) -> bool:
+ return self.kind in ("elf", "raw")
+
+
+def _read_bytes(path: str) -> bytes:
+ with open(path, "rb") as fh:
+ return fh.read()
+
+
+def load_any_target(path: str) -> LoadedTarget:
+ """Read a target as RISC-V ELF or as a raw relocated image (no download)."""
+ name = Path(path).stem
+ if not os.path.isfile(path):
+ return LoadedTarget(path=path, name=name, kind="missing", reason="file not found")
+ size = os.path.getsize(path)
+ sha = sha256_file(path)
+ with open(path, "rb") as fh:
+ magic = fh.read(4)
+ if magic == b"\x7fELF":
+ try:
+ image = load_elf(path)
+ except Exception as exc: # malformed ELF -> skipped, never fatal
+ return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=size,
+ reason=f"{type(exc).__name__}: {exc}")
+ if not image.is_riscv:
+ return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=size,
+ reason=f"not a RISC-V ELF (e_machine={image.e_machine})")
+ span = image.max_vaddr - image.min_vaddr
+ if span > MAX_FLAT_BYTES:
+ return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=size,
+ reason=f"image span {span:#x} exceeds the {MAX_FLAT_BYTES:#x} harness cap")
+ return LoadedTarget(path=path, name=name, kind="elf", image=image, sha256=sha, size=size,
+ entry=image.entry, bias=LOAD_ADDR - image.min_vaddr)
+ if size == 0:
+ return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=0,
+ reason="empty raw image")
+ if size > MAX_FLAT_BYTES:
+ return LoadedTarget(path=path, name=name, kind="unsupported", sha256=sha, size=size,
+ reason=f"raw image {size:#x} exceeds the {MAX_FLAT_BYTES:#x} harness cap")
+ data = _read_bytes(path)
+ segment = Segment(p_type=PT_LOAD, p_flags=0x7, p_offset=0, p_vaddr=0, p_paddr=0,
+ p_filesz=len(data), p_memsz=len(data), p_align=0x1000)
+ image = FirmwareImage(path=path, is64=True, e_machine=EM_RISCV, entry=0, segments=[segment],
+ symbols=[], _blob=data)
+ return LoadedTarget(path=path, name=name, kind="raw", image=image, sha256=sha, size=size,
+ entry=0, bias=LOAD_ADDR)
+
+
+# ---------------------------------------------------------------------------
+# 1. image / shape
+# ---------------------------------------------------------------------------
+
+
+def _cstr(blob: bytes, offset: int) -> str:
+ if offset >= len(blob):
+ return ""
+ end = blob.find(b"\x00", offset)
+ if end == -1:
+ end = len(blob)
+ return blob[offset:end].decode("utf-8", "replace")
+
+
+def elf_sections(data: bytes) -> list[dict]:
+ """Section name/type/addr/size table (minimal reader; [] when unusable)."""
+ if len(data) < 64 or data[:4] != b"\x7fELF" or data[4] not in (1, 2):
+ return []
+ is64 = data[4] == 2
+ try:
+ if is64:
+ sh_off = struct.unpack_from("<Q", data, 40)[0]
+ shentsize, shnum, shstrndx = struct.unpack_from("<HHH", data, 58)
+ sh_fmt = "<IIQQQQIIQQ"
+ else:
+ sh_off = struct.unpack_from("<I", data, 32)[0]
+ shentsize, shnum, shstrndx = struct.unpack_from("<HHH", data, 46)
+ sh_fmt = "<IIIIIIIIII"
+ except struct.error:
+ return []
+ sh_size = struct.calcsize(sh_fmt)
+ if not sh_off or sh_off + sh_size > len(data):
+ return []
+ shstr = b""
+ if 0 <= shstrndx < shnum:
+ base = sh_off + shstrndx * shentsize
+ if base + sh_size <= len(data):
+ sh = struct.unpack_from(sh_fmt, data, base)
+ shstr = data[sh[4] : sh[4] + sh[5]]
+ sections: list[dict] = []
+ for index in range(shnum):
+ base = sh_off + index * shentsize
+ if base + sh_size > len(data):
+ break
+ sh = struct.unpack_from(sh_fmt, data, base)
+ sections.append(
+ {
+ "name": _cstr(shstr, sh[0]) or f"<section-{index}>",
+ "type": sh[1],
+ "addr": sh[3],
+ "offset": sh[4],
+ "size": sh[5],
+ }
+ )
+ return sections
+
+
+def shape_payload(loaded: LoadedTarget) -> dict:
+ payload = {
+ "kind": loaded.kind,
+ "sha256": loaded.sha256,
+ "size": loaded.size,
+ "reason": loaded.reason,
+ }
+ if not loaded.ok or loaded.image is None:
+ return payload
+ image = loaded.image
+ payload.update(
+ {
+ "entry": loaded.entry,
+ "bias": loaded.bias,
+ "min_vaddr": image.min_vaddr,
+ "max_vaddr": image.max_vaddr,
+ "span": image.max_vaddr - image.min_vaddr,
+ "loads": [
+ {
+ "index": index,
+ "vaddr": seg.p_vaddr,
+ "paddr": seg.p_paddr,
+ "filesz": seg.p_filesz,
+ "memsz": seg.p_memsz,
+ "flags": seg.p_flags,
+ "align": seg.p_align,
+ }
+ for index, seg in enumerate(image.load_segments)
+ ],
+ }
+ )
+ if loaded.kind == "elf":
+ payload["sections"] = elf_sections(image.read())
+ else:
+ payload["sections"] = []
+ payload["note"] = "raw image: one implicit load at vaddr 0; no ELF section table"
+ return payload
+
+
+def _scalar_delta(a, b, name: str) -> dict:
+ return {"a": a, "b": b, "delta": b - a if isinstance(a, int) and isinstance(b, int) else None,
+ "changed": a != b}
+
+
+def _cap(rows: list, max_rows: int) -> list:
+ return rows[:max_rows]
+
+
+def image_diff(old: LoadedTarget, new: LoadedTarget, max_rows: int) -> dict:
+ a, b = shape_payload(old), shape_payload(new)
+ result = {"status": "skipped", "reason": None, "a": a, "b": b, "same": None, "deltas": None}
+ if not old.ok or not new.ok:
+ result["reason"] = "; ".join(
+ f"{side}: {loaded.reason or loaded.kind}" for side, loaded in (("old", old), ("new", new)) if not loaded.ok
+ )
+ return result
+
+ loads_a, loads_b = a["loads"], b["loads"]
+ load_added = [payload for index, payload in enumerate(loads_b) if index >= len(loads_a)]
+ load_removed = [payload for index, payload in enumerate(loads_a) if index >= len(loads_b)]
+ load_changed = []
+ for index, (la, lb) in enumerate(zip(loads_a, loads_b)):
+ fields = {key: _scalar_delta(la[key], lb[key], key) for key in ("vaddr", "filesz", "memsz", "flags")}
+ if any(row["changed"] for row in fields.values()):
+ load_changed.append({"index": index, "fields": fields})
+
+ sec_a = {row["name"]: row for row in a.get("sections", [])}
+ sec_b = {row["name"]: row for row in b.get("sections", [])}
+ sec_added = [{key: sec_b[name][key] for key in ("name", "type", "addr", "size")} for name in sorted(set(sec_b) - set(sec_a))]
+ sec_removed = [{key: sec_a[name][key] for key in ("name", "type", "addr", "size")} for name in sorted(set(sec_a) - set(sec_b))]
+ sec_moved, sec_resized = [], []
+ for name in sorted(set(sec_a) & set(sec_b)):
+ ra, rb = sec_a[name], sec_b[name]
+ if ra["addr"] != rb["addr"]:
+ sec_moved.append({"name": name, "old_addr": ra["addr"], "new_addr": rb["addr"]})
+ if ra["size"] != rb["size"]:
+ sec_resized.append({"name": name, "old_size": ra["size"], "new_size": rb["size"]})
+
+ deltas = {
+ "entry": _scalar_delta(a["entry"], b["entry"], "entry"),
+ "span": _scalar_delta(a["span"], b["span"], "span"),
+ "file_size": _scalar_delta(a["size"], b["size"], "size"),
+ "loads": {
+ "added": _cap(load_added, max_rows),
+ "removed": _cap(load_removed, max_rows),
+ "changed": _cap(load_changed, max_rows),
+ },
+ "sections": {
+ "added": _cap(sec_added, max_rows),
+ "removed": _cap(sec_removed, max_rows),
+ "moved": _cap(sec_moved, max_rows),
+ "resized": _cap(sec_resized, max_rows),
+ },
+ }
+ same = (
+ not deltas["entry"]["changed"]
+ and not deltas["span"]["changed"]
+ and not deltas["file_size"]["changed"]
+ and not any(deltas["loads"][key] for key in ("added", "removed", "changed"))
+ and not any(deltas["sections"][key] for key in ("added", "removed", "moved", "resized"))
+ )
+ result.update({"status": "ok", "same": same, "deltas": deltas})
+ return result
+
+
+# ---------------------------------------------------------------------------
+# 2. symbol inventory (diff_corpus.py matching discipline)
+# ---------------------------------------------------------------------------
+
+
+def looks_like_three_column_export(path: str) -> bool:
+ """``addr<TAB>name<TAB>size`` (ExportForGreenEye) vs names/exports TSVs."""
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
+ for line in fh:
+ line = line.strip()
+ if not line or line.startswith("#"):
+ continue
+ cols = line.split("\t")
+ if len(cols) >= 3 and [c.strip() for c in cols[:3]] == ["addr", "name", "size"]:
+ return True
+ if len(cols) == 3:
+ try:
+ int(cols[0], 16)
+ int(cols[2], 0)
+ return True
+ except ValueError:
+ return False
+ return False
+ except OSError:
+ return False
+ return False
+
+
+def load_three_column_export(db: SymbolDB, path: str, bias: int = 0) -> int:
+ count = 0
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
+ for line in fh:
+ line = line.rstrip("\n")
+ if not line or line.startswith("#"):
+ continue
+ cols = line.split("\t")
+ if len(cols) != 3 or cols[0].strip() == "addr":
+ continue
+ try:
+ addr = int(cols[0], 16)
+ size = int(cols[2], 0)
+ except ValueError:
+ continue
+ if not cols[1]:
+ continue
+ db.add(Symbol(addr=addr + bias, name=cols[1], size=size, source="ghidra-export"))
+ count += 1
+ return count
+
+
+def load_symbol_sources(
+ loaded: LoadedTarget,
+ *,
+ symbols_json: str | None = None,
+ ghidra_names: str | None = None,
+ ghidra_index: str | None = None,
+ ghidra_exports: str | None = None,
+ ghidra_bias: int = 0,
+) -> tuple[SymbolDB, list[str], list[str]]:
+ """Merge every supplied symbol source for one side into one SymbolDB."""
+ db = SymbolDB()
+ sources: list[str] = []
+ notes: list[str] = []
+ if loaded.ok and loaded.image is not None and loaded.image.symbols:
+ count = db.load_elf(loaded.image, bias=loaded.bias, source="elf")
+ if count:
+ sources.append(f"elf:{count}")
+ if symbols_json:
+ if os.path.isfile(symbols_json):
+ count = db.load_json(symbols_json)
+ sources.append(f"{os.path.basename(symbols_json)}:{count}")
+ else:
+ notes.append(f"symbol DB not found: {symbols_json}")
+ if ghidra_names:
+ if os.path.isfile(ghidra_names):
+ count = db.load_names_tsv(ghidra_names, bias=ghidra_bias)
+ sources.append(f"{os.path.basename(ghidra_names)}:{count}")
+ else:
+ notes.append(f"Ghidra names TSV not found: {ghidra_names}")
+ if ghidra_index:
+ if os.path.isfile(ghidra_index):
+ count = db.load_index_tsv(ghidra_index, bias=ghidra_bias)
+ sources.append(f"{os.path.basename(ghidra_index)}:{count}")
+ else:
+ notes.append(f"Ghidra index TSV not found: {ghidra_index}")
+ if ghidra_exports:
+ if os.path.isfile(ghidra_exports):
+ if looks_like_three_column_export(ghidra_exports):
+ count = load_three_column_export(db, ghidra_exports, bias=ghidra_bias)
+ sources.append(f"{os.path.basename(ghidra_exports)}:{count} (3-column export)")
+ else:
+ count = db.load_index_tsv(ghidra_exports, bias=ghidra_bias)
+ sources.append(f"{os.path.basename(ghidra_exports)}:{count}")
+ else:
+ notes.append(f"Ghidra exports TSV not found: {ghidra_exports}")
+ return db, sources, notes
+
+
+def inventory_records(db: SymbolDB) -> list[dict]:
+ records = [
+ {
+ "name": symbol.name,
+ "addr": symbol.addr,
+ "size": symbol.size,
+ "source": symbol.source,
+ }
+ for symbol in db
+ ]
+ records.sort(key=lambda row: (row["addr"], row["name"], row["size"]))
+ return records
+
+
+def _is_named(name: str) -> bool:
+ return bool(name) and not name.startswith("FUN_")
+
+
+def _record_brief(record: dict | None) -> dict | None:
+ if record is None:
+ return None
+ return {"addr": record["addr"], "name": record["name"], "size": record["size"]}
+
+
+def inventory_diff(a_records: list[dict], b_records: list[dict], max_rows: int = 40) -> dict:
+ """Named-by-name + anonymous-at-shifted-address matching (diff_corpus.py)."""
+ a_by_addr = {row["addr"]: row for row in a_records}
+ b_by_addr = {row["addr"]: row for row in b_records}
+ named_a: dict[str, list[int]] = defaultdict(list)
+ named_b: dict[str, list[int]] = defaultdict(list)
+ for row in a_records:
+ if _is_named(row["name"]):
+ named_a[row["name"]].append(row["addr"])
+ for row in b_records:
+ if _is_named(row["name"]):
+ named_b[row["name"]].append(row["addr"])
+
+ matched_named: list[tuple[str, int, int]] = []
+ for name in sorted(set(named_a) & set(named_b)):
+ for addr_a, addr_b in zip(sorted(named_a[name]), sorted(named_b[name])):
+ matched_named.append((name, addr_a, addr_b))
+
+ deltas = [addr_b - addr_a for _, addr_a, addr_b in matched_named]
+ shift = int(statistics.median(deltas)) if deltas else 0
+ dominant = (sum(1 for value in deltas if abs(value - shift) <= 0x40) / len(deltas)) if deltas else 0.0
+
+ used_a = {addr_a for _, addr_a, _ in matched_named}
+ used_b = {addr_b for _, _, addr_b in matched_named}
+ moved = [(name, addr_a, addr_b) for name, addr_a, addr_b in matched_named if addr_a != addr_b]
+ resized_named = [
+ (name, addr_a, addr_b)
+ for name, addr_a, addr_b in matched_named
+ if a_by_addr[addr_a]["size"] != b_by_addr[addr_b]["size"]
+ ]
+
+ shifted: list[tuple[int, int]] = []
+ for row in sorted(a_records, key=lambda item: item["addr"]):
+ addr_a = row["addr"]
+ if addr_a in used_a or _is_named(row["name"]):
+ continue
+ addr_b = addr_a + shift
+ if addr_b in b_by_addr and addr_b not in used_b:
+ shifted.append((addr_a, addr_b))
+ used_a.add(addr_a)
+ used_b.add(addr_b)
+ shifted_resized = [(fa, fb) for fa, fb in shifted if a_by_addr[fa]["size"] != b_by_addr[fb]["size"]]
+ shifted_ok = [(fa, fb) for fa, fb in shifted if a_by_addr[fa]["size"] == b_by_addr[fb]["size"]]
+
+ removed = sorted(addr for addr in a_by_addr if addr not in used_a)
+ added = sorted(addr for addr in b_by_addr if addr not in used_b)
+ matched_total = len(matched_named) + len(shifted)
+ named_count_a = sum(len(v) for v in named_a.values())
+ named_count_b = sum(len(v) for v in named_b.values())
+
+ return {
+ "shift": shift,
+ "shift_dominant": round(dominant, 4),
+ "matched_named": len(matched_named),
+ "matched_shifted": len(shifted_ok),
+ "matched_shifted_resized": len(shifted_resized),
+ "moved": _cap(
+ [{"name": name, "old_addr": fa, "new_addr": fb, "delta": fb - fa} for name, fa, fb in moved],
+ max_rows,
+ ),
+ "resized": _cap(
+ [
+ {
+ "name": name,
+ "old_addr": fa,
+ "new_addr": fb,
+ "old_size": a_by_addr[fa]["size"],
+ "new_size": b_by_addr[fb]["size"],
+ }
+ for name, fa, fb in resized_named
+ ],
+ max_rows,
+ ),
+ "resized_anonymous": _cap(
+ [
+ {
+ "old_addr": fa,
+ "new_addr": fb,
+ "old_size": a_by_addr[fa]["size"],
+ "new_size": b_by_addr[fb]["size"],
+ }
+ for fa, fb in shifted_resized
+ ],
+ max_rows,
+ ),
+ "added": _cap([_record_brief(b_by_addr[addr]) for addr in added], max_rows),
+ "removed": _cap([_record_brief(a_by_addr[addr]) for addr in removed], max_rows),
+ "totals": {
+ "a": len(a_records),
+ "b": len(b_records),
+ "named_a": named_count_a,
+ "named_b": named_count_b,
+ "anonymous_a": sum(1 for row in a_records if not _is_named(row["name"])),
+ "anonymous_b": sum(1 for row in b_records if not _is_named(row["name"])),
+ "added": len(added),
+ "removed": len(removed),
+ "moved": len(moved),
+ "resized_named": len(resized_named),
+ },
+ "rates": {
+ "match_a": round(matched_total / len(a_records), 4) if a_records else 0.0,
+ "match_b": round(matched_total / len(b_records), 4) if b_records else 0.0,
+ "named_transfer": round(len(matched_named) / named_count_a, 4) if named_count_a else 0.0,
+ },
+ }
+
+
+def symbol_section(
+ old: LoadedTarget,
+ new: LoadedTarget,
+ old_db: SymbolDB,
+ old_sources: list[str],
+ old_notes: list[str],
+ new_db: SymbolDB,
+ new_sources: list[str],
+ new_notes: list[str],
+ options: "VersionDiffOptions",
+ out_dir: str,
+) -> dict:
+ result = {
+ "status": "skipped",
+ "reason": None,
+ "a": {"count": 0, "sources": old_sources, "notes": old_notes, "db": None},
+ "b": {"count": 0, "sources": new_sources, "notes": new_notes, "db": None},
+ }
+ if not old.ok or not new.ok:
+ result["reason"] = "; ".join(
+ f"{side}: {loaded.reason or loaded.kind}" for side, loaded in (("old", old), ("new", new)) if not loaded.ok
+ )
+ return result
+ a_records = inventory_records(old_db)
+ b_records = inventory_records(new_db)
+ result["a"]["count"] = len(a_records)
+ result["b"]["count"] = len(b_records)
+ if not a_records and not b_records:
+ result["reason"] = (
+ "no symbols on either side: no ELF .symtab and no Ghidra/JSON symbol sources supplied"
+ )
+ return result
+ result.update(inventory_diff(a_records, b_records, options.max_rows))
+ result["status"] = "ok"
+ for key, db, loaded, sources, notes in (
+ ("a", old_db, old, old_sources, old_notes),
+ ("b", new_db, new, new_sources, new_notes),
+ ):
+ if not len(db):
+ continue
+ rel = os.path.join("symbols", f"{loaded.name}.json")
+ path = os.path.join(out_dir, rel)
+ ensure_dir(os.path.dirname(path))
+ db.save_json(path, meta={"firmware": os.path.basename(loaded.path), "bias": loaded.bias,
+ "sources": sources, "notes": notes})
+ result[key]["db"] = rel
+ return result
+
+
+# ---------------------------------------------------------------------------
+# 3. decompiled-change hints
+# ---------------------------------------------------------------------------
+
+
+def corpus_records(path: str) -> list[dict]:
+ db = SymbolDB()
+ if looks_like_three_column_export(path):
+ load_three_column_export(db, path, bias=0)
+ else:
+ db.load_index_tsv(path, bias=0)
+ return inventory_records(db)
+
+
+def corpus_diff(path_a: str, path_b: str, max_rows: int = 40) -> dict:
+ diff = inventory_diff(corpus_records(path_a), corpus_records(path_b), max_rows)
+ diff["a"] = os.path.basename(path_a)
+ diff["b"] = os.path.basename(path_b)
+ return diff
+
+
+def corpus_section(options: "VersionDiffOptions", out_dir: str) -> dict:
+ if options.corpus_report:
+ if not os.path.isfile(options.corpus_report):
+ return {"status": "skipped", "reason": f"corpus report not found: {options.corpus_report}",
+ "mode": "corpus-report"}
+ data = _read_bytes(options.corpus_report)
+ preview = data.decode("utf-8", "replace")
+ return {
+ "status": "ok",
+ "reason": None,
+ "mode": "corpus-report",
+ "report": {
+ "path": os.path.basename(options.corpus_report),
+ "sha256": sha256_file(options.corpus_report),
+ "bytes": len(data),
+ "preview": preview[:4000],
+ "truncated": len(preview) > 4000,
+ },
+ }
+ if options.corpus_diff:
+ path_a, path_b = options.corpus_diff
+ missing = [path for path in (path_a, path_b) if not os.path.isfile(path)]
+ if missing:
+ return {"status": "skipped", "mode": "corpus-diff",
+ "reason": "corpus TSV not found: " + ", ".join(missing)}
+ diff = corpus_diff(path_a, path_b, options.max_rows)
+ rel = os.path.join("corpus", "corpus-diff.json")
+ write_json(os.path.join(out_dir, rel), diff)
+ return {"status": "ok", "reason": None, "mode": "corpus-diff", "diff": diff, "report": rel}
+ return {
+ "status": "skipped",
+ "reason": "not supplied: pass --corpus-diff OLD_TSV NEW_TSV or --corpus-report FILE",
+ "mode": None,
+ }
+
+
+# ---------------------------------------------------------------------------
+# QEMU sessions for ELF and raw targets
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class OpenTarget:
+ loaded: LoadedTarget
+ session: Session
+ target: QemuTarget
+ workdir: str
+ owns_workdir: bool = False
+
+
+@contextlib.contextmanager
+def open_target_session(
+ loaded: LoadedTarget,
+ *,
+ record: str | bool | None,
+ name: str,
+ symbols: SymbolDB | None = None,
+ log_faults: bool = True,
+ workdir: str | None = None,
+):
+ """Relocate the target to LOAD_ADDR, start QEMU and yield a live session."""
+ owns_workdir = workdir is None
+ if workdir is None:
+ workdir = tempfile.mkdtemp(prefix="greeneye-vdiff-")
+ flat_kernel = os.path.join(workdir, "flat.bin")
+ assert loaded.image is not None
+ if loaded.kind == "elf":
+ write_flat_kernel(loaded.path, flat_kernel, load_addr=LOAD_ADDR)
+ else:
+ with open(flat_kernel, "wb") as fh:
+ fh.write(loaded.image.read())
+ target = QemuTarget(
+ kernel=flat_kernel,
+ memory_mb=memory_mb_for(loaded.image),
+ dlog="guest_errors,unimp,int" if log_faults else None,
+ workdir=workdir,
+ )
+ target.start(paused=True)
+ client = RspClient(target.host, target.port)
+ client.connect()
+ client.sync()
+ db = symbols if symbols is not None else SymbolDB()
+ session = Session(
+ client=client,
+ target=target,
+ symbols=db,
+ record=record,
+ name=name,
+ meta={
+ "backend": "qemu",
+ "bias": loaded.bias,
+ "firmware": loaded.path,
+ "kind": loaded.kind,
+ "harness": "version_diff",
+ },
+ )
+ session.load_image(loaded.image, bias=loaded.bias, load_symbols=symbols is None)
+ try:
+ yield OpenTarget(loaded=loaded, session=session, target=target, workdir=workdir,
+ owns_workdir=owns_workdir)
+ finally:
+ try:
+ session.close()
+ finally:
+ if owns_workdir:
+ shutil.rmtree(workdir, ignore_errors=True)
+
+
+def enrich_fault(fault: dict, bias: int, span: int) -> dict:
+ """Add firmware-vaddr translations for epc and the access address."""
+ out = {
+ "cause": fault.get("cause"),
+ "desc": fault.get("desc"),
+ "epc": fault.get("epc"),
+ "tval": fault.get("tval"),
+ "access": fault.get("access"),
+ }
+ epc = fault.get("epc")
+ out["epc_firmware"] = epc - bias if isinstance(epc, int) else None
+ access = fault.get("access") or {}
+ access_addr = access.get("access_addr")
+ if isinstance(access_addr, int):
+ if LOAD_ADDR <= access_addr < LOAD_ADDR + span:
+ out["access_firmware"] = access_addr - bias
+ else:
+ out["access_firmware"] = None
+ else:
+ out["access_firmware"] = None
+ return out
+
+
+# ---------------------------------------------------------------------------
+# 4. probe first-touch diff
+# ---------------------------------------------------------------------------
+
+
+def _skipped_probe(reason: str | None) -> dict:
+ return {
+ "status": "skipped",
+ "reason": reason,
+ "session": None,
+ "instructions": None,
+ "stopped_pc": None,
+ "stopped_symbol": None,
+ "fault_count": 0,
+ "faults_capped": False,
+ "first_fault": None,
+ }
+
+
+def run_probe(
+ loaded: LoadedTarget,
+ db: SymbolDB,
+ options: "VersionDiffOptions",
+ out_dir: str,
+ qemu_ok: bool,
+) -> dict:
+ if not qemu_ok:
+ return _skipped_probe("qemu-system-riscv64 not found")
+ if not loaded.ok:
+ return _skipped_probe(loaded.reason)
+ result = _skipped_probe(None)
+ result["instructions"] = options.probe_instructions
+ session_rel = os.path.join("sessions", f"{loaded.name}-probe")
+ try:
+ with open_target_session(
+ loaded,
+ record=os.path.join(out_dir, session_rel),
+ name=f"{loaded.name}-probe",
+ symbols=db,
+ log_faults=True,
+ ) as ot:
+ session = ot.session
+ if options.probe_instructions > 0:
+ session.step(options.probe_instructions)
+ else:
+ session.run(duration=options.probe_duration)
+ result["stopped_pc"] = session.pc
+ symbol = session.symbols.resolve(session.pc)
+ result["stopped_symbol"] = symbol.name if symbol else None
+ text, capped = read_log_window(ot.target.qemu_log, 0, options.log_bytes)
+ faults = parse_qemu_faults(text)
+ result["fault_count"] = len(faults)
+ result["faults_capped"] = capped
+ span = loaded.image.max_vaddr - loaded.image.min_vaddr if loaded.image else 0
+ result["first_fault"] = enrich_fault(faults[0], loaded.bias, span) if faults else None
+ result["session"] = session_rel
+ if text:
+ session.recorder.meta["qemu_log_blob"] = session.recorder.blob("qemu.log", text.encode("utf-8"))
+ session.recorder.meta["faults_capped"] = capped
+ result["status"] = "ok"
+ except Exception as exc: # a failed probe must not abort the diff
+ result["status"] = "error"
+ result["reason"] = f"{type(exc).__name__}: {exc}"
+ return result
+
+
+def _probe_signature(probe: dict) -> tuple:
+ first = probe.get("first_fault") or {}
+ return (
+ probe.get("stopped_pc"),
+ first.get("desc"),
+ first.get("cause"),
+ first.get("epc_firmware"),
+ first.get("access_firmware"),
+ (first.get("access") or {}).get("access_addr"),
+ )
+
+
+def probe_diff(
+ old: LoadedTarget,
+ new: LoadedTarget,
+ old_db: SymbolDB,
+ new_db: SymbolDB,
+ options: "VersionDiffOptions",
+ out_dir: str,
+ qemu_ok: bool,
+) -> dict:
+ a = run_probe(old, old_db, options, out_dir, qemu_ok)
+ b = run_probe(new, new_db, options, out_dir, qemu_ok)
+ statuses = {a["status"], b["status"]}
+ if statuses == {"ok"}:
+ status, reason = "ok", None
+ elif "error" in statuses:
+ status = "error"
+ reason = "; ".join(row["reason"] for row in (a, b) if row.get("reason")) or "probe failed"
+ else:
+ status = "skipped"
+ reason = "; ".join(row["reason"] for row in (a, b) if row.get("reason")) or "probe not run"
+ changed = _probe_signature(a) != _probe_signature(b)
+ if status == "ok":
+ summary = "first fault and stopped PC unchanged" if not changed else "first fault or stopped PC changed"
+ else:
+ summary = None
+ return {"status": status, "reason": reason, "a": a, "b": b, "changed": changed, "summary": summary}
+
+
+# ---------------------------------------------------------------------------
+# 5. behavior diff
+# ---------------------------------------------------------------------------
+
+
+def _skipped_behavior(reason: str | None) -> dict:
+ return {
+ "status": "skipped",
+ "reason": reason,
+ "session": None,
+ "hooks": [],
+ "uart_banner": None,
+ "ctrl": None,
+ "event_counts": {},
+ "bp": {},
+ "watch": {},
+ "writes": {},
+ "fault_count": 0,
+ "faults_capped": False,
+ "first_fault": None,
+ }
+
+
+def run_behavior(
+ loaded: LoadedTarget,
+ db: SymbolDB,
+ options: "VersionDiffOptions",
+ out_dir: str,
+ qemu_ok: bool,
+) -> dict:
+ if not qemu_ok:
+ return _skipped_behavior("qemu-system-riscv64 not found")
+ if not loaded.ok:
+ return _skipped_behavior(loaded.reason)
+ result = _skipped_behavior(None)
+ session_rel = os.path.join("sessions", f"{loaded.name}-behavior")
+ try:
+ with open_target_session(
+ loaded,
+ record=os.path.join(out_dir, session_rel),
+ name=f"{loaded.name}-behavior",
+ symbols=db,
+ log_faults=True,
+ ) as ot:
+ session = ot.session
+ result["hooks"] = install_scenario(session)
+ ctrl_symbol = session.symbols.get("gsp_ctrl")
+ ctrl_addr = ctrl_symbol.addr if ctrl_symbol else None
+ if ctrl_addr is not None:
+ session.watch(ctrl_addr + CTRL_RPC_COUNT, size=8, kind="w",
+ listener="version-diff:rpc_count")
+ if options.stimulus:
+ import greeneye as ge
+
+ script_path = Path(options.stimulus)
+ namespace = {
+ "ge": ge,
+ "greeneye": ge,
+ "session": session,
+ "s": session,
+ "memory": session.memory,
+ "symbols": session.symbols,
+ "recorder": session.recorder,
+ }
+ exec(compile(script_path.read_text(encoding="utf-8"), str(script_path), "exec"), namespace)
+ else:
+ session.run(duration=options.behavior_duration, max_stops=options.max_stops)
+ if ctrl_addr is not None:
+ session.memory.write_u64(ctrl_addr + 32, 1) # PING
+ session.memory.write_u64(ctrl_addr + 40, 0x1234)
+ session.run(duration=options.behavior_duration, max_stops=options.max_stops)
+ result["ctrl"] = read_ctrl(session, ctrl_symbol)
+ uart = ot.target.read_uart()
+ result["uart_banner"] = uart.strip().splitlines()[0] if uart.strip() else None
+ text, capped = read_log_window(ot.target.qemu_log, 0, options.log_bytes)
+ faults = parse_qemu_faults(text)
+ result["fault_count"] = len(faults)
+ result["faults_capped"] = capped
+ span = loaded.image.max_vaddr - loaded.image.min_vaddr if loaded.image else 0
+ result["first_fault"] = enrich_fault(faults[0], loaded.bias, span) if faults else None
+ result.update(profile_from_events(load_events(os.path.join(out_dir, session_rel))))
+ result["session"] = session_rel
+ result["status"] = "ok"
+ except Exception as exc: # a failed scenario must not abort the diff
+ result["status"] = "error"
+ result["reason"] = f"{type(exc).__name__}: {exc}"
+ return result
+
+
+def behavior_diff(
+ old: LoadedTarget,
+ new: LoadedTarget,
+ old_db: SymbolDB,
+ new_db: SymbolDB,
+ probe_a: dict,
+ probe_b: dict,
+ options: "VersionDiffOptions",
+ out_dir: str,
+ qemu_ok: bool,
+) -> dict:
+ a = run_behavior(old, old_db, options, out_dir, qemu_ok)
+ b = run_behavior(new, new_db, options, out_dir, qemu_ok)
+ entry = {
+ "status": "skipped",
+ "reason": None,
+ "a": a,
+ "b": b,
+ "compare_report": None,
+ "deltas": {"bp": [], "watch": [], "writes": []},
+ "all_zero": None,
+ "metrics": [],
+ }
+ a["metrics"] = metric_rows(probe_a, a)
+ b["metrics"] = metric_rows(probe_b, b)
+ if a["status"] != "ok" or b["status"] != "ok":
+ reasons = [row.get("reason") for row in (a, b) if row.get("reason")]
+ entry["reason"] = "; ".join(reasons) or "a behavior session is not available on both targets"
+ entry["status"] = "error" if "error" in {a["status"], b["status"]} else "skipped"
+ return entry
+ a_dir = os.path.join(out_dir, a["session"])
+ b_dir = os.path.join(out_dir, b["session"])
+ try:
+ report = compare_sessions(a_dir, b_dir, max_rows=options.max_rows)
+ rel = os.path.join("compare", f"{old.name}--{new.name}.md")
+ write_text(os.path.join(out_dir, rel), report)
+ entry["compare_report"] = rel
+ except Exception as exc:
+ entry["reason"] = f"{type(exc).__name__}: {exc}"
+ deltas = {
+ name: counter_delta(a.get(name) or {}, b.get(name) or {})
+ for name in ("bp", "watch", "writes")
+ }
+ entry["deltas"] = deltas
+ entry["all_zero"] = all(row["delta"] == 0 for rows in deltas.values() for row in rows)
+ a_metrics = {row["id"]: row for row in a["metrics"]}
+ b_metrics = {row["id"]: row for row in b["metrics"]}
+ entry["metrics"] = [
+ {
+ "id": metric_id,
+ "name": name,
+ "status": "not measured",
+ "reason": NO_REFERENCE,
+ "a_observed": (a_metrics.get(metric_id) or {}).get("observed"),
+ "b_observed": (b_metrics.get(metric_id) or {}).get("observed"),
+ }
+ for metric_id, name in METRIC_DEFS
+ ]
+ entry["status"] = "ok"
+ return entry
+
+
+# ---------------------------------------------------------------------------
+# report assembly + rendering
+# ---------------------------------------------------------------------------
+
+
+@dataclass
+class VersionDiffOptions:
+ old: str
+ new: str
+ out: str
+ old_label: str | None = None
+ new_label: str | None = None
+ release: str | None = None
+ old_symbols: str | None = None
+ new_symbols: str | None = None
+ old_ghidra_names: str | None = None
+ new_ghidra_names: str | None = None
+ old_ghidra_index: str | None = None
+ new_ghidra_index: str | None = None
+ old_ghidra_exports: str | None = None
+ new_ghidra_exports: str | None = None
+ old_ghidra_bias: int = 0
+ new_ghidra_bias: int = 0
+ corpus_diff: tuple[str, str] | None = None
+ corpus_report: str | None = None
+ stimulus: str | None = None
+ probe_instructions: int = 2000
+ probe_duration: float = 0.5
+ behavior_duration: float = 0.4
+ max_stops: int = 5000
+ max_rows: int = 40
+ log_bytes: int = MAX_LOG_BYTES
+ approval_token: str | None = None
+
+ def labels(self) -> list[str]:
+ return [
+ value
+ for value in (
+ self.release,
+ self.old_label,
+ self.new_label,
+ os.path.basename(self.old),
+ os.path.basename(self.new),
+ )
+ if value
+ ]
+
+ def label_for(self, side: str) -> str:
+ explicit = self.old_label if side == "old" else self.new_label
+ path = self.old if side == "old" else self.new
+ return explicit or Path(path).stem
+
+
+def _target_brief(label: str, loaded: LoadedTarget, qemu_ok: bool, options: VersionDiffOptions) -> dict:
+ return {
+ "label": label,
+ "path": os.path.basename(loaded.path),
+ "kind": loaded.kind,
+ "status": "ok" if loaded.ok else "skipped",
+ "reason": loaded.reason,
+ "sha256": loaded.sha256,
+ "size": loaded.size,
+ "entry": loaded.entry if loaded.ok else None,
+ "bias": loaded.bias if loaded.ok else None,
+ "probe_instructions": options.probe_instructions,
+ "qemu_available": qemu_ok,
+ }
+
+
+def run_version_diff(options: VersionDiffOptions) -> dict:
+ """Run every section, write JSON+markdown under ``--out``, return the report."""
+ check_rm610_gate(options.labels(), approval_token=options.approval_token)
+ ensure_dir(options.out)
+ old_label, new_label = options.label_for("old"), options.label_for("new")
+ qemu_ok = qemu_available()
+ old = load_any_target(options.old)
+ new = load_any_target(options.new)
+
+ if old.ok:
+ old_db, old_sources, old_notes = load_symbol_sources(
+ old, symbols_json=options.old_symbols, ghidra_names=options.old_ghidra_names,
+ ghidra_index=options.old_ghidra_index, ghidra_exports=options.old_ghidra_exports,
+ ghidra_bias=options.old_ghidra_bias,
+ )
+ else:
+ old_db, old_sources, old_notes = SymbolDB(), [], []
+ if new.ok:
+ new_db, new_sources, new_notes = load_symbol_sources(
+ new, symbols_json=options.new_symbols, ghidra_names=options.new_ghidra_names,
+ ghidra_index=options.new_ghidra_index, ghidra_exports=options.new_ghidra_exports,
+ ghidra_bias=options.new_ghidra_bias,
+ )
+ else:
+ new_db, new_sources, new_notes = SymbolDB(), [], []
+
+ image = image_diff(old, new, options.max_rows)
+ symbols = symbol_section(old, new, old_db, old_sources, old_notes, new_db, new_sources, new_notes,
+ options, options.out)
+ corpus = corpus_section(options, options.out)
+ probe = probe_diff(old, new, old_db, new_db, options, options.out, qemu_ok)
+ behavior = behavior_diff(old, new, old_db, new_db, probe["a"], probe["b"], options, options.out, qemu_ok)
+
+ sections = {"image": image, "symbols": symbols, "corpus": corpus, "probe": probe, "behavior": behavior}
+ report = {
+ "format": FORMAT,
+ "tool": "harness/version_diff.py",
+ "backend": "qemu",
+ "qemu_available": qemu_ok,
+ "old": _target_brief(old_label, old, qemu_ok, options),
+ "new": _target_brief(new_label, new, qemu_ok, options),
+ "options": {
+ "probe_instructions": options.probe_instructions,
+ "probe_duration": options.probe_duration,
+ "behavior_duration": options.behavior_duration,
+ "max_rows": options.max_rows,
+ "stimulus": os.path.basename(options.stimulus) if options.stimulus else None,
+ "corpus_report": os.path.basename(options.corpus_report) if options.corpus_report else None,
+ "corpus_diff": [os.path.basename(p) for p in options.corpus_diff] if options.corpus_diff else None,
+ },
+ "sections": sections,
+ "summary": {
+ "sections_ok": sorted(name for name, section in sections.items() if section["status"] == "ok"),
+ "sections_skipped": {
+ name: section.get("reason")
+ for name, section in sections.items()
+ if section["status"] == "skipped"
+ },
+ "sections_error": {
+ name: section.get("reason")
+ for name, section in sections.items()
+ if section["status"] == "error"
+ },
+ },
+ }
+ write_json(os.path.join(options.out, "version-diff.json"), report)
+ write_text(os.path.join(options.out, "version-diff.md"), render_markdown(report, options))
+ return report
+
+
+# ---------------------------------------------------------------------------
+# markdown rendering
+# ---------------------------------------------------------------------------
+
+
+def _table(headers: list[str], rows: list[list]) -> list[str]:
+ lines = ["| " + " | ".join(headers) + " |", "|" + "|".join("---" for _ in headers) + "|"]
+ for row in rows:
+ lines.append("| " + " | ".join(str(cell) for cell in row) + " |")
+ return lines
+
+
+def _hex(value) -> str:
+ return f"`{value:#x}`" if isinstance(value, int) else "-"
+
+
+def render_markdown(report: dict, options: VersionDiffOptions) -> str:
+ old, new = report["old"], report["new"]
+ lines = [
+ "# green-eye version diff",
+ "",
+ "> **# TOOL IN DEVELOPMENT - NOT READY YET!** Generated by `harness/version_diff.py`.",
+ "",
+ f"* format: `{report['format']}`",
+ f"* targets: `{old['label']}` -> `{new['label']}`",
+ f"* backend: `{report['backend']}` (qemu available: {report['qemu_available']})",
+ "",
+ "## Targets",
+ "",
+ ]
+ lines += _table(
+ ["side", "label", "kind", "size", "sha256", "entry"],
+ [
+ [side, f"`{item['label']}`", item["kind"], item["size"], f"`{(item['sha256'] or '')[:16]}`",
+ _hex(item["entry"])]
+ for side, item in (("old", old), ("new", new))
+ ],
+ )
+ lines += [""]
+
+ image = report["sections"]["image"]
+ lines += ["## 1. Image / shape", "", f"* status: {image['status']}"]
+ if image.get("reason"):
+ lines += [f"* reason: {image['reason']}"]
+ if image["status"] == "ok":
+ deltas = image["deltas"]
+ lines += _table(
+ ["field", "old", "new", "delta"],
+ [
+ ["entry", _hex(deltas["entry"]["a"]), _hex(deltas["entry"]["b"]), _hex(deltas["entry"]["delta"])],
+ ["span", _hex(deltas["span"]["a"]), _hex(deltas["span"]["b"]), _hex(deltas["span"]["delta"])],
+ ["file size", deltas["file_size"]["a"], deltas["file_size"]["b"], deltas["file_size"]["delta"]],
+ ],
+ )
+ lines += ["", f"* identical shape: {'yes' if image['same'] else 'no'}", ""]
+ lines += _table(
+ ["load delta", "count"],
+ [[key, len(image["deltas"]["loads"][key])] for key in ("added", "removed", "changed")]
+ + [["section " + key, len(image["deltas"]["sections"][key])]
+ for key in ("added", "removed", "moved", "resized")],
+ )
+ lines += [""]
+
+ symbols = report["sections"]["symbols"]
+ lines += ["## 2. Symbol inventory", "", f"* status: {symbols['status']}"]
+ if symbols.get("reason"):
+ lines += [f"* reason: {symbols['reason']}"]
+ if symbols["status"] == "ok":
+ lines += _table(
+ ["side", "symbols", "named", "anonymous", "sources"],
+ [
+ [side, symbols[side]["count"], symbols["totals"][f"named_{side}"],
+ symbols["totals"][f"anonymous_{side}"], ", ".join(symbols[side]["sources"]) or "-"]
+ for side in ("a", "b")
+ ],
+ )
+ lines += [""]
+ lines += _table(
+ ["address shift", "dominant", "matched named", "matched anon", "moved", "resized", "added", "removed"],
+ [[
+ _hex(symbols["shift"]), f"{symbols['shift_dominant'] * 100:.1f}%", symbols["matched_named"],
+ symbols["matched_shifted"], symbols["totals"]["moved"], symbols["totals"]["resized_named"],
+ symbols["totals"]["added"], symbols["totals"]["removed"],
+ ]],
+ )
+ lines += [""]
+ for key, title in (("moved", "Moved named"), ("resized", "Resized named"), ("added", "Added"),
+ ("removed", "Removed")):
+ rows = symbols.get(key) or []
+ lines += [f"**{title} (first {options.max_rows})**", ""]
+ if not rows:
+ lines += ["_none_", ""]
+ continue
+ if key in ("added", "removed"):
+ lines += _table(["addr", "name", "size"], [[_hex(r["addr"]), f"`{r['name']}`", r["size"]] for r in rows])
+ elif key == "moved":
+ lines += _table(["name", "old addr", "new addr", "delta"],
+ [[f"`{r['name']}`", _hex(r["old_addr"]), _hex(r["new_addr"]), _hex(r["delta"])] for r in rows])
+ else:
+ lines += _table(["name", "old addr", "new addr", "old size", "new size"],
+ [[f"`{r['name']}`", _hex(r["old_addr"]), _hex(r["new_addr"]), r["old_size"], r["new_size"]]
+ for r in rows])
+ lines += [""]
+ else:
+ lines += [""]
+
+ corpus = report["sections"]["corpus"]
+ lines += ["## 3. Decompiled-change hints", "", f"* status: {corpus['status']}",
+ f"* mode: {corpus.get('mode') or '-'}"]
+ if corpus.get("reason"):
+ lines += [f"* reason: {corpus['reason']}"]
+ if corpus["status"] == "ok" and corpus.get("mode") == "corpus-diff":
+ diff = corpus["diff"]
+ lines += _table(
+ ["functions A", "functions B", "shift", "added", "removed", "moved", "resized"],
+ [[diff["totals"]["a"], diff["totals"]["b"], _hex(diff["shift"]), diff["totals"]["added"],
+ diff["totals"]["removed"], diff["totals"]["moved"], diff["totals"]["resized_named"]]],
+ )
+ elif corpus["status"] == "ok" and corpus.get("mode") == "corpus-report":
+ info = corpus["report"]
+ lines += [f"* report: `{info['path']}` ({info['bytes']} bytes, sha256 `{info['sha256'][:16]}`)"]
+ lines += [""]
+
+ probe = report["sections"]["probe"]
+ lines += ["## 4. Probe first-touch", "", f"* status: {probe['status']}"]
+ if probe.get("reason"):
+ lines += [f"* reason: {probe['reason']}"]
+ if probe["status"] == "ok":
+ lines += [f"* {probe['summary']}", ""]
+ lines += _table(
+ ["side", "stopped pc", "stopped symbol", "fault", "epc (fw vaddr)", "access", "access (fw vaddr)"],
+ [
+ [
+ side,
+ _hex(probe[side].get("stopped_pc")),
+ probe[side].get("stopped_symbol") or "-",
+ (probe[side].get("first_fault") or {}).get("desc") or "none",
+ _hex((probe[side].get("first_fault") or {}).get("epc_firmware")),
+ _hex(((probe[side].get("first_fault") or {}).get("access") or {}).get("access_addr")),
+ _hex((probe[side].get("first_fault") or {}).get("access_firmware")),
+ ]
+ for side in ("a", "b")
+ ],
+ )
+ lines += [""]
+
+ behavior = report["sections"]["behavior"]
+ lines += ["## 5. Behavior", "", f"* status: {behavior['status']}"]
+ if behavior.get("reason"):
+ lines += [f"* reason: {behavior['reason']}"]
+ if behavior["status"] == "ok":
+ lines += _table(
+ ["side", "session", "hooks", "uart banner", "faults", "events"],
+ [
+ [
+ side,
+ f"`{behavior[side].get('session')}`",
+ ", ".join(behavior[side].get("hooks") or []),
+ behavior[side].get("uart_banner") or "-",
+ behavior[side].get("fault_count"),
+ sum((behavior[side].get("event_counts") or {}).values()),
+ ]
+ for side in ("a", "b")
+ ],
+ )
+ lines += [f"* zero deltas: {'yes' if behavior['all_zero'] else 'no'}"]
+ if behavior.get("compare_report"):
+ lines += [f"* compare report: `{behavior['compare_report']}`"]
+ lines += [""]
+ for name in ("bp", "watch", "writes"):
+ rows = [row for row in behavior["deltas"][name] if row["delta"] != 0]
+ rows.sort(key=lambda row: (-abs(row["delta"]), str(row["key"])))
+ lines += [f"**{name} delta (nonzero, first {options.max_rows})**", ""]
+ if rows:
+ lines += _table(["key", "old", "new", "delta"],
+ [[f"`{row['key']}`", row["a"], row["b"], row["delta"]] for row in rows[: options.max_rows]])
+ else:
+ lines += ["_no deltas_"]
+ lines += [""]
+ lines += [f"> {NO_REFERENCE}", ""]
+ lines += _table(
+ ["#", "metric", "old observed", "new observed"],
+ [[row["id"], row["name"], _short_json(row["a_observed"]), _short_json(row["b_observed"])]
+ for row in behavior["metrics"]],
+ )
+ lines += [""]
+
+ summary = report["summary"]
+ lines += ["## Summary", ""]
+ lines += [f"* ok: {', '.join(summary['sections_ok']) or '-'}"]
+ if summary["sections_skipped"]:
+ for name, reason in sorted(summary["sections_skipped"].items()):
+ lines += [f"* skipped `{name}`: {reason}"]
+ if summary["sections_error"]:
+ for name, reason in sorted(summary["sections_error"].items()):
+ lines += [f"* error `{name}`: {reason}"]
+ return "\n".join(lines) + "\n"
+
+
+def _short_json(value, limit: int = 72) -> str:
+ import json
+
+ if value is None:
+ return "-"
+ text = json.dumps(value, sort_keys=True)
+ return f"`{text}`" if len(text) <= limit else f"`{text[: limit - 1]}...`"
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+def _add_side_symbols(parser: argparse.ArgumentParser, side: str) -> None:
+ parser.add_argument(f"--{side}-symbols", help="green-eye symbol DB JSON for the %s target" % side)
+ parser.add_argument(f"--{side}-ghidra-names", help="Ghidra names.tsv for the %s target" % side)
+ parser.add_argument(f"--{side}-ghidra-index", help="Ghidra decompiled_index.tsv for the %s target" % side)
+ parser.add_argument(
+ f"--{side}-ghidra-exports",
+ help="Ghidra exports.tsv or 3-column ExportForGreenEye TSV for the %s target" % side,
+ )
+ parser.add_argument(f"--{side}-ghidra-bias", type=lambda value: int(value, 0), default=0,
+ help="bias added to %s Ghidra symbol addresses" % side)
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(prog="version_diff", description=__doc__.splitlines()[0])
+ parser.add_argument("--old", required=True, help="old target (RISC-V ELF or raw .fwimage)")
+ parser.add_argument("--new", required=True, help="new target (RISC-V ELF or raw .fwimage)")
+ parser.add_argument("--out", required=True, help="output directory for the report, sessions and DBs")
+ parser.add_argument("--old-label", help="report label for the old target (default: file stem)")
+ parser.add_argument("--new-label", help="report label for the new target (default: file stem)")
+ parser.add_argument("--release", help="release label; an rm610 release is gated by docs/23")
+ parser.add_argument("--approval-token", help=f"explicit RM610 token ({RM610_TOKEN})")
+ _add_side_symbols(parser, "old")
+ _add_side_symbols(parser, "new")
+ parser.add_argument("--corpus-diff", nargs=2, metavar=("OLD_TSV", "NEW_TSV"),
+ help="diff two decompiled_index.tsv corpus exports")
+ parser.add_argument("--corpus-report", help="attach a previously produced corpus diff report")
+ parser.add_argument("--stimulus", help="green-eye script replacing the builtin demo scenario")
+ parser.add_argument("--probe-instructions", type=int, default=2000,
+ help="probe step budget (0 = wall-clock --probe-duration)")
+ parser.add_argument("--probe-duration", type=float, default=0.5,
+ help="probe wall budget when --probe-instructions=0")
+ parser.add_argument("--behavior-duration", type=float, default=0.4,
+ help="budget for each behavior phase")
+ parser.add_argument("--max-stops", type=int, default=5000, help="safety bound on serviced stops per phase")
+ parser.add_argument("--max-rows", type=int, default=40, help="table row cap in reports")
+ parser.add_argument("--log-bytes", type=int, default=MAX_LOG_BYTES, help="QEMU log window per fault check")
+ return parser
+
+
+def options_from_args(args: argparse.Namespace) -> VersionDiffOptions:
+ corpus = tuple(args.corpus_diff) if args.corpus_diff else None
+ return VersionDiffOptions(
+ old=args.old,
+ new=args.new,
+ out=os.path.abspath(args.out),
+ old_label=args.old_label,
+ new_label=args.new_label,
+ release=args.release,
+ old_symbols=args.old_symbols,
+ new_symbols=args.new_symbols,
+ old_ghidra_names=args.old_ghidra_names,
+ new_ghidra_names=args.new_ghidra_names,
+ old_ghidra_index=args.old_ghidra_index,
+ new_ghidra_index=args.new_ghidra_index,
+ old_ghidra_exports=args.old_ghidra_exports,
+ new_ghidra_exports=args.new_ghidra_exports,
+ old_ghidra_bias=args.old_ghidra_bias,
+ new_ghidra_bias=args.new_ghidra_bias,
+ corpus_diff=corpus, # type: ignore[arg-type]
+ corpus_report=args.corpus_report,
+ stimulus=args.stimulus,
+ probe_instructions=args.probe_instructions,
+ probe_duration=args.probe_duration,
+ behavior_duration=args.behavior_duration,
+ max_stops=args.max_stops,
+ max_rows=args.max_rows,
+ log_bytes=args.log_bytes,
+ approval_token=args.approval_token,
+ )
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ options = options_from_args(args)
+ try:
+ report = run_version_diff(options)
+ except Rm610GateError as exc:
+ print(f"[version-diff] RM610 gate refused: {exc}", file=sys.stderr)
+ return 3
+ except GreenEyeError as exc:
+ print(f"[version-diff] error: {exc}", file=sys.stderr)
+ return 2
+ print(f"[version-diff] {report['old']['label']} -> {report['new']['label']}")
+ for name in ("image", "symbols", "corpus", "probe", "behavior"):
+ section = report["sections"][name]
+ print(f"[version-diff] {name}: status={section['status']}"
+ + (f" reason={section['reason']}" if section.get("reason") else ""))
+ print(f"[version-diff] wrote {os.path.join(options.out, 'version-diff.json')}")
+ print(f"[version-diff] wrote {os.path.join(options.out, 'version-diff.md')}")
+ return 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())