"""Post-run analysis: turn a session directory into a symbol-annotated report. The report is the "complete map" half of the deliverable: hits by function, watch hits, memory writes, coverage and snapshot inventory, ready for offline comparison between runs (bare-metal vs VFIO, old firmware vs new). """ from __future__ import annotations import json import os import re from collections import Counter, defaultdict from .record import load_events, load_meta _TRACE_KINDS = ("exec", "access", "read", "write") _ACCESS_RE = re.compile( r"Invalid (read|write) at addr (0x[0-9a-fA-F]+), size (\d+), region '([^']*)', reason: (\w+)" ) _INTERRUPT_RE = re.compile( r"riscv_cpu_do_interrupt: hart:(\d+), async:(\d+), cause:([0-9a-fA-F]+), " r"epc:(0x[0-9a-fA-F]+), tval:(0x[0-9a-fA-F]+), desc=([\w_]+)" ) def parse_qemu_faults(log_text: str) -> list[dict]: """Parse QEMU ``-d guest_errors,unimp,int`` logs into fault records. Each record pairs the most recent invalid access (if any) with the exception it raised, which is exactly the "first divergence" report a firmware probe needs. """ faults: list[dict] = [] pending_access: dict | None = None for line in log_text.splitlines(): access = _ACCESS_RE.search(line) if access: pending_access = { "rw": access.group(1), "access_addr": int(access.group(2), 16), "size": int(access.group(3)), "region": access.group(4), "reason": access.group(5), } continue interrupt = _INTERRUPT_RE.search(line) if interrupt: faults.append( { "hart": int(interrupt.group(1)), "async": int(interrupt.group(2)), "cause": int(interrupt.group(3), 16), "epc": int(interrupt.group(4), 16), "tval": int(interrupt.group(5), 16), "desc": interrupt.group(6), "access": pending_access, } ) pending_access = None return faults _RISCV_CAUSES = { 0: "instruction address misaligned", 1: "instruction access fault", 2: "illegal instruction", 3: "breakpoint", 4: "load address misaligned", 5: "load access fault", 6: "store address misaligned", 7: "store access fault", } def describe_fault(fault: dict, bias: int = 0) -> str: epc = fault["epc"] fw_pc = epc - bias cause = fault["cause"] desc = _RISCV_CAUSES.get(cause & 0xFF, fault.get("desc", "exception")) lines = [ f"first exception: {fault.get('desc', '?')} (cause {cause:#x}: {desc})", f" epc: {epc:#x}" + (f" (firmware vaddr {fw_pc:#x})" if bias else ""), f" tval: {fault.get('tval', 0):#x}", ] access = fault.get("access") if access: lines.insert( 1, f" access: {access['rw']} at {access['access_addr']:#x} " f"({access['size']} bytes, reason={access['reason']})", ) return "\n".join(lines) def _trace_kind(record: dict) -> str | None: """Native/TCG trace records use ``t``; folded session events use ``type``.""" return record.get("type") or record.get("t") def _number(value, default: int = 0) -> int: if isinstance(value, int): return value if isinstance(value, str): try: return int(value, 0) except ValueError: return default return default def load_trace(session_dir: str, meta: dict | None = None) -> list[dict]: """Load the trace blob referenced by ``meta['trace_blob']`` (R4).""" meta = meta if meta is not None else load_meta(session_dir) rel = meta.get("trace_blob") if not rel: return [] path = rel if os.path.isabs(rel) else os.path.join(session_dir, rel) records: list[dict] = [] try: with open(path, "r", encoding="utf-8") as fh: for line in fh: line = line.strip() if not line: continue record = json.loads(line) if _trace_kind(record) != "summary": records.append(record) except (OSError, ValueError): return [] return records def render_summary(session_dir: str, symbols=None, max_rows: int = 40) -> str: meta = load_meta(session_dir) events = load_events(session_dir) return render_summary_from( meta, events, symbols=symbols, max_rows=max_rows, trace=load_trace(session_dir, meta) ) def render_summary_from(meta: dict, events: list[dict], symbols=None, max_rows: int = 40, trace: list[dict] | None = None) -> str: if trace is None: trace = [e for e in events if _trace_kind(e) in _TRACE_KINDS] lines: list[str] = [] lines.append(f"# green-eye session summary: {meta.get('name', '?')}") lines.append("") lines.append(f"* format: `{meta.get('format', '?')}` green-eye {meta.get('greeneye', '?')}") lines.append(f"* directory: `{meta.get('dir', '.')}`") lines.append(f"* events: {len(events)}") counts = Counter(event.get("type", "?") for event in events) lines.append("* event counts: " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))) counts_meta = meta.get("counts") if counts_meta: lines.append("* recorder counts: " + ", ".join(f"{k}={v}" for k, v in sorted(counts_meta.items()))) lines.append("") # QEMU exception report (probe sessions) fault_count = meta.get("qemu_fault_count") first = meta.get("qemu_first_fault") if fault_count or first: lines.append("## QEMU exceptions") lines.append("") lines.append(f"* total exceptions: {fault_count if fault_count is not None else '?'}") if meta.get("qemu_log_blob"): lines.append(f"* qemu log blob: `{meta['qemu_log_blob']}`") if first: access = first.get("access") or {} lines.append("") lines.append("| field | value |") lines.append("|---|---|") lines.append(f"| descriptor | `{first.get('desc', '?')}` |") lines.append(f"| cause | `{first.get('cause', 0):#x}` ({_RISCV_CAUSES.get(first.get('cause', 0) & 0xFF, '?')}) |") epc = first.get("epc", 0) bias = meta.get("bias") or 0 epc_cell = f"`{epc:#x}`" + (f" (firmware vaddr `{epc - bias:#x}`)" if bias else "") lines.append(f"| epc | {epc_cell} |") lines.append(f"| tval | `{first.get('tval', 0):#x}` |") if access: lines.append( f"| first access | `{access.get('rw')}` at `{access.get('access_addr', 0):#x}` " f"({access.get('size')} bytes, reason `{access.get('reason')}`) |" ) lines.append("") def annotate(addr): if not isinstance(addr, int): return "" if symbols is not None: return symbols.annotate(addr) return f"{addr:#x}" # Breakpoint hits bp_hits = [e for e in events if e.get("type") == "bp_hit"] if bp_hits: by_site: Counter = Counter() by_symbol: Counter = Counter() for event in bp_hits: key = event.get("symbol") or f"{event.get('pc', 0):#x}" by_site[(event.get("pc"), key)] += 1 by_symbol[key] += 1 lines.append("## Breakpoint hits") lines.append("") lines.append("| hits | pc | symbol |") lines.append("|---:|---|---|") for (pc, key), count in by_site.most_common(max_rows): lines.append(f"| {count} | `{pc:#x}` | `{key}` |") lines.append("") # Watchpoint hits watch_hits = [e for e in events if e.get("type") == "watch_hit"] if watch_hits: by_addr: Counter = Counter() for event in watch_hits: by_addr[(event.get("addr"), event.get("kind"))] += 1 lines.append("## Watchpoint hits") lines.append("") lines.append("| hits | address | kind | symbol |") lines.append("|---:|---|---|---|") for (addr, kind), count in by_addr.most_common(max_rows): sym = symbols.resolve(addr) if symbols else None lines.append(f"| {count} | `{addr:#x}` | {kind} | `{sym.name if sym else ''}` |") lines.append("") # Memory writes writes = [e for e in events if e.get("type") == "mem_write"] if writes: regions = Counter() for event in writes: regions[(event.get("addr", 0) >> 12) << 12] += 1 lines.append("## Memory writes") lines.append("") lines.append(f"* writes: {len(writes)}, distinct 4 KiB pages: {len(regions)}") lines.append("") lines.append("| page | writes | symbol |") lines.append("|---|---:|---|") for page, count in regions.most_common(max_rows): sym = symbols.resolve(page) if symbols else None lines.append(f"| `{page:#x}` | {count} | `{sym.name if sym else ''}` |") lines.append("") # Execution/access trace coverage (native R4 or folded TCG trace) if trace: def access_kind(record): kind = _trace_kind(record) if kind == "access": return "write" if str(record.get("rw", "")).startswith("w") else "read" return kind execs = [r for r in trace if access_kind(r) == "exec"] reads = [r for r in trace if access_kind(r) == "read"] writes = [r for r in trace if access_kind(r) == "write"] lines.append("## Execution trace") lines.append("") blob = meta.get("trace_blob") if blob: lines.append(f"* trace blob: `{blob}`") pcs = Counter() for record in execs: pcs[_number(record.get("pc"))] += 1 suffix = f", unique PCs: {len(pcs)}" if execs else " (exec=off)" lines.append(f"* exec events: {len(execs)}{suffix}") read_pages = Counter() for record in reads: read_pages[(_number(record.get("addr")) >> 12) << 12] += 1 write_pages = Counter() for record in writes: write_pages[(_number(record.get("addr")) >> 12) << 12] += 1 lines.append( f"* accesses: {len(reads)} reads ({len(read_pages)} pages), " f"{len(writes)} writes ({len(write_pages)} pages)" ) config = meta.get("trace") or {} if config: bits = [f"exec={'on' if config.get('exec') else 'off'}"] for key in ("start", "end"): if config.get(key) is not None: bits.append(f"{key}={config[key]:#x}" if isinstance(config[key], int) else f"{key}={config[key]}") if config.get("max_events"): bits.append(f"max_events={config['max_events']}") if config.get("dropped"): bits.append(f"dropped={config['dropped']}") lines.append("* trace config: " + ", ".join(bits)) lines.append("") if execs: by_sym = defaultdict(lambda: [0, 0]) # unique PCs, exec count for pc, count in pcs.items(): sym = symbols.resolve(pc) if symbols else None entry = by_sym[sym.name if sym else f"{pc:#x}"] entry[0] += 1 entry[1] += count lines.append("### Unique PCs by symbol") lines.append("") lines.append("| unique PCs | execs | symbol |") lines.append("|---:|---:|---|") for name, (unique, count) in sorted(by_sym.items(), key=lambda kv: -kv[1][1])[:max_rows]: lines.append(f"| {unique} | {count} | `{name}` |") lines.append("") if read_pages or write_pages: lines.append("### Accessed pages") lines.append("") lines.append("| page | reads | writes | symbol |") lines.append("|---|---:|---:|---|") for page in sorted(set(read_pages) | set(write_pages), key=lambda p: -(read_pages.get(p, 0) + write_pages.get(p, 0)))[:max_rows]: sym = symbols.resolve(page) if symbols else None lines.append( f"| `{page:#x}` | {read_pages.get(page, 0)} | {write_pages.get(page, 0)} " f"| `{sym.name if sym else ''}` |" ) lines.append("") # Coverage steps = [e for e in events if e.get("type") == "step"] if steps: pcs = {(e.get("pc")) for e in steps} lines.append("## Stepped coverage") lines.append("") lines.append(f"* steps: {len(steps)}, unique PCs: {len(pcs)}") by_sym = Counter() for pc in pcs: sym = symbols.resolve(pc) if symbols else None by_sym[sym.name if sym else f"{pc:#x}"] += 1 lines.append("") lines.append("| PCs | symbol |") lines.append("|---:|---|") for name, count in by_sym.most_common(max_rows): lines.append(f"| {count} | `{name}` |") lines.append("") # Snapshots snapshots = meta.get("snapshots") or [e for e in events if e.get("type") == "snapshot_saved"] if snapshots: lines.append("## Snapshots") lines.append("") lines.append("| label | address | size | path | sha256 |") lines.append("|---|---|---:|---|---|") for snap in snapshots[:max_rows]: lines.append( f"| {snap.get('label')} | `{snap.get('addr', 0):#x}` | {snap.get('size', 0)} | " f"`{snap.get('path')}` | `{(snap.get('sha256') or '')[:16]}` |" ) lines.append("") # Timeline excerpt notable = [e for e in events if e.get("type") in ("bp_hit", "leave", "watch_hit", "mem_write", "note", "exit")] if notable: lines.append("## Timeline (first hits)") lines.append("") for event in notable[:max_rows]: t = event.get("t", 0) kind = event.get("type") if kind in ("bp_hit", "leave"): site = event.get("symbol") or f"{event.get('pc', 0):#x}" extra = "" regs = event.get("regs") or {} if "a0" in regs: extra = f" a0={regs['a0']:#x}" if kind == "leave" and "retval" in event: extra += f" -> {event['retval']:#x}" lines.append(f"* `{t:8.3f}s` {kind}: {site}{extra}") elif kind == "watch_hit": lines.append(f"* `{t:8.3f}s` watch: {annotate(event.get('addr'))} ({event.get('kind')})") elif kind == "mem_write": lines.append( f"* `{t:8.3f}s` write: {annotate(event.get('addr'))} " f"{event.get('size')}B {event.get('data', '')[:32]}" ) elif kind == "note": lines.append(f"* `{t:8.3f}s` note: {event.get('text')}") elif kind == "exit": lines.append(f"* `{t:8.3f}s` exit status={event.get('status')}") lines.append("") # Symbol-annotation table for automation lines.append("## Annotated sites") lines.append("") sites = Counter() for event in events: for key in ("pc", "addr"): value = event.get(key) if isinstance(value, int) and value and event.get("type") in ("bp_hit", "watch_hit", "mem_write"): sites[value] += 1 lines.append("| address | count | annotation |") lines.append("|---|---:|---|") for addr, count in sites.most_common(max_rows): lines.append(f"| `{addr:#x}` | {count} | `{annotate(addr)}` |") lines.append("") return "\n".join(lines) + "\n" def compare_sessions(a_dir: str, b_dir: str, symbols=None, max_rows: int = 40) -> str: """Diff two recorded sessions at the symbolic event level (A/B tooling).""" a_meta, b_meta = load_meta(a_dir), load_meta(b_dir) a_events, b_events = load_events(a_dir), load_events(b_dir) a_trace, b_trace = load_trace(a_dir, a_meta), load_trace(b_dir, b_meta) lines = [f"# green-eye session comparison", "", f"* A: {a_meta.get('name')} ({a_dir})", f"* B: {b_meta.get('name')} ({b_dir})", ""] def trace_kind(record: dict) -> str: kind = _trace_kind(record) if kind == "access": return "write" if str(record.get("rw", "")).startswith("w") else "read" return kind def profile(events, trace): bp = Counter() watch = Counter() writes = Counter() for event in events: if event.get("type") == "bp_hit": bp[event.get("symbol") or f"{event.get('pc', 0):#x}"] += 1 elif event.get("type") == "watch_hit": watch[(event.get("addr"), event.get("kind"))] += 1 elif event.get("type") == "mem_write": writes[(event.get("addr", 0) >> 12) << 12] += 1 for record in trace: if trace_kind(record) == "write": writes[(_number(record.get("addr")) >> 12) << 12] += 1 return bp, watch, writes a_bp, a_watch, a_writes = profile(a_events, a_trace) b_bp, b_watch, b_writes = profile(b_events, b_trace) lines.append("## Breakpoint profile delta (A vs B)") lines.append("") lines.append("| symbol | A hits | B hits | delta |") lines.append("|---|---:|---:|---:|") for name in sorted(set(a_bp) | set(b_bp), key=lambda k: -(a_bp.get(k, 0) + b_bp.get(k, 0)))[:max_rows]: lines.append(f"| `{name}` | {a_bp.get(name, 0)} | {b_bp.get(name, 0)} | {b_bp.get(name, 0) - a_bp.get(name, 0):+d} |") lines.append("") lines.append("## Watchpoint delta") lines.append("") lines.append("| address | kind | A | B |") lines.append("|---|---|---:|---:|") for key in sorted(set(a_watch) | set(b_watch), key=lambda k: -(a_watch.get(k, 0) + b_watch.get(k, 0)))[:max_rows]: lines.append(f"| `{key[0]:#x}` | {key[1]} | {a_watch.get(key, 0)} | {b_watch.get(key, 0)} |") lines.append("") lines.append("## Written pages delta") lines.append("") lines.append("| page | A writes | B writes |") lines.append("|---|---:|---:|") for page in sorted(set(a_writes) | set(b_writes), key=lambda k: -(a_writes.get(k, 0) + b_writes.get(k, 0)))[:max_rows]: lines.append(f"| `{page:#x}` | {a_writes.get(page, 0)} | {b_writes.get(page, 0)} |") lines.append("") def trace_totals(trace): totals = Counter() for record in trace: kind = trace_kind(record) if kind in ("exec", "read", "write"): totals[kind] += 1 return totals a_totals, b_totals = trace_totals(a_trace), trace_totals(b_trace) if a_trace or b_trace: lines.append("## Trace stream totals (A vs B)") lines.append("") lines.append("| stream | A | B | delta |") lines.append("|---|---:|---:|---:|") for kind in ("exec", "read", "write"): a_count, b_count = a_totals.get(kind, 0), b_totals.get(kind, 0) lines.append(f"| {kind} | {a_count} | {b_count} | {b_count - a_count:+d} |") lines.append("") return "\n".join(lines) + "\n"