summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm/harness
diff options
context:
space:
mode:
Diffstat (limited to 'debug/risc-v-gsp-rm-vm/harness')
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/contracts.py396
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/launch_audit.py691
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/log_decode.py660
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/rehost.py428
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/rm_capture.py1251
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/run_contracts.py131
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/server_snapshot.py698
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/state_decode.py1082
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/state_seed.py990
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/valprobe.py651
10 files changed, 6978 insertions, 0 deletions
diff --git a/debug/risc-v-gsp-rm-vm/harness/contracts.py b/debug/risc-v-gsp-rm-vm/harness/contracts.py
new file mode 100644
index 0000000..ec387fe
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/contracts.py
@@ -0,0 +1,396 @@
+#!/usr/bin/env python3
+"""Contract registry: every solved wall becomes a re-runnable regression.
+
+Why this exists
+---------------
+The GR campaign's dominant cost is wall-hell: one blocking firmware problem
+after another, each triaged by supervised hardware cycles. Several wall
+classes now have offline, deterministic tools (docs/45-48), but their value is
+only durable if the *answer* survives as a re-runnable check. This registry
+pins the proven entry points of those tools to one explicit expectation each
+and re-runs them: fast contracts run in the main suite, slow ones on demand.
+
+It wraps existing entry points as-is (no measurement logic is reimplemented)
+and every contract names its wall class from ``docs/50-WALL-WORKFLOW.md``, so
+per-domain coverage is visible - including the classes with no offline
+contract by design (engine execution awaits supervised captures).
+
+Offline only: never touches a GPU, kernel module, mdev, guest or network.
+"""
+from __future__ import annotations
+
+import os
+import time
+from dataclasses import dataclass
+from typing import Any, Callable
+
+TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+from harness.rehost import DEFAULT_IMAGE, DEMO_ELF # noqa: E402
+
+#: Schema tag of a registry run summary.
+RUN_FORMAT = "greeneye-contract-run/1"
+
+FAST = "fast"
+SLOW = "slow"
+
+#: Wall classes from docs/50 section 2. A contract names exactly one.
+WALL_CLASSES = {
+ "a": "firmware boot/status",
+ "b": "RM server behaviour",
+ "c": "engine memory contract",
+ "d": "engine execution/dispatch",
+ "e": "guest-driver/protocol interface",
+ "f": "version skew/portability",
+}
+
+
+class ContractError(Exception):
+ """A contract record or registry request is invalid."""
+
+
+class ContractSkip(Exception):
+ """A contract precondition is missing at run time (not a pass/fail)."""
+
+
+@dataclass(frozen=True)
+class Contract:
+ """One re-runnable regression over an existing tool entry point."""
+
+ name: str
+ wall_class: str # key into WALL_CLASSES (docs/50 section 2)
+ doc: str # where the wall and the tool are documented
+ entry: str # exact wrapped entry point, e.g. "sim.gmmu_walk:run_demo()"
+ speed: str # FAST runs in the suite; SLOW is --all only
+ expect: str # the pass condition, in words
+ run: Callable[[], dict] # no-arg callable returning the tool's report
+ check: Callable[[dict], tuple[bool, str]] # (passed, detail)
+ requires: tuple[str, ...] = () # paths that must exist; otherwise skip
+
+ @property
+ def domain(self) -> str:
+ return WALL_CLASSES.get(self.wall_class, self.wall_class)
+
+
+@dataclass(frozen=True)
+class ContractResult:
+ """Outcome of one contract run; ``status`` is pass | fail | skip."""
+
+ name: str
+ wall_class: str
+ speed: str
+ doc: str
+ entry: str
+ expect: str
+ status: str
+ detail: str
+ seconds: float
+
+ @property
+ def domain(self) -> str:
+ return WALL_CLASSES.get(self.wall_class, self.wall_class)
+
+ @property
+ def passed(self) -> bool:
+ return self.status == "pass"
+
+ def to_dict(self) -> dict:
+ return {
+ "name": self.name,
+ "wall_class": self.wall_class,
+ "domain": self.domain,
+ "speed": self.speed,
+ "doc": self.doc,
+ "entry": self.entry,
+ "expect": self.expect,
+ "status": self.status,
+ "detail": self.detail,
+ "seconds": round(self.seconds, 4),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Runners: thin wrappers over the proven tool entry points.
+#
+# Every wrapper only imports, calls and (where the demo writes artifacts)
+# suppresses output paths. The expectation lives in the checker next to it.
+# ---------------------------------------------------------------------------
+
+def _run_fecs_matrix() -> dict:
+ from harness.valprobe import fecs_spec, run_spec
+
+ return run_spec(spec=fecs_spec(), record=False, name="contract-fecs-matrix")
+
+
+def _check_fecs_matrix(report: dict) -> tuple[bool, str]:
+ variants = report.get("variants", [])
+ evaluated = report.get("evaluated")
+ failed = report.get("failed")
+ ok = (
+ report.get("format") == "greeneye-valprobe-matrix/1"
+ and len(variants) == 15
+ and evaluated == 15
+ and failed == 0
+ and all(row.get("pass") is True for row in variants)
+ )
+ return ok, f"{evaluated}/{len(variants)} PROMOTE_CTX variants labelled, {failed} failed"
+
+
+def _run_fecs_determinism() -> dict:
+ from harness.valprobe import run_demo
+
+ return run_demo()
+
+
+def _check_fecs_determinism(report: dict) -> tuple[bool, str]:
+ ok = (
+ report.get("format") == "greeneye-valprobe-demo/1"
+ and report.get("ok") is True
+ and report.get("deterministic") is True
+ and report.get("failed") == 0
+ )
+ return ok, (
+ f"evaluated={report.get('evaluated')} failed={report.get('failed')} "
+ f"deterministic={report.get('deterministic')}"
+ )
+
+
+def _run_gmmu_demo() -> dict:
+ from sim.gmmu_walk import run_demo
+
+ return run_demo()
+
+
+def _check_gmmu_demo(report: dict) -> tuple[bool, str]:
+ rows = report.get("rows", [])
+ matched = sum(1 for row in rows if row.get("match") is True)
+ ok = report.get("format") == "greeneye-gmmu-walk-demo/1" and report.get("ok") is True \
+ and matched == len(rows) == 5
+ return ok, f"{matched}/{len(rows)} VAs walk to the captured DMA addresses"
+
+
+def _run_launch_audit_demo() -> dict:
+ from harness.launch_audit import run_demo
+
+ return run_demo()
+
+
+def _check_launch_audit_demo(report: dict) -> tuple[bool, str]:
+ from harness.launch_audit import VERDICT_OK, VERDICT_PA_MISMATCH, VERDICT_UNMAPPED
+
+ legs = report.get("captured_channel", {}).get("contract", [])
+ ok_legs = sum(1 for leg in legs if leg.get("verdict") == VERDICT_OK)
+ classes = {
+ (scenario.get("report", {}).get("contract") or [{}])[0].get("verdict")
+ for scenario in report.get("qmd_hypotheses", [])
+ }
+ ok = (
+ report.get("format") == "greeneye-launch-audit-demo/1"
+ and report.get("ok") is True
+ and ok_legs == len(legs) == 5
+ and {VERDICT_PA_MISMATCH, VERDICT_UNMAPPED} <= classes
+ )
+ return ok, (
+ f"captured channel {ok_legs}/{len(legs)} legs ok; "
+ f"QMD verdict classes {sorted(c for c in classes if c)}"
+ )
+
+
+def _run_rehost_demo() -> dict:
+ from harness.rehost import run_demo
+
+ return run_demo()
+
+
+def _check_rehost_demo(report: dict) -> tuple[bool, str]:
+ first = report.get("first", {})
+ second = report.get("second", {})
+ agree = (
+ first.get("status") == second.get("status")
+ and first.get("retval") == second.get("retval")
+ )
+ ok = (
+ report.get("format") == "greeneye-rehost-demo/1"
+ and report.get("ok") is True
+ and first.get("status") == "return"
+ and first.get("retval") == report.get("expected")
+ )
+ return ok, (
+ f"status={first.get('status')} retval={first.get('retval')} "
+ f"(expected {report.get('expected')}); two runs agree={agree}"
+ )
+
+
+def _run_rm_capture_demo() -> dict:
+ from harness.rm_capture import run_demo
+
+ return run_demo()
+
+
+def _check_rm_capture_demo(report: dict) -> tuple[bool, str]:
+ summary = report.get("summary", {})
+ ok = report.get("format") == "greeneye-rm-rpc-capture-demo/1" and report.get("ok") is True
+ return ok, (
+ f"records {report.get('records_a')}/{report.get('records_b')}; "
+ f"stimulus messages {report.get('messages')}; "
+ f"agree={summary.get('agree')} shape_agree={summary.get('shape_agree')} "
+ f"a_only={summary.get('a_only')} b_only={summary.get('b_only')}"
+ )
+
+
+# ---------------------------------------------------------------------------
+# Registry
+# ---------------------------------------------------------------------------
+
+def _registry() -> list[Contract]:
+ return [
+ Contract(
+ name="fecs-predicate-matrix",
+ wall_class="b",
+ doc="docs/47, docs/50",
+ entry="harness.valprobe:run_spec(fecs_spec())",
+ speed=FAST,
+ expect="15/15 PROMOTE_CTX predicate variants reach their labelled outcome (failed == 0)",
+ requires=(DEFAULT_IMAGE,),
+ run=_run_fecs_matrix,
+ check=_check_fecs_matrix,
+ ),
+ Contract(
+ name="fecs-predicate-determinism",
+ wall_class="b",
+ doc="docs/47, docs/25, docs/50",
+ entry="harness.valprobe:run_demo()",
+ speed=SLOW,
+ expect="the bundled matrix runs twice with identical verdicts (ok and deterministic)",
+ requires=(DEFAULT_IMAGE,),
+ run=_run_fecs_determinism,
+ check=_check_fecs_determinism,
+ ),
+ Contract(
+ name="gmmu-golden-vector",
+ wall_class="c",
+ doc="docs/48, docs/50",
+ entry="sim.gmmu_walk:run_demo()",
+ speed=FAST,
+ expect="the captured channel-PDB vector walks 5/5 VAs to the logged DMA addresses",
+ run=_run_gmmu_demo,
+ check=_check_gmmu_demo,
+ ),
+ Contract(
+ name="launch-audit-captured-contract",
+ wall_class="c",
+ doc="docs/48, docs/50",
+ entry="harness.launch_audit:run_demo()",
+ speed=FAST,
+ expect="captured contract all ok; QMD hypotheses classify pa_mismatch and unmapped",
+ run=_run_launch_audit_demo,
+ check=_check_launch_audit_demo,
+ ),
+ Contract(
+ name="rehost-demo",
+ wall_class="b",
+ doc="docs/46, docs/25, docs/50",
+ entry="harness.rehost:run_demo()",
+ speed=FAST,
+ expect="the demo function returns the expected sum twice (ok == true)",
+ requires=(DEMO_ELF,),
+ run=_run_rehost_demo,
+ check=_check_rehost_demo,
+ ),
+ Contract(
+ name="rm-capture-decode-demo",
+ wall_class="e",
+ doc="docs/45, docs/25, docs/50",
+ entry="harness.rm_capture:run_demo()",
+ speed=FAST,
+ expect="synthetic capture decodes, compares and yields a stimulus (ok == true)",
+ run=_run_rm_capture_demo,
+ check=_check_rm_capture_demo,
+ ),
+ ]
+
+
+def all_contracts() -> list[Contract]:
+ """Every contract in the registry, fast and slow."""
+ return _registry()
+
+
+def fast_contracts() -> list[Contract]:
+ """Contracts the main suite runs."""
+ return [c for c in all_contracts() if c.speed == FAST]
+
+
+def slow_contracts() -> list[Contract]:
+ """Contracts only ``run_contracts.py --all`` runs."""
+ return [c for c in all_contracts() if c.speed == SLOW]
+
+
+def _result(contract: Contract, status: str, detail: str, seconds: float) -> ContractResult:
+ return ContractResult(
+ name=contract.name,
+ wall_class=contract.wall_class,
+ speed=contract.speed,
+ doc=contract.doc,
+ entry=contract.entry,
+ expect=contract.expect,
+ status=status,
+ detail=detail,
+ seconds=seconds,
+ )
+
+
+def run_contract(contract: Contract) -> ContractResult:
+ """Run one contract, classifying every outcome without raising."""
+ missing = [path for path in contract.requires if not os.path.exists(path)]
+ if missing:
+ return _result(contract, "skip", "prerequisite missing: " + ", ".join(missing), 0.0)
+ started = time.monotonic()
+ try:
+ report = contract.run()
+ except ContractSkip as exc:
+ return _result(contract, "skip", str(exc), time.monotonic() - started)
+ except Exception as exc: # noqa: BLE001 - a raising entry point is a failed contract
+ return _result(
+ contract, "fail", f"{type(exc).__name__}: {exc}", time.monotonic() - started
+ )
+ elapsed = time.monotonic() - started
+ try:
+ passed, detail = contract.check(report)
+ except Exception as exc: # noqa: BLE001 - a broken checker is a failed contract
+ return _result(contract, "fail", f"checker error: {type(exc).__name__}: {exc}", elapsed)
+ return _result(contract, "pass" if passed else "fail", str(detail), elapsed)
+
+
+def run_contracts(contracts: list[Contract]) -> list[ContractResult]:
+ """Run the given contracts in order; never raises for a contract outcome."""
+ return [run_contract(contract) for contract in contracts]
+
+
+def summarize(results: list[ContractResult]) -> dict:
+ """Machine-readable run summary with per-wall-class coverage."""
+ counts = {"pass": 0, "fail": 0, "skip": 0}
+ domains: dict[str, dict[str, Any]] = {
+ key: {
+ "wall_class": key,
+ "domain": label,
+ "total": 0,
+ "pass": 0,
+ "fail": 0,
+ "skip": 0,
+ }
+ for key, label in WALL_CLASSES.items()
+ }
+ for result in results:
+ if result.status not in counts:
+ raise ContractError(f"unknown contract status {result.status!r}")
+ counts[result.status] += 1
+ bucket = domains[result.wall_class]
+ bucket["total"] += 1
+ bucket[result.status] += 1
+ return {
+ "format": RUN_FORMAT,
+ "ok": counts["fail"] == 0,
+ "counts": counts,
+ "domains": [domains[key] for key in sorted(domains)],
+ "contracts": [result.to_dict() for result in results],
+ }
diff --git a/debug/risc-v-gsp-rm-vm/harness/launch_audit.py b/debug/risc-v-gsp-rm-vm/harness/launch_audit.py
new file mode 100644
index 0000000..ba7a623
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/launch_audit.py
@@ -0,0 +1,691 @@
+#!/usr/bin/env python3
+"""Launch memory-contract audit: where would the engine read/write?
+
+A forwarded submission goes silent when one of the engine-visible memory legs
+is wrong: the QMD destination the front-end copies to, the release semaphore
+the engine writes, the ring the front-end fetches, the parameter/program
+pages the QMD points at. The host's bookkeeping (shadows, guest pages) is not
+what the engine sees -- the *page tables* are. This harness walks each
+address of a submission's memory contract through a captured/constructed
+page-table view (``sim.gmmu_walk``) and reports, per leg, the PA the engine
+would use, its access flags, and any divergence from what the host believes.
+
+It answers the class of question behind "the front-end never wrote the QMD"/
+"the release never fired": *is the mapping the engine uses the mapping we are
+checking?* -- offline, deterministically, without hardware.
+
+``check`` closes the capture loop: run it against a partial or empty
+``memory[]`` view and it reports, per contract leg, the FIRST table page the
+walk needed but the view does not contain (with the level/index path), plus a
+consolidated ``needed_pages[]`` list. Dump exactly those pages, re-run, and
+repeat -- one capture iteration per table level.
+
+Schemas:
+
+* ``greeneye-launch-audit-spec/1`` -- inputs (page tables + contract).
+* ``greeneye-launch-audit/1`` -- the verdict report.
+* ``greeneye-launch-audit-check/1`` -- the dump-completeness report.
+
+Honesty: a verdict is only as good as the captured table view in the spec.
+``capture-hints`` prints what to capture for a real audit and never executes
+anything. Fidelity is the address view (L1); no timing claim.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import struct
+import sys
+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 sim.gmmu_pte import KIND_VER2, LEVELS, SUPPORTED_KINDS # noqa: E402
+from sim.gmmu_walk import ( # noqa: E402
+ FAULT_TABLE_MISSING,
+ MemoryView,
+ WalkError,
+ channel_pdb_fixture,
+ encode_dual_slot,
+ encode_pde_pointer,
+ encode_pte,
+ walk,
+)
+
+SPEC_FORMAT = "greeneye-launch-audit-spec/1"
+AUDIT_FORMAT = "greeneye-launch-audit/1"
+CHECK_FORMAT = "greeneye-launch-audit-check/1"
+DEMO_FORMAT = "greeneye-launch-audit-demo/1"
+
+VERDICT_OK = "ok"
+VERDICT_UNMAPPED = "unmapped"
+VERDICT_PA_MISMATCH = "pa_mismatch"
+VERDICT_ACCESS_DENIED = "access_denied"
+
+#: Walk depth -> display label (root first, leaf page table last). Mirrors
+#: the ``LEVELS`` geometry in ``sim.gmmu_pte``; kept here as display names so
+#: check reports read like the capture hints (root/pde2/pde1/pde0/pte).
+LEVEL_LABELS = ("root", "pde2", "pde1", "pde0", "pte")
+
+
+class AuditError(ValueError):
+ """A spec or request the tool cannot honour."""
+
+
+def _parse_int(value: Any) -> int:
+ if isinstance(value, bool):
+ raise AuditError(f"bad integer {value!r}")
+ if isinstance(value, int):
+ return value
+ if isinstance(value, str):
+ try:
+ return int(value, 0)
+ except ValueError as exc:
+ raise AuditError(f"bad integer {value!r}") from exc
+ raise AuditError(f"bad integer {value!r}")
+
+
+def _parse_hex_bytes(text: str) -> bytes:
+ data = "".join(str(text).split())
+ if len(data) % 2:
+ raise AuditError("odd hex length")
+ try:
+ return bytes.fromhex(data)
+ except ValueError as exc:
+ raise AuditError(f"bad hex payload: {exc}") from exc
+
+
+def load_memory(items) -> MemoryView:
+ mem = MemoryView()
+ for item in items or ():
+ if "pa" not in item or "hex" not in item:
+ raise AuditError(f"memory item needs pa and hex: {item!r}")
+ mem.add(_parse_int(item["pa"]), _parse_hex_bytes(item["hex"]))
+ return mem
+
+
+def audit(spec: dict) -> dict:
+ """Run one audit spec; return a ``greeneye-launch-audit/1`` report."""
+ fmt = spec.get("format", SPEC_FORMAT)
+ if fmt != SPEC_FORMAT:
+ raise AuditError(f"unsupported spec format {fmt!r} (want {SPEC_FORMAT})")
+ if "pdb_pa" not in spec or "contract" not in spec:
+ raise AuditError("spec needs pdb_pa and contract")
+ kind = spec.get("gmmu", KIND_VER2)
+ if kind not in SUPPORTED_KINDS:
+ raise AuditError(f"unsupported gmmu kind {kind!r} (support: {SUPPORTED_KINDS})")
+ page_mode = spec.get("page_mode", "auto")
+ root_pa = _parse_int(spec["pdb_pa"])
+ mem = load_memory(spec.get("memory"))
+
+ rows = []
+ for item in spec["contract"]:
+ label = item.get("label") or "unnamed"
+ if "va" not in item:
+ raise AuditError(f"contract item needs va: {item!r}")
+ va = _parse_int(item["va"])
+ access = item.get("access", "read")
+ report = walk(va, root_pa=root_pa, mem=mem, kind=kind, page_mode=page_mode)
+ row: dict[str, Any] = {
+ "label": label,
+ "va": f"{va:#x}",
+ "access": access,
+ "note": item.get("note"),
+ "verdict": None,
+ "walk": report,
+ }
+ if report["status"] != "ok":
+ row["verdict"] = VERDICT_UNMAPPED
+ row["detail"] = report.get("fault_message")
+ else:
+ expect_pa = item.get("expect_pa")
+ if access == "write" and not report.get("writable", False):
+ row["verdict"] = VERDICT_ACCESS_DENIED
+ row["detail"] = "PTE is read-only but the engine must write here"
+ elif expect_pa is not None and _parse_int(expect_pa) != _parse_int(report["pa"]):
+ row["verdict"] = VERDICT_PA_MISMATCH
+ row["detail"] = (f"engine walk -> {report['pa']}; host believes "
+ f"{_parse_int(expect_pa):#x}")
+ else:
+ row["verdict"] = VERDICT_OK
+ rows.append(row)
+
+ summary = {v: sum(1 for r in rows if r["verdict"] == v)
+ for v in (VERDICT_OK, VERDICT_UNMAPPED, VERDICT_PA_MISMATCH,
+ VERDICT_ACCESS_DENIED)}
+ report = {
+ "format": AUDIT_FORMAT,
+ "name": spec.get("name"),
+ "firmware": spec.get("firmware"),
+ "gmmu": kind,
+ "page_mode": page_mode,
+ "pdb_pa": f"{root_pa:#x}",
+ "contract": rows,
+ "summary": summary,
+ "ok": summary[VERDICT_UNMAPPED] == 0 and summary[VERDICT_PA_MISMATCH] == 0
+ and summary[VERDICT_ACCESS_DENIED] == 0,
+ "limits": [
+ "Address view only (L1): a clean audit proves the engine CAN reach these PAs, not that it runs.",
+ "Verdicts depend on the captured table view in the spec; an incomplete view reports unmapped, not unreachability.",
+ ],
+ }
+ return report
+
+
+# ---------------------------------------------------------------------------
+# Check: which table pages does the walk still need?
+# ---------------------------------------------------------------------------
+
+def _path_from_steps(steps: list[dict]) -> list[dict]:
+ """The completed walk hops, in walk order, as check ``path`` elements."""
+ return [
+ {
+ "level": LEVEL_LABELS[step["depth"]],
+ "depth": step["depth"],
+ "table_pa": step["table_pa"],
+ "index": step["index"],
+ "entry_pa": step["entry_pa"],
+ "present": True,
+ }
+ for step in steps
+ ]
+
+
+def _missing_table(report: dict, root_pa: int, va: int) -> dict:
+ """Describe the first table page a ``table_missing`` walk needed.
+
+ ``report["steps"]`` holds the levels that completed before the failing
+ read; the missing table is the page that read was addressing. The
+ returned dict carries the page base (``pa`` and ``table_pa``), the level
+ label, the walk depth, the index inside the table and the exact entry
+ address. ``report`` must be a ``table_missing`` fault.
+ """
+ depth = len(report["steps"])
+ if depth == 0:
+ table_pa = root_pa
+ else:
+ prev = report["steps"][-1]
+ if prev.get("role") == "dual_pde":
+ half = prev.get("page_mode_used")
+ table_pa = (prev["big"]["address"] if half == "64k"
+ else prev["small"]["address"])
+ else:
+ table_pa = prev["address"]
+ if depth not in range(len(LEVEL_LABELS)):
+ # Cannot happen for a walker fault, but never guess a geometry.
+ return {"pa": f"{table_pa:#x}", "table_pa": f"{table_pa:#x}",
+ "depth": depth, "level": f"depth{depth}", "index": None,
+ "entry_pa": None, "present": False}
+ if depth == 4:
+ prev = report["steps"][-1]
+ shift = 16 if prev.get("page_mode_used") == "64k" else 12
+ index = (va >> shift) & 0x1FF
+ entry_size = 8
+ else:
+ geo = LEVELS[depth]
+ index = (va >> geo["va_bits"][1]) & ((1 << geo["index_bits"]) - 1)
+ entry_size = geo["entry_size"]
+ return {
+ "pa": f"{table_pa:#x}",
+ "table_pa": f"{table_pa:#x}",
+ "depth": depth,
+ "level": LEVEL_LABELS[depth],
+ "index": index,
+ "entry_pa": f"{table_pa + index * entry_size:#x}",
+ "present": False,
+ }
+
+
+def check(spec: dict) -> dict:
+ """Report the table pages a partial ``memory[]`` view still needs.
+
+ Runs the same walks as :func:`audit` but, instead of verdicts, reports per
+ contract leg the FIRST table page the walk could not read (the page the
+ dump is missing) with the level/index path that reached it, and a
+ consolidated ``needed_pages`` list. Iterate: run ``check`` with a partial
+ or empty dump, dump exactly the named pages, repeat until ``complete`` is
+ true, then run :func:`audit` for verdicts. A leg whose walk faults for a
+ non-dump reason (invalid aperture/PTE, unsupported encoding) is reported
+ as ``fault`` and needs no more pages.
+
+ An empty spec memory is valid: every leg then reports the root page as
+ its first needed page.
+ """
+ fmt = spec.get("format", SPEC_FORMAT)
+ if fmt != SPEC_FORMAT:
+ raise AuditError(f"unsupported spec format {fmt!r} (want {SPEC_FORMAT})")
+ if "pdb_pa" not in spec or "contract" not in spec:
+ raise AuditError("spec needs pdb_pa and contract")
+ kind = spec.get("gmmu", KIND_VER2)
+ if kind not in SUPPORTED_KINDS:
+ raise AuditError(f"unsupported gmmu kind {kind!r} (support: {SUPPORTED_KINDS})")
+ page_mode = spec.get("page_mode", "auto")
+ root_pa = _parse_int(spec["pdb_pa"])
+ mem = load_memory(spec.get("memory"))
+
+ rows = []
+ needed: dict[int, dict[str, Any]] = {}
+ for item in spec["contract"]:
+ label = item.get("label") or "unnamed"
+ if "va" not in item:
+ raise AuditError(f"contract item needs va: {item!r}")
+ va = _parse_int(item["va"])
+ report = walk(va, root_pa=root_pa, mem=mem, kind=kind, page_mode=page_mode)
+ row: dict[str, Any] = {
+ "label": label,
+ "va": f"{va:#x}",
+ "access": item.get("access", "read"),
+ "expect_pa": None,
+ "status": None,
+ "resolved_pa": None,
+ "writable": None,
+ "first_missing": None,
+ "fault": report.get("fault"),
+ "detail": report.get("fault_message"),
+ "path": _path_from_steps(report["steps"]),
+ }
+ if item.get("expect_pa") is not None:
+ row["expect_pa"] = f"{_parse_int(item['expect_pa']):#x}"
+ if report["status"] == "ok":
+ row["status"] = "complete"
+ row["resolved_pa"] = report["pa"]
+ row["writable"] = report.get("writable")
+ row["fault"] = None
+ row["detail"] = None
+ elif report.get("fault") == FAULT_TABLE_MISSING:
+ missing = _missing_table(report, root_pa, va)
+ row["status"] = "missing"
+ row["first_missing"] = missing
+ row["path"].append(missing)
+ entry = needed.setdefault(int(missing["pa"], 16),
+ {"levels": set(), "legs": []})
+ entry["levels"].add(missing["level"])
+ entry["legs"].append(label)
+ else:
+ row["status"] = "fault"
+ rows.append(row)
+
+ needed_pages = [
+ {"pa": f"{pa:#x}", "levels": sorted(value["levels"]),
+ "legs": sorted(value["legs"])}
+ for pa, value in sorted(needed.items())
+ ]
+ return {
+ "format": CHECK_FORMAT,
+ "name": spec.get("name"),
+ "firmware": spec.get("firmware"),
+ "gmmu": kind,
+ "page_mode": page_mode,
+ "pdb_pa": f"{root_pa:#x}",
+ "memory_pages": len(mem.spans()),
+ "contract": rows,
+ "needed_pages": needed_pages,
+ "complete": all(row["status"] != "missing" for row in rows),
+ "limits": [
+ "Dump-completeness view only: `complete` means every table page the walk needed is in the spec, not that any mapping is correct.",
+ "A `fault` leg needs no more pages: the walk read its entries and refused them (invalid aperture/PTE or unsupported encoding).",
+ "`needed_pages` is the FIRST missing page per leg; dumping it can reveal the next one, so iterate.",
+ ],
+ }
+
+
+# ---------------------------------------------------------------------------
+# Demo: the captured channel-PDB contract + the QMD-destination hypotheses
+# ---------------------------------------------------------------------------
+
+def _tree_for_va(root_pa: int, target_va: int, target_pa: int | None,
+ *, tables_base: int = 0x50000000) -> MemoryView:
+ """Build a minimal VER2 tree mapping one VA to one PA (None = unmapped)."""
+ va = target_va
+ i0 = (va >> 47) & 0x3
+ i1 = (va >> 38) & 0x1FF
+ i2 = (va >> 29) & 0x1FF
+ i3 = (va >> 21) & 0xFF
+ i4 = (va >> 12) & 0x1FF
+ pd2 = tables_base + 0x1000
+ pd1 = tables_base + 0x2000
+ pd0 = tables_base + 0x3000
+ pt = tables_base + 0x4000
+ mem = MemoryView()
+ root = bytearray(0x1000)
+ struct.pack_into("<Q", root, i0 * 8, encode_pde_pointer(pd2))
+ mem.add(root_pa, bytes(root))
+ page = bytearray(0x1000)
+ struct.pack_into("<Q", page, i1 * 8, encode_pde_pointer(pd1))
+ mem.add(pd2, bytes(page))
+ page = bytearray(0x1000)
+ struct.pack_into("<Q", page, i2 * 8, encode_pde_pointer(pd0))
+ mem.add(pd1, bytes(page))
+ page = bytearray(0x1000)
+ struct.pack_into("<16s", page, i3 * 16, encode_dual_slot(None, pt))
+ mem.add(pd0, bytes(page))
+ page = bytearray(0x1000)
+ if target_pa is not None:
+ struct.pack_into("<Q", page, i4 * 8, encode_pte(target_pa))
+ mem.add(pt, bytes(page))
+ return mem
+
+
+def run_demo(out: str | None = None) -> dict:
+ """Audit the captured channel contract and the fork classes.
+
+ Expected verdicts (all pinned by tests):
+
+ * captured channel contract -- all 5 low-window legs ``ok``;
+ * QMD destination (VA ``0x204608000``): engine resolves to the host shadow
+ while the host checks the guest page -> ``pa_mismatch``; bookkeeping
+ trusting the shadow -> ``ok``; unmapped leaf -> ``unmapped``;
+ * engine-write through shadow (the next wall class: CUDA output buffers at
+ high VAs, where Tier-1 refuses 0x1f and the leg resolves through the
+ guest scratch PT pool): the write leg resolves to a shadow PA while the
+ host bookkeeping still expects the guest PA -> ``pa_mismatch``; when the
+ bookkeeping is aligned to the engine-visible shadow PA -> ``ok``.
+ The shadow/guest PAs in this scenario are synthetic (documented in
+ docs/48), not m7 captures.
+ """
+ # 1) Captured channel PDB contract (validated environment probe).
+ root_pa, mem = channel_pdb_fixture()
+ captured = {
+ "format": SPEC_FORMAT,
+ "name": "captured-channel-pdb",
+ "firmware": "570.144",
+ "gmmu": KIND_VER2,
+ "page_mode": "4k",
+ "pdb_pa": f"{root_pa:#x}",
+ "memory": [{"pa": f"{pa:#x}", "hex": b.hex()} for pa, b in _memory_items(mem)],
+ "contract": [
+ {"label": "gpfifo-ring", "va": "0x100000", "access": "read",
+ "expect_pa": "0x2045d9000", "note": "front-end GPFIFO fetch"},
+ {"label": "push-buffer", "va": "0x101000", "access": "write",
+ "expect_pa": "0x121bd9000"},
+ {"label": "copy-src", "va": "0x102000", "access": "read",
+ "expect_pa": "0x152115000"},
+ {"label": "copy-dst", "va": "0x103000", "access": "write",
+ "expect_pa": "0x15a5f5000"},
+ {"label": "semaphore", "va": "0x104000", "access": "write",
+ "expect_pa": "0x10cf27000"},
+ ],
+ }
+ captured_report = audit(captured)
+
+ # 2) QMD-destination hypotheses, same VA (0x204608000):
+ # A: engine resolves to the host shadow (0x15e8fa000) -- host checks
+ # the guest page (0x204608000) -> pa_mismatch (the "wrong page" bug).
+ # B: no mapping / invalid leaf -> unmapped (the "copy dropped" bug).
+ qmd_va = 0x204608000
+ shadow_pa = 0x15E8FA000
+ scenarios = []
+ for label, target, expect in (
+ ("qmd-dest-shadow-trusted", shadow_pa, shadow_pa),
+ ("qmd-dest-host-checks-guest", shadow_pa, 0x204608000),
+ ("qmd-dest-unmapped", None, None),
+ ):
+ root = 0x60000000
+ tree = _tree_for_va(root, qmd_va, target)
+ spec = {
+ "format": SPEC_FORMAT, "name": label, "firmware": "570.144",
+ "gmmu": KIND_VER2, "page_mode": "4k", "pdb_pa": f"{root:#x}",
+ "memory": [{"pa": f"{pa:#x}", "hex": b.hex()} for pa, b in _memory_items(tree)],
+ "contract": [{"label": "inline-qmd-destination", "va": f"{qmd_va:#x}",
+ "access": "write", "expect_pa": f"{expect:#x}" if expect else None,
+ "note": "front-end copies the inline QMD here"}],
+ }
+ report = audit(spec)
+ scenarios.append({"name": label, "report": report})
+
+ # 3) Engine-write through shadow (the next wall class). A high guest VA
+ # is refused by Tier-1 and resolved through the guest scratch PT pool
+ # to a shadow page; the host bookkeeping for the output may still track
+ # the guest page. Same walk, two bookkeeping states:
+ # A: host expects the guest PA -> pa_mismatch (the write is invisible).
+ # B: bookkeeping aligned to the engine-visible shadow PA -> ok.
+ shadow_va = 0x210000
+ shadow_target = 0x13D2A0000
+ guest_pa = 0x2045C0000
+ shadow_scenarios = []
+ for label, expect, want in (
+ ("engine-write-shadow-host-checks-guest", guest_pa, VERDICT_PA_MISMATCH),
+ ("engine-write-shadow-bookkeeping-aligned", shadow_target, VERDICT_OK),
+ ):
+ root = 0x70000000
+ tree = _tree_for_va(root, shadow_va, shadow_target)
+ spec = {
+ "format": SPEC_FORMAT, "name": label, "firmware": "570.144",
+ "gmmu": KIND_VER2, "page_mode": "4k", "pdb_pa": f"{root:#x}",
+ "memory": [{"pa": f"{pa:#x}", "hex": b.hex()} for pa, b in _memory_items(tree)],
+ "contract": [{"label": "high-va-output", "va": f"{shadow_va:#x}",
+ "access": "write", "expect_pa": f"{expect:#x}",
+ "note": "Tier-2 shadow alias for a high-VA write leg"}],
+ }
+ report = audit(spec)
+ shadow_scenarios.append({"name": label, "expected_verdict": want,
+ "report": report})
+
+ demo = {
+ "format": DEMO_FORMAT,
+ "ok": (captured_report["ok"]
+ and scenarios[0]["report"]["ok"]
+ and scenarios[1]["report"]["contract"][0]["verdict"] == VERDICT_PA_MISMATCH
+ and scenarios[2]["report"]["contract"][0]["verdict"] == VERDICT_UNMAPPED
+ and all(s["report"]["contract"][0]["verdict"] == s["expected_verdict"]
+ for s in shadow_scenarios)),
+ "captured_channel": captured_report,
+ "qmd_hypotheses": scenarios,
+ "engine_write_shadow": shadow_scenarios,
+ }
+ if out:
+ os.makedirs(out, exist_ok=True)
+ path = os.path.join(out, "launch-audit-demo.json")
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(demo, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ demo["artifacts"] = {"demo_json": os.path.abspath(path)}
+ return demo
+
+
+def _memory_items(mem: MemoryView):
+ return [(int(item["pa"], 16), mem.read(int(item["pa"], 16), item["size"]))
+ for item in mem.spans()]
+
+
+CAPTURE_HINTS = """\
+CAPTURE HINTS (print-only; nothing is executed by this tool)
+============================================================
+For a real launch audit you need the *engine's* table view, not the host's
+bookkeeping. Capture, for one failing submission:
+
+1. The channel's page-directory base PA (PDB) -- from the channel's INSTANCE
+ dump field, or the SET_PAGE_DIRECTORY / GPU_PDB_ADDRESS your stack programs.
+2. The table pages reachable from that root for the contract VAs: root, PDE2,
+ PDE1, PDE0 (dual slot) and the leaf page table(s). A raw PA->bytes dump of
+ those 4 KB pages is enough; keep them byte-exact (no pretty-printing).
+3. The submitted stream's own pointers: GPFIFO ring, inline-QMD destination
+ (SET_INLINE_QMD_ADDRESS_A/B), the QMD's parameter/program/release-slot
+ addresses, and the channel report semaphore.
+4. Your shadow/guest bookkeeping map for those same VAs (what you currently
+ check after a timeout).
+5. Put 1-4 into a `greeneye-launch-audit-spec/1` JSON (see docs/48).
+
+ITERATE WITH `check` (one capture round per table level):
+ # start with only pdb_pa + contract and `"memory": []` (or a partial dump)
+ python3 harness/launch_audit.py check --spec m7-launch.json
+ # -> prints the FIRST missing table page per leg + `needed_pages[]`
+ # dump exactly those pages, append them to memory[], re-run check
+ # when check says complete:
+ python3 harness/launch_audit.py run --spec m7-launch.json
+
+CURRENT LIVE WALL (m7, 2026-10-06) -- exact dump list:
+ Host-side chain (the validated 5/5 fixture; dump all five raw pages):
+ root 0x12f16e000
+ pd2 0x12f16f000
+ pd1 0x11065e000
+ pd0 0x110663000 (dual slot pd0[0].small -> pt)
+ pt 0x15908d000 (pt entries 256..260 = the low-window legs)
+ Forward-window PT pages (Tier-1 DMA_UPDATE_PDE_2 windows 4128 / 4131):
+ 0x1b8706000 (pdeIndex 4128)
+ 0x1197a6000 (pdeIndex 4131)
+ Geometry rule: pdeIndex = (pd1_idx << 8) | pd0_idx, so the forward windows
+ land at pd1 index 16. Their pd1[16] and pd0-level chain entries are
+ written by the SERVER on DMA_UPDATE_PDE_2 acceptance and are in no host
+ log -- include them (and any page they point at) if they fall outside the
+ host pages above; `check` names the exact PA.
+ Tier-2 shadows (high-VA legs):
+ * the 16 guest-scratch-PT page DMAs -- module stage-2 chunk list,
+ built as `guest scratch PT built: pages=16 vaBase=0x200000
+ capacity=512`; shadow VA = 0x200000 + (page << 21) + (line << 16);
+ * the rmcall_gr_shadow chunk DMAs -- engine-write shadow pages for the
+ high-VA legs (the next wall class after dispatch clears).
+ Key contract VAs:
+ qmd-dest 0x204608000 write (QMD destination prewritten by host;
+ post-mortem reads the same hpa)
+ release-slot 0x204027f7c write (engine release; relw never fired)
+ ring 0x100000..0x104000 read/write (GPFIFO ring, push-buffer,
+ copy src/dst, channel semaphore)
+ param-alias 0x210000 read (Tier-2 shadow alias; NOT directly
+ mapped; PTEs come from guest scratch)
+ program-alias 0x220000 read (same)
+ indirect-alias 0x260000 read (shadow slot 6; same)
+ The high-VA param/program guest VAs (0x7e06f0.../0x735aa0...) are NOT
+ directly mapped: Tier-1 refuses status 0x1f and they resolve through the
+ shadow aliases above.
+
+COPY-PASTE SPEC SKELETON (start with empty memory and let `check` drive; fill
+each `hex` with the 4096-byte raw dump of the page -- no pretty-printing):
+{
+ "format": "greeneye-launch-audit-spec/1",
+ "name": "m7-launch",
+ "firmware": "570.144",
+ "gmmu": "ver2",
+ "page_mode": "4k",
+ "pdb_pa": "0x12f16e000",
+ "memory": [],
+ "contract": [
+ {"label": "qmd-dest", "va": "0x204608000", "access": "write", "expect_pa": "0x204608000"},
+ {"label": "release-slot", "va": "0x204027f7c", "access": "write"},
+ {"label": "gpfifo-ring", "va": "0x100000", "access": "read"},
+ {"label": "push-buffer", "va": "0x101000", "access": "write"},
+ {"label": "copy-src", "va": "0x102000", "access": "read"},
+ {"label": "copy-dst", "va": "0x103000", "access": "write"},
+ {"label": "semaphore", "va": "0x104000", "access": "write"},
+ {"label": "param-alias", "va": "0x210000", "access": "read"},
+ {"label": "program-alias", "va": "0x220000", "access": "read"},
+ {"label": "indirect-alias","va": "0x260000", "access": "read"}
+ ]
+}
+Then: python3 harness/launch_audit.py check --spec m7-launch.json
+
+Reading the result: `pa_mismatch` means the engine reads/writes a different PA
+than the one you are checking (look at the shadow/copy bookkeeping);
+`unmapped` means the walk cannot reach any PA (the engine's access is dropped
+or faults); `access_denied` means the leaf is read-only for a write leg.
+`ok` means the engine CAN reach every leg -- the silence is downstream of the
+address path (front-end/engine/context), not in the mapping.
+"""
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="launch_audit.py",
+ description="Audit a submission's engine-visible memory contract (offline).",
+ )
+ sub = parser.add_subparsers(dest="command")
+ run = sub.add_parser("run", help="run an audit spec")
+ run.add_argument("--spec", required=True)
+ run.add_argument("--out", help="write the report JSON to this directory")
+ check_cmd = sub.add_parser(
+ "check", help="report the first table pages a partial/empty dump is missing")
+ check_cmd.add_argument("--spec", required=True)
+ check_cmd.add_argument("--out", help="write the report JSON to this directory")
+ demo = sub.add_parser("demo", help="captured contract + QMD/shadow hypotheses")
+ demo.add_argument("--out", help="output directory")
+ sub.add_parser("capture-hints", help="print what to capture for a real audit")
+ return parser
+
+
+def _write_out(report: dict, out: str, name: str) -> str:
+ os.makedirs(out, exist_ok=True)
+ path = os.path.join(out, name)
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(report, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ return os.path.abspath(path)
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ if args.command == "run":
+ if not os.path.isfile(args.spec):
+ raise AuditError(f"spec not found: {args.spec}")
+ with open(args.spec, "r", encoding="utf-8") as fh:
+ spec = json.load(fh)
+ report = audit(spec)
+ for row in report["contract"]:
+ print(f"{row['verdict']:<14} {row['label']:<28} {row['va']}"
+ + (f" {row['detail']}" if row.get("detail") else ""))
+ summary = {"format": report["format"], "ok": report["ok"],
+ "summary": report["summary"]}
+ if args.out:
+ summary["report_json"] = _write_out(report, args.out, "launch-audit.json")
+ print(json.dumps(summary, indent=2, sort_keys=True))
+ return 0 if report["ok"] else 3
+ if args.command == "check":
+ if not os.path.isfile(args.spec):
+ raise AuditError(f"spec not found: {args.spec}")
+ with open(args.spec, "r", encoding="utf-8") as fh:
+ spec = json.load(fh)
+ report = check(spec)
+ for row in report["contract"]:
+ if row["status"] == "missing":
+ miss = row["first_missing"]
+ print(f"missing {row['label']:<28} {row['va']}"
+ f" need {miss['pa']} ({miss['level']}[{miss['index']}])")
+ elif row["status"] == "complete":
+ print(f"complete {row['label']:<28} {row['va']}"
+ f" -> {row['resolved_pa']}")
+ else:
+ print(f"fault {row['label']:<28} {row['va']}"
+ f" {row['fault']}: {row['detail']}")
+ if report["needed_pages"]:
+ print("dump next:")
+ for page in report["needed_pages"]:
+ print(f" {page['pa']} {'/'.join(page['levels'])}"
+ f" legs: {', '.join(page['legs'])}")
+ summary = {
+ "format": report["format"],
+ "complete": report["complete"],
+ "needed_pages": [page["pa"] for page in report["needed_pages"]],
+ }
+ if args.out:
+ summary["report_json"] = _write_out(
+ report, args.out, "launch-audit-check.json")
+ print(json.dumps(summary, indent=2, sort_keys=True))
+ return 0 if report["complete"] else 3
+ if args.command == "demo":
+ demo = run_demo(args.out)
+ cap = demo["captured_channel"]
+ print(f"captured channel contract: ok={cap['ok']} {cap['summary']}")
+ for scenario in demo["qmd_hypotheses"]:
+ row = scenario["report"]["contract"][0]
+ print(f"{row['verdict']:<14} {scenario['name']} {row.get('detail') or ''}")
+ for scenario in demo["engine_write_shadow"]:
+ row = scenario["report"]["contract"][0]
+ print(f"{row['verdict']:<14} {scenario['name']} {row.get('detail') or ''}")
+ print(json.dumps({"ok": demo["ok"]}, indent=2))
+ return 0 if demo["ok"] else 3
+ if args.command == "capture-hints":
+ print(CAPTURE_HINTS)
+ return 0
+ parser.print_help()
+ return 2
+ except (AuditError, WalkError) as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/log_decode.py b/debug/risc-v-gsp-rm-vm/harness/log_decode.py
new file mode 100644
index 0000000..e1d351c
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/log_decode.py
@@ -0,0 +1,660 @@
+#!/usr/bin/env python3
+"""Decode the two real GSP-RM log dump forms into one normalized record stream.
+
+The live GSP-RM writes its "server stderr" into an 8 KiB-word libos log ring.
+Two dump forms exist in the campaign evidence and they are the inputs here:
+
+1. Raw ring dumps (debugfs ``gsp-phys-log-dump`` saved to evidence files).
+ A ``<LABEL>-raw: <n> bytes (...) VA=... dma=...`` header is followed by
+ hexdump rows ``00000000: 000002d2 00000000 ...`` (little-endian u64 words).
+ Each header's rows decode into one ``raw-hex`` record whose ``frames`` are
+ the u64 words and whose ``raw`` is the decoded byte buffer as lowercase
+ hex. A later ``<LABEL>-decoded:`` marker switches to the offline
+ liblogdecode text records ``rec 449 ts=0x.. meta=0x.. args=0x..`` which
+ become ``record`` entries (``args`` -> ``frames``); both the
+ ``args=[0x.., 0x..]`` and ``args=0x..,0x..`` spellings are accepted.
+
+2. Live dmesg / evidence lines, e.g.::
+
+ vgsp-nocat: rec seq=1 recType=5 errorCode=0x57f1344 subsystem=0x0 bugcheck=0x0 payload=1212 source="ASSERT" faultingEngine=""
+ vgsp-nocat: rec seq=1 assertCallStack count=1 [0x401000,0x402000] (offsets from load base 0x4000000)
+ vgsp-nocat: entry[0]: count=2 recType=5 errorCode=0x57f1344 firstSeq=1 lastSeq=2 source="ASSERT"
+ vgsp: WARNING: ... (NOCAT recType=5 errorCode=0x52a8b04) ...
+ rec 449 ts=0x18dbb29118ec6200 meta=0x0000000005bb9c48 args=0x1,0x2
+
+ ``key=value`` pairs fill ``rec_type`` / ``error_code`` / ``source_name`` /
+ ``seq``; ``assertCallStack [..]`` lines attach their bracketed frame list
+ to the immediately preceding record with the same ``seq``; anything that
+ matches none of the shapes is counted in ``unparsed`` (never dropped).
+ Lines starting with ``#``, ``<LABEL>-raw:``/``<LABEL>-decoded:`` headers and
+ blank lines are structure, not records.
+
+Report schema ``greeneye-log-decode/1``::
+
+ {"schema": "greeneye-log-decode/1",
+ "source": "<basename of the input>",
+ "count": <records after filters>,
+ "unparsed": <lines that matched no shape>,
+ "records": [{"kind": "raw-hex"|"record", "label"?: str,
+ "rec_type"?: int|str, "error_code"?: "0x...",
+ "source_name"?: str, "seq"?: int,
+ "frames": ["0x...", ...], "raw": str,
+ "resolved"?: [{"frame": "0x...", "symbol": str|None,
+ "offset": "0x..."|None}, ...]}],
+ "histogram": {"rec_type": {..}, "error_code": {..}, "source": {..}}}
+
+``resolved`` appears only when a symbol table is supplied and is aligned 1:1
+with ``frames``. ``--symbols`` accepts the project symbol-DB shape: a
+top-level list, or an object with a ``symbols`` list, of entries
+``{"name": str, "addr": int | "0x..."}`` (extra keys ignored). Each frame is
+matched to the nearest symbol with ``addr <= frame`` and the offset
+(``frame - addr``) is reported; an unreadable or malformed symbols file exits
+non-zero with a clear message.
+
+Filtering (``decode`` subcommand): ``--kind`` selects raw-hex/record,
+``--rec-type``/``--error-code`` accept decimal or ``0x`` hex, ``--source`` is
+a case-insensitive substring on ``source_name`` and ``--limit`` caps the
+records; ``count``/``histogram`` describe the filtered stream. ``--json
+out.json`` writes the full report.
+
+Honesty: offline decoder only (L1 at best; no timing claims). Raw words are
+exposed, not structurally ring-decoded; ``assertCallStack`` frames are offsets
+from the load base named in the line and are symbolized as given. There is no
+hardware, module, QEMU or guest interaction.
+
+Run::
+
+ python3 harness/log_decode.py decode <dump> [--symbols symbols.json]
+ python3 harness/log_decode.py demo
+"""
+from __future__ import annotations
+
+import argparse
+import bisect
+import json
+import os
+import re
+import sys
+import tempfile
+from typing import Any
+
+SCHEMA = "greeneye-log-decode/1"
+DEMO_FORMAT = "greeneye-log-decode-demo/1"
+
+#: How many ``resolved`` entries the human-readable output prints per record
+#: (the JSON report always carries all of them).
+RESOLVED_PRINT_LIMIT = 8
+
+
+class LogDecodeError(ValueError):
+ """An input or request the decoder cannot honour."""
+
+
+# ---------------------------------------------------------------------------
+# Symbols: the project symbol-DB shape, nearest-below resolution
+# ---------------------------------------------------------------------------
+
+def _parse_sym_addr(value: Any, where: str) -> int:
+ if isinstance(value, bool):
+ raise LogDecodeError(f"{where}: bad symbol addr {value!r}")
+ if isinstance(value, int):
+ return value
+ if isinstance(value, str):
+ try:
+ return int(value, 0)
+ except ValueError as exc:
+ raise LogDecodeError(f"{where}: bad symbol addr {value!r}") from exc
+ raise LogDecodeError(f"{where}: bad symbol addr {value!r}")
+
+
+class SymbolTable:
+ """Nearest-below ``frame -> (name, offset)`` resolver.
+
+ Holds the same entries the project symbol DBs carry; only ``addr`` and
+ ``name`` are used here. ``resolve`` returns ``None`` when no symbol has an
+ address at or below the frame.
+ """
+
+ def __init__(self, entries: list[tuple[int, str]]) -> None:
+ self._entries = sorted(entries, key=lambda item: item[0])
+ self._addrs = [addr for addr, _ in self._entries]
+
+ def __len__(self) -> int:
+ return len(self._entries)
+
+ def resolve(self, addr: int) -> tuple[str, int] | None:
+ pos = bisect.bisect_right(self._addrs, addr) - 1
+ if pos < 0:
+ return None
+ sym_addr, name = self._entries[pos]
+ return name, addr - sym_addr
+
+ def annotate_frame(self, frame: str) -> dict:
+ try:
+ value = int(frame, 0)
+ except ValueError:
+ return {"frame": frame, "symbol": None, "offset": None}
+ hit = self.resolve(value)
+ if hit is None:
+ return {"frame": frame, "symbol": None, "offset": None}
+ name, offset = hit
+ return {"frame": frame, "symbol": name, "offset": f"{offset:#x}"}
+
+
+def load_symbols(path: str) -> SymbolTable:
+ """Load the accepted symbol JSON; raise :class:`LogDecodeError` when bad."""
+ if not os.path.isfile(path):
+ raise LogDecodeError(f"symbols file not found: {path}")
+ try:
+ with open(path, "r", encoding="utf-8") as fh:
+ payload = json.load(fh)
+ except OSError as exc:
+ raise LogDecodeError(f"cannot read symbols {path}: {exc}") from exc
+ except json.JSONDecodeError as exc:
+ raise LogDecodeError(f"symbols JSON {path} is not valid JSON: {exc}") from exc
+ if isinstance(payload, dict):
+ entries = payload.get("symbols")
+ if entries is None:
+ raise LogDecodeError(
+ f"symbols JSON {path}: object must have a 'symbols' list")
+ elif isinstance(payload, list):
+ entries = payload
+ else:
+ raise LogDecodeError(
+ f"symbols JSON {path}: expected a list or an object with 'symbols'")
+ if not isinstance(entries, list):
+ raise LogDecodeError(f"symbols JSON {path}: 'symbols' must be a list")
+ table: list[tuple[int, str]] = []
+ for index, item in enumerate(entries):
+ where = f"symbols JSON {path}: entry[{index}]"
+ if not isinstance(item, dict):
+ raise LogDecodeError(f"{where} is not an object")
+ if "addr" not in item:
+ raise LogDecodeError(f"{where} has no 'addr'")
+ addr = _parse_sym_addr(item["addr"], where)
+ name = item.get("name")
+ if name is None:
+ name = f"sub_{addr:x}"
+ if not isinstance(name, str):
+ raise LogDecodeError(f"{where}: 'name' must be a string")
+ table.append((addr, name))
+ return SymbolTable(table)
+
+
+# ---------------------------------------------------------------------------
+# Line shapes observed in the real evidence
+# ---------------------------------------------------------------------------
+
+RAW_SECTION_RE = re.compile(r"^(?P<label>[A-Za-z0-9_.+-]+)-raw:\s+\d+\s+bytes\b")
+DECODED_SECTION_RE = re.compile(r"^(?P<label>[A-Za-z0-9_.+-]+)-decoded:")
+COMMENT_LABEL_RE = re.compile(r"^#\s*(?P<label>[A-Za-z][A-Za-z0-9_.+-]*)\s*$")
+HEX_ROW_RE = re.compile(
+ r"^(?P<offset>[0-9a-fA-F]{6,16}):\s+"
+ r"(?P<words>[0-9a-fA-F]{1,16}(?:\s+[0-9a-fA-F]{1,16})*)\s*$")
+DMSG_PREFIX_RE = re.compile(
+ r"^(?:\[[^\]]*\]\s*)?(?P<label>[A-Za-z0-9_.+-]+):\s+(?P<body>.*)$")
+KV_RE = re.compile(r"(?P<key>[A-Za-z_][A-Za-z0-9_]*)="
+ r"(?P<value>\"[^\"]*\"|[^\s,()]+)")
+FRAME_LIST_RE = re.compile(r"\[([^\[\]]*)\]")
+CONTINUATION_RE = re.compile(r"\brec\s+seq=(?P<seq>\d+)\s+assertCallStack\b")
+DECODED_REC_RE = re.compile(
+ r"^rec\s+(?P<seq>\d+)\s+ts=(?P<ts>0x[0-9a-fA-F]+)\s+"
+ r"meta=(?P<meta>\S+)\s+args=(?P<args>.*)$")
+REC_SEQ_RE = re.compile(r"\brec\s+seq=(?P<seq>\d+)")
+
+
+def _parse_kv(body: str) -> dict[str, str]:
+ fields: dict[str, str] = {}
+ for match in KV_RE.finditer(body):
+ value = match.group("value")
+ if value.startswith('"') and value.endswith('"') and len(value) >= 2:
+ value = value[1:-1]
+ fields[match.group("key")] = value
+ return fields
+
+
+def _int_or_text(value: str) -> int | str:
+ try:
+ return int(value, 0)
+ except ValueError:
+ return value
+
+
+def _norm_hex(value: str) -> str:
+ try:
+ return f"{int(value, 0):#x}"
+ except ValueError:
+ return value
+
+
+def _parse_frame_tokens(text: str) -> list[str]:
+ text = text.strip()
+ if text.startswith("[") and text.endswith("]"):
+ text = text[1:-1]
+ frames: list[str] = []
+ for token in text.replace(",", " ").split():
+ try:
+ frames.append(f"{int(token, 0):#x}")
+ except ValueError:
+ continue
+ return frames
+
+
+def _frames_from_brackets(text: str) -> list[str]:
+ match = FRAME_LIST_RE.search(text)
+ if not match:
+ return []
+ return _parse_frame_tokens(match.group(1))
+
+
+def _looks_like_record(body: str) -> bool:
+ if DECODED_REC_RE.match(body):
+ return True
+ if "recType=" in body or "errorCode=" in body:
+ return True
+ if REC_SEQ_RE.search(body):
+ return True
+ return False
+
+
+def _record_from_body(body: str, label: str | None, raw: str | None = None) -> dict:
+ if raw is None:
+ raw = body
+ match = DECODED_REC_RE.match(body)
+ if match:
+ return {
+ "kind": "record",
+ "label": label,
+ "seq": int(match.group("seq")),
+ "frames": _parse_frame_tokens(match.group("args")),
+ "raw": raw,
+ }
+ record: dict[str, Any] = {"kind": "record", "label": label,
+ "frames": [], "raw": raw}
+ fields = _parse_kv(body)
+ if "recType" in fields:
+ record["rec_type"] = _int_or_text(fields["recType"])
+ if "errorCode" in fields:
+ record["error_code"] = _norm_hex(fields["errorCode"])
+ if "source" in fields:
+ record["source_name"] = fields["source"]
+ if "seq" in fields:
+ record["seq"] = int(fields["seq"], 0)
+ return record
+
+
+def _raw_record(label: str | None, words: list[int]) -> dict:
+ data = b"".join(word.to_bytes(8, "little") for word in words)
+ return {
+ "kind": "raw-hex",
+ "label": label,
+ "source_name": label,
+ "frames": [f"{word:#x}" for word in words],
+ "raw": data.hex(),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Decode
+# ---------------------------------------------------------------------------
+
+def _histogram(records: list[dict]) -> dict[str, dict[str, int]]:
+ hist: dict[str, dict[str, int]] = {"rec_type": {}, "error_code": {},
+ "source": {}}
+ for record in records:
+ if "rec_type" in record:
+ key = str(record["rec_type"])
+ hist["rec_type"][key] = hist["rec_type"].get(key, 0) + 1
+ if "error_code" in record:
+ key = str(record["error_code"])
+ hist["error_code"][key] = hist["error_code"].get(key, 0) + 1
+ if record.get("source_name"):
+ key = str(record["source_name"])
+ hist["source"][key] = hist["source"].get(key, 0) + 1
+ return {name: dict(sorted(values.items())) for name, values in hist.items()}
+
+
+def decode_text(text: str, source: str = "<text>",
+ symbols: SymbolTable | None = None) -> dict:
+ """Decode one dump/evidence text into a ``greeneye-log-decode/1`` report."""
+ records: list[dict] = []
+ unparsed = 0
+ raw_state: dict[str, Any] | None = None
+ current_label: str | None = None
+
+ def flush_raw() -> None:
+ nonlocal raw_state
+ if raw_state is None:
+ return
+ records.append(_raw_record(raw_state["label"], raw_state["words"]))
+ raw_state = None
+
+ for line in text.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ continue
+ if stripped.startswith("#"):
+ match = COMMENT_LABEL_RE.match(stripped)
+ if match:
+ current_label = match.group("label")
+ continue
+ match = RAW_SECTION_RE.match(stripped)
+ if match:
+ flush_raw()
+ raw_state = {"label": match.group("label"), "words": []}
+ current_label = match.group("label")
+ continue
+ match = DECODED_SECTION_RE.match(stripped)
+ if match:
+ flush_raw()
+ current_label = match.group("label")
+ continue
+ if raw_state is not None:
+ match = HEX_ROW_RE.match(stripped)
+ if match:
+ raw_state["words"].extend(
+ int(token, 16) for token in match.group("words").split())
+ continue
+ flush_raw()
+ else:
+ match = HEX_ROW_RE.match(stripped)
+ if match:
+ raw_state = {"label": None, "words": []}
+ raw_state["words"].extend(
+ int(token, 16) for token in match.group("words").split())
+ continue
+
+ label = current_label
+ body = stripped
+ prefix = DMSG_PREFIX_RE.match(stripped)
+ if prefix:
+ label = prefix.group("label")
+ body = prefix.group("body").strip()
+
+ continuation = CONTINUATION_RE.search(body)
+ if continuation:
+ seq = int(continuation.group("seq"))
+ frames = _frames_from_brackets(body)
+ last = records[-1] if records else None
+ if (last is not None and last.get("kind") == "record"
+ and last.get("seq") == seq and not last.get("frames")):
+ last["frames"] = frames
+ else:
+ records.append({"kind": "record", "label": label, "seq": seq,
+ "frames": frames, "raw": stripped})
+ continue
+
+ if _looks_like_record(body):
+ records.append(_record_from_body(body, label, stripped))
+ continue
+ unparsed += 1
+
+ flush_raw()
+ if symbols is not None:
+ for record in records:
+ if record.get("frames"):
+ record["resolved"] = [symbols.annotate_frame(frame)
+ for frame in record["frames"]]
+ return {
+ "schema": SCHEMA,
+ "source": source,
+ "count": len(records),
+ "unparsed": unparsed,
+ "records": records,
+ "histogram": _histogram(records),
+ }
+
+
+def decode_path(path: str, symbols_path: str | None = None) -> dict:
+ """Decode the file at ``path``; ``source`` in the report is its basename."""
+ if not os.path.isfile(path):
+ raise LogDecodeError(f"input not found: {path}")
+ symbols = load_symbols(symbols_path) if symbols_path else None
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
+ text = fh.read()
+ except OSError as exc:
+ raise LogDecodeError(f"cannot read input {path}: {exc}") from exc
+ return decode_text(text, source=os.path.basename(path), symbols=symbols)
+
+
+# ---------------------------------------------------------------------------
+# Filters
+# ---------------------------------------------------------------------------
+
+def _want_int(value: Any, what: str) -> int:
+ if isinstance(value, bool):
+ raise LogDecodeError(f"bad {what} {value!r}")
+ if isinstance(value, int):
+ return value
+ try:
+ return int(str(value), 0)
+ except ValueError as exc:
+ raise LogDecodeError(f"bad {what} {value!r}") from exc
+
+
+def _match_int(record_value: Any, wanted: int) -> bool:
+ if record_value is None:
+ return False
+ if isinstance(record_value, int):
+ return record_value == wanted
+ try:
+ return int(str(record_value), 0) == wanted
+ except ValueError:
+ return False
+
+
+def apply_filters(report: dict, *, rec_type: Any = None, source: str | None = None,
+ error_code: Any = None, kind: str | None = None,
+ limit: int | None = None) -> dict:
+ """Return a copy of ``report`` with the requested record filters applied."""
+ records = list(report["records"])
+ if kind is not None:
+ if kind not in ("raw-hex", "record"):
+ raise LogDecodeError(f"bad kind {kind!r} (want raw-hex or record)")
+ records = [r for r in records if r["kind"] == kind]
+ if rec_type is not None:
+ wanted = _want_int(rec_type, "rec-type")
+ records = [r for r in records if _match_int(r.get("rec_type"), wanted)]
+ if error_code is not None:
+ wanted = _want_int(error_code, "error-code")
+ records = [r for r in records if _match_int(r.get("error_code"), wanted)]
+ if source is not None:
+ needle = source.lower()
+ records = [r for r in records
+ if needle in str(r.get("source_name") or "").lower()]
+ if limit is not None:
+ if limit < 0:
+ raise LogDecodeError(f"bad limit {limit!r} (must be >= 0)")
+ records = records[:limit]
+ filtered = dict(report)
+ filtered["records"] = records
+ filtered["count"] = len(records)
+ filtered["histogram"] = _histogram(records)
+ return filtered
+
+
+# ---------------------------------------------------------------------------
+# Demo: tiny crafted inputs for both real formats (tempdir only)
+# ---------------------------------------------------------------------------
+
+_DEMO_RAW = """\
+LOGINIT-raw: 64 bytes (allocated 64) VA=0xffff0000 dma=0x1000
+00000000: 00000005 00000000 0000000a deadbeef
+00000010: 01020304 05060708 090a0b0c 0d0e0f10
+"""
+
+_DEMO_RECORDS = """\
+[Tue Oct 6 12:40:52 2026] vgsp-nocat: rec seq=1 recType=5 errorCode=0x57f1344 subsystem=0x0 bugcheck=0x0 payload=1212 source="ASSERT" faultingEngine=""
+[Tue Oct 6 12:40:52 2026] vgsp-nocat: rec seq=1 assertCallStack count=1 [0x401000,0x402000,0x0] (offsets from load base 0x4000000)
+[Tue Oct 6 12:40:53 2026] vgsp-nocat: entry[0]: count=1 recType=5 errorCode=0x57f1344 firstSeq=1 lastSeq=1 source="ASSERT"
+an unmatched filler line
+rec 7 ts=0x18dbb29118ec6200 meta=0x0000000005bb9c48 args=0x401000,0x402000
+"""
+
+_DEMO_SYMBOLS = {
+ "symbols": [
+ {"name": "gr_init", "addr": "0x401000"},
+ {"name": "gr_done", "addr": int("0x403000", 0)},
+ ]
+}
+
+
+def run_demo() -> dict:
+ """Decode crafted inputs for both formats; deterministic, tempdir only."""
+ with tempfile.TemporaryDirectory(prefix="log-decode-demo-") as tmp:
+ raw_path = os.path.join(tmp, "raw.txt")
+ records_path = os.path.join(tmp, "records.txt")
+ symbols_path = os.path.join(tmp, "symbols.json")
+ with open(raw_path, "w", encoding="utf-8") as fh:
+ fh.write(_DEMO_RAW)
+ with open(records_path, "w", encoding="utf-8") as fh:
+ fh.write(_DEMO_RECORDS)
+ with open(symbols_path, "w", encoding="utf-8") as fh:
+ json.dump(_DEMO_SYMBOLS, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ raw_report = decode_path(raw_path)
+ records_report = decode_path(records_path, symbols_path)
+
+ raw_records = raw_report["records"]
+ record_records = records_report["records"]
+ first = record_records[0] if record_records else {}
+ resolved = first.get("resolved") or []
+ ok = (
+ raw_report["count"] == 1
+ and raw_report["unparsed"] == 0
+ and len(raw_records) == 1
+ and len(raw_records[0]["frames"]) == 8
+ and raw_records[0]["frames"][3] == "0xdeadbeef"
+ and len(raw_records[0]["raw"]) == 2 * 8 * 8
+ and records_report["count"] == 3
+ and records_report["unparsed"] == 1
+ and first.get("rec_type") == 5
+ and first.get("error_code") == "0x57f1344"
+ and first.get("source_name") == "ASSERT"
+ and first.get("frames") == ["0x401000", "0x402000", "0x0"]
+ and len(resolved) == 3
+ and resolved[0].get("symbol") == "gr_init"
+ and resolved[1].get("symbol") == "gr_init"
+ and resolved[1].get("offset") == "0x1000"
+ and resolved[2].get("symbol") is None
+ )
+ return {
+ "format": DEMO_FORMAT,
+ "ok": ok,
+ "raw": raw_report,
+ "records": records_report,
+ }
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def _format_record(record: dict) -> list[str]:
+ bits = [f"{record['kind']:<8}"]
+ for key in ("label", "seq", "rec_type", "error_code", "source_name"):
+ if record.get(key) is not None:
+ bits.append(f"{key}={record[key]}")
+ bits.append(f"frames={len(record.get('frames') or [])}")
+ lines = [" ".join(bits)]
+ resolved = record.get("resolved") or []
+ for item in resolved[:RESOLVED_PRINT_LIMIT]:
+ if item.get("symbol") is None:
+ lines.append(f" {item['frame']} <unknown>")
+ else:
+ lines.append(
+ f" {item['frame']} {item['symbol']}+{item['offset']}")
+ if len(resolved) > RESOLVED_PRINT_LIMIT:
+ lines.append(f" ... {len(resolved) - RESOLVED_PRINT_LIMIT} more")
+ return lines
+
+
+def _write_json(report: dict, path: str) -> str:
+ target = os.path.abspath(path)
+ parent = os.path.dirname(target)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+ with open(target, "w", encoding="utf-8") as fh:
+ json.dump(report, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ return target
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="log_decode.py",
+ description="Decode GSP-RM log-ring and NOCAT record dumps (offline).",
+ )
+ sub = parser.add_subparsers(dest="command")
+ decode = sub.add_parser(
+ "decode", help="decode a raw ring dump or dmesg/evidence log")
+ decode.add_argument("input", help="dump/log text file to decode")
+ decode.add_argument("--symbols", help="symbol DB JSON (list or {symbols:[..]})")
+ decode.add_argument("--rec-type", help="keep records with this recType")
+ decode.add_argument("--source", help="case-insensitive source substring")
+ decode.add_argument("--error-code", help="keep records with this errorCode")
+ decode.add_argument("--kind", choices=("raw-hex", "record"),
+ help="keep only this record kind")
+ decode.add_argument("--limit", type=int, help="max records to emit")
+ decode.add_argument("--json", help="write the full report to this file")
+ sub.add_parser("demo", help="decode crafted samples for both real formats")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ if args.command == "decode":
+ report = decode_path(args.input, args.symbols)
+ report = apply_filters(
+ report,
+ rec_type=args.rec_type,
+ source=args.source,
+ error_code=args.error_code,
+ kind=args.kind,
+ limit=args.limit,
+ )
+ for record in report["records"]:
+ for line in _format_record(record):
+ print(line)
+ summary = {
+ "schema": report["schema"],
+ "source": report["source"],
+ "count": report["count"],
+ "unparsed": report["unparsed"],
+ "histogram": report["histogram"],
+ }
+ if args.json:
+ summary["report_json"] = _write_json(report, args.json)
+ print(json.dumps(summary, indent=2, sort_keys=True))
+ return 0
+ if args.command == "demo":
+ demo = run_demo()
+ raw = demo["raw"]
+ records = demo["records"]
+ print(f"{raw['source']}: records={raw['count']} "
+ f"unparsed={raw['unparsed']} "
+ f"frames={len(raw['records'][0]['frames'])} "
+ f"raw_bytes={len(raw['records'][0]['raw']) // 2}")
+ print(f"{records['source']}: records={records['count']} "
+ f"unparsed={records['unparsed']}")
+ print(f"histogram: {json.dumps(records['histogram'], sort_keys=True)}")
+ for item in (records["records"][0].get("resolved") or []):
+ if item.get("symbol") is None:
+ print(f" {item['frame']} <unknown>")
+ else:
+ print(f" {item['frame']} {item['symbol']}+{item['offset']}")
+ print(json.dumps({"format": demo["format"], "ok": demo["ok"]},
+ sort_keys=True))
+ return 0 if demo["ok"] else 3
+ parser.print_help()
+ return 2
+ except LogDecodeError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/rehost.py b/debug/risc-v-gsp-rm-vm/harness/rehost.py
new file mode 100644
index 0000000..fb554db
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/rehost.py
@@ -0,0 +1,428 @@
+#!/usr/bin/env python3
+"""Surgical rehost: run a bounded function of a firmware image on the native emulator.
+
+The standalone simulator boots the real 570.144 image to the message-loop port
+wait (B3). Code *after* that wall (the RM handlers, e.g. the GR promote/commit
+chain) is only reachable when host traffic exists, which is the B4/B5 rung.
+This harness provides the offline alternative the workflow review ranks first
+(docs/39 U2): start the machine at a chosen function entry with a crafted
+register/memory state, run forward with a bounded budget, and stop on return,
+fault, unhandled ecall or budget. Every run records a session and a report;
+snapshots before/after a run show what the function changed.
+
+Honesty rules:
+
+* This tool executes firmware code; it never touches a GPU, kernel module,
+ mdev, guest or network. It is development-only.
+* A rehosted run is **not** a boot: the function runs with only the state the
+ caller supplied. The report says so, and any fault (first missing state) is
+ recorded as evidence, never papered over.
+* No timing/cycle claim; fidelity is L0/L1 (docs/06).
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import sys
+import time
+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.elf import load_elf # noqa: E402
+from greeneye.errors import GreenEyeError # noqa: E402
+from greeneye.sim_backend import launch_native # noqa: E402
+from sim.devices import load_profile, registry_from_profile # noqa: E402
+from sim.gsp_boot import _apply_boot_shim # noqa: E402
+from sim.libos import LibosSyscalls # noqa: E402
+from sim.machine import R_BP, R_ECALL, R_FAULT, R_WATCH # noqa: E402
+
+#: Schema tag of the rehost report.
+REHOST_FORMAT = "greeneye-rehost/1"
+#: Schema tag of the demo result.
+DEMO_FORMAT = "greeneye-rehost-demo/1"
+
+DEFAULT_IMAGE = os.path.join(
+ TOOL_ROOT, "..", "..", "firmware", "57X-GSP", "570.144", "gsp-570.144.fwimage.bin"
+)
+DEFAULT_PROFILE = os.path.join(TOOL_ROOT, "sim", "profiles", "tu104-570.144.json")
+DEMO_ELF = os.path.join(TOOL_ROOT, "firmware", "uproc_demo", "build", "uproc_demo.elf")
+
+#: Wall-clock guard: a rehost that spins in a fault loop must not hang the tool.
+DEFAULT_TIMEOUT = 60.0
+#: Instruction budget per default run.
+DEFAULT_BUDGET = 5_000_000
+#: Chunk size between stop checks (keeps the loop responsive to timeouts).
+RUN_CHUNK = 250_000
+#: Sentinel instruction (``ebreak``) written at the return trampoline.
+EBREAK = 0x00100073
+
+
+class RehostError(GreenEyeError):
+ """A rehost input, image or argument is invalid."""
+
+
+def _parse_u64(value: Any) -> int:
+ if isinstance(value, bool):
+ return int(value)
+ if isinstance(value, int):
+ return value & 0xFFFF_FFFF_FFFF_FFFF
+ if isinstance(value, str):
+ text = value.strip()
+ if not text:
+ raise RehostError("empty integer")
+ return int(text, 0) & 0xFFFF_FFFF_FFFF_FFFF
+ raise RehostError(f"cannot parse integer from {value!r}")
+
+
+def parse_hex_bytes(spec: str) -> bytes:
+ """Parse ``aabbcc`` (whitespace tolerated) into bytes."""
+ text = "".join(spec.split())
+ if len(text) % 2:
+ raise RehostError(f"hex payload must have an even number of digits: {spec!r}")
+ try:
+ return bytes.fromhex(text)
+ except ValueError as exc:
+ raise RehostError(f"bad hex payload {spec!r}: {exc}") from exc
+
+
+def parse_mem_spec(spec: str) -> tuple[int, bytes]:
+ """Parse ``<addr>=<hex>`` into ``(address, bytes)``."""
+ if "=" not in spec:
+ raise RehostError(f"memory spec must be <addr>=<hex>: {spec!r}")
+ addr_text, _, data_text = spec.partition("=")
+ return _parse_u64(addr_text), parse_hex_bytes(data_text)
+
+
+def parse_snap_spec(spec: str) -> tuple[int, int]:
+ """Parse ``<addr>:<size>`` into ``(address, size)``."""
+ addr_text, sep, size_text = spec.partition(":")
+ if not sep:
+ raise RehostError(f"snapshot spec must be <addr>:<size>: {spec!r}")
+ size = _parse_u64(size_text)
+ if size <= 0:
+ raise RehostError(f"snapshot size must be positive: {spec!r}")
+ return _parse_u64(addr_text), size
+
+
+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 _region_diff(before: bytes, after: bytes) -> dict:
+ changed = 0
+ first = None
+ for offset, (a, b) in enumerate(zip(before, after)):
+ if a != b:
+ changed += 1
+ if first is None:
+ first = offset
+ return {
+ "changed_bytes": changed,
+ "first_diff_offset": first,
+ "before_sha256": hashlib.sha256(before).hexdigest(),
+ "after_sha256": hashlib.sha256(after).hexdigest(),
+ }
+
+
+def _registers(machine) -> dict:
+ regs = {f"x{i}": machine.x[i] for i in range(32) if i and machine.x[i]}
+ regs["pc"] = machine.pc
+ return regs
+
+
+def rehost(
+ *,
+ image: str,
+ entry: int | str,
+ profile_path: str | None = None,
+ args: tuple = (),
+ mem: tuple = (),
+ snap: tuple = (),
+ budget: int = DEFAULT_BUDGET,
+ timeout: float = DEFAULT_TIMEOUT,
+ out: str | None = None,
+ record=None,
+ name: str = "rehost",
+ apply_shim: bool = True,
+ unknown_mmio: str = "lenient",
+) -> dict:
+ """Run one bounded function call on the native emulator; return a report."""
+ if not os.path.isfile(image):
+ raise RehostError(f"image not found: {image}")
+ if budget <= 0:
+ raise RehostError(f"budget must be positive, got {budget}")
+ image_obj = load_elf(image)
+ if not image_obj.is_riscv:
+ raise RehostError(f"{image}: not a RISC-V ELF image")
+
+ profile = load_profile(profile_path) if profile_path else None
+ load = (profile or {}).get("load", {})
+ ram_base = _parse_u64(load["ram_base"]) if "ram_base" in load else 0x80000000
+ memory_mb = int(load.get("memory_mb", 256))
+
+ session = launch_native(
+ kernel=image,
+ memory_mb=memory_mb,
+ ram_base=ram_base,
+ record=record if record is not None else bool(out),
+ name=name,
+ device_map=None,
+ unknown_mmio=unknown_mmio,
+ bias=0,
+ )
+ machine = session.target.machine
+ try:
+ if profile is not None:
+ machine.devices = registry_from_profile(profile, machine=machine)
+ machine.translator = getattr(machine.devices, "dma", None)
+ libos = LibosSyscalls()
+ machine.syscall_handler = libos.handle
+ if apply_shim:
+ shim_meta = {"enabled": True, "assumptions": []}
+ shim = dict(profile.get("boot_shim", {}))
+ shim["_image_path"] = image
+ _apply_boot_shim(machine, shim, image_obj, shim_meta)
+
+ # Resolve a symbol name through the ELF symbol table when possible.
+ if isinstance(entry, str):
+ try:
+ entry_addr = session.symbols.lookup(entry) if session.symbols else None
+ except GreenEyeError:
+ entry_addr = None
+ if entry_addr is None:
+ raise RehostError(f"symbol {entry!r} not found in {os.path.basename(image)}")
+ else:
+ entry_addr = _parse_u64(entry)
+
+ for addr, data in mem:
+ machine.write_mem(addr, data)
+
+ snapshots = []
+ for addr, size in snap:
+ before = machine.read_mem(addr, size)
+ snapshots.append({"addr": addr, "size": size, "before": before})
+
+ # Return trampoline well above the image, inside the RAM arena.
+ sentinel = (machine.ram_base + machine.ram_size - 0x100) & ~0xF
+ stack = (sentinel - 0x2000) & ~0xF
+ trampoline = EBREAK.to_bytes(4, "little")
+ machine.write_mem(sentinel, trampoline)
+ machine.write_mem(sentinel + 4, trampoline)
+ for index, value in enumerate(tuple(args)[:8]):
+ machine.x[10 + index] = _parse_u64(value)
+ machine.x[1] = sentinel # ra
+ machine.x[2] = stack # sp
+ machine.pc = entry_addr
+
+ entered = _registers(machine)
+ started = time.monotonic()
+ status = "budget"
+ fault: dict | None = None
+ stop_pc = machine.pc
+ remaining = budget
+ while remaining > 0:
+ if time.monotonic() - started > timeout:
+ status = "timeout"
+ break
+ chunk = min(RUN_CHUNK, remaining)
+ reason, stop_pc, a, b = machine.run(chunk, breakpoints={sentinel})
+ remaining -= chunk
+ if reason == R_BP:
+ status = "return"
+ break
+ if reason == R_FAULT:
+ status = "fault"
+ fault = {"cause": a, "tval": f"{b:#x}"}
+ break
+ if reason == R_ECALL:
+ handled, result = machine.syscall_handler(machine, stop_pc) if machine.syscall_handler else (False, None)
+ if not handled:
+ status = "ecall"
+ break
+ if result is not None:
+ machine.x[10] = result & 0xFFFF_FFFF_FFFF_FFFF
+ continue
+ if reason == R_WATCH:
+ continue # watchpoints recorded by the caller's own tooling
+ # R_BUDGET: keep going until the total budget is spent.
+ if status == "budget" and remaining <= 0:
+ status = "budget"
+
+ report: dict[str, Any] = {
+ "format": REHOST_FORMAT,
+ "image": os.path.abspath(image),
+ "image_sha256": _sha256_file(image),
+ "profile": os.path.basename(profile_path) if profile_path else None,
+ "entry": f"{entry_addr:#x}",
+ "args": [_parse_u64(v) for v in tuple(args)[:8]],
+ "status": status,
+ "stop_pc": f"{stop_pc:#x}",
+ "fault": fault,
+ "retval": machine.x[10],
+ "instructions": 0 if machine.insns is None else machine.insns,
+ "wall_seconds": round(time.monotonic() - started, 4),
+ "registers_entered": {k: f"{v:#x}" if isinstance(v, int) else v for k, v in entered.items()},
+ "registers_exited": {k: f"{v:#x}" if isinstance(v, int) else v for k, v in _registers(machine).items()},
+ "mmio_unknown": machine.mmio_unknown,
+ "snapshots": [],
+ "limits": [
+ "Not a boot: the function ran with only the supplied state.",
+ "No timing/cycle claim (L0/L1 fidelity, docs/06).",
+ "A fault is the first missing state observed, not proof of a product defect.",
+ ],
+ }
+ for item in snapshots:
+ after = machine.read_mem(item["addr"], item["size"])
+ report["snapshots"].append(
+ {
+ "addr": f"{item['addr']:#x}",
+ "size": item["size"],
+ **_region_diff(item["before"], after),
+ }
+ )
+ if getattr(session.recorder, "dir", None) is not None:
+ session.note("rehost", entry=f"{entry_addr:#x}", status=status, stop_pc=f"{stop_pc:#x}")
+ report["session"] = session.recorder.dir
+ if out:
+ os.makedirs(out, exist_ok=True)
+ path = os.path.join(out, "rehost-report.json")
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(report, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ report["report_json"] = os.path.abspath(path)
+ return report
+ finally:
+ session.close()
+
+
+def run_demo(out: str | None = None) -> dict:
+ """Rehost a known demo function to prove the loop (synthetic, deterministic)."""
+ if not os.path.isfile(DEMO_ELF):
+ raise RehostError(
+ f"demo firmware not built: {DEMO_ELF} (run `make demo` in the tool dir)"
+ )
+ buffer_addr, payload = 0x80012000, bytes([1, 2, 3, 4])
+ expected = sum(payload)
+ first = rehost(
+ image=DEMO_ELF,
+ entry="rpc_sum",
+ args=(buffer_addr, len(payload)),
+ mem=((buffer_addr, payload),),
+ snap=(),
+ budget=100_000,
+ timeout=20.0,
+ record=False,
+ name="rehost-demo",
+ )
+ second = rehost(
+ image=DEMO_ELF,
+ entry="rpc_sum",
+ args=(buffer_addr, len(payload)),
+ mem=((buffer_addr, payload),),
+ snap=(),
+ budget=100_000,
+ timeout=20.0,
+ record=False,
+ name="rehost-demo",
+ )
+ ok = (
+ first["status"] == "return"
+ and first["retval"] == expected
+ and first["status"] == second["status"]
+ and first["retval"] == second["retval"]
+ )
+ payload_result = {
+ "format": DEMO_FORMAT,
+ "ok": ok,
+ "expected": expected,
+ "first": {
+ "status": first["status"],
+ "retval": first["retval"],
+ "wall_seconds": first["wall_seconds"],
+ },
+ "second": {
+ "status": second["status"],
+ "retval": second["retval"],
+ },
+ }
+ if out:
+ os.makedirs(out, exist_ok=True)
+ path = os.path.join(out, "rehost-demo.json")
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(payload_result, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ payload_result["artifacts"] = {"demo_json": os.path.abspath(path)}
+ return payload_result
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="rehost.py",
+ description=(
+ "Run one bounded firmware function on the native emulator "
+ "(surgical rehost). Offline only; never touches hardware."
+ ),
+ )
+ sub = parser.add_subparsers(dest="command")
+ run_cmd = sub.add_parser("run", help="rehost a function from an image")
+ run_cmd.add_argument("--image", default=DEFAULT_IMAGE, help="firmware ELF")
+ run_cmd.add_argument("--profile", default=DEFAULT_PROFILE, help="device profile JSON")
+ run_cmd.add_argument("--entry", required=True, help="function entry (hex address or symbol)")
+ run_cmd.add_argument("--arg", action="append", default=[], help="a0..a7 value (repeatable)")
+ run_cmd.add_argument("--mem", action="append", default=[], help="<addr>=<hex> pre-write (repeatable)")
+ run_cmd.add_argument("--snap", action="append", default=[], help="<addr>:<size> snapshot (repeatable)")
+ run_cmd.add_argument("--budget", type=int, default=DEFAULT_BUDGET)
+ run_cmd.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
+ run_cmd.add_argument("--no-shim", action="store_true", help="do not apply the profile boot shim")
+ run_cmd.add_argument("--out", help="output directory (report + session)")
+ demo_cmd = sub.add_parser("demo", help="synthetic self-test on the demo firmware")
+ demo_cmd.add_argument("--out", help="output directory")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ if args.command == "run":
+ report = rehost(
+ image=args.image,
+ profile_path=args.profile or None,
+ entry=args.entry,
+ args=tuple(args.arg),
+ mem=tuple(parse_mem_spec(spec) for spec in args.mem),
+ snap=tuple(parse_snap_spec(spec) for spec in args.snap),
+ budget=args.budget,
+ timeout=args.timeout,
+ out=args.out,
+ apply_shim=not args.no_shim,
+ )
+ elif args.command == "demo":
+ report = run_demo(args.out)
+ else:
+ parser.print_help()
+ return 2
+ except RehostError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+ summary_keys = (
+ "format", "ok", "entry", "status", "stop_pc", "retval", "fault",
+ "instructions", "wall_seconds", "report_json", "session", "artifacts",
+ )
+ print(json.dumps({k: report[k] for k in summary_keys if k in report}, indent=2, sort_keys=True))
+ if args.command == "demo" and not report.get("ok"):
+ return 3
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/rm_capture.py b/debug/risc-v-gsp-rm-vm/harness/rm_capture.py
new file mode 100644
index 0000000..4aa459b
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/rm_capture.py
@@ -0,0 +1,1251 @@
+#!/usr/bin/env python3
+"""Real-client GSP RPC capture ingestion, comparison and replay prep.
+
+Ingests the text output of the supervised host-client capture (the GSP RPC
+send-path instrumentation described by the CAPTURE-RUNBOOK) and turns it into
+structured, replayable artifacts:
+
+* **decode** — a raw capture log (dmesg text with the documented ``GE-SEND``,
+ ``GE-CTRL``, ``GE-ALLOC``, ``GE-GOLDEN-*`` and ``GE-CTRL-DONE`` tags) into a
+ ``greeneye-rm-rpc-capture/1`` document: one ordered record per RPC/alloc,
+ with the wire function id, the named control/class id, handles, caller
+ return address, and the captured parameter bytes.
+* **compare** — two capture documents (for example, the real host client's
+ sequence vs. the open-iov host ladder's replay, or two firmware/driver
+ versions) aligned by record identity, with per-record parameter agreement,
+ coverage and explicit unmatched lists.
+* **stimulus** — a ``greeneye-rm-rpc-stimulus/1`` ordered message list for the
+ simulator's B4/B5 CSB/message-queue seam (not wired yet).
+
+Honesty rules:
+
+* Truncated payloads are flagged, never silently padded. The runbook's hex
+ helper caps a dump at 192 bytes; a capture produced by that helper will mark
+ every larger payload ``truncated`` so a reader can see what is missing.
+* Handles, physical addresses and caller addresses are capture-specific; the
+ compare report labels them, and the stimulus output says so explicitly.
+* This tool only reads files and writes reports. It never touches a GPU,
+ kernel module, mdev, guest or network. The capture it ingests is a
+ supervised, human-run physical action (RUNBOOK P1/P7); this tool never runs
+ or instructs it beyond printing the tag grammar.
+* No timing/cycle claim is made anywhere; fidelity is L0/L1 (docs/06).
+"""
+from __future__ import annotations
+
+import argparse
+import difflib
+import hashlib
+import json
+import os
+import re
+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
+
+#: Schema tag of the decoded capture document.
+CAPTURE_FORMAT = "greeneye-rm-rpc-capture/1"
+#: Schema tag of the comparison report.
+COMPARE_FORMAT = "greeneye-rm-rpc-compare/1"
+#: Schema tag of the replay stimulus document.
+STIMULUS_FORMAT = "greeneye-rm-rpc-stimulus/1"
+#: Schema tag of the demo result line.
+DEMO_FORMAT = "greeneye-rm-rpc-capture-demo/1"
+
+#: Size of ``rpc_message_header_v``: the wire payload starts after it.
+RPC_HEADER_BYTES = 32
+
+# Wire function ids (``rpc_global_enums.h``): used to derive the embedded
+# control/class id from a ``GE-SEND`` payload when no named ``GE-CTRL`` line
+# was captured for it.
+GSP_RM_CONTROL = 76
+GSP_RM_ALLOC = 103
+# Payload offsets inside the flat parameter blob (runbook §4).
+CONTROL_CMD_OFFSET = 8
+ALLOC_CLASS_OFFSET = 12
+
+KINDS = ("send", "control", "alloc", "golden", "ctrl-done")
+HEX_KIND_TAG = {"send": "SEND", "control": "CTRL", "alloc": "ALLOC"}
+TAG_KIND = {v: k for k, v in HEX_KIND_TAG.items()}
+
+# FROZEN BEFORE THE FIRST MEASUREMENT (2026-10-06). Do not move silently:
+# a threshold change requires a new recorded run and a note in docs/45.
+#: Conservative ESTIMATE (not measured on a human) of decoding one golden
+#: sequence by eye from a capture log: grep the tags, count records, walk each
+#: parameter blob against the RPC structs, transcribe into a replay table.
+MANUAL_DECODE_BASELINE_SECONDS = 1800
+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
+
+_HEX_RE = re.compile(
+ r"GE-(SEND|CTRL|ALLOC)\s+([0-9a-fA-F]{1,4})\s*:\s*((?:[0-9a-fA-F]{2}\s*)+)"
+)
+_KV_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)=(\S+)")
+_TS_RE = re.compile(r"^\[([^\]]+)\]\s*")
+_GOLDEN_TRIGGER_RE = re.compile(
+ r"GE-GOLDEN-TRIGGER\s+gspclient=(\d+)(?:\s+mig=(\d+))?"
+)
+_GOLDEN_END_RE = re.compile(r"GE-GOLDEN-END\s+status=(\S+)")
+_GOLDEN_BEGIN_RE = re.compile(r"GE-GOLDEN-BEGIN")
+_CTRL_DONE_RE = re.compile(r"GE-CTRL-DONE\b")
+
+
+class RmCaptureError(GreenEyeError):
+ """A capture input is absent, unreadable or not in a supported schema."""
+
+
+def _parse_int(value: Any, default: int | None = None) -> int | None:
+ """Parse ints, decimal strings and ``0x`` strings; ``default`` 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 _sha256_bytes(data: bytes) -> str:
+ return hashlib.sha256(data).hexdigest()
+
+
+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 canonical_json(payload: Any) -> str:
+ """Deterministic JSON text (sorted keys, fixed indent, trailing newline)."""
+ return json.dumps(payload, indent=2, sort_keys=True) + "\n"
+
+
+@dataclass
+class CaptureRecord:
+ """One decoded capture record (an RPC, an alloc, or a golden marker)."""
+
+ index: int
+ kind: str
+ line: int
+ ts: str = ""
+ fn: int | None = None
+ length: int | None = None
+ size: int | None = None
+ cmd: int | None = None
+ hclass: int | None = None
+ hclient: int | None = None
+ hobject: int | None = None
+ hparent: int | None = None
+ caller: int | None = None
+ caller_raw: str = ""
+ marker: str = ""
+ gspclient: int | None = None
+ mig: int | None = None
+ status: int | None = None
+ rpc_status: int | None = None
+ params_status: int | None = None
+ chunks: dict[int, bytes] = field(default_factory=dict)
+ anomalies: list[str] = field(default_factory=list)
+
+ # -- parameter assembly -------------------------------------------------
+ @property
+ def params(self) -> bytes:
+ """Captured parameter bytes, assembled from the hex chunks in order.
+
+ A gap between chunks is zero-filled and recorded as an anomaly; the
+ raw chunk map stays available on the record.
+ """
+ if not self.chunks:
+ return b""
+ end = max(offset + len(data) for offset, data in self.chunks.items())
+ out = bytearray(end)
+ for offset, data in sorted(self.chunks.items()):
+ out[offset : offset + len(data)] = data
+ return bytes(out)
+
+ @property
+ def expected_params_size(self) -> int | None:
+ """Declared parameter size, or ``None`` when the tag does not carry one."""
+ if self.kind == "send":
+ if self.length is None or self.length < RPC_HEADER_BYTES:
+ return None
+ return self.length - RPC_HEADER_BYTES
+ if self.kind in ("control", "alloc"):
+ return self.size
+ return None
+
+ @property
+ def truncated(self) -> bool:
+ expected = self.expected_params_size
+ return expected is not None and len(self.params) < expected
+
+ @property
+ def overrun(self) -> bool:
+ expected = self.expected_params_size
+ return expected is not None and len(self.params) > expected
+
+ @property
+ def wire_id(self) -> int | None:
+ """Named id: ``cmd``/``hclass`` when captured, else derived from payload."""
+ if self.kind == "control":
+ return self.cmd
+ if self.kind == "alloc":
+ return self.hclass
+ if self.kind == "send":
+ payload = self.params
+ if self.fn == GSP_RM_CONTROL and len(payload) >= CONTROL_CMD_OFFSET + 4:
+ return int.from_bytes(
+ payload[CONTROL_CMD_OFFSET : CONTROL_CMD_OFFSET + 4], "little"
+ )
+ if self.fn == GSP_RM_ALLOC and len(payload) >= ALLOC_CLASS_OFFSET + 4:
+ return int.from_bytes(
+ payload[ALLOC_CLASS_OFFSET : ALLOC_CLASS_OFFSET + 4], "little"
+ )
+ return None
+
+ @property
+ def signature(self) -> str:
+ """Identity used for compare alignment (kind + id + declared size)."""
+ if self.kind == "golden":
+ return f"golden:{self.marker}"
+ if self.kind == "ctrl-done":
+ return f"ctrl-done:{self.cmd:#x}" if self.cmd is not None else "ctrl-done:?"
+ identifier = self.wire_id
+ id_text = f"{identifier:#x}" if identifier is not None else "?"
+ fn_text = f"{self.fn:#x}" if self.fn is not None else "?"
+ size = self.expected_params_size
+ size_text = f"{size:#x}" if size is not None else "?"
+ return f"{self.kind}:{id_text}:{fn_text}:{size_text}"
+
+ @property
+ def label(self) -> str:
+ """Short human label for reports."""
+ if self.kind == "golden":
+ return f"GOLDEN {self.marker}" + (
+ f" status={self.status:#x}" if self.status is not None else ""
+ )
+ identifier = self.wire_id
+ id_text = f"{identifier:#x}" if identifier is not None else "?"
+ if self.kind == "send":
+ return f"SEND fn={self.fn:#x} id={id_text}" if self.fn is not None else "SEND"
+ if self.kind == "control":
+ return f"CTRL cmd={id_text}"
+ if self.kind == "alloc":
+ return f"ALLOC cls={id_text}"
+ return f"CTRL-DONE cmd={id_text}"
+
+
+@dataclass
+class RmCapture:
+ """A decoded capture document."""
+
+ path: str
+ sha256: str
+ records: list[CaptureRecord] = field(default_factory=list)
+ source: str = ""
+ format: str = CAPTURE_FORMAT
+ warnings: list[str] = field(default_factory=list)
+
+ @property
+ def truncated_records(self) -> list[CaptureRecord]:
+ return [r for r in self.records if r.truncated]
+
+ def kind_counts(self) -> dict[str, int]:
+ counts: dict[str, int] = {}
+ for record in self.records:
+ counts[record.kind] = counts.get(record.kind, 0) + 1
+ return counts
+
+ def fn_counts(self) -> dict[str, int]:
+ counts: dict[str, int] = {}
+ for record in self.records:
+ if record.kind == "send" and record.fn is not None:
+ key = f"0x{record.fn:x}"
+ counts[key] = counts.get(key, 0) + 1
+ return counts
+
+ def golden_status(self) -> int | None:
+ for record in self.records:
+ if record.kind == "golden" and record.marker == "end":
+ return record.status
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Decode
+# ---------------------------------------------------------------------------
+
+
+def _kv_fields(line: str) -> dict[str, str]:
+ return {key: value for key, value in _KV_RE.findall(line)}
+
+
+def _new_record(index: int, kind: str, line_no: int, ts: str) -> CaptureRecord:
+ return CaptureRecord(index=index, kind=kind, line=line_no, ts=ts)
+
+
+def decode_text(text: str, source: str = "<text>") -> RmCapture:
+ """Decode capture-log text into a capture document.
+
+ Lines are matched anywhere in the line (dmesg/NVRM prefixes are fine).
+ Hex lines attach to the most recent record of their own tag; a hex line
+ with no open record of that tag is recorded as an ``orphan_hex`` warning
+ instead of being guessed onto some other record.
+ """
+ records: list[CaptureRecord] = []
+ warnings: list[str] = []
+ open_records: dict[str, CaptureRecord] = {}
+
+ for line_no, raw_line in enumerate(text.splitlines(), start=1):
+ ts_match = _TS_RE.search(raw_line)
+ ts = ts_match.group(1) if ts_match else ""
+
+ hex_match = _HEX_RE.search(raw_line)
+ if hex_match:
+ tag = hex_match.group(1)
+ kind = TAG_KIND[tag]
+ offset = int(hex_match.group(2), 16)
+ data = bytes.fromhex(re.sub(r"\s+", "", hex_match.group(3)))
+ record = open_records.get(kind)
+ if record is None:
+ warnings.append(f"line {line_no}: orphan {tag} hex block (no header)")
+ continue
+ if offset in record.chunks:
+ record.anomalies.append(f"duplicate hex offset {offset:#x}")
+ record.chunks[offset] = data
+ continue
+
+ if _GOLDEN_TRIGGER_RE.search(raw_line):
+ match = _GOLDEN_TRIGGER_RE.search(raw_line)
+ record = _new_record(len(records), "golden", line_no, ts)
+ record.marker = "trigger"
+ record.gspclient = int(match.group(1)) if match else None
+ record.mig = int(match.group(2)) if match and match.group(2) else None
+ records.append(record)
+ continue
+
+ if _GOLDEN_BEGIN_RE.search(raw_line):
+ record = _new_record(len(records), "golden", line_no, ts)
+ record.marker = "begin"
+ records.append(record)
+ continue
+
+ if _GOLDEN_END_RE.search(raw_line):
+ match = _GOLDEN_END_RE.search(raw_line)
+ record = _new_record(len(records), "golden", line_no, ts)
+ record.marker = "end"
+ record.status = _parse_int(match.group(1) if match else None)
+ records.append(record)
+ continue
+
+ if _CTRL_DONE_RE.search(raw_line):
+ fields = _kv_fields(raw_line)
+ record = _new_record(len(records), "ctrl-done", line_no, ts)
+ record.cmd = _parse_int(fields.get("cmd"))
+ record.rpc_status = _parse_int(fields.get("rpc_status"))
+ record.params_status = _parse_int(fields.get("params_status"))
+ records.append(record)
+ continue
+
+ header_kind = None
+ for tag, kind in TAG_KIND.items():
+ if re.search(rf"GE-{tag}\b", raw_line):
+ header_kind = kind
+ break
+ if header_kind is None:
+ continue
+
+ fields = _kv_fields(raw_line)
+ record = _new_record(len(records), header_kind, line_no, ts)
+ if header_kind == "send":
+ record.fn = _parse_int(fields.get("fn"))
+ record.length = _parse_int(fields.get("len"))
+ elif header_kind == "control":
+ record.cmd = _parse_int(fields.get("cmd"))
+ record.hclient = _parse_int(fields.get("hClient"))
+ record.hobject = _parse_int(fields.get("hObject"))
+ record.size = _parse_int(fields.get("size"))
+ elif header_kind == "alloc":
+ record.hclass = _parse_int(fields.get("cls"))
+ record.hclient = _parse_int(fields.get("hClient"))
+ record.hparent = _parse_int(fields.get("hParent"))
+ record.hobject = _parse_int(fields.get("hObject"))
+ record.size = _parse_int(fields.get("size"))
+ caller_raw = fields.get("ret", "")
+ record.caller_raw = caller_raw
+ record.caller = _parse_int(caller_raw)
+ records.append(record)
+ open_records[header_kind] = record
+
+ # Post-pass: chunk-gap and size anomalies.
+ for record in records:
+ expected = record.expected_params_size
+ if record.chunks and 0 not in record.chunks:
+ record.anomalies.append("hex chunks do not start at offset 0")
+ if record.overrun:
+ record.anomalies.append(
+ f"captured {len(record.params)} bytes > declared {expected} bytes"
+ )
+
+ return RmCapture(
+ path=os.path.abspath(source),
+ sha256=_sha256_bytes(text.encode("utf-8", "replace")),
+ records=records,
+ source=source,
+ warnings=warnings,
+ )
+
+
+def decode_file(path: str) -> RmCapture:
+ if not os.path.isfile(path):
+ raise RmCaptureError(f"capture log not found: {path}")
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
+ text = fh.read()
+ except OSError as exc:
+ raise RmCaptureError(f"cannot read capture log {path}: {exc}") from exc
+ return decode_text(text, source=path)
+
+
+def load_capture(path: str) -> RmCapture:
+ """Load a ``greeneye-rm-rpc-capture/1`` document or decode a raw log."""
+ if not os.path.isfile(path):
+ raise RmCaptureError(f"capture not found: {path}")
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
+ head = fh.read(4096)
+ if not head.lstrip().startswith("{"):
+ return decode_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 RmCaptureError(f"cannot read capture {path}: {exc}") from exc
+ if not isinstance(payload, dict):
+ raise RmCaptureError(f"{path}: capture must be an object")
+ fmt = str(payload.get("format") or payload.get("schema") or "")
+ if fmt != CAPTURE_FORMAT:
+ raise RmCaptureError(f"{path}: format {fmt!r} is not {CAPTURE_FORMAT!r}")
+ raw_records = payload.get("records")
+ if not isinstance(raw_records, list):
+ raise RmCaptureError(f"{path}: missing 'records' list")
+ records: list[CaptureRecord] = []
+ for index, raw in enumerate(raw_records):
+ if not isinstance(raw, dict):
+ continue
+ record = _new_record(
+ index, str(raw.get("kind") or "unknown"), int(raw.get("line") or 0),
+ str(raw.get("ts") or ""),
+ )
+ record.fn = _parse_int(raw.get("fn"))
+ record.length = _parse_int(raw.get("length"))
+ record.size = _parse_int(raw.get("size"))
+ record.cmd = _parse_int(raw.get("cmd"))
+ record.hclass = _parse_int(raw.get("hclass"))
+ record.hclient = _parse_int(raw.get("hclient"))
+ record.hobject = _parse_int(raw.get("hobject"))
+ record.hparent = _parse_int(raw.get("hparent"))
+ record.caller = _parse_int(raw.get("caller"))
+ record.caller_raw = str(raw.get("caller_raw") or "")
+ record.marker = str(raw.get("marker") or "")
+ record.gspclient = _parse_int(raw.get("gspclient"))
+ record.mig = _parse_int(raw.get("mig"))
+ record.status = _parse_int(raw.get("status"))
+ record.rpc_status = _parse_int(raw.get("rpc_status"))
+ record.params_status = _parse_int(raw.get("params_status"))
+ params_hex = raw.get("params_hex")
+ if isinstance(params_hex, str) and params_hex:
+ try:
+ record.chunks[0] = bytes.fromhex(re.sub(r"\s+", "", params_hex))
+ except ValueError as exc:
+ raise RmCaptureError(f"{path}: record {index}: bad params hex: {exc}") from exc
+ records.append(record)
+ capture = RmCapture(
+ path=os.path.abspath(path),
+ sha256=_sha256_file(path),
+ records=records,
+ source=str(payload.get("source") or path),
+ )
+ warnings = payload.get("warnings")
+ if isinstance(warnings, list):
+ capture.warnings = [str(w) for w in warnings]
+ return capture
+
+
+# ---------------------------------------------------------------------------
+# Compare
+# ---------------------------------------------------------------------------
+
+
+def _param_agreement(a: CaptureRecord, b: CaptureRecord) -> dict:
+ pa, pb = a.params, b.params
+ matched = sum(1 for x, y in zip(pa, pb) if x == y)
+ first_diff = None
+ for offset, (x, y) in enumerate(zip(pa, pb)):
+ if x != y:
+ first_diff = offset
+ break
+ if first_diff is None and len(pa) != len(pb):
+ first_diff = min(len(pa), len(pb))
+ denominator = max(len(pa), len(pb))
+ return {
+ "a_bytes": len(pa),
+ "b_bytes": len(pb),
+ "matched_bytes": matched,
+ "byte_agreement_pct": (
+ round(100.0 * matched / denominator, 2) if denominator else None
+ ),
+ "first_diff_offset": first_diff,
+ "a_truncated": a.truncated,
+ "b_truncated": b.truncated,
+ }
+
+
+def _pair_status(a: CaptureRecord, b: CaptureRecord) -> tuple[str, dict]:
+ if a.kind == "golden":
+ if (
+ a.status == b.status
+ and a.gspclient == b.gspclient
+ and a.mig == b.mig
+ ):
+ return "agree", {}
+ return "mismatch", {
+ "a_status": a.status,
+ "b_status": b.status,
+ "a_gspclient": a.gspclient,
+ "b_gspclient": b.gspclient,
+ "a_mig": a.mig,
+ "b_mig": b.mig,
+ }
+ if a.kind == "ctrl-done":
+ if a.rpc_status == b.rpc_status and a.params_status == b.params_status:
+ return "agree", {}
+ return "mismatch", {
+ "a_rpc_status": a.rpc_status,
+ "b_rpc_status": b.rpc_status,
+ "a_params_status": a.params_status,
+ "b_params_status": b.params_status,
+ }
+ agreement = _param_agreement(a, b)
+ if a.truncated or b.truncated:
+ return "partial", agreement
+ if agreement["a_bytes"] == agreement["b_bytes"] and agreement["first_diff_offset"] is None:
+ return "agree", agreement
+ return "shape-agree", agreement
+
+
+def compare(a: RmCapture, b: RmCapture) -> dict:
+ """Ordered, per-record comparison of two captures; no timing claims."""
+ sig_a = [record.signature for record in a.records]
+ sig_b = [record.signature for record in b.records]
+ matcher = difflib.SequenceMatcher(a=sig_a, b=sig_b, autojunk=False)
+ rows: list[dict] = []
+ a_only: list[dict] = []
+ b_only: list[dict] = []
+ counts = {
+ "agree": 0,
+ "shape-agree": 0,
+ "partial": 0,
+ "mismatch": 0,
+ "a-only": 0,
+ "b-only": 0,
+ }
+ for opcode, i1, i2, j1, j2 in matcher.get_opcodes():
+ if opcode == "equal":
+ for offset in range(i2 - i1):
+ record_a = a.records[i1 + offset]
+ record_b = b.records[j1 + offset]
+ status, detail = _pair_status(record_a, record_b)
+ counts[status] += 1
+ rows.append(
+ {
+ "a_index": record_a.index,
+ "b_index": record_b.index,
+ "a_label": record_a.label,
+ "b_label": record_b.label,
+ "signature": record_a.signature,
+ "status": status,
+ "detail": detail,
+ "a_truncated": record_a.truncated,
+ "b_truncated": record_b.truncated,
+ "a_caller": record_a.caller,
+ "b_caller": record_b.caller,
+ }
+ )
+ else:
+ for index in range(i1, i2):
+ counts["a-only"] += 1
+ record = a.records[index]
+ a_only.append(
+ {
+ "index": record.index,
+ "label": record.label,
+ "signature": record.signature,
+ "params_bytes": len(record.params),
+ "line": record.line,
+ }
+ )
+ for index in range(j1, j2):
+ counts["b-only"] += 1
+ record = b.records[index]
+ b_only.append(
+ {
+ "index": record.index,
+ "label": record.label,
+ "signature": record.signature,
+ "params_bytes": len(record.params),
+ "line": record.line,
+ }
+ )
+ matched = counts["agree"] + counts["shape-agree"] + counts["partial"] + counts["mismatch"]
+ byte_rows = [
+ row["detail"].get("byte_agreement_pct")
+ for row in rows
+ if isinstance(row.get("detail"), dict)
+ and row["detail"].get("byte_agreement_pct") is not None
+ ]
+ return {
+ "format": COMPARE_FORMAT,
+ "inputs": {
+ "a": {
+ "path": a.path,
+ "sha256": a.sha256,
+ "source": a.source,
+ "records": len(a.records),
+ "kinds": a.kind_counts(),
+ "fn_counts": a.fn_counts(),
+ "golden_status": a.golden_status(),
+ "truncated_records": len(a.truncated_records),
+ },
+ "b": {
+ "path": b.path,
+ "sha256": b.sha256,
+ "source": b.source,
+ "records": len(b.records),
+ "kinds": b.kind_counts(),
+ "fn_counts": b.fn_counts(),
+ "golden_status": b.golden_status(),
+ "truncated_records": len(b.truncated_records),
+ },
+ },
+ "summary": {
+ "a_records": len(a.records),
+ "b_records": len(b.records),
+ "matched": matched,
+ "agree": counts["agree"],
+ "shape_agree": counts["shape-agree"],
+ "partial": counts["partial"],
+ "mismatch": counts["mismatch"],
+ "a_only": counts["a-only"],
+ "b_only": counts["b-only"],
+ "agreement_pct": round(100.0 * counts["agree"] / matched, 2) if matched else None,
+ "coverage_pct": round(100.0 * matched / len(a.records), 2) if a.records else None,
+ "mean_byte_agreement_pct": (
+ round(sum(byte_rows) / len(byte_rows), 2) if byte_rows else None
+ ),
+ "claim": (
+ "per-record identity and parameter agreement on the aligned intersection; "
+ "not a timing, silicon or full-coverage claim"
+ ),
+ },
+ "rows": rows,
+ "a_only": a_only,
+ "b_only": b_only,
+ "fidelity": {
+ "a": f"file-ingested capture ({a.source or 'unlabelled'})",
+ "b": f"file-ingested capture ({b.source or 'unlabelled'})",
+ "levels": "L0/L1 only; L2 timing and L3 device environment not claimed (docs/06)",
+ },
+ "limits": [
+ "This tool never ran the capture: a supervised human produced both inputs.",
+ "Handles, physical addresses and caller return addresses are capture-specific and are labelled, not normalised.",
+ "A truncated payload is reported as partial/truncated, never padded to look complete.",
+ "A-only/b-only records are ordering or presence differences under this alignment; they are not proof of a wrong value.",
+ ],
+ "warnings": (a.warnings if a.warnings else []) + (b.warnings if b.warnings else []),
+ }
+
+
+def render_compare_markdown(report: dict) -> str:
+ summary = report["summary"]
+ lines = [
+ "# GSP RPC capture comparison",
+ "",
+ f"> Format `{report['format']}`. Fidelity: {report['fidelity']['levels']}",
+ "> No cycle/timing claim. Underclaim every number (docs/06, docs/45).",
+ "",
+ "## Inputs",
+ "",
+ f"* A: `{report['inputs']['a']['path']}` "
+ f"({report['inputs']['a']['records']} records, "
+ f"sha256 `{report['inputs']['a']['sha256'][:16]}…`"
+ + (
+ f", golden status `{report['inputs']['a']['golden_status']:#x}`"
+ if report["inputs"]["a"]["golden_status"] is not None
+ else ""
+ )
+ + ")",
+ f"* B: `{report['inputs']['b']['path']}` "
+ f"({report['inputs']['b']['records']} records, "
+ f"sha256 `{report['inputs']['b']['sha256'][:16]}…`"
+ + (
+ f", golden status `{report['inputs']['b']['golden_status']:#x}`"
+ if report["inputs"]["b"]["golden_status"] is not None
+ else ""
+ )
+ + ")",
+ "",
+ "## Summary",
+ "",
+ "| matched | agree | shape-agree | partial | mismatch | A-only | B-only | agreement % | coverage % | mean byte agreement % |",
+ "|---|---|---|---|---|---|---|---|---|---|",
+ f"| {summary['matched']} | {summary['agree']} | {summary['shape_agree']} | "
+ f"{summary['partial']} | {summary['mismatch']} | {summary['a_only']} | {summary['b_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['mean_byte_agreement_pct'] if summary['mean_byte_agreement_pct'] is not None else 'n/a'} |",
+ "",
+ f"* {summary['claim']}",
+ "",
+ "## Per-record rows",
+ "",
+ "| A | B | status | bytes A/B | byte agreement % | first diff | truncated A/B |",
+ "|---|---|---|---|---|---|---|",
+ ]
+ for row in report["rows"]:
+ detail = row.get("detail") if isinstance(row.get("detail"), dict) else {}
+ a_bytes = detail.get("a_bytes", "")
+ b_bytes = detail.get("b_bytes", "")
+ byte_pct = detail.get("byte_agreement_pct")
+ first_diff = detail.get("first_diff_offset")
+ lines.append(
+ f"| `{row['a_label']}` | `{row['b_label']}` | {row['status']} | "
+ f"{a_bytes}/{b_bytes} | {byte_pct if byte_pct is not None else ''} | "
+ f"{first_diff if first_diff is not None else ''} | "
+ f"{'Y' if row.get('a_truncated') else '-'}/{'Y' if row.get('b_truncated') else '-'} |"
+ )
+ lines += ["", "## A-only records", ""]
+ if report["a_only"]:
+ for item in report["a_only"]:
+ lines.append(
+ f"* #{item['index']} `{item['label']}` ({item['params_bytes']} B, line {item['line']})"
+ )
+ else:
+ lines.append("* none")
+ lines += ["", "## B-only records", ""]
+ if report["b_only"]:
+ for item in report["b_only"]:
+ lines.append(
+ f"* #{item['index']} `{item['label']}` ({item['params_bytes']} B, line {item['line']})"
+ )
+ else:
+ lines.append("* none")
+ lines += ["", "## Limits", ""]
+ for limit in report["limits"]:
+ lines.append(f"* {limit}")
+ if report.get("warnings"):
+ lines += ["", "## Decode warnings", ""]
+ for warning in report["warnings"]:
+ lines.append(f"* {warning}")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def run_decode(log_path: str, out_dir: str | None) -> dict:
+ capture = decode_file(log_path)
+ payload = capture_to_dict(capture)
+ if out_dir:
+ os.makedirs(out_dir, exist_ok=True)
+ json_path = os.path.join(out_dir, "rm-capture.json")
+ with open(json_path, "w", encoding="utf-8") as fh:
+ fh.write(canonical_json(payload))
+ md_path = os.path.join(out_dir, "rm-capture.md")
+ with open(md_path, "w", encoding="utf-8") as fh:
+ fh.write(render_capture_markdown(capture))
+ payload["artifacts"] = {
+ "capture_json": os.path.abspath(json_path),
+ "capture_md": os.path.abspath(md_path),
+ }
+ return payload
+
+
+def run_compare(a_path: str, b_path: str, out_dir: str) -> dict:
+ capture_a = load_capture(a_path)
+ capture_b = load_capture(b_path)
+ report = compare(capture_a, capture_b)
+ os.makedirs(out_dir, exist_ok=True)
+ json_path = os.path.join(out_dir, "rm-capture-compare.json")
+ md_path = os.path.join(out_dir, "rm-capture-compare.md")
+ with open(json_path, "w", encoding="utf-8") as fh:
+ fh.write(canonical_json(report))
+ with open(md_path, "w", encoding="utf-8") as fh:
+ fh.write(render_compare_markdown(report))
+ report["artifacts"] = {
+ "report_json": os.path.abspath(json_path),
+ "report_md": os.path.abspath(md_path),
+ }
+ return report
+
+
+def run_stimulus(capture_path: str, out_path: str | None) -> dict:
+ capture = load_capture(capture_path)
+ payload = stimulus_from_capture(capture)
+ if out_path:
+ os.makedirs(os.path.dirname(os.path.abspath(out_path)), exist_ok=True)
+ with open(out_path, "w", encoding="utf-8") as fh:
+ fh.write(canonical_json(payload))
+ payload["artifacts"] = {"stimulus_json": os.path.abspath(out_path)}
+ return payload
+
+
+# ---------------------------------------------------------------------------
+# Serialisation / stimulus
+# ---------------------------------------------------------------------------
+
+
+def record_to_dict(record: CaptureRecord) -> dict:
+ out: dict[str, Any] = {
+ "index": record.index,
+ "kind": record.kind,
+ "line": record.line,
+ }
+ if record.ts:
+ out["ts"] = record.ts
+ for key in (
+ "fn",
+ "length",
+ "size",
+ "cmd",
+ "hclass",
+ "hclient",
+ "hobject",
+ "hparent",
+ "caller",
+ ):
+ value = getattr(record, key)
+ if value is not None:
+ out[key] = value
+ if record.caller_raw:
+ out["caller_raw"] = record.caller_raw
+ if record.marker:
+ out["marker"] = record.marker
+ if record.gspclient is not None:
+ out["gspclient"] = record.gspclient
+ if record.mig is not None:
+ out["mig"] = record.mig
+ for key in ("status", "rpc_status", "params_status"):
+ value = getattr(record, key)
+ if value is not None:
+ out[key] = value
+ params = record.params
+ if params:
+ out["params_hex"] = params.hex()
+ out["params_bytes"] = len(params)
+ out["params_size_expected"] = record.expected_params_size
+ out["truncated"] = record.truncated
+ if record.anomalies:
+ out["anomalies"] = list(record.anomalies)
+ return out
+
+
+def capture_to_dict(capture: RmCapture) -> dict:
+ return {
+ "format": CAPTURE_FORMAT,
+ "source": capture.source,
+ "sha256": capture.sha256,
+ "records": [record_to_dict(record) for record in capture.records],
+ "summary": {
+ "records": len(capture.records),
+ "kinds": capture.kind_counts(),
+ "fn_counts": capture.fn_counts(),
+ "truncated_records": len(capture.truncated_records),
+ "golden_status": capture.golden_status(),
+ },
+ "warnings": list(capture.warnings),
+ "limits": [
+ "Decoded from the documented capture tags only (GE-SEND/GE-CTRL/GE-ALLOC/GE-GOLDEN-*/GE-CTRL-DONE).",
+ "Handles and addresses are capture-specific; do not reuse them across boots.",
+ "Truncated payloads are flagged (the runbook hex helper caps at 192 bytes unless extended).",
+ "This tool never ran the capture; the log is a supervised human artifact.",
+ ],
+ }
+
+
+def render_capture_markdown(capture: RmCapture) -> str:
+ summary = capture_to_dict(capture)["summary"]
+ lines = [
+ "# GSP RPC capture",
+ "",
+ f"> Format `{CAPTURE_FORMAT}`; source `{capture.source}`; "
+ f"sha256 `{capture.sha256[:16]}…`. Fidelity L0/L1; no timing claim.",
+ "",
+ "## Summary",
+ "",
+ f"* records: {summary['records']} {summary['kinds']}",
+ f"* wire fn counts: {summary['fn_counts']}",
+ f"* truncated records: {summary['truncated_records']}",
+ f"* golden end status: {summary['golden_status']}",
+ "",
+ "## Records",
+ "",
+ "| # | kind | ts | id | size | captured B | expected B | truncated | caller |",
+ "|---|---|---|---|---|---|---|---|---|",
+ ]
+ for record in capture.records:
+ identifier = record.wire_id
+ id_text = f"{identifier:#x}" if identifier is not None else ""
+ expected = record.expected_params_size
+ lines.append(
+ f"| {record.index} | {record.kind} | {record.ts} | {id_text} | "
+ f"{record.size if record.size is not None else ''} | {len(record.params)} | "
+ f"{expected if expected is not None else ''} | "
+ f"{'Y' if record.truncated else '-'} | "
+ f"{f'{record.caller:#x}' if record.caller is not None else record.caller_raw} |"
+ )
+ if capture.warnings:
+ lines += ["", "## Decode warnings", ""]
+ for warning in capture.warnings:
+ lines.append(f"* {warning}")
+ lines += ["", "## Limits", ""]
+ for limit in capture_to_dict(capture)["limits"]:
+ lines.append(f"* {limit}")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def stimulus_from_capture(capture: RmCapture) -> dict:
+ messages: list[dict] = []
+ markers: list[dict] = []
+ for record in capture.records:
+ if record.kind == "golden":
+ markers.append(
+ {
+ "line": record.line,
+ "marker": record.marker,
+ "status": record.status,
+ "gspclient": record.gspclient,
+ }
+ )
+ continue
+ if record.kind == "ctrl-done":
+ continue
+ message: dict[str, Any] = {
+ "seq": len(messages),
+ "kind": record.kind,
+ "params_hex": record.params.hex(),
+ "params_size": len(record.params),
+ "params_size_expected": record.expected_params_size,
+ "truncated": record.truncated,
+ }
+ if record.fn is not None:
+ message["fn"] = record.fn
+ if record.cmd is not None:
+ message["cmd"] = record.cmd
+ if record.hclass is not None:
+ message["hclass"] = record.hclass
+ if record.kind == "send" and record.wire_id is not None:
+ message["wire_id"] = record.wire_id
+ messages.append(message)
+ return {
+ "format": STIMULUS_FORMAT,
+ "source": {
+ "path": capture.path,
+ "sha256": capture.sha256,
+ "records": len(capture.records),
+ },
+ "markers": markers,
+ "messages": messages,
+ "limits": [
+ "Handles, physical addresses and caller addresses are capture-specific and deliberately omitted; a replay must re-map or allocate them.",
+ "Truncated messages (params_size < params_size_expected) are not replayable as-is; extend the capture dump first.",
+ "Consumption by the simulator B4/B5 CSB/message-queue seam is not wired yet (docs/45).",
+ "No ordering claim beyond the captured log order; server replies were not captured by the send-side probes.",
+ ],
+ }
+
+
+# ---------------------------------------------------------------------------
+# Demo (synthetic; measured value contract)
+# ---------------------------------------------------------------------------
+
+
+def _hex_lines(tag: str, payload: bytes, chunk: int = 192) -> list[str]:
+ lines: list[str] = []
+ for start in range(0, len(payload), chunk):
+ piece = payload[start : start + chunk]
+ lines.append(f"GE-{tag} {start:03x}:" + "".join(f" {b:02x}" for b in piece))
+ return lines
+
+
+def _synthetic_log() -> str:
+ allocs = [
+ ("0x90f1", 0x30),
+ ("0x003e", 0x100),
+ ("0x50a0", 0x40),
+ ("0x0040", 0x1000),
+ ("0xc46f", 0x180),
+ ("0xc597", 0x0),
+ ]
+ controls = [
+ ("0x2080012b", 560),
+ ("0x20801201", 64),
+ ("0x20800a2a", 32),
+ ("0x20809001", 16),
+ ]
+ lines = [
+ "[ 100.000000] NVRM: GE-GOLDEN-TRIGGER gspclient=1 mig=0",
+ "[ 100.000010] NVRM: GE-GOLDEN-BEGIN",
+ ]
+ step = 1
+ for hclass, size in allocs:
+ hclass_value = int(hclass, 16)
+ params = bytes((i * 7 + hclass_value) & 0xFF for i in range(size))
+ lines.append(
+ f"[ 100.{step:06d}] NVRM: GE-ALLOC cls={hclass} hClient=0xbaba0001 "
+ f"hParent=0x0 hObject=0xbaba10{step:02x} size={size:#x} "
+ f"paramp=0xffffffffc0a00000 ret=0xffffffffc0b00000"
+ )
+ lines.extend(f"[ 100.{step:06d}] NVRM: {line}" for line in _hex_lines("ALLOC", params))
+ step += 1
+ for cmd, size in controls:
+ cmd_value = int(cmd, 16)
+ params = bytes((i * 3 + cmd_value) & 0xFF for i in range(size))
+ lines.append(
+ f"[ 100.{step:06d}] NVRM: GE-CTRL cmd={cmd} hClient=0xc2000006 "
+ f"hObject=0xabcd0080 size={size:#x} paramp=0xffffffffc0a10000 "
+ f"ret=0xffffffffc0b00000"
+ )
+ lines.extend(f"[ 100.{step:06d}] NVRM: {line}" for line in _hex_lines("CTRL", params))
+ step += 1
+ payload = (
+ (0xC2000006).to_bytes(4, "little")
+ + (0xABCD0080).to_bytes(4, "little")
+ + (0x2080012B).to_bytes(4, "little")
+ + bytes(range(60))
+ )
+ lines.append(
+ f"[ 100.{step:06d}] NVRM: GE-SEND fn=0x0000004c len={len(payload) + RPC_HEADER_BYTES:#x} "
+ f"ret=0xffffffffc0b00000"
+ )
+ lines.extend(f"[ 100.{step:06d}] NVRM: {line}" for line in _hex_lines("SEND", payload))
+ step += 1
+ lines.append(
+ f"[ 100.{step:06d}] NVRM: GE-CTRL-DONE cmd=0x2080012b rpc_status=0x0 params_status=0x0"
+ )
+ lines.append("[ 100.000999] NVRM: GE-GOLDEN-END status=0x0")
+ return "\n".join(lines) + "\n"
+
+
+def _mutate_log(text: str) -> str:
+ """Replay-side variant: one parameter byte differs, one alloc is dropped."""
+ kept: list[str] = []
+ dropping = False
+ dropped = False
+ for line in text.splitlines():
+ if not dropped and "GE-ALLOC cls=0x003e" in line:
+ dropped = True
+ dropping = True
+ continue
+ if dropping:
+ if re.search(r"GE-ALLOC\s+[0-9a-fA-F]{1,4}:", line):
+ continue
+ dropping = False
+ kept.append(line)
+ text_b = "\n".join(kept) + "\n"
+
+ def flip(match: re.Match) -> str:
+ value = int(match.group(2), 16) ^ 0x01
+ return f"{match.group(1)}{value:02x}"
+
+ text_b, _ = re.subn(r"(GE-CTRL 000: )([0-9a-fA-F]{2})", flip, text_b, count=1)
+ return text_b
+
+
+def run_demo(out_dir: str | None = None) -> dict:
+ started = time.perf_counter()
+ capture_a = decode_text(_synthetic_log(), source="demo-capture-a")
+ capture_b = decode_text(_mutate_log(_synthetic_log()), source="demo-replay-b")
+ report = compare(capture_a, capture_b)
+ stimulus = stimulus_from_capture(capture_a)
+ wall = time.perf_counter() - started
+ threshold_seconds = MANUAL_DECODE_BASELINE_SECONDS * WORKFLOW_THRESHOLD_FRACTION
+ ok = (
+ wall <= threshold_seconds
+ and report["summary"]["agree"] + report["summary"]["shape_agree"] >= 1
+ and report["summary"]["a_only"] + report["summary"]["b_only"] >= 1
+ and not capture_a.truncated_records
+ )
+ payload = {
+ "format": DEMO_FORMAT,
+ "ok": ok,
+ "wall_seconds": round(wall, 4),
+ "baseline_seconds": MANUAL_DECODE_BASELINE_SECONDS,
+ "baseline_label": MANUAL_BASELINE_LABEL,
+ "threshold_fraction": WORKFLOW_THRESHOLD_FRACTION,
+ "threshold_seconds": threshold_seconds,
+ "records_a": len(capture_a.records),
+ "records_b": len(capture_b.records),
+ "messages": len(stimulus["messages"]),
+ "summary": report["summary"],
+ }
+ if out_dir:
+ os.makedirs(out_dir, exist_ok=True)
+ capture_path = os.path.join(out_dir, "demo-capture-a.json")
+ replay_path = os.path.join(out_dir, "demo-replay-b.json")
+ with open(capture_path, "w", encoding="utf-8") as fh:
+ fh.write(canonical_json(capture_to_dict(capture_a)))
+ with open(replay_path, "w", encoding="utf-8") as fh:
+ fh.write(canonical_json(capture_to_dict(capture_b)))
+ compare_path = os.path.join(out_dir, "demo-compare.json")
+ with open(compare_path, "w", encoding="utf-8") as fh:
+ fh.write(canonical_json(report))
+ stimulus_path = os.path.join(out_dir, "demo-stimulus.json")
+ with open(stimulus_path, "w", encoding="utf-8") as fh:
+ fh.write(canonical_json(stimulus))
+ payload["artifacts"] = {
+ "capture_a": os.path.abspath(capture_path),
+ "replay_b": os.path.abspath(replay_path),
+ "compare": os.path.abspath(compare_path),
+ "stimulus": os.path.abspath(stimulus_path),
+ }
+ return payload
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+CAPTURE_HINTS = """\
+================================================================================
+GSP RPC CAPTURE TAG GRAMMAR — SUPERVISED, HUMAN-RUN PHYSICAL ACTION
+================================================================================
+This command prints the tag contract the decoder understands; it executes
+NOTHING and never runs the capture.
+
+The capture itself is supervised-only (RUNBOOK P1/P7, SIMULATOR-WORK-
+BOUNDARY.txt): driver swap on the physical GPU, one attempt per boot, stop on
+Zid/WPR2/pstore; the authored procedure lives in the capture runbook
+(authored, not executed). This tool only ingests the resulting log.
+
+Tags (anywhere in the line; dmesg/NVRM prefixes are fine):
+ GE-GOLDEN-TRIGGER gspclient=<0|1> mig=<0|1>
+ GE-GOLDEN-BEGIN
+ GE-GOLDEN-END status=0x...
+ GE-SEND fn=0x... len=0x... ret=0x...
+ GE-CTRL cmd=0x... hClient=0x... hObject=0x... size=0x... paramp=0x... ret=0x...
+ GE-ALLOC cls=0x... hClient=0x... hParent=0x... hObject=0x... size=0x... paramp=0x... ret=0x...
+ GE-CTRL-DONE cmd=0x... rpc_status=0x... params_status=0x...
+ Hex lines attach to the most recent record of their own tag:
+ GE-<TAG> <offset-hex>: <hex bytes...>
+
+NOTE — the runbook's hex helper currently caps a dump at 192 bytes; payloads
+larger than that (e.g. a 560-byte PROMOTE_CTX) decode as `truncated` and are
+not replayable until the helper is extended to chunk. The decoder flags this
+instead of padding.
+
+Offline use (safe):
+ python3 harness/rm_capture.py decode --log capture.log --out dumps/rm-capture
+ python3 harness/rm_capture.py compare --a real.json --b replay.json --out dumps/rm-compare
+ python3 harness/rm_capture.py stimulus --capture real.json --out dumps/rm-stimulus.json
+ python3 harness/rm_capture.py demo --out /tmp/rm-capture-demo
+================================================================================
+"""
+
+
+def print_capture_hints(out=None) -> None:
+ """Print the supervised capture tag grammar; execute nothing."""
+ print(CAPTURE_HINTS, file=out or sys.stdout)
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="rm_capture.py",
+ description=(
+ "Decode, compare and replay-prep a supervised GSP RPC capture. "
+ "Offline only; never executes physical commands."
+ ),
+ )
+ parser.add_argument(
+ "--print-capture-hints",
+ action="store_true",
+ help="print the capture tag grammar (executes nothing)",
+ )
+ sub = parser.add_subparsers(dest="command")
+ decode_cmd = sub.add_parser("decode", help="decode a raw capture log")
+ decode_cmd.add_argument("--log", required=True, help="capture log (text)")
+ decode_cmd.add_argument("--out", help="output directory (optional)")
+ compare_cmd = sub.add_parser("compare", help="compare two captures")
+ compare_cmd.add_argument("--a", required=True, help="capture JSON or raw log")
+ compare_cmd.add_argument("--b", required=True, help="capture JSON or raw log")
+ compare_cmd.add_argument("--out", required=True, help="output directory")
+ stimulus_cmd = sub.add_parser("stimulus", help="emit a replay stimulus document")
+ stimulus_cmd.add_argument("--capture", required=True, help="capture JSON or raw log")
+ stimulus_cmd.add_argument("--out", help="output JSON path (optional)")
+ demo_cmd = sub.add_parser("demo", help="synthetic measured end-to-end demo")
+ demo_cmd.add_argument("--out", help="output directory (optional)")
+ 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
+ try:
+ if args.command == "decode":
+ payload = run_decode(args.log, args.out)
+ elif args.command == "compare":
+ payload = run_compare(args.a, args.b, args.out)
+ elif args.command == "stimulus":
+ payload = run_stimulus(args.capture, args.out)
+ elif args.command == "demo":
+ payload = run_demo(args.out)
+ else:
+ parser.print_help()
+ return 2
+ except RmCaptureError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+ summary = payload.get("summary", {})
+ print(
+ json.dumps(
+ {
+ "format": payload.get("format"),
+ "ok": payload.get("ok"),
+ "wall_seconds": payload.get("wall_seconds"),
+ "summary": summary,
+ "artifacts": payload.get("artifacts"),
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ )
+ if args.command == "demo" and not payload.get("ok"):
+ return 3
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/run_contracts.py b/debug/risc-v-gsp-rm-vm/harness/run_contracts.py
new file mode 100644
index 0000000..1ea8474
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/run_contracts.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""Run the wall-contract registry (``harness/contracts.py``, docs/50).
+
+Every solved wall is pinned as a contract over an existing tool entry point;
+this CLI runs them and prints pass/fail per contract plus per-wall-class
+coverage. Fast contracts are the suite default; slow ones run with ``--all``
+(or by name).
+
+Offline only: never touches a GPU, kernel module, mdev, guest or network.
+Exit codes: 0 = every selected contract passed (or skipped for a missing
+prerequisite), 1 = at least one failure, 2 = usage error.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+
+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.contracts import ( # noqa: E402
+ Contract,
+ all_contracts,
+ fast_contracts,
+ run_contracts,
+ summarize,
+)
+
+
+def _select(args: argparse.Namespace) -> tuple[list[Contract], list[str]]:
+ if args.name:
+ wanted = set(args.name)
+ chosen = [c for c in all_contracts() if c.name in wanted]
+ unknown = sorted(wanted - {c.name for c in chosen})
+ return chosen, unknown
+ return (all_contracts() if args.all else fast_contracts()), []
+
+
+def _table(rows: list[tuple[str, ...]], headers: tuple[str, ...]) -> list[str]:
+ widths = [
+ max(len(headers[index]), *(len(row[index]) for row in rows))
+ for index in range(len(headers))
+ ]
+ lines = [" ".join(head.ljust(widths[i]) for i, head in enumerate(headers))]
+ lines.append(" ".join("-" * width for width in widths))
+ for row in rows:
+ lines.append(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row)))
+ return lines
+
+
+def _render(results, summary: dict) -> list[str]:
+ rows = [
+ (
+ result.name,
+ result.wall_class,
+ result.speed,
+ result.status.upper(),
+ f"{result.seconds:.2f}s",
+ result.detail,
+ )
+ for result in results
+ ]
+ lines = _table(rows, ("contract", "class", "speed", "status", "time", "detail"))
+ lines.append("")
+ lines.append("wall-class coverage (selected):")
+ for domain in summary["domains"]:
+ lines.append(
+ f" {domain['wall_class']} {domain['domain']:<28} "
+ f"{domain['total']} contracts: {domain['pass']} pass, "
+ f"{domain['fail']} fail, {domain['skip']} skip"
+ )
+ counts = summary["counts"]
+ lines.append("")
+ lines.append(
+ f"total: {counts['pass']} pass, {counts['fail']} fail, {counts['skip']} skip "
+ f"-> {'OK' if summary['ok'] else 'FAIL'}"
+ )
+ return lines
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="run_contracts.py",
+ description=(
+ "Run the wall-contract registry: fast contracts by default, all "
+ "with --all (offline; never touches hardware)."
+ ),
+ )
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument(
+ "--fast", action="store_true", help="run fast contracts only (default)"
+ )
+ group.add_argument("--all", action="store_true", help="run fast and slow contracts")
+ parser.add_argument(
+ "--name",
+ action="append",
+ default=[],
+ metavar="NAME",
+ help="run one named contract (repeatable); overrides --fast/--all selection",
+ )
+ parser.add_argument("--json", metavar="PATH", help="write the run summary as JSON here")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ selected, unknown = _select(args)
+ if unknown:
+ parser.error("unknown contract name(s): " + ", ".join(unknown))
+ results = run_contracts(selected)
+ summary = summarize(results)
+ for line in _render(results, summary):
+ print(line)
+ if args.json:
+ out_path = os.path.abspath(args.json)
+ parent = os.path.dirname(out_path)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+ with open(out_path, "w", encoding="utf-8") as fh:
+ json.dump(summary, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ print(f"json: {out_path}")
+ return 0 if summary["ok"] else 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/server_snapshot.py b/debug/risc-v-gsp-rm-vm/harness/server_snapshot.py
new file mode 100644
index 0000000..1099fb0
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/server_snapshot.py
@@ -0,0 +1,698 @@
+#!/usr/bin/env python3
+"""Collect a canonical server-state snapshot from cycle evidence directories.
+
+The differential loop (live run vs emulated run, run N vs run N+1) needs one
+machine-readable view of the server state a cycle produced. Cycles already
+write that state as text: labelled field variables (``LABEL[offset]=0xVALUE``),
+labelled hexdump blocks (an ``INSTANCE[0000]: w w w w``-style row, or a header
+line naming the label followed by ``OFFSET: w w w w`` rows -- e.g.
+``LOGINIT-raw`` / ``LOGRM-raw`` raw task-log dumps), and ``vgsp-nocat`` record
+lines carrying ``recType=`` / ``errorCode=``. This harness discovers those
+files under evidence/session directories, normalizes every dump to
+little-endian u32 words, and emits one JSON snapshot that two runs can be
+compared with ``diff``.
+
+Schemas
+-------
+
+``greeneye-server-snapshot/1``::
+
+ {"schema": "greeneye-server-snapshot/1",
+ "source": "<string>",
+ "created": "<ISO-8601>",
+ "chunks": [{"label": "INSTANCE", "base": "0x0",
+ "words": ["0x00000000", ...]}],
+ "records": [{"kind": "record", "raw": "...",
+ "rec_type": 5, "error_code": 0x57f1344,
+ "source_name": "ASSERT"}]}
+
+``greeneye-server-snapshot-diff/1``::
+
+ {"schema": "greeneye-server-snapshot-diff/1",
+ "a": {"source": ..., "created": ...}, "b": {...},
+ "chunks": [{"label": "INSTANCE", "status": "changed|added|removed|unchanged",
+ "base": {"a": "0x0", "b": "0x0"},
+ "words": {"a": 1024, "b": 1024},
+ "added": [{"index": 8, "value": "0x00000000"}],
+ "removed": [{"index": 8, "value": "0x00000001"}],
+ "changed": [{"index": 8, "a": "0x...", "b": "0x..."}]}],
+ "records": {"added": [...], "removed": [...]},
+ "summary": {"identical": false, "chunks_added": 0, ...}}
+
+Normalization rules (the consumer depends on them):
+
+* words are 8-hex-digit lowercase strings of little-endian u32 values;
+* a ``LABEL[offset]=0xVALUE`` value wider than 32 bits becomes two words
+ (low word first); its offset orders the entries and selects ``base``, and
+ the values are appended in offset order (the offset is a table index there,
+ not a byte address);
+* hexdump rows are placed by byte offset (``(offset - base) // 4``) when
+ 4-byte aligned, so a row missing in one run still aligns the rest;
+* ``base`` is the smallest offset seen for the chunk (``0x0`` when none);
+* chunks are sorted by label; a repeated label is merged only when its base
+ and word list are byte-identical, otherwise it is kept as ``LABEL#2``,
+ ``LABEL#3`` ... and the collision is reported in the run stats;
+* non-empty records are deduplicated by raw line and sorted by raw line;
+* ``created`` is the only variable field: pin it with ``--created`` or
+ ``SOURCE_DATE_EPOCH`` for deterministic output.
+
+Honesty: this is an evidence normalizer, not a state oracle. It preserves
+what the cycle wrote -- offsets are honoured, identical duplicates are merged,
+differing duplicates are renamed, and dump-looking lines that fail to parse
+are counted (``unparsed``) instead of being silently dropped. It never
+executes anything, touches no GPU and reads only files given on the command
+line.
+"""
+from __future__ import annotations
+
+import argparse
+import fnmatch
+import json
+import os
+import re
+import sys
+import tempfile
+import time
+from typing import Any, Iterable
+
+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)
+
+SNAPSHOT_FORMAT = "greeneye-server-snapshot/1"
+DIFF_FORMAT = "greeneye-server-snapshot-diff/1"
+DEMO_FORMAT = "greeneye-server-snapshot-demo/1"
+
+#: File-name globs discovered under each ``--dir`` unless extended with
+#: ``--include``. fnmatch's ``*`` matches leading dots, so the rolling host
+#: dmesg captures (``.dmesg-*.rolling``) are covered by ``*.rolling``.
+DEFAULT_INCLUDES = ("*.txt", "*.log", "*.rolling")
+
+#: ``INSTANCE[0000]: w w w w`` -- one labelled dump row inside an arbitrary
+#: prefix (dmesg timestamp, subsystem, case id). Offsets are hexadecimal.
+_INLINE_ROW_RE = re.compile(
+ r"(?P<label>[A-Za-z_][A-Za-z0-9_.]*)\[(?P<offset>(?:0x)?[0-9A-Fa-f]+)\]:"
+ r"[ \t]*(?P<tail>[0-9A-Fa-f]{1,8}(?:[ \t]+[0-9A-Fa-f]{1,8})*)[ \t]*$"
+)
+
+#: ``LABEL[offset]=0xVALUE`` (optionally ``LABEL[0].suffix=``), several per
+#: line allowed (``root[0]=... pd2[0]=... pd0[0].small=...``).
+_LABELED_VALUE_RE = re.compile(
+ r"(?P<label>[A-Za-z_][A-Za-z0-9_.]*)\[(?P<offset>(?:0x)?[0-9A-Fa-f]+)\]"
+ r"(?P<suffix>\.[A-Za-z_][A-Za-z0-9_]*)?[ \t]*=[ \t]*"
+ r"(?P<value>0[xX][0-9A-Fa-f]+)\b"
+)
+
+#: A bare hexdump row (``00000010: w w w w``) inside a labelled block.
+_BLOCK_ROW_RE = re.compile(
+ r"^(?P<offset>[0-9A-Fa-f]{4,16}):[ \t]+"
+ r"(?P<tail>[0-9A-Fa-f]{1,8}(?:[ \t]+[0-9A-Fa-f]{1,8})*)[ \t]*$"
+)
+
+#: A block header naming the label for the rows that follow: ``LOGINIT-raw:``,
+#: ``LOGRM-raw:``, ``RAMFC:`` ... Used only as one-line lookahead.
+_BLOCK_HEADER_RE = re.compile(r"^(?P<label>[A-Za-z_][A-Za-z0-9_.-]*)[ \t]*:")
+
+#: A GSP-RM error record line (``rec seq=1 recType=5 errorCode=0x...``).
+_RECORD_RE = re.compile(
+ r"\brecType=(?P<rec_type>\d+)\b.*?\berrorCode=(?P<error_code>0[xX][0-9A-Fa-f]+)"
+)
+
+_SOURCE_RE = re.compile(r'\bsource="(?P<source_name>[^"]*)"')
+
+#: Loose shape check for a line that looks like a dump but failed to parse;
+#: these are counted so silent data loss is visible in the run stats.
+_UNPARSED_HINT_RE = re.compile(
+ r"\[[0-9A-Fa-fx]+\][ \t]*[=:]|^[0-9A-Fa-f]{4,}:|\brecType="
+)
+
+
+class SnapshotError(ValueError):
+ """An input file or snapshot the tool cannot honour."""
+
+
+def _new_stats() -> dict[str, Any]:
+ return {
+ "files": 0,
+ "lines": 0,
+ "chunks": 0,
+ "words": 0,
+ "records": 0,
+ "unparsed": 0,
+ "merged": 0,
+ "collisions": 0,
+ "conflicts": 0,
+ "misaligned": 0,
+ "collision_list": [],
+ }
+
+
+class _Builder:
+ """Words captured for one label occurrence before normalization.
+
+ Entries are ``(offset, [u32 word, ...])`` in encounter order. Hexdump
+ builders are positional: entries are placed at ``(offset - base) // 4``
+ when the byte offset is 4-byte aligned; otherwise they are appended after
+ the highest placed word. Value-line builders (``LABEL[offset]=0xVALUE``)
+ are sequential: the offset only orders the entries and selects ``base``
+ (its unit is a table entry index there, not a byte), so each entry's words
+ are appended in offset order. A later value that would overwrite a
+ different value at the same slot is dropped and counted as a
+ ``conflict``.
+ """
+
+ __slots__ = ("label", "positional", "entries", "conflicts", "misaligned")
+
+ def __init__(self, label: str, positional: bool = True):
+ self.label = label
+ self.positional = positional
+ self.entries: list[tuple[int, list[str]]] = []
+ self.conflicts = 0
+ self.misaligned = 0
+
+ def add(self, offset: int, words: list[str]) -> None:
+ if not words:
+ return
+ for existing_offset, existing_words in self.entries:
+ if existing_offset == offset and existing_words == words:
+ return
+ self.entries.append((offset, words))
+
+ def build(self) -> tuple[int, list[str]]:
+ base = min(offset for offset, _ in self.entries)
+ placed: dict[int, str] = {}
+ append_at = 0
+ conflicts = 0
+ misaligned = 0
+ for offset, words in sorted(self.entries, key=lambda entry: entry[0]):
+ if not self.positional:
+ index = append_at
+ elif (offset - base) % 4 == 0:
+ index = (offset - base) // 4
+ else:
+ index = append_at
+ misaligned += 1
+ for step, word in enumerate(words):
+ slot = index + step
+ if slot in placed and placed[slot] != word:
+ conflicts += 1
+ elif slot not in placed:
+ placed[slot] = word
+ append_at = max(append_at, index + len(words))
+ self.conflicts = conflicts
+ self.misaligned = misaligned
+ return base, [placed[index] for index in sorted(placed)]
+
+
+def _word(value: int) -> str:
+ return f"0x{value & 0xFFFFFFFF:08x}"
+
+
+def _value_words(value: int) -> list[str]:
+ """Split a value into little-endian u32 words (low word first)."""
+ words = []
+ while True:
+ words.append(_word(value))
+ value >>= 32
+ if value == 0:
+ return words
+
+
+def _split_words(tail: str) -> list[str]:
+ return [f"0x{int(token, 16):08x}" for token in tail.split()]
+
+
+def _row_offset(text: str) -> int:
+ return int(text, 16)
+
+
+def _labeled_offset(text: str) -> int:
+ if text.lower().startswith("0x"):
+ return int(text, 16)
+ try:
+ return int(text, 10)
+ except ValueError:
+ return int(text, 16)
+
+
+def _match_record(line: str) -> dict[str, Any] | None:
+ match = _RECORD_RE.search(line)
+ if not match:
+ return None
+ record: dict[str, Any] = {
+ "kind": "record",
+ "raw": line,
+ "rec_type": int(match.group("rec_type")),
+ "error_code": int(match.group("error_code"), 16),
+ }
+ source = _SOURCE_RE.search(line)
+ if source:
+ record["source_name"] = source.group("source_name")
+ return record
+
+
+def _parse_text(text: str, stats: dict[str, Any]) -> tuple[list[_Builder], list[dict]]:
+ occurrences: list[_Builder] = []
+ records: list[dict] = []
+ by_value_label: dict[str, _Builder] = {}
+ current_block: _Builder | None = None
+ inline_block: _Builder | None = None
+ pending_header: str | None = None
+
+ for line in text.splitlines():
+ stripped = line.strip()
+ if not stripped:
+ current_block = pending_header = inline_block = None
+ continue
+
+ record = _match_record(stripped)
+ if record is not None:
+ records.append(record)
+ current_block = pending_header = inline_block = None
+ continue
+
+ match = _INLINE_ROW_RE.search(stripped)
+ if match:
+ label = match.group("label")
+ words = _split_words(match.group("tail"))
+ if inline_block is not None and inline_block.label == label:
+ inline_block.add(_row_offset(match.group("offset")), words)
+ else:
+ inline_block = _Builder(label)
+ inline_block.add(_row_offset(match.group("offset")), words)
+ occurrences.append(inline_block)
+ current_block = pending_header = None
+ continue
+
+ match = _BLOCK_ROW_RE.match(stripped)
+ if match:
+ if current_block is None:
+ if pending_header is None:
+ stats["unparsed"] += 1
+ continue
+ current_block = _Builder(pending_header)
+ occurrences.append(current_block)
+ current_block.add(int(match.group("offset"), 16),
+ _split_words(match.group("tail")))
+ pending_header = inline_block = None
+ continue
+
+ value_matches = list(_LABELED_VALUE_RE.finditer(stripped))
+ if value_matches:
+ for value_match in value_matches:
+ label = value_match.group("label")
+ if value_match.group("suffix"):
+ label += value_match.group("suffix")
+ builder = by_value_label.get(label)
+ if builder is None:
+ builder = _Builder(label, positional=False)
+ by_value_label[label] = builder
+ occurrences.append(builder)
+ builder.add(_labeled_offset(value_match.group("offset")),
+ _value_words(int(value_match.group("value"), 16)))
+ current_block = pending_header = inline_block = None
+ continue
+
+ if _UNPARSED_HINT_RE.search(stripped):
+ stats["unparsed"] += 1
+
+ current_block = inline_block = None
+ header = _BLOCK_HEADER_RE.match(stripped)
+ pending_header = header.group("label") if header else None
+
+ return occurrences, records
+
+
+def _chunk_sort_key(label: str) -> tuple[str, int, str]:
+ stem, separator, tail = label.rpartition("#")
+ if separator and tail.isdigit():
+ return stem, int(tail), label
+ return label, 0, label
+
+
+def _finalize(occurrences: Iterable[_Builder],
+ stats: dict[str, Any]) -> list[dict[str, Any]]:
+ chunks: dict[str, dict[str, Any]] = {}
+ for builder in occurrences:
+ base, words = builder.build()
+ stats["conflicts"] += builder.conflicts
+ stats["misaligned"] += builder.misaligned
+ if not words:
+ continue
+ key = builder.label
+ suffix = 1
+ merged = False
+ while key in chunks:
+ existing = chunks[key]
+ if existing["base"] == f"{base:#x}" and existing["words"] == words:
+ stats["merged"] += 1
+ merged = True
+ break
+ suffix += 1
+ key = f"{builder.label}#{suffix}"
+ if merged:
+ continue
+ chunks[key] = {"label": key, "base": f"{base:#x}", "words": words}
+ if suffix > 1:
+ stats["collisions"] += 1
+ stats["collision_list"].append(
+ {"label": builder.label, "renamed": key,
+ "base": f"{base:#x}", "words": len(words)})
+ return [chunks[key] for key in sorted(chunks, key=_chunk_sort_key)]
+
+
+def discover(dirs: Iterable[str] | None, files: Iterable[str] | None,
+ includes: Iterable[str] | None = None) -> list[str]:
+ """Return the sorted absolute paths matched by the dirs/files/includes."""
+ patterns = tuple(DEFAULT_INCLUDES) + tuple(includes or ())
+ found: set[str] = set()
+ for directory in dirs or ():
+ if not os.path.isdir(directory):
+ raise SnapshotError(f"not a directory: {directory}")
+ for root, _subdirs, names in os.walk(directory):
+ for name in names:
+ if any(fnmatch.fnmatch(name, pattern) for pattern in patterns):
+ found.add(os.path.abspath(os.path.join(root, name)))
+ for path in files or ():
+ if not os.path.isfile(path):
+ raise SnapshotError(f"not a file: {path}")
+ found.add(os.path.abspath(path))
+ return sorted(found)
+
+
+def _default_source(dirs: Iterable[str] | None,
+ files: Iterable[str] | None) -> str:
+ roots = sorted(os.path.abspath(path)
+ for path in list(dirs or ()) + list(files or ()))
+ return ",".join(roots) if roots else "empty"
+
+
+def _created_iso() -> str:
+ """Build timestamp; stable when ``SOURCE_DATE_EPOCH`` is set."""
+ raw = os.environ.get("SOURCE_DATE_EPOCH")
+ try:
+ epoch = int(raw) if raw else int(time.time())
+ except ValueError:
+ epoch = 0
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(epoch))
+
+
+def collect(dirs: Iterable[str] | None = None,
+ files: Iterable[str] | None = None,
+ includes: Iterable[str] | None = None,
+ source: str | None = None,
+ created: str | None = None) -> tuple[dict[str, Any], dict[str, Any]]:
+ """Collect every dump in ``dirs``/``files`` into one snapshot.
+
+ Returns ``(snapshot, stats)``; ``stats`` carries the parse accounting
+ (files, lines, chunks, words, records, unparsed candidates, merged
+ duplicates, renamed collisions, conflicts, misaligned offsets) and is
+ printed by the CLI but is deliberately not part of the consumed schema.
+ """
+ paths = discover(dirs, files, includes)
+ if not paths:
+ raise SnapshotError(
+ "no input files matched the requested --dir/--file/--include set")
+ stats = _new_stats()
+ occurrences: list[_Builder] = []
+ records: dict[str, dict] = {}
+ for path in paths:
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as handle:
+ text = handle.read()
+ except OSError as exc:
+ raise SnapshotError(f"cannot read {path}: {exc}") from exc
+ stats["files"] += 1
+ stats["lines"] += len(text.splitlines())
+ file_occurrences, file_records = _parse_text(text, stats)
+ occurrences.extend(file_occurrences)
+ for record in file_records:
+ records.setdefault(record["raw"], record)
+ chunks = _finalize(occurrences, stats)
+ stats["chunks"] = len(chunks)
+ stats["words"] = sum(len(chunk["words"]) for chunk in chunks)
+ stats["records"] = len(records)
+ snapshot = {
+ "schema": SNAPSHOT_FORMAT,
+ "source": source if source is not None else _default_source(dirs, files),
+ "created": created if created is not None else _created_iso(),
+ "chunks": chunks,
+ "records": sorted(records.values(), key=lambda record: record["raw"]),
+ }
+ return snapshot, stats
+
+
+def diff_snapshots(a: dict[str, Any], b: dict[str, Any]) -> dict[str, Any]:
+ """Compare two ``greeneye-server-snapshot/1`` snapshots word by word.
+
+ Emits a ``greeneye-server-snapshot-diff/1`` report: per shared chunk the
+ added/removed/changed word lists (with word indices), whole-chunk
+ added/removed rows, per-chunk and total counts, and record deltas. Output
+ is sorted by label and by word index, so two diffs of the same inputs are
+ byte-identical.
+ """
+ for name, snapshot in (("a", a), ("b", b)):
+ if not isinstance(snapshot, dict) or snapshot.get("schema") != SNAPSHOT_FORMAT:
+ raise SnapshotError(f"snapshot {name} is not {SNAPSHOT_FORMAT}")
+ a_chunks = {chunk["label"]: chunk for chunk in a.get("chunks", [])}
+ b_chunks = {chunk["label"]: chunk for chunk in b.get("chunks", [])}
+ rows: list[dict[str, Any]] = []
+ counts = {
+ "chunks_added": 0, "chunks_removed": 0, "chunks_changed": 0,
+ "chunks_unchanged": 0, "words_added": 0, "words_removed": 0,
+ "words_changed": 0,
+ }
+ for label in sorted(set(a_chunks) | set(b_chunks), key=_chunk_sort_key):
+ left = a_chunks.get(label)
+ right = b_chunks.get(label)
+ if left is None:
+ added = [{"index": index, "value": word}
+ for index, word in enumerate(right["words"])]
+ row = {"label": label, "status": "added",
+ "base": {"a": None, "b": right["base"]},
+ "words": {"a": 0, "b": len(right["words"])},
+ "added": added, "removed": [], "changed": []}
+ counts["chunks_added"] += 1
+ counts["words_added"] += len(added)
+ elif right is None:
+ removed = [{"index": index, "value": word}
+ for index, word in enumerate(left["words"])]
+ row = {"label": label, "status": "removed",
+ "base": {"a": left["base"], "b": None},
+ "words": {"a": len(left["words"]), "b": 0},
+ "added": [], "removed": removed, "changed": []}
+ counts["chunks_removed"] += 1
+ counts["words_removed"] += len(removed)
+ else:
+ a_words, b_words = left["words"], right["words"]
+ added, removed, changed = [], [], []
+ for index in range(max(len(a_words), len(b_words))):
+ if index >= len(a_words):
+ added.append({"index": index, "value": b_words[index]})
+ elif index >= len(b_words):
+ removed.append({"index": index, "value": a_words[index]})
+ elif a_words[index] != b_words[index]:
+ changed.append({"index": index, "a": a_words[index],
+ "b": b_words[index]})
+ unchanged = (not added and not removed and not changed
+ and left["base"] == right["base"])
+ row = {"label": label,
+ "status": "unchanged" if unchanged else "changed",
+ "base": {"a": left["base"], "b": right["base"]},
+ "words": {"a": len(a_words), "b": len(b_words)},
+ "added": added, "removed": removed, "changed": changed}
+ if unchanged:
+ counts["chunks_unchanged"] += 1
+ else:
+ counts["chunks_changed"] += 1
+ counts["words_added"] += len(added)
+ counts["words_removed"] += len(removed)
+ counts["words_changed"] += len(changed)
+ rows.append(row)
+
+ a_records = {record.get("raw", "") for record in a.get("records", [])}
+ b_records = {record.get("raw", "") for record in b.get("records", [])}
+ records_added = sorted(b_records - a_records)
+ records_removed = sorted(a_records - b_records)
+ counts["records_added"] = len(records_added)
+ counts["records_removed"] = len(records_removed)
+ identical = (counts["chunks_added"] == 0 and counts["chunks_removed"] == 0
+ and counts["chunks_changed"] == 0
+ and not records_added and not records_removed)
+ return {
+ "schema": DIFF_FORMAT,
+ "a": {"source": a.get("source"), "created": a.get("created")},
+ "b": {"source": b.get("source"), "created": b.get("created")},
+ "chunks": rows,
+ "records": {"added": records_added, "removed": records_removed},
+ "summary": {"identical": identical, **counts},
+ }
+
+
+# ---------------------------------------------------------------------------
+# Demo: two tiny snapshots in a tempdir, collected and diffed
+# ---------------------------------------------------------------------------
+
+DEMO_A = """\
+[demo] MOCK[0000]: 00000001 00000002 00000003 00000004
+alpha[0]=0x00000011
+alpha[4]=0x00000022
+"""
+
+DEMO_B = """\
+[demo] MOCK[0000]: 00000001 000000ff 00000003 00000004 00000005
+alpha[0]=0x00000011
+alpha[4]=0x00000033
+"""
+
+
+def _write_text(path: str, text: str) -> str:
+ with open(path, "w", encoding="utf-8") as handle:
+ handle.write(text)
+ return os.path.abspath(path)
+
+
+def _write_json(path: str, payload: dict[str, Any]) -> str:
+ with open(path, "w", encoding="utf-8") as handle:
+ json.dump(payload, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+ return os.path.abspath(path)
+
+
+def run_demo(workdir: str) -> dict[str, Any]:
+ """Craft two tiny evidence files in ``workdir``, collect both and diff."""
+ a_path = _write_text(os.path.join(workdir, "run-a.txt"), DEMO_A)
+ b_path = _write_text(os.path.join(workdir, "run-b.txt"), DEMO_B)
+ snapshot_a, _stats_a = collect(files=[a_path], source="demo-a",
+ created="2026-01-01T00:00:00Z")
+ snapshot_b, _stats_b = collect(files=[b_path], source="demo-b",
+ created="2026-01-01T00:00:00Z")
+ return {
+ "schema": DEMO_FORMAT,
+ "workdir": os.path.abspath(workdir),
+ "snapshot_a": snapshot_a,
+ "snapshot_b": snapshot_b,
+ "diff": diff_snapshots(snapshot_a, snapshot_b),
+ }
+
+
+def _load_json(path: str) -> dict[str, Any]:
+ try:
+ with open(path, "r", encoding="utf-8") as handle:
+ return json.load(handle)
+ except (OSError, json.JSONDecodeError) as exc:
+ raise SnapshotError(f"cannot read {path}: {exc}") from exc
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="server_snapshot.py",
+ description="Collect and diff canonical server-state snapshots "
+ "(offline evidence normalizer).",
+ )
+ sub = parser.add_subparsers(dest="command")
+
+ collect_cmd = sub.add_parser(
+ "collect", help="normalize evidence files into one snapshot")
+ collect_cmd.add_argument("--dir", action="append", default=[],
+ help="evidence/session directory (repeatable)")
+ collect_cmd.add_argument("--file", action="append", default=[],
+ help="explicit evidence file (repeatable)")
+ collect_cmd.add_argument(
+ "--include", action="append", default=[],
+ help="additional file-name glob under --dir (repeatable); "
+ f"defaults: {', '.join(DEFAULT_INCLUDES)}")
+ collect_cmd.add_argument("--source", help="override the snapshot source")
+ collect_cmd.add_argument(
+ "--created", help="override the ISO-8601 timestamp (SOURCE_DATE_EPOCH "
+ "is honoured otherwise)")
+ collect_cmd.add_argument("--json", help="write the snapshot here instead "
+ "of stdout")
+
+ diff_cmd = sub.add_parser("diff", help="compare two snapshot JSON files")
+ diff_cmd.add_argument("--a", required=True, help="first snapshot JSON")
+ diff_cmd.add_argument("--b", required=True, help="second snapshot JSON")
+ diff_cmd.add_argument("--json", help="write the diff here instead of stdout")
+
+ demo_cmd = sub.add_parser(
+ "demo", help="craft two tiny snapshots in a tempdir and diff them")
+ demo_cmd.add_argument("--out", help="keep the crafted files in this directory")
+ return parser
+
+
+def _collect_command(args) -> int:
+ if not args.dir and not args.file:
+ raise SnapshotError("collect needs at least one --dir or --file")
+ snapshot, stats = collect(dirs=args.dir, files=args.file,
+ includes=args.include, source=args.source,
+ created=args.created)
+ if args.json:
+ path = _write_json(args.json, snapshot)
+ summary = {
+ "schema": snapshot["schema"],
+ "source": snapshot["source"],
+ "created": snapshot["created"],
+ "chunks": len(snapshot["chunks"]),
+ "words": stats["words"],
+ "records": len(snapshot["records"]),
+ "unparsed": stats["unparsed"],
+ "collisions": stats["collisions"],
+ "snapshot_json": path,
+ }
+ print(json.dumps(summary, indent=2, sort_keys=True))
+ else:
+ print(json.dumps(snapshot, indent=2, sort_keys=True))
+ print(f"server_snapshot: {stats['files']} file(s), {stats['lines']} line(s), "
+ f"{stats['chunks']} chunk(s), {stats['words']} word(s), "
+ f"{stats['records']} record(s), {stats['unparsed']} unparsed, "
+ f"{stats['merged']} merged, {stats['collisions']} collision(s), "
+ f"{stats['conflicts']} conflict(s)", file=sys.stderr)
+ return 0
+
+
+def _diff_command(args) -> int:
+ report = diff_snapshots(_load_json(args.a), _load_json(args.b))
+ if args.json:
+ path = _write_json(args.json, report)
+ print(json.dumps({"schema": report["schema"], **report["summary"],
+ "diff_json": path}, indent=2, sort_keys=True))
+ else:
+ print(json.dumps(report, indent=2, sort_keys=True))
+ return 0 if report["summary"]["identical"] else 3
+
+
+def _demo_command(args) -> int:
+ if args.out:
+ os.makedirs(args.out, exist_ok=True)
+ demo = run_demo(args.out)
+ else:
+ with tempfile.TemporaryDirectory(prefix="server-snapshot-demo-") as workdir:
+ demo = run_demo(workdir)
+ for row in demo["diff"]["chunks"]:
+ if row["status"] != "unchanged":
+ print(f"{row['status']:<9} {row['label']:<16} "
+ f"+{len(row['added'])} -{len(row['removed'])} "
+ f"~{len(row['changed'])}")
+ print(json.dumps(demo["diff"]["summary"], indent=2, sort_keys=True))
+ return 0
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ if args.command == "collect":
+ return _collect_command(args)
+ if args.command == "diff":
+ return _diff_command(args)
+ if args.command == "demo":
+ return _demo_command(args)
+ parser.print_help()
+ return 2
+ except SnapshotError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/state_decode.py b/debug/risc-v-gsp-rm-vm/harness/state_decode.py
new file mode 100644
index 0000000..351515a
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/state_decode.py
@@ -0,0 +1,1082 @@
+#!/usr/bin/env python3
+"""Decode engine-visible channel state words into named fields, and diff captures.
+
+During GR bring-up the answer often hides in a handful of 32-bit words inside
+the INSTANCE block, the RAMFC chunk and context-buffer headers: one bit turns
+"nothing happens" into an engine that runs or refuses. Raw hex hides that;
+this tool turns a captured dump into a named field table and makes two
+captures differ field-by-field.
+
+Schemas:
+
+* ``greeneye-state-decode/1`` -- the decoded report produced by ``decode``.
+* ``greeneye-state-diff/1`` -- the word/field diff produced by ``diff``.
+* ``greeneye-server-snapshot/1`` -- an input snapshot emitted elsewhere::
+
+ {"schema": "greeneye-server-snapshot/1", "source": "...",
+ "chunks": [{"label": "INSTANCE", "base": "0x0",
+ "words": ["0x...", "..."]}]}
+
+ (one hex string or integer per 32-bit word; accepted exactly).
+* ``greeneye-state-map/1`` -- an optional field map (``--map``), see below.
+
+Input formats (auto-detected per ``--input``):
+
+(a) text lines ``LABEL[OFFSET]=VALUE`` -- label case-insensitive, offset and
+ value hex (``0x..``, letters, or leading-zero padded) or decimal; every
+ other line is ignored;
+(b) a dump row with a bracketed chunk offset and words, e.g.
+ ``... INSTANCE[0040]: 00100000 00030000 ...`` -- this is the shape live
+ ladder traces print (8 words per row; any count accepted);
+(c) plain hexdump rows ``00000000: 000002d2 00000000 ...`` named with
+ ``--label``/``--base``;
+(d) raw little-endian u32 words (``--bin``, or auto-detected when a text parse
+ finds nothing and the bytes are control-heavy and 4-byte aligned);
+(e) the snapshot JSON above.
+
+Offsets inside a chunk are byte offsets relative to the chunk start (all
+inputs above are u32 word dumps, so offsets are multiples of 4). ``base`` is
+the engine-visible address of chunk-relative offset 0: it is recorded as
+provenance and for the diff, but it does not shift map lookup.
+
+Bundled map (deliberately small; extend or override it from a map file, never
+by guessing here). Provenance is live bring-up observation (2026-10 GSP
+ladder runs):
+
+* ``INSTANCE`` 0x010: magic ``0xface``.
+* ``INSTANCE`` 0x048: GPFIFO VA, observed as ``0x100000``.
+* ``RAMFC`` 0x048: ``RAMFC_TARGET`` -- bits 16/17 ``ENG_CTX_VALID`` /
+ ``CE_CTX_VALID``; the observed live success word ``0x00030000`` sets both.
+ The bit assignment is observation-driven and fully configurable via the map.
+
+Map file::
+
+ {"format": "greeneye-state-map/1", "mode": "extend" | "replace",
+ "entries": [
+ {"chunk": "RAMFC", "label": "RAMFC_TARGET", "offset": "0x48",
+ "width": 4,
+ "bits": [{"name": "ENG_CTX_VALID", "lo": 16, "hi": 16,
+ "values": {"0": "clear", "1": "set"}}],
+ "note": "..."}
+ ]}
+
+A user entry with the same ``chunk`` and ``offset`` replaces the bundled
+entry; new chunk/offset pairs extend the table. ``mode: replace`` drops the
+bundled table first. An entry without ``bits`` decodes as a named value.
+
+Honesty: this tool names observed words; it does not prove their meaning. An
+unmapped word is silence, not absence. Field offsets without evidence stay
+unnamed until a map supplies them. Offline only: no hardware, no module, no
+guest, no network.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import shutil
+import sys
+import tempfile
+from typing import Any
+
+DECODE_FORMAT = "greeneye-state-decode/1"
+DIFF_FORMAT = "greeneye-state-diff/1"
+SNAPSHOT_FORMAT = "greeneye-server-snapshot/1"
+MAP_FORMAT = "greeneye-state-map/1"
+DEMO_FORMAT = "greeneye-state-decode-demo/1"
+
+WORD_BYTES = 4
+WORD_MASK = 0xFFFFFFFF
+WORD_WIDTHS = (4, 8)
+
+
+class StateDecodeError(ValueError):
+ """An input, map or request the tool cannot honour."""
+
+
+# ---------------------------------------------------------------------------
+# Bundled field table
+# ---------------------------------------------------------------------------
+# Every entry is an observation from live GSP bring-up traces (2026-10). The
+# table is intentionally tiny: unnamed offsets stay unnamed until evidence
+# earns them a name, and the map file carries everything else.
+BUILTIN_MAP: dict[str, Any] = {
+ "format": MAP_FORMAT,
+ "mode": "extend",
+ "entries": [
+ {
+ "chunk": "INSTANCE",
+ "label": "MAGIC_0FACE",
+ "offset": "0x010",
+ "width": 4,
+ "note": "Instance magic observed as 0xface at chunk byte 0x010.",
+ },
+ {
+ "chunk": "INSTANCE",
+ "label": "GPFIFO_VA",
+ "offset": "0x048",
+ "width": 4,
+ "note": "GPFIFO VA observed as 0x100000 at chunk byte 0x048.",
+ },
+ {
+ "chunk": "RAMFC",
+ "label": "RAMFC_TARGET",
+ "offset": "0x048",
+ "width": 4,
+ "bits": [
+ {"name": "ENG_CTX_VALID", "lo": 16, "hi": 16},
+ {"name": "CE_CTX_VALID", "lo": 17, "hi": 17},
+ ],
+ "note": ("Engine/CE context-valid bits; the observed live success "
+ "word 0x00030000 sets both. Bits are configurable via "
+ "--map."),
+ },
+ ],
+}
+
+# ---------------------------------------------------------------------------
+# Number and token parsing
+# ---------------------------------------------------------------------------
+
+_EQ_RE = re.compile(
+ r"^\s*(?P<label>[A-Za-z_][A-Za-z0-9_.-]*)\s*"
+ r"\[\s*(?P<off>[0-9a-fA-FxX]+)\s*\]\s*=\s*(?P<val>[0-9a-fA-FxX]+)\s*$")
+
+_ROW_RE = re.compile(
+ r"(?P<label>[A-Za-z_][A-Za-z0-9_.-]*)\s*"
+ r"\[\s*(?P<off>[0-9a-fA-FxX]+)\s*\]\s*:\s*"
+ r"(?P<words>[0-9a-fA-FxX]+(?:\s+[0-9a-fA-FxX]+)*)\s*$")
+
+_HEXDUMP_RE = re.compile(
+ r"^\s*(?P<off>[0-9a-fA-FxX]{2,16})\s*:\s*"
+ r"(?P<words>[0-9a-fA-FxX]+(?:\s+[0-9a-fA-FxX]+)*)\s*$")
+
+_HEX_RE = re.compile(r"[0-9a-fA-F]+")
+
+_TEXT_BYTES = frozenset(b"\t\n\r") | frozenset(range(0x20, 0x7F))
+
+
+def parse_number(value: Any, *, why: str = "number") -> int:
+ """Parse an int, a ``0x`` string, or a bare token.
+
+ Bare tokens with a hex letter (``face``) or a leading zero and more
+ digits (``0040``) are read as hex, matching dump padding; other bare
+ digit strings are decimal. Write ``0x..`` when in doubt.
+ """
+ if isinstance(value, bool):
+ raise StateDecodeError(f"bad {why} {value!r}")
+ if isinstance(value, int):
+ return value
+ if not isinstance(value, str):
+ raise StateDecodeError(f"bad {why} {value!r}")
+ text = value.strip()
+ if not text:
+ raise StateDecodeError(f"bad {why} {value!r}")
+ sign = 1
+ if text[0] in "+-":
+ if text[0] == "-":
+ sign = -1
+ text = text[1:]
+ low = text.lower()
+ if low.startswith("0x"):
+ base, digits = 16, text[2:]
+ elif re.fullmatch(r"[0-9]+", text):
+ base = 16 if text.startswith("0") and len(text) > 1 else 10
+ digits = text
+ elif _HEX_RE.fullmatch(text):
+ base, digits = 16, text
+ else:
+ raise StateDecodeError(f"bad {why} {value!r}")
+ try:
+ return sign * int(digits, base)
+ except ValueError as exc:
+ raise StateDecodeError(f"bad {why} {value!r}") from exc
+
+
+def _parse_word(value: Any, *, where: str) -> int:
+ number = parse_number(value, why=where)
+ if not 0 <= number <= WORD_MASK:
+ raise StateDecodeError(f"{where} {value!r} does not fit in 32 bits")
+ return number
+
+
+def _hex(value: int, width: int = 4) -> str:
+ return f"{value:#0{2 + width * 2}x}"
+
+
+def _parse_base(value: Any) -> int:
+ if value is None:
+ return 0
+ return parse_number(value, why="base")
+
+
+# ---------------------------------------------------------------------------
+# Map loading (bundled <- override/extension)
+# ---------------------------------------------------------------------------
+
+def _normalize_entry(raw: Any, *, where: str) -> dict[str, Any]:
+ if not isinstance(raw, dict):
+ raise StateDecodeError(f"{where} must be an object")
+ chunk = raw.get("chunk")
+ label = raw.get("label")
+ if not isinstance(chunk, str) or not chunk:
+ raise StateDecodeError(f"{where} needs a non-empty chunk")
+ if not isinstance(label, str) or not label:
+ raise StateDecodeError(f"{where} needs a non-empty label")
+ if raw.get("offset") is None:
+ raise StateDecodeError(f"{where} needs an offset")
+ offset = parse_number(raw["offset"], why=f"{where} offset")
+ width = raw.get("width", 4)
+ width = parse_number(width, why=f"{where} width")
+ if width not in WORD_WIDTHS:
+ raise StateDecodeError(f"{where} width {width!r} is not 4 or 8")
+ if offset < 0 or offset % WORD_BYTES:
+ raise StateDecodeError(f"{where} offset {offset:#x} is not word-aligned")
+ bits_raw = raw.get("bits") or []
+ if not isinstance(bits_raw, list):
+ raise StateDecodeError(f"{where} bits must be a list")
+ nbits = width * 8
+ bits = []
+ for index, bit in enumerate(bits_raw):
+ if not isinstance(bit, dict):
+ raise StateDecodeError(f"{where} bits[{index}] must be an object")
+ name = bit.get("name")
+ if not isinstance(name, str) or not name:
+ raise StateDecodeError(f"{where} bits[{index}] needs a name")
+ lo = parse_number(bit.get("lo"), why=f"{where} bits[{index}].lo")
+ hi = parse_number(bit.get("hi"), why=f"{where} bits[{index}].hi")
+ if not 0 <= lo <= hi < nbits:
+ raise StateDecodeError(
+ f"{where} bits[{index}] range {lo}..{hi} outside width {width}")
+ values = bit.get("values")
+ norm_values: dict[int, str] = {}
+ if values is not None:
+ if not isinstance(values, dict):
+ raise StateDecodeError(
+ f"{where} bits[{index}].values must be an object")
+ for key, text in values.items():
+ norm_values[parse_number(key, why=f"{where} values key")] = str(text)
+ bits.append({"name": name, "lo": lo, "hi": hi, "values": norm_values})
+ note = raw.get("note")
+ return {
+ "chunk": chunk,
+ "label": label,
+ "offset": offset,
+ "width": width,
+ "bits": bits,
+ "note": note if isinstance(note, str) else None,
+ }
+
+
+def load_map(path: str | None = None) -> tuple[dict[tuple[str, int], dict], str]:
+ """Build the field table; returns ``(table, source)``.
+
+ ``table`` is keyed by ``(chunk.lower(), offset)``. Without ``path`` the
+ bundled table is used. With ``path``, entries merge over the bundled
+ table (same chunk+offset replaces, new pairs extend) unless the file sets
+ ``"mode": "replace"``.
+ """
+ entries: list[Any] = list(BUILTIN_MAP["entries"])
+ source = "builtin"
+ if path:
+ if not os.path.isfile(path):
+ raise StateDecodeError(f"map not found: {path}")
+ with open(path, "r", encoding="utf-8") as handle:
+ doc = json.load(handle)
+ source = os.path.abspath(path)
+ if isinstance(doc, list):
+ raw_entries, mode = doc, "extend"
+ elif isinstance(doc, dict):
+ fmt = doc.get("format", MAP_FORMAT)
+ if fmt != MAP_FORMAT:
+ raise StateDecodeError(
+ f"unsupported map format {fmt!r} (want {MAP_FORMAT})")
+ raw_entries = doc.get("entries")
+ mode = doc.get("mode", "extend")
+ else:
+ raise StateDecodeError(f"map {path} must be an object or a list")
+ if mode not in ("extend", "replace"):
+ raise StateDecodeError(f"map mode {mode!r} is not extend/replace")
+ if not isinstance(raw_entries, list):
+ raise StateDecodeError(f"map {path} needs an entries list")
+ if mode == "replace":
+ entries = []
+ entries.extend(raw_entries)
+ table: dict[tuple[str, int], dict] = {}
+ for index, raw in enumerate(entries):
+ entry = _normalize_entry(raw, where=f"map entry {index}")
+ table[(entry["chunk"].lower(), entry["offset"])] = entry
+ return table, source
+
+
+# ---------------------------------------------------------------------------
+# Input loading (auto-detected per input)
+# ---------------------------------------------------------------------------
+
+def _read_bytes(path: str) -> bytes:
+ if not os.path.isfile(path):
+ raise StateDecodeError(f"input not found: {path}")
+ with open(path, "rb") as handle:
+ return handle.read()
+
+
+def _try_json(data: bytes) -> Any:
+ """Return parsed JSON when the bytes look like JSON, else ``None``."""
+ try:
+ text = data.decode("utf-8")
+ except UnicodeDecodeError:
+ return None
+ stripped = text.lstrip()
+ if not stripped.startswith("{"):
+ return None
+ try:
+ return json.loads(text)
+ except json.JSONDecodeError as exc:
+ raise StateDecodeError(f"invalid JSON input: {exc}") from exc
+
+
+def _try_text(data: bytes) -> str | None:
+ try:
+ return data.decode("utf-8")
+ except UnicodeDecodeError:
+ return None
+
+
+def _looks_binary(data: bytes) -> bool:
+ if not data or len(data) % WORD_BYTES:
+ return False
+ return any(byte not in _TEXT_BYTES for byte in data)
+
+
+def _chunk_from_binary(data: bytes, label: str, base: int, source: str) -> dict:
+ if not data:
+ raise StateDecodeError(f"binary input {source} is empty")
+ if len(data) % WORD_BYTES:
+ raise StateDecodeError(
+ f"binary input {source} size {len(data)} is not a multiple of 4")
+ words = {offset: int.from_bytes(data[offset:offset + WORD_BYTES], "little")
+ for offset in range(0, len(data), WORD_BYTES)}
+ return {"label": label, "base": base, "words": words,
+ "format": "binary", "source": source}
+
+
+def _document(path: str, fmt: str, label: str | None, base: int,
+ chunks: list[dict]) -> dict:
+ return {
+ "path": os.path.abspath(path),
+ "format": fmt,
+ "label": label,
+ "base": base,
+ "chunks": chunks,
+ }
+
+
+def parse_text(text: str, *, default_label: str | None = None,
+ default_base: int = 0, source: str | None = None) -> list[dict]:
+ """Parse the text/hexdump/row formats into chunks.
+
+ Chunk names are case-insensitive: ``INSTANCE`` and ``inst`` land in one
+ chunk, displayed with the first spelling seen. Every line that matches
+ none of the three patterns is ignored.
+ """
+ chunks: dict[str, dict] = {}
+
+ def chunk_for(label: str) -> dict:
+ key = label.lower()
+ chunk = chunks.get(key)
+ if chunk is None:
+ chunk = {"label": label, "base": default_base, "words": {},
+ "format": "text", "source": source}
+ chunks[key] = chunk
+ return chunk
+
+ def put(label: str, offset: int, value: int, where: str) -> None:
+ if offset < 0 or offset % WORD_BYTES:
+ raise StateDecodeError(f"{where}: offset {offset:#x} is not word-aligned")
+ if not 0 <= value <= WORD_MASK:
+ raise StateDecodeError(f"{where}: value {value:#x} does not fit in 32 bits")
+ chunk_for(label)["words"][offset] = value
+
+ for lineno, raw_line in enumerate(text.splitlines(), 1):
+ line = raw_line.strip()
+ if not line:
+ continue
+ where = f"{source or '<text>'}:{lineno}"
+ match = _EQ_RE.match(line)
+ if match:
+ offset = parse_number(match.group("off"), why=f"{where} offset")
+ value = parse_number(match.group("val"), why=f"{where} value")
+ put(match.group("label"), offset, value, where)
+ continue
+ match = _ROW_RE.search(line)
+ if match:
+ offset = parse_number(match.group("off"), why=f"{where} offset")
+ for index, token in enumerate(match.group("words").split()):
+ put(match.group("label"), offset + index * WORD_BYTES,
+ int(token, 16), where)
+ continue
+ match = _HEXDUMP_RE.match(line)
+ if match:
+ label = default_label or "RAW"
+ offset = int(match.group("off"), 16)
+ for index, token in enumerate(match.group("words").split()):
+ put(label, offset + index * WORD_BYTES, int(token, 16), where)
+ continue
+ return [chunks[key] for key in chunks]
+
+
+def load_input(spec: dict) -> dict:
+ """Load one input spec dict (path/label/base/binary) into a document."""
+ path = spec.get("path")
+ if not path:
+ raise StateDecodeError("input spec needs a path")
+ data = _read_bytes(path)
+ label = spec.get("label")
+ base = _parse_base(spec.get("base"))
+ if spec.get("binary"):
+ chunk = _chunk_from_binary(data, label or "RAW", base, path)
+ return _document(path, "binary", label, base, [chunk])
+ doc = _try_json(data)
+ if doc is not None:
+ return _document_from_json(path, doc, label, base)
+ text = _try_text(data)
+ if text is not None:
+ chunks = parse_text(text, default_label=label, default_base=base,
+ source=path)
+ if chunks:
+ return _document(path, "text", label, base, chunks)
+ if _looks_binary(data):
+ chunk = _chunk_from_binary(data, label or "RAW", base, path)
+ return _document(path, "binary", label, base, [chunk])
+ raise StateDecodeError(
+ f"cannot decode {path}: not text fields, not {SNAPSHOT_FORMAT} JSON, "
+ f"not raw u32 words")
+
+
+def _document_from_json(path: str, doc: Any, label: str | None,
+ base: int) -> dict:
+ if isinstance(doc, dict) and doc.get("format") == DECODE_FORMAT:
+ raise StateDecodeError(f"{path} is a decoded report; use `diff` on it")
+ if not isinstance(doc, dict) or doc.get("schema") != SNAPSHOT_FORMAT:
+ got = doc.get("schema") if isinstance(doc, dict) else type(doc).__name__
+ raise StateDecodeError(
+ f"unsupported JSON input {path}: schema={got!r} "
+ f"(want {SNAPSHOT_FORMAT})")
+ raw_chunks = doc.get("chunks")
+ if not isinstance(raw_chunks, list) or not raw_chunks:
+ raise StateDecodeError(f"snapshot {path} has no chunks")
+ chunks = []
+ for index, raw in enumerate(raw_chunks):
+ if not isinstance(raw, dict):
+ raise StateDecodeError(f"{path}:chunks[{index}] must be an object")
+ chunk_label = raw.get("label") or label or "RAW"
+ chunk_base = (_parse_base(raw["base"]) if raw.get("base") is not None
+ else base)
+ words_raw = raw.get("words")
+ if not isinstance(words_raw, list):
+ raise StateDecodeError(
+ f"{path}:chunks[{index}] needs a words list")
+ words = {}
+ for position, word in enumerate(words_raw):
+ words[position * WORD_BYTES] = _parse_word(
+ word, where=f"{path}:chunks[{index}].words[{position}]")
+ chunks.append({"label": str(chunk_label), "base": chunk_base,
+ "words": words, "format": "snapshot-json",
+ "source": path})
+ return _document(path, "snapshot-json", label, base, chunks)
+
+
+# ---------------------------------------------------------------------------
+# Decode
+# ---------------------------------------------------------------------------
+
+def _read_field_value(words: dict[int, int], offset: int,
+ width: int) -> int | None:
+ """Little-endian read of a 4/8-byte field; ``None`` when words are absent."""
+ data = b""
+ for step in range(0, width, WORD_BYTES):
+ word = words.get(offset + step)
+ if word is None:
+ return None
+ data += word.to_bytes(WORD_BYTES, "little")
+ return int.from_bytes(data, "little")
+
+
+def _bit_state(bit: dict, value: int) -> str:
+ values = bit.get("values") or {}
+ if value in values:
+ return values[value]
+ mask = (1 << (bit["hi"] - bit["lo"] + 1)) - 1
+ if value == 0:
+ return "clear"
+ if value == mask:
+ return "set"
+ return f"{value:#x}"
+
+
+def _decode_field(entry: dict, value: int) -> dict:
+ width = entry["width"]
+ field: dict[str, Any] = {
+ "name": entry["label"],
+ "offset": f"{entry['offset']:#x}",
+ "width": width,
+ "kind": "bits" if entry["bits"] else "value",
+ "raw": _hex(value, width),
+ "value": value,
+ }
+ if entry.get("note"):
+ field["note"] = entry["note"]
+ if entry["bits"]:
+ bitfields = []
+ for bit in entry["bits"]:
+ raw_bit = (value >> bit["lo"]) & ((1 << (bit["hi"] - bit["lo"] + 1)) - 1)
+ bitfields.append({
+ "name": bit["name"],
+ "lo": bit["lo"],
+ "hi": bit["hi"],
+ "value": raw_bit,
+ "state": _bit_state(bit, raw_bit),
+ })
+ field["bitfields"] = bitfields
+ return field
+
+
+def decode_chunk(chunk: dict, entries: dict[tuple[str, int], dict]) -> dict:
+ """Decode one internal chunk into the ``greeneye-state-decode/1`` shape."""
+ label = chunk["label"]
+ words: dict[int, int] = chunk["words"]
+ matched = sorted(
+ (entry for (chunk_key, _off), entry in entries.items()
+ if chunk_key == label.lower()),
+ key=lambda entry: (entry["offset"], entry["label"]))
+ fields = []
+ covered: set[int] = set()
+ for entry in matched:
+ value = _read_field_value(words, entry["offset"], entry["width"])
+ if value is None:
+ continue
+ fields.append(_decode_field(entry, value))
+ covered.update(range(entry["offset"],
+ entry["offset"] + entry["width"], WORD_BYTES))
+ unmapped = [{"offset": f"{offset:#x}", "raw": _hex(words[offset])}
+ for offset in sorted(words) if offset not in covered]
+ return {
+ "label": label,
+ "base": f"{chunk['base']:#x}",
+ "source": chunk.get("source"),
+ "input_format": chunk.get("format"),
+ "word_width": WORD_BYTES,
+ "word_count": len(words),
+ "words": {f"{offset:#x}": _hex(value)
+ for offset, value in sorted(words.items())},
+ "fields": fields,
+ "unmapped": unmapped,
+ "unmapped_count": len(unmapped),
+ }
+
+
+def decode_specs(specs: list[dict], entries: dict[tuple[str, int], dict],
+ map_source: str = "builtin") -> dict:
+ """Load and decode input specs into a ``greeneye-state-decode/1`` report."""
+ sources = []
+ chunks = []
+ for spec in specs:
+ document = load_input(spec)
+ sources.append({
+ "path": document["path"],
+ "format": document["format"],
+ "label": document["label"],
+ "base": f"{document['base']:#x}",
+ "chunk_labels": [chunk["label"] for chunk in document["chunks"]],
+ })
+ for chunk in document["chunks"]:
+ chunks.append(decode_chunk(chunk, entries))
+ chunks.sort(key=lambda chunk: (chunk["label"].lower(), chunk["label"],
+ int(chunk["base"], 16)))
+ return {
+ "format": DECODE_FORMAT,
+ "map_source": map_source,
+ "sources": sources,
+ "chunks": chunks,
+ "limits": [
+ "Field names come from the bundled/extended map; an unmapped word "
+ "is silence, not absence.",
+ "Offsets are chunk-relative bytes; base is recorded provenance and "
+ "does not shift field lookup.",
+ "No timing (L2) or device-environment (L3) claim: this is a field "
+ "decoder over captured words.",
+ ],
+ }
+
+
+# ---------------------------------------------------------------------------
+# Diff
+# ---------------------------------------------------------------------------
+
+def _validate_report(doc: Any, side: str) -> None:
+ if not isinstance(doc, dict) or doc.get("format") != DECODE_FORMAT:
+ raise StateDecodeError(f"{side} is not a {DECODE_FORMAT} report")
+ if not isinstance(doc.get("chunks"), list):
+ raise StateDecodeError(f"{side} report has no chunks list")
+
+
+def _words_to_int(chunk: dict) -> dict[int, int]:
+ return {int(offset, 16): int(raw, 16)
+ for offset, raw in chunk["words"].items()}
+
+
+def _field_index(chunk: dict | None) -> dict[tuple[int, str], dict]:
+ if chunk is None:
+ return {}
+ return {(int(field["offset"], 16), field["name"]): field
+ for field in chunk["fields"]}
+
+
+def _field_summary(field: dict | None) -> str | None:
+ if field is None:
+ return None
+ if field.get("kind") == "bits":
+ parts = [f"{bit['name']}={bit['state']}"
+ for bit in field.get("bitfields", [])]
+ return ", ".join(parts) if parts else field["raw"]
+ return f"value={field['raw']}"
+
+
+def _changed_fields(left: dict | None, right: dict | None) -> list[dict]:
+ left_fields = _field_index(left)
+ right_fields = _field_index(right)
+ rows = []
+ for offset, name in sorted(set(left_fields) | set(right_fields)):
+ left_field = left_fields.get((offset, name))
+ right_field = right_fields.get((offset, name))
+ if (left_field is not None and right_field is not None
+ and left_field["raw"] == right_field["raw"]):
+ continue
+ row: dict[str, Any] = {
+ "offset": f"{offset:#x}",
+ "name": name,
+ "old": left_field["raw"] if left_field else None,
+ "new": right_field["raw"] if right_field else None,
+ "old_summary": _field_summary(left_field),
+ "new_summary": _field_summary(right_field),
+ }
+ if (left_field is not None and right_field is not None
+ and left_field.get("kind") == right_field.get("kind") == "bits"):
+ left_bits = {bit["name"]: bit for bit in left_field["bitfields"]}
+ right_bits = {bit["name"]: bit for bit in right_field["bitfields"]}
+ bits = []
+ for bit_name in sorted(set(left_bits) | set(right_bits)):
+ bits.append({
+ "name": bit_name,
+ "old": (left_bits[bit_name]["state"]
+ if bit_name in left_bits else None),
+ "new": (right_bits[bit_name]["state"]
+ if bit_name in right_bits else None),
+ })
+ row["bits"] = bits
+ rows.append(row)
+ return rows
+
+
+def _word_rows(words: dict[int, int]) -> list[dict]:
+ return [{"offset": f"{offset:#x}", "raw": _hex(raw)}
+ for offset, raw in sorted(words.items())]
+
+
+def _diff_chunk(left: dict | None, right: dict | None) -> dict:
+ if left is None or right is None:
+ present = right if left is None else left
+ words = _words_to_int(present)
+ rows = _word_rows(words)
+ row = {
+ "label": present["label"],
+ "base": present["base"],
+ "left_base": left["base"] if left else None,
+ "right_base": right["base"] if right else None,
+ "status": "right_only" if left is None else "left_only",
+ "added": rows if left is None else [],
+ "removed": rows if right is None else [],
+ "changed": [],
+ "changed_fields": _changed_fields(left, right),
+ }
+ return row
+ left_words = _words_to_int(left)
+ right_words = _words_to_int(right)
+ added = _word_rows({offset: right_words[offset]
+ for offset in right_words if offset not in left_words})
+ removed = _word_rows({offset: left_words[offset]
+ for offset in left_words if offset not in right_words})
+ changed = [{"offset": f"{offset:#x}", "old": _hex(left_words[offset]),
+ "new": _hex(right_words[offset])}
+ for offset in sorted(set(left_words) & set(right_words))
+ if left_words[offset] != right_words[offset]]
+ changed_fields = _changed_fields(left, right)
+ status = "changed" if (added or removed or changed or changed_fields) \
+ else "identical"
+ return {
+ "label": left["label"],
+ "base": left["base"],
+ "left_base": left["base"],
+ "right_base": right["base"],
+ "status": status,
+ "added": added,
+ "removed": removed,
+ "changed": changed,
+ "changed_fields": changed_fields,
+ }
+
+
+def diff_reports(left: dict, right: dict) -> dict:
+ """Deterministic word/field diff of two decoded reports."""
+ _validate_report(left, "left")
+ _validate_report(right, "right")
+ left_groups: dict[str, list[dict]] = {}
+ right_groups: dict[str, list[dict]] = {}
+ for chunk in left["chunks"]:
+ left_groups.setdefault(chunk["label"].lower(), []).append(chunk)
+ for chunk in right["chunks"]:
+ right_groups.setdefault(chunk["label"].lower(), []).append(chunk)
+ labels = sorted(set(left_groups) | set(right_groups))
+ chunks = []
+ for label in labels:
+ left_list = left_groups.get(label, [])
+ right_list = right_groups.get(label, [])
+ for index in range(max(len(left_list), len(right_list))):
+ left_chunk = left_list[index] if index < len(left_list) else None
+ right_chunk = right_list[index] if index < len(right_list) else None
+ chunks.append(_diff_chunk(left_chunk, right_chunk))
+ summary = {
+ "chunks": len(chunks),
+ "changed_chunks": sum(1 for chunk in chunks
+ if chunk["status"] != "identical"),
+ "added": sum(len(chunk["added"]) for chunk in chunks),
+ "removed": sum(len(chunk["removed"]) for chunk in chunks),
+ "changed_words": sum(len(chunk["changed"]) for chunk in chunks),
+ "changed_fields": sum(len(chunk["changed_fields"]) for chunk in chunks),
+ }
+ return {
+ "format": DIFF_FORMAT,
+ "left": {"format": left["format"], "map_source": left.get("map_source"),
+ "chunks": len(left["chunks"])},
+ "right": {"format": right["format"], "map_source": right.get("map_source"),
+ "chunks": len(right["chunks"])},
+ "chunks": chunks,
+ "summary": summary,
+ "identical": all(chunk["status"] == "identical" for chunk in chunks),
+ "limits": [
+ "Word/field diff only; an unchanged word does not prove untested "
+ "behaviour.",
+ "Chunks pair by label and occurrence order; a differing base is "
+ "recorded but does not re-pair.",
+ ],
+ }
+
+
+# ---------------------------------------------------------------------------
+# Rendering
+# ---------------------------------------------------------------------------
+
+def render_decode(report: dict) -> str:
+ """Readable field table for humans; the JSON report stays canonical."""
+ lines = []
+ for chunk in report["chunks"]:
+ lines.append(
+ f"chunk {chunk['label']} base={chunk['base']} "
+ f"words={chunk['word_count']} mapped={len(chunk['fields'])} "
+ f"unmapped={chunk['unmapped_count']}")
+ for field in chunk["fields"]:
+ if field["kind"] == "bits":
+ detail = " ".join(f"{bit['name']}={bit['state']}"
+ for bit in field["bitfields"])
+ else:
+ detail = f"value={field['raw']}"
+ lines.append(f" {field['offset']:>6} {field['raw']:<12} "
+ f"{field['name']:<20} {detail}")
+ nonzero = [word for word in chunk["unmapped"]
+ if int(word["raw"], 16)]
+ if nonzero:
+ shown = " ".join(f"{word['offset']}={word['raw']}"
+ for word in nonzero[:12])
+ more = "" if len(nonzero) <= 12 else f" (+{len(nonzero) - 12} more)"
+ lines.append(f" unmapped nonzero: {shown}{more}")
+ return "\n".join(lines)
+
+
+def render_diff(report: dict) -> str:
+ """Readable diff for humans; the JSON report stays canonical."""
+ lines = []
+ for chunk in report["chunks"]:
+ lines.append(f"chunk {chunk['label']} base={chunk['base']}: "
+ f"{chunk['status']}")
+ for word in chunk["added"]:
+ lines.append(f" added {word['offset']} {word['raw']}")
+ for word in chunk["removed"]:
+ lines.append(f" removed {word['offset']} {word['raw']}")
+ for word in chunk["changed"]:
+ lines.append(f" changed {word['offset']} "
+ f"{word['old']} -> {word['new']}")
+ for field in chunk["changed_fields"]:
+ lines.append(f" field {field['name']} @ {field['offset']}: "
+ f"{field['old']} -> {field['new']} "
+ f"({field['old_summary']} -> {field['new_summary']})")
+ for bit in field.get("bits", []):
+ lines.append(f" bit {bit['name']}: "
+ f"{bit['old']} -> {bit['new']}")
+ lines.append(f"summary: {json.dumps(report['summary'], sort_keys=True)} "
+ f"identical={report['identical']}")
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# Demo
+# ---------------------------------------------------------------------------
+
+def _demo_words(word48: int) -> list[str]:
+ words = ["0x0"] * 20 # chunk words 0x00..0x4c
+ words[0x10 // WORD_BYTES] = "0xface"
+ words[0x48 // WORD_BYTES] = _hex(word48)
+ return words
+
+
+def _demo_snapshot(name: str, *, gpfifo_va: int, ramfc_target: int) -> dict:
+ return {
+ "schema": SNAPSHOT_FORMAT,
+ "source": name,
+ "chunks": [
+ {"label": "INSTANCE", "base": "0x0",
+ "words": _demo_words(gpfifo_va)},
+ {"label": "RAMFC", "base": "0x0",
+ "words": _demo_words(ramfc_target)},
+ ],
+ }
+
+
+def run_demo(out: str | None = None) -> dict:
+ """Craft two snapshots in a tempdir, decode+diff them, and report.
+
+ Expected: INSTANCE word 0x048 moves 0x100000 -> 0x200000 and RAMFC word
+ 0x048 moves 0x00030000 -> 0x00010000 (CE_CTX_VALID clears), i.e. two
+ changed words and two changed named fields.
+ """
+ demo_dir = tempfile.mkdtemp(prefix="greeneye-state-decode-demo-")
+ try:
+ left_path = os.path.join(demo_dir, "left.json")
+ right_path = os.path.join(demo_dir, "right.json")
+ left_snapshot = _demo_snapshot("demo-left", gpfifo_va=0x100000,
+ ramfc_target=0x00030000)
+ right_snapshot = _demo_snapshot("demo-right", gpfifo_va=0x200000,
+ ramfc_target=0x00010000)
+ _write_json(left_path, left_snapshot)
+ _write_json(right_path, right_snapshot)
+ entries, map_source = load_map(None)
+ left = decode_specs(
+ [{"path": left_path, "label": None, "base": None, "binary": False}],
+ entries, map_source)
+ right = decode_specs(
+ [{"path": right_path, "label": None, "base": None, "binary": False}],
+ entries, map_source)
+ diff = diff_reports(left, right)
+ ok = (not diff["identical"]
+ and diff["summary"]["changed_words"] == 2
+ and diff["summary"]["changed_fields"] == 2
+ and diff["summary"]["added"] == 0
+ and diff["summary"]["removed"] == 0)
+ demo = {
+ "format": DEMO_FORMAT,
+ "ok": ok,
+ "left": left,
+ "right": right,
+ "diff": diff,
+ }
+ if out:
+ os.makedirs(out, exist_ok=True)
+ artifacts = {}
+ for name, doc in (("state-decode-left.json", left),
+ ("state-decode-right.json", right),
+ ("state-diff.json", diff)):
+ artifacts[name] = _write_json(os.path.join(out, name), doc)
+ for name, path in (("state-snapshot-left.json", left_path),
+ ("state-snapshot-right.json", right_path)):
+ target = os.path.join(out, name)
+ shutil.copyfile(path, target)
+ artifacts[name] = os.path.abspath(target)
+ demo["artifacts"] = artifacts
+ return demo
+ finally:
+ shutil.rmtree(demo_dir, ignore_errors=True)
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+class _InputAction(argparse.Action):
+ """Collect repeatable ``--input``/``--bin`` specs in command order."""
+
+ def __init__(self, option_strings, dest, binary=False, **kwargs):
+ self.binary = binary
+ super().__init__(option_strings, dest, **kwargs)
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ specs = getattr(namespace, self.dest, None)
+ if specs is None:
+ specs = []
+ setattr(namespace, self.dest, specs)
+ spec = {"path": values, "label": None, "base": None,
+ "binary": self.binary}
+ specs.append(spec)
+ setattr(namespace, "_current_spec", spec)
+
+
+class _InputMetaAction(argparse.Action):
+ """Attach ``--label``/``--base`` to the preceding input, or set a default."""
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ current = getattr(namespace, "_current_spec", None)
+ if current is not None:
+ current[self.dest] = values
+ setattr(namespace, self.dest, values)
+
+
+def _apply_defaults(specs: list[dict], label: str | None,
+ base: str | None) -> list[dict]:
+ resolved = []
+ for spec in specs:
+ item = dict(spec)
+ if item.get("label") is None:
+ item["label"] = label
+ if item.get("base") is None:
+ item["base"] = base
+ resolved.append(item)
+ return resolved
+
+
+def _peek_report(path: str) -> dict | None:
+ data = _read_bytes(path)
+ doc = _try_json(data)
+ if isinstance(doc, dict) and doc.get("format") == DECODE_FORMAT:
+ _validate_report(doc, path)
+ return doc
+ return None
+
+
+def _load_side(spec: dict, entries: dict[tuple[str, int], dict],
+ map_source: str) -> dict:
+ report = _peek_report(spec["path"])
+ if report is not None:
+ return report
+ return decode_specs([spec], entries, map_source)
+
+
+def _write_json(path: str, doc: Any) -> str:
+ absolute = os.path.abspath(path)
+ parent = os.path.dirname(absolute)
+ if parent:
+ os.makedirs(parent, exist_ok=True)
+ with open(absolute, "w", encoding="utf-8") as handle:
+ json.dump(doc, handle, indent=2, sort_keys=True)
+ handle.write("\n")
+ return absolute
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="state_decode.py",
+ description="Decode engine-visible channel state words into named "
+ "fields, and diff two captures (offline).")
+ sub = parser.add_subparsers(dest="command")
+
+ decode = sub.add_parser(
+ "decode", help="decode one or more raw inputs into a field table")
+ decode.add_argument("--input", action=_InputAction, dest="inputs",
+ default=None, metavar="FILE",
+ help="input file; repeatable, auto-detected format")
+ decode.add_argument("--bin", action=_InputAction, dest="inputs",
+ default=None, metavar="FILE",
+ help="raw little-endian u32 input; repeatable")
+ decode.add_argument("--label", action=_InputMetaAction, default=None,
+ help="chunk label for the preceding --input/--bin "
+ "(default for the rest)")
+ decode.add_argument("--base", action=_InputMetaAction, default="0x0",
+ help="engine address of chunk offset 0 for the "
+ "preceding input (default 0x0)")
+ decode.add_argument("--map", dest="map_path", default=None,
+ help=f"{MAP_FORMAT} override/extension file")
+ decode.add_argument("--json", dest="json_path", default=None,
+ help="write the decoded report JSON here")
+
+ diff = sub.add_parser(
+ "diff", help="diff two decoded reports or two raw inputs")
+ diff.add_argument("left", help=f"{DECODE_FORMAT} report or raw input")
+ diff.add_argument("right", help=f"{DECODE_FORMAT} report or raw input")
+ diff.add_argument("--left-label", dest="left_label", default=None,
+ help="chunk label for a raw left input")
+ diff.add_argument("--left-base", dest="left_base", default="0x0",
+ help="base for a raw left input")
+ diff.add_argument("--left-bin", dest="left_bin", action="store_true",
+ help="force the left input to raw u32 words")
+ diff.add_argument("--right-label", dest="right_label", default=None,
+ help="chunk label for a raw right input")
+ diff.add_argument("--right-base", dest="right_base", default="0x0",
+ help="base for a raw right input")
+ diff.add_argument("--right-bin", dest="right_bin", action="store_true",
+ help="force the right input to raw u32 words")
+ diff.add_argument("--map", dest="map_path", default=None,
+ help=f"{MAP_FORMAT} override/extension file")
+ diff.add_argument("--json", dest="json_path", default=None,
+ help="write the diff report JSON here")
+
+ demo = sub.add_parser(
+ "demo", help="craft two snapshots, decode+diff them, print the result")
+ demo.add_argument("--out", default=None,
+ help="write demo snapshots and reports to this directory")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ if args.command == "decode":
+ specs = _apply_defaults(list(args.inputs or []), args.label,
+ args.base)
+ if not specs:
+ raise StateDecodeError("decode needs at least one --input/--bin")
+ entries, map_source = load_map(args.map_path)
+ report = decode_specs(specs, entries, map_source)
+ print(render_decode(report))
+ if args.json_path:
+ _write_json(args.json_path, report)
+ return 0
+ if args.command == "diff":
+ entries, map_source = load_map(args.map_path)
+ left = _load_side({"path": args.left, "label": args.left_label,
+ "base": args.left_base,
+ "binary": args.left_bin}, entries, map_source)
+ right = _load_side({"path": args.right, "label": args.right_label,
+ "base": args.right_base,
+ "binary": args.right_bin}, entries, map_source)
+ report = diff_reports(left, right)
+ print(render_diff(report))
+ if args.json_path:
+ _write_json(args.json_path, report)
+ return 0 if report["identical"] else 3
+ if args.command == "demo":
+ demo = run_demo(args.out)
+ print(f"demo: {demo['format']} ok={demo['ok']}")
+ print(render_decode(demo["left"]))
+ print(render_diff(demo["diff"]))
+ print(json.dumps({"ok": demo["ok"],
+ "summary": demo["diff"]["summary"]},
+ indent=2, sort_keys=True))
+ return 0 if demo["ok"] else 3
+ parser.print_help()
+ return 2
+ except StateDecodeError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/state_seed.py b/debug/risc-v-gsp-rm-vm/harness/state_seed.py
new file mode 100644
index 0000000..e4d9a42
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/state_seed.py
@@ -0,0 +1,990 @@
+#!/usr/bin/env python3
+"""RM-object state seeding: declarative state for rehost/valprobe runs.
+
+Why this exists
+---------------
+``harness/rehost.py`` runs one firmware function with caller-supplied bytes and
+``harness/valprobe.py`` runs one validation branch with crafted registers and
+memory. Both hit the same wall on RM handlers: the function early-outs (or
+faults on a NULL callback) before the branch of interest because the RM object
+state it walks -- clients, channels, graphics contexts, memdesc/context-buffer
+objects -- does not exist. This harness adds the missing layer: a declarative
+**state seed** (``greeneye-state-seed/1``) that places symbolic objects at
+chosen addresses, fills their fields and pointers, sets registers/entry, and
+runs the function under a bounded budget while recording which seeded state the
+firmware actually reads and where it first stops.
+
+Every run reports the ordered first-touch state reads and a ``gate``: the
+address and the state the returning/faulting path checked. A clean early-out
+with the gate identified is the success criterion; a fault is the first missing
+state observed, never a product defect.
+
+Honesty rules:
+
+* Not a boot and not a model: only the seeded state exists. The report lists
+ the layout, every field it wrote, and every field marked ``unknown``.
+* The object field map below is partial and cites its evidence per field; a
+ field without evidence is marked ``unknown`` and must not be invented.
+* No timing/cycle claim (L0/L1 fidelity, docs/06).
+
+Offline only: never touches a GPU, kernel module, mdev, guest or network.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+import time
+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.elf import load_elf # noqa: E402
+from greeneye.errors import GreenEyeError # noqa: E402
+from greeneye.sim_backend import launch_native # noqa: E402
+from sim.devices import load_profile, registry_from_profile # noqa: E402
+from sim.gsp_boot import _apply_boot_shim # noqa: E402
+from sim.libos import LibosSyscalls # noqa: E402
+from sim.machine import Fault, R_BP, R_BUDGET, R_ECALL, R_FAULT, R_WATCH # noqa: E402
+
+from harness.rehost import ( # noqa: E402
+ DEFAULT_IMAGE,
+ DEFAULT_PROFILE,
+ EBREAK,
+ RUN_CHUNK,
+ _parse_u64,
+ _registers,
+ _sha256_file,
+)
+from harness.valprobe import ( # noqa: E402
+ BUFFER_ID,
+ INSTANCE_GETTER,
+ OUTCOMES,
+ PROMOTE_VALIDATION,
+ decode_memory_spec,
+ encode_return_stub,
+ reg_index,
+)
+
+#: Schema tag of a state seed.
+SEED_FORMAT = "greeneye-state-seed/1"
+#: Schema tag of a single seeded run.
+REPORT_FORMAT = "greeneye-state-seed-report/1"
+#: Schema tag of the bundled demo.
+DEMO_FORMAT = "greeneye-state-seed-demo/1"
+
+DEFAULT_BUDGET = 50_000
+DEFAULT_TIMEOUT = 20.0
+DEFAULT_ALLOC_BASE = 0x10000000
+DEFAULT_ALLOC_ALIGN = 16
+
+#: Object types that carry a documented field map (evidence per field below).
+_CHANNEL_FIELDS = {
+ 0x0D28: {"name": "instance_index", "width": 4, "status": "known",
+ "role": "graphics-instance index (u32); selects client+0x37c+idx",
+ "evidence": "promote-validation predicate (docs/47)"},
+ 0x0D01: {"name": "flag_d01", "width": 1, "status": "unknown",
+ "role": "u8 flag on the failed-lookup path (0 takes the panic path)"},
+ 0x0CD1: {"name": "flag_cd1", "width": 1, "status": "unknown",
+ "role": "u8 flag before the promote calls"},
+ 0x0648: {"name": "callback_648", "width": 8, "status": "unknown",
+ "role": "indirect callback on the invalid-state path"},
+ 0x0898: {"name": "callback_898", "width": 8, "status": "unknown",
+ "role": "indirect callback first reached after the +0x600 gate"},
+ 0x03B8: {"name": "marker_3b8", "width": 8, "status": "unknown",
+ "role": "identity marker compared across objects"},
+ 0x00EE: {"name": "obj_ee", "width": 8, "status": "unknown",
+ "role": "sub-object; +0xe8 read as the gfid/index"},
+}
+
+_GRAPHICS_INSTANCE_FIELDS = {
+ 0x0190: {"name": "memdesc_table", "width": 8, "status": "known",
+ "role": "pointer to the per-buffer memDesc slot table",
+ "evidence": "promote-validation predicate (docs/47)"},
+ 0x0161: {"name": "flag_161", "width": 1, "status": "unknown",
+ "role": "u8 flag gating the 0x529bd44 call"},
+ 0x03B8: {"name": "marker_3b8", "width": 8, "status": "unknown",
+ "role": "identity marker compared across objects"},
+}
+
+FIELD_MAP: dict[str, dict[int, dict]] = {
+ "client": {
+ 0x1BE0: {"name": "instance_table", "width": 8, "status": "known",
+ "role": "graphics-instance pointer array base (index 0x37c + idx)",
+ "evidence": "promote-validation predicate (docs/47)"},
+ 0x1AA8: {"name": "obj_1aa8", "width": 8, "status": "unknown",
+ "role": "sub-object consumed by the construct path (+0x24c callback)"},
+ 0x1CC8: {"name": "obj_1cc8", "width": 8, "status": "unknown",
+ "role": "sub-object consumed by the construct path (+0x150, +0x1d0)"},
+ 0x1DD8: {"name": "engine_ctx", "width": 8, "status": "known",
+ "role": "engine-context pointer; 0 selects the no-engine path",
+ "evidence": "construct-path disassembly (docs/52)"},
+ 0x1DE8: {"name": "subdevice", "width": 8, "status": "known",
+ "role": "sub-device object whose +0x600 callback is called",
+ "evidence": "construct-path disassembly (docs/52)"},
+ 0x1E48: {"name": "obj_1e48", "width": 8, "status": "unknown",
+ "role": "sub-object consumed on the no-engine path (+0x110 callback)"},
+ },
+ "channel": _CHANNEL_FIELDS,
+ "graphics_context": _CHANNEL_FIELDS,
+ "graphics_instance": _GRAPHICS_INSTANCE_FIELDS,
+ "memdesc": {
+ 0x0000: {"name": "unknown_0", "width": 8, "status": "unknown",
+ "role": "slot value semantics outside the promote predicate"},
+ 0x0008: {"name": "unknown_8", "width": 8, "status": "unknown",
+ "role": "unknown"},
+ },
+ "ctx_buffer": {
+ 0x0000: {"name": "unknown_0", "width": 8, "status": "unknown",
+ "role": "context-buffer (memdesc) object; field semantics unknown"},
+ },
+ "rm_object": {},
+}
+
+#: Object types accepted in a seed. ``rm_object`` carries no field map.
+KNOWN_TYPES = tuple(FIELD_MAP)
+MISSING_MARKER = "unknown"
+
+
+class StateSeedError(GreenEyeError):
+ """A state seed, image or scenario is invalid."""
+
+
+def _require_u64(value: Any, what: str = "value") -> int:
+ try:
+ return _parse_u64(value)
+ except GreenEyeError as exc:
+ raise StateSeedError(f"{what}: {exc}") from exc
+
+
+def _field_width(obj_type: str, offset: int) -> int:
+ spec = FIELD_MAP.get(obj_type, {}).get(offset)
+ return int(spec["width"]) if spec else 8
+
+
+def _field_spec(obj_type: str, offset: int) -> dict | None:
+ return FIELD_MAP.get(obj_type, {}).get(offset)
+
+
+def _parse_offset(value: Any) -> int:
+ text = str(value).strip()
+ if text.startswith("+"):
+ text = text[1:]
+ try:
+ return int(text, 0)
+ except ValueError as exc:
+ raise StateSeedError(f"bad field offset {value!r}") from exc
+
+
+def _encode_int(value: int, width: int) -> bytes:
+ if width not in (1, 2, 4, 8):
+ raise StateSeedError(f"unsupported field width {width}")
+ return (value & ((1 << (8 * width)) - 1)).to_bytes(width, "little")
+
+
+def _is_symbol(value: Any) -> bool:
+ return isinstance(value, str) and value.strip().startswith("@")
+
+
+def _symbol_expr(value: str) -> tuple[str, int]:
+ text = value.strip()[1:]
+ name, sep, offset = text.partition("+")
+ if not name:
+ raise StateSeedError(f"bad pointer expression {value!r}")
+ if sep:
+ try:
+ delta = int(offset, 0)
+ except ValueError as exc:
+ raise StateSeedError(f"bad pointer expression {value!r}") from exc
+ else:
+ delta = 0
+ return name, delta
+
+
+class _Symbols:
+ """Name -> address table for symbolic object/stub pointers."""
+
+ def __init__(self) -> None:
+ self._by_name: dict[str, int] = {}
+
+ def add(self, name: str, addr: int, owner: str) -> None:
+ if name in self._by_name:
+ raise StateSeedError(f"duplicate symbol {name!r} ({owner})")
+ self._by_name[name] = addr
+
+ def resolve(self, value: str, *, owner: str) -> int:
+ name, delta = _symbol_expr(value)
+ if name not in self._by_name:
+ raise StateSeedError(f"{owner}: unknown symbol {name!r}")
+ return (self._by_name[name] + delta) & 0xFFFF_FFFF_FFFF_FFFF
+
+
+def load_seed(seed: dict) -> dict:
+ """Validate a ``greeneye-state-seed/1`` seed and compile an application plan."""
+ if not isinstance(seed, dict):
+ raise StateSeedError(f"seed must be an object, got {type(seed).__name__}")
+ fmt = seed.get("format", SEED_FORMAT)
+ if fmt != SEED_FORMAT:
+ raise StateSeedError(f"unsupported seed format {fmt!r} (want {SEED_FORMAT})")
+ name = str(seed.get("name") or "state-seed")
+ seeded_registers = seed.get("registers") or {}
+ if not isinstance(seeded_registers, dict):
+ raise StateSeedError("registers must be an object")
+ entry_value = seed.get("entry", seeded_registers.get("pc"))
+ if entry_value is None:
+ raise StateSeedError("seed needs an entry address (or registers.pc)")
+ entry = _require_u64(entry_value)
+
+ allocator = seed.get("allocator") or {}
+ cursor = _require_u64(allocator.get("base", DEFAULT_ALLOC_BASE))
+ default_align = _require_u64(allocator.get("align", DEFAULT_ALLOC_ALIGN))
+ if default_align <= 0 or default_align & (default_align - 1):
+ raise StateSeedError("allocator align must be a positive power of two")
+
+ objects = seed.get("objects") or []
+ if not isinstance(objects, list):
+ raise StateSeedError("objects must be a list")
+ layout: list[dict] = []
+ symbols = _Symbols()
+ for obj in objects:
+ if not isinstance(obj, dict):
+ raise StateSeedError(f"object must be an object, got {obj!r}")
+ obj_type = str(obj.get("type") or "")
+ if obj_type not in KNOWN_TYPES:
+ raise StateSeedError(f"unknown object type {obj_type!r} (known: {', '.join(KNOWN_TYPES)})")
+ obj_name = str(obj.get("name") or "")
+ if not obj_name:
+ raise StateSeedError(f"{obj_type} object needs a name")
+ align = _require_u64(obj.get("align", default_align))
+ if align <= 0 or align & (align - 1):
+ raise StateSeedError(f"{obj_name}: align must be a positive power of two")
+ fields = obj.get("fields") or {}
+ if not isinstance(fields, dict):
+ raise StateSeedError(f"{obj_name}: fields must be an object")
+ extent = 0
+ for key in fields:
+ offset = _parse_offset(key)
+ extent = max(extent, offset + _field_width(obj_type, offset))
+ size = _require_u64(obj.get("size", max(extent, default_align)))
+ if size <= 0:
+ raise StateSeedError(f"{obj_name}: size must be positive")
+ if "pa" in obj and "va" in obj:
+ raise StateSeedError(f"{obj_name}: use pa or va, not both")
+ explicit = obj.get("pa", obj.get("va"))
+ if explicit is not None:
+ pa = _require_u64(explicit)
+ else:
+ cursor = (cursor + align - 1) & ~(align - 1)
+ pa = cursor
+ cursor += (size + align - 1) & ~(align - 1)
+ record = {"name": obj_name, "type": obj_type, "pa": pa, "size": size,
+ "fields": {}, "unknown": []}
+ layout.append(record)
+ symbols.add(obj_name, pa, obj_name)
+ for key, value in fields.items():
+ record["fields"][_parse_offset(key)] = value
+
+ for i, first in enumerate(layout):
+ for second in layout[i + 1:]:
+ if first["pa"] < second["pa"] + second["size"] and second["pa"] < first["pa"] + first["size"]:
+ raise StateSeedError(
+ f"objects {first['name']!r} and {second['name']!r} overlap"
+ )
+
+ writes: list[dict] = []
+ unknown_fields: list[dict] = []
+
+ stubs = seed.get("stubs") or []
+ if not isinstance(stubs, list):
+ raise StateSeedError("stubs must be a list")
+ stub_records: list[dict] = []
+ for stub in stubs:
+ if not isinstance(stub, dict) or "return" not in stub:
+ raise StateSeedError(f"stub needs 'return': {stub!r}")
+ ret = _require_u64(stub["return"])
+ if "addr" in stub:
+ addr = _require_u64(stub["addr"])
+ else:
+ cursor = (cursor + default_align - 1) & ~(default_align - 1)
+ addr = cursor
+ cursor += default_align
+ stub_name = stub.get("name")
+ if stub_name:
+ symbols.add(str(stub_name), addr, f"stub {stub_name}")
+ code = encode_return_stub(ret)
+ stub_records.append({"name": stub_name, "addr": addr, "return": ret,
+ "code_hex": code.hex()})
+ writes.append({"pa": addr, "hex": code.hex(), "label": f"stub:{stub_name or hex(addr)}",
+ "kind": "stub"})
+
+ def resolve_field(owner: str, obj_type: str, offset: int, value: Any) -> tuple[int | None, int, str]:
+ width = _field_width(obj_type, offset)
+ if value == MISSING_MARKER or (isinstance(value, dict) and value.get("unknown")):
+ return None, width, MISSING_MARKER
+ if isinstance(value, dict):
+ if "unknown" in value and value["unknown"]:
+ return None, width, MISSING_MARKER
+ if "ptr" in value:
+ value = value["ptr"]
+ elif "value" in value:
+ raw = value["value"]
+ if raw == MISSING_MARKER:
+ return None, _require_u64(value.get("size", width)), MISSING_MARKER
+ width = _require_u64(value.get("size", width))
+ return _require_u64(raw), width, "value"
+ else:
+ raise StateSeedError(f"{owner}+{offset:#x}: field dict needs value/ptr/unknown")
+ if _is_symbol(value):
+ return symbols.resolve(str(value), owner=f"{owner}+{offset:#x}"), width, "ptr"
+ return _parse_u64(value), width, "value"
+
+ for record in layout:
+ pa = record["pa"]
+ for offset in sorted(record["fields"]):
+ value = record["fields"][offset]
+ resolved, width, kind = resolve_field(record["name"], record["type"], offset, value)
+ spec = _field_spec(record["type"], offset)
+ label = f"{record['name']}+{offset:#x}"
+ if resolved is None:
+ unknown_fields.append({
+ "object": record["name"], "type": record["type"], "off": f"{offset:#x}",
+ "name": spec["name"] if spec else None,
+ "role": spec["role"] if spec else "not documented",
+ "reason": "seed marks this field unknown; left at zero",
+ })
+ continue
+ writes.append({"pa": pa + offset, "hex": _encode_int(resolved, width).hex(),
+ "label": label, "kind": kind})
+
+ memory = seed.get("memory") or []
+ if not isinstance(memory, list):
+ raise StateSeedError("memory must be a list")
+ for item in memory:
+ if not isinstance(item, dict):
+ raise StateSeedError(f"memory item must be an object: {item!r}")
+ normalized = dict(item)
+ if "pa" in normalized and "addr" in normalized:
+ raise StateSeedError(f"memory item: use pa or addr, not both: {item!r}")
+ if "pa" in normalized:
+ normalized["addr"] = normalized.pop("pa")
+ try:
+ addr, data = decode_memory_spec(normalized)
+ except GreenEyeError as exc:
+ raise StateSeedError(str(exc)) from exc
+ writes.append({"pa": addr, "hex": data.hex(),
+ "label": item.get("label") or f"mem+{addr:#x}", "kind": "memory"})
+
+ registers: dict[str, int] = {}
+ for reg_name, value in seeded_registers.items():
+ if str(reg_name).strip().lower() == "pc":
+ continue
+ try:
+ reg_index(str(reg_name))
+ except GreenEyeError as exc:
+ raise StateSeedError(str(exc)) from exc
+ if _is_symbol(value):
+ registers[str(reg_name)] = symbols.resolve(str(value), owner=f"register {reg_name}")
+ else:
+ registers[str(reg_name)] = _require_u64(value)
+
+ stack = seed.get("stack")
+ outcomes = {_require_u64(a): str(label)
+ for a, label in (seed.get("outcomes") or {}).items()}
+ reads = []
+ for item in seed.get("reads") or ():
+ if "pa" in item and "addr" not in item:
+ item = dict(item)
+ item["addr"] = item.pop("pa")
+ if "addr" not in item:
+ raise StateSeedError(f"read item needs addr/pa: {item!r}")
+ reads.append(item)
+ watch: list[dict] = []
+ seen_labels: dict[str, int] = {}
+
+ def _add_watch(addr: int, size: int, label: str) -> None:
+ count = seen_labels.get(label, 0) + 1
+ seen_labels[label] = count
+ if count > 1:
+ label = f"{label}~{count}"
+ watch.append({"addr": addr, "size": size, "label": label})
+
+ for item in seed.get("watch") or ():
+ if "pa" in item and "addr" not in item:
+ item = dict(item)
+ item["addr"] = item.pop("pa")
+ if "addr" not in item:
+ raise StateSeedError(f"watch item needs addr/pa: {item!r}")
+ _add_watch(_require_u64(item["addr"]), _require_u64(item.get("size", 8)),
+ str(item.get("label") or f"{_require_u64(item['addr']):#x}"))
+ for record in layout:
+ for offset in record["fields"]:
+ _add_watch(record["pa"] + offset, _field_width(record["type"], offset),
+ f"{record['name']}+{offset:#x}")
+
+ return {
+ "format": SEED_FORMAT,
+ "name": name,
+ "entry": entry,
+ "stack": _require_u64(stack) if stack is not None else None,
+ "registers": registers,
+ "writes": writes,
+ "layout": layout,
+ "unknown_fields": unknown_fields,
+ "stubs": stub_records,
+ "outcomes": outcomes,
+ "reads": reads,
+ "watch": watch,
+ "provenance": seed.get("provenance") or {},
+ "notes": seed.get("notes") or [],
+ }
+
+
+def apply_seed(machine, plan: dict) -> dict:
+ """Apply a compiled plan to a machine; return the same plan (chainable)."""
+ for item in plan["writes"]:
+ machine.write_mem(item["pa"], bytes.fromhex(item["hex"]))
+ for reg_name, value in plan["registers"].items():
+ machine.x[reg_index(reg_name)] = value
+ return plan
+
+
+def _read_after(machine, reads) -> list[dict]:
+ out = []
+ for item in reads or ():
+ addr = _require_u64(item["addr"])
+ size = _require_u64(item.get("size", 8))
+ data = bytes(machine.read_mem(addr, size))
+ record = {"addr": f"{addr:#x}", "size": size, "hex": data.hex()}
+ if item.get("label"):
+ record["label"] = item["label"]
+ record["u64"] = int.from_bytes(data.ljust(8, b"\x00"), "little")
+ out.append(record)
+ return out
+
+
+def _gate_from_run(status: str, stop_pc: int, fault: dict | None,
+ state_reads: list[dict]) -> dict | None:
+ if not state_reads:
+ return None
+ last = state_reads[-1]
+ if status == "fault" and stop_pc == 0 and last.get("value") == 0:
+ return {
+ "kind": "null_indirect_call",
+ "pc": last["pc"],
+ "addr": last["addr"],
+ "label": last["label"],
+ "value": 0,
+ "detail": (
+ f"indirect call target {last['label']} is NULL; execution faulted at "
+ f"pc 0x0 (fault={fault})"
+ ),
+ }
+ if status == "fault":
+ return {
+ "kind": "fault",
+ "pc": f"{stop_pc:#x}",
+ "addr": last["addr"],
+ "label": last["label"],
+ "value": last.get("value"),
+ "detail": f"fault before the next seeded-state read (fault={fault})",
+ }
+ if status == "outcome":
+ return None
+ return {
+ "kind": f"{status}_before_gate",
+ "pc": f"{stop_pc:#x}",
+ "addr": last["addr"],
+ "label": last["label"],
+ "value": last.get("value"),
+ "detail": f"run stopped as {status}; last seeded state read shown",
+ }
+
+
+def run_seed(
+ seed: dict,
+ *,
+ image: str = DEFAULT_IMAGE,
+ profile_path: str | None = DEFAULT_PROFILE,
+ entry: int | str | None = None,
+ out: str | None = None,
+ record: bool | None = None,
+ budget: int = DEFAULT_BUDGET,
+ timeout: float = DEFAULT_TIMEOUT,
+ name: str = "state-seed",
+) -> dict:
+ """Load a seed into the native machine, run it bounded, return a report."""
+ if not os.path.isfile(image):
+ raise StateSeedError(f"image not found: {image}")
+ if budget <= 0:
+ raise StateSeedError(f"budget must be positive, got {budget}")
+ image_obj = load_elf(image)
+ if not image_obj.is_riscv:
+ raise StateSeedError(f"{image}: not a RISC-V ELF image")
+ plan = load_seed(seed)
+ entry_addr = _require_u64(entry) if entry is not None else plan["entry"]
+
+ profile = load_profile(profile_path) if profile_path else None
+ load = (profile or {}).get("load", {})
+ ram_base = _require_u64(load.get("ram_base", 0))
+ memory_mb = int(load.get("memory_mb", 256))
+ ram_top = ram_base + memory_mb * 1024 * 1024
+
+ if record is None:
+ record = bool(out)
+ session = launch_native(
+ kernel=image,
+ memory_mb=memory_mb,
+ ram_base=ram_base,
+ record=record,
+ name=f"{name}-{plan['name']}"[:80],
+ device_map=None,
+ unknown_mmio="lenient",
+ bias=0,
+ )
+ machine = session.target.machine
+ try:
+ if profile is not None:
+ machine.devices = registry_from_profile(profile, machine=machine)
+ machine.translator = getattr(machine.devices, "dma", None)
+ machine.syscall_handler = LibosSyscalls().handle
+ shim = dict(profile.get("boot_shim", {}))
+ shim["_image_path"] = image
+ _apply_boot_shim(machine, shim, image_obj, {"enabled": True, "assumptions": []})
+
+ sentinel = (ram_top - 0x100) & ~0xF
+ default_sp = (ram_top - 0x8000) & ~0xF
+ machine.write_mem(sentinel, EBREAK.to_bytes(4, "little"))
+
+ apply_seed(machine, plan)
+ machine.x[1] = sentinel
+ if machine.x[2] == 0:
+ machine.x[2] = plan["stack"] if plan["stack"] is not None else default_sp
+ machine.pc = entry_addr
+
+ breakpoints = set(plan["outcomes"]) | {sentinel}
+ watchers = []
+ for item in plan["watch"]:
+ addr = _require_u64(item["addr"])
+ size = _require_u64(item.get("size", 8))
+ label = str(item.get("label") or f"{addr:#x}")
+ watchers.append((addr, addr + size, label, 3))
+
+ entered = {f"x{i}": machine.x[i] for i in range(32) if i and machine.x[i]}
+ entered["pc"] = entry_addr
+ state_reads: list[dict] = []
+ started = time.monotonic()
+ status = "budget"
+ stop_pc = entry_addr
+ fault: dict | None = None
+ outcome: str | None = None
+ remaining = budget
+ while remaining > 0:
+ if time.monotonic() - started > timeout:
+ status = "timeout"
+ break
+ chunk = min(RUN_CHUNK, remaining)
+ reason, stop_pc, a, b = machine.run(
+ chunk, breakpoints=breakpoints, watchers=tuple(watchers)
+ )
+ if reason == R_WATCH:
+ width = 8
+ for watcher in watchers:
+ if watcher[2] == b:
+ width = watcher[1] - watcher[0]
+ break
+ try:
+ value = int.from_bytes(bytes(machine.read_mem(a, width)), "little")
+ except (GreenEyeError, Fault):
+ value = None
+ state_reads.append({"pc": f"{stop_pc:#x}", "addr": f"{a:#x}",
+ "label": b, "value": value, "width": width})
+ watchers = [watcher for watcher in watchers if watcher[2] != b]
+ continue
+ remaining -= chunk
+ if reason == R_BP:
+ if stop_pc == sentinel:
+ status = "return"
+ break
+ outcome = plan["outcomes"].get(stop_pc, "unlabelled")
+ status = "outcome"
+ break
+ if reason == R_FAULT:
+ status = "fault"
+ fault = {"cause": a, "tval": f"{b:#x}"}
+ break
+ if reason == R_ECALL:
+ handled, result = (machine.syscall_handler(machine, stop_pc)
+ if machine.syscall_handler else (False, None))
+ if not handled:
+ status = "ecall"
+ break
+ if result is not None:
+ machine.x[10] = result & 0xFFFF_FFFF_FFFF_FFFF
+ continue
+ if reason == R_BUDGET:
+ status = "budget"
+
+ gate = _gate_from_run(status, stop_pc, fault, state_reads)
+ report: dict[str, Any] = {
+ "format": REPORT_FORMAT,
+ "seed": plan["name"],
+ "seed_format": plan["format"],
+ "seed_provenance": plan["provenance"],
+ "image": os.path.abspath(image),
+ "image_sha256": _sha256_file(image),
+ "profile": os.path.basename(profile_path) if profile_path else None,
+ "entry": f"{entry_addr:#x}",
+ "status": status,
+ "outcome": outcome,
+ "stop_pc": f"{stop_pc:#x}",
+ "fault": fault,
+ "retval": machine.x[10],
+ "instructions": machine.insns,
+ "wall_seconds": round(time.monotonic() - started, 4),
+ "objects": [
+ {"name": item["name"], "type": item["type"], "pa": f"{item['pa']:#x}",
+ "size": item["size"]}
+ for item in plan["layout"]
+ ],
+ "unknown_fields": plan["unknown_fields"],
+ "stubs": plan["stubs"],
+ "registers_entered": {k: f"{v:#x}" if isinstance(v, int) else v
+ for k, v in entered.items()},
+ "registers_exited": {k: f"{v:#x}" if isinstance(v, int) else v
+ for k, v in _registers(machine).items()},
+ "state_reads": state_reads,
+ "gate": gate,
+ "reads": _read_after(machine, plan["reads"]),
+ "limits": [
+ "Seeded run, not a boot: only the supplied objects/memory exist.",
+ "A fault is the first missing state observed, not a product defect.",
+ "No timing/cycle claim (L0/L1 fidelity, docs/06).",
+ "A field marked unknown was left at zero and reported.",
+ ],
+ }
+ if getattr(session.recorder, "dir", None) is not None:
+ session.note("state-seed", seed=plan["name"], status=status,
+ outcome=outcome, stop_pc=f"{stop_pc:#x}",
+ gate=(gate or {}).get("kind"))
+ report["session"] = session.recorder.dir
+ if out:
+ os.makedirs(out, exist_ok=True)
+ path = os.path.join(out, "state-seed-report.json")
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(report, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ report["report_json"] = os.path.abspath(path)
+ return report
+ finally:
+ session.close()
+
+
+# ---------------------------------------------------------------------------
+# Bundled seeds.
+#
+# 1. fecs_seed: the promote-validation predicate seeded through this loader
+# (the mechanism proof -- it works without RM objects). It reproduces the
+# valprobe variant memory/registers/stub exactly, so a slot non-NULL seed
+# must refuse 0x1f and a slot NULL seed must take the commit path.
+# 2. target_seed: the construct target 0x52a70ec with a minimal crafted
+# client/channel/graphics-context/memdesc set. The first gate is reported
+# (see docs/52); with the +0x600 callback stubbed it advances to the next.
+# ---------------------------------------------------------------------------
+
+
+def _fecs_seed(*, slot: int, label: str) -> dict:
+ scratch = 0x10000000
+ layout = {
+ "scratch": scratch,
+ "sp": scratch + 0x4000,
+ "s0": scratch + 0x5000,
+ "pgpu": scratch + 0x1000,
+ "getter_out": scratch + 0x1800,
+ "instance": scratch + 0x3800,
+ "entry": scratch + 0x6000,
+ }
+ slot_addr = layout["instance"] + 0x300 + 6 * 8
+ memory = [
+ {"pa": f"{layout['entry'] + 0x30:#x}", "u64": 0x1F4150000},
+ {"pa": f"{layout['entry'] + 0x38:#x}", "u64": 0x204608000},
+ {"pa": f"{layout['entry'] + 0x40:#x}", "u64": 0x10000},
+ {"pa": f"{layout['entry'] + 0x48:#x}", "u32": 4},
+ {"pa": f"{layout['entry'] + 0x4c:#x}", "u16": BUFFER_ID["FECS_EVENT"]},
+ {"pa": f"{layout['entry'] + 0x4e:#x}", "u8": 1},
+ {"pa": f"{layout['entry'] + 0x4f:#x}", "u8": 0},
+ {"pa": f"{layout['pgpu'] + 0x1be0:#x}", "u64": layout["instance"]},
+ {"pa": f"{layout['instance'] + 0x190:#x}", "u64": layout["instance"] + 0x300},
+ {"pa": f"{slot_addr:#x}", "u64": slot, "label": "fecs-slot"},
+ ]
+ return {
+ "format": SEED_FORMAT,
+ "name": label,
+ "entry": f"{PROMOTE_VALIDATION:#x}",
+ "outcomes": dict(OUTCOMES),
+ "registers": {
+ "s0": f"{layout['s0']:#x}",
+ "s2": f"{layout['pgpu']:#x}",
+ "s5": f"{layout['entry']:#x}",
+ "sp": f"{layout['sp']:#x}",
+ "a3": BUFFER_ID["FECS_EVENT"],
+ "t1": 0xC,
+ },
+ "stubs": [{"addr": f"{INSTANCE_GETTER:#x}", "return": f"{layout['getter_out']:#x}",
+ "name": "instance_getter"}],
+ "memory": memory,
+ "watch": [{"pa": f"{slot_addr:#x}", "size": 8, "label": "fecs-slot"}],
+ "reads": [{"pa": f"{slot_addr:#x}", "size": 8, "label": "fecs-slot-value"}],
+ "provenance": {
+ "source": "promote-validation predicate state (docs/47), rebuilt as a seed",
+ "slot": slot,
+ },
+ "notes": [
+ "Mechanism proof: a path that needs no RM objects, seeded end to end.",
+ ],
+ }
+
+
+def fecs_seed(*, slot: int = 1) -> dict:
+ """Build the FECS promote-validation seed (slot 1 refuses, slot 0 accepts)."""
+ label = "fecs-promote-slot-nonnull" if slot else "fecs-promote-slot-null"
+ return _fecs_seed(slot=slot, label=label)
+
+
+def target_seed(*, advance: bool = False) -> dict:
+ """Build the minimal client/channel/context seed for the 0x52a70ec target.
+
+ ``advance=False`` leaves the +0x600 callback unknown (the first gate).
+ ``advance=True`` points it at a return-0 stub so the next gate is reached.
+ """
+ callback = "@engine_stub" if advance else MISSING_MARKER
+ name = "construct-gate-advanced" if advance else "construct-gate-minimal"
+ return {
+ "format": SEED_FORMAT,
+ "name": name,
+ "entry": "0x52a70ec",
+ "allocator": {"base": "0x10010000", "align": "0x1000"},
+ "stubs": [{"name": "engine_stub", "return": 0}] if advance else [],
+ "registers": {
+ "a0": "@client",
+ "a1": "@channel",
+ "a2": "0x1003f000",
+ },
+ "stack": "0x1003e000",
+ "objects": [
+ {"name": "client", "type": "client", "size": "0x2000", "fields": {
+ "0x1be0": "@graphics_instance",
+ "0x1aa8": "@obj_1aa8",
+ "0x1cc8": "@obj_1cc8",
+ "0x1dd8": {"value": 0, "size": 8},
+ "0x1de8": "@engine_node",
+ "0x1e48": "@obj_1e48",
+ }},
+ {"name": "channel", "type": "channel", "size": "0x1000", "fields": {
+ "0xd28": {"value": 0, "size": 4},
+ "0xd01": {"value": 1, "size": 1},
+ "0xcd1": {"value": 0, "size": 1},
+ "0x898": MISSING_MARKER,
+ }},
+ {"name": "graphics_instance", "type": "graphics_instance", "size": "0x1000",
+ "fields": {
+ "0x190": "@memdesc_table",
+ "0x161": {"value": 0, "size": 1},
+ }},
+ {"name": "engine_node", "type": "rm_object", "size": "0x1000", "fields": {
+ "0x600": callback,
+ }},
+ {"name": "obj_1cc8", "type": "rm_object", "size": "0x400", "fields": {
+ "0x150": MISSING_MARKER,
+ "0x1d0": {"value": 0x20, "size": 4},
+ }},
+ {"name": "obj_1aa8", "type": "rm_object", "size": "0x400", "fields": {
+ "0x24c": MISSING_MARKER,
+ }},
+ {"name": "obj_1e48", "type": "rm_object", "size": "0x400", "fields": {
+ "0x110": MISSING_MARKER,
+ }},
+ {"name": "memdesc_table", "type": "rm_object", "size": "0x100", "fields": {}},
+ {"name": "fecs_memdesc", "type": "memdesc", "size": "0x100", "fields": {
+ "0x0": MISSING_MARKER,
+ "0x8": MISSING_MARKER,
+ }},
+ ],
+ "reads": [{"pa": "0x1003f000", "size": 8, "label": "a2-out-scratch"}],
+ "provenance": {
+ "source": "construct-path disassembly (docs/52, this wave)",
+ "callback": callback,
+ },
+ "notes": [
+ "Only the fields the known firmware path reads are seeded; a field marked",
+ "unknown is left zero and reported so the gate is not mistaken for a seed bug.",
+ ],
+ }
+
+
+def run_demo(out: str | None = None) -> dict:
+ """Prove the seeder on the FECS predicate, then attempt the construct target."""
+ if not os.path.isfile(DEFAULT_IMAGE):
+ raise StateSeedError(f"firmware image absent: {DEFAULT_IMAGE}")
+ profile = DEFAULT_PROFILE
+
+ fecs_nonnull = run_seed(fecs_seed(slot=1), profile_path=profile, record=False,
+ name="state-seed-fecs")
+ fecs_nonnull_again = run_seed(fecs_seed(slot=1), profile_path=profile, record=False,
+ name="state-seed-fecs")
+ fecs_null = run_seed(fecs_seed(slot=0), profile_path=profile, record=False,
+ name="state-seed-fecs")
+ target = run_seed(target_seed(advance=False), profile_path=profile, record=False,
+ name="state-seed-target")
+ advanced = run_seed(target_seed(advance=True), profile_path=profile, record=False,
+ name="state-seed-target")
+
+ verdicts = [
+ {"label": "FECS init slot non-NULL", "outcome": fecs_nonnull["outcome"],
+ "expect": "refuse-fecs-slot-exists-0x1f",
+ "pass": fecs_nonnull["outcome"] == "refuse-fecs-slot-exists-0x1f"},
+ {"label": "FECS init slot NULL", "outcome": fecs_null["outcome"],
+ "expect": "accept-commit-path",
+ "pass": fecs_null["outcome"] == "accept-commit-path"},
+ ]
+ deterministic = (
+ fecs_nonnull["outcome"] == fecs_nonnull_again["outcome"]
+ and fecs_nonnull["gate"] == fecs_nonnull_again["gate"]
+ )
+ result = {
+ "format": DEMO_FORMAT,
+ "ok": all(row["pass"] for row in verdicts) and deterministic,
+ "deterministic": deterministic,
+ "mechanism": {
+ "seed": "fecs-promote",
+ "verdicts": verdicts,
+ "wall_seconds": round(fecs_nonnull["wall_seconds"] + fecs_null["wall_seconds"], 4),
+ },
+ "target": {
+ "entry": "0x52a70ec",
+ "status": target["status"],
+ "stop_pc": target["stop_pc"],
+ "gate": target["gate"],
+ "state_reads": target["state_reads"],
+ "unknown_fields": target["unknown_fields"],
+ },
+ "target_advanced": {
+ "entry": "0x52a70ec",
+ "status": advanced["status"],
+ "stop_pc": advanced["stop_pc"],
+ "gate": advanced["gate"],
+ "state_reads": advanced["state_reads"],
+ },
+ }
+ if out:
+ os.makedirs(out, exist_ok=True)
+ path = os.path.join(out, "state-seed-demo.json")
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(result, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ result["artifacts"] = {"demo_json": os.path.abspath(path)}
+ return result
+
+
+def _load_seed_file(path: str) -> dict:
+ if not os.path.isfile(path):
+ raise StateSeedError(f"seed not found: {path}")
+ with open(path, "r", encoding="utf-8") as fh:
+ return json.load(fh)
+
+
+def _emit_seed(kind: str) -> dict:
+ if kind == "fecs":
+ return fecs_seed()
+ if kind == "target-minimal":
+ return target_seed(advance=False)
+ if kind == "target-advanced":
+ return target_seed(advance=True)
+ raise StateSeedError(f"unknown seed kind {kind!r}")
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="state_seed.py",
+ description=(
+ "Seed declarative RM object state into a bounded firmware run "
+ "(offline; never touches hardware)."
+ ),
+ )
+ sub = parser.add_subparsers(dest="command")
+ run_cmd = sub.add_parser("run", help="run one seed file")
+ run_cmd.add_argument("--seed", required=True, help="greeneye-state-seed/1 JSON")
+ run_cmd.add_argument("--entry", default=None, help="override the seed entry (hex)")
+ run_cmd.add_argument("--image", default=DEFAULT_IMAGE, help="firmware ELF")
+ run_cmd.add_argument("--profile", default=DEFAULT_PROFILE, help="device profile JSON")
+ run_cmd.add_argument("--budget", type=int, default=DEFAULT_BUDGET)
+ run_cmd.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT)
+ run_cmd.add_argument("--out", help="output directory (session + report JSON)")
+ run_cmd.add_argument("--no-record", action="store_true", help="skip session recording")
+ validate = sub.add_parser("validate", help="validate a seed and print its layout")
+ validate.add_argument("--seed", required=True)
+ emit = sub.add_parser("emit", help="write a bundled seed")
+ emit.add_argument("--kind", required=True,
+ choices=["fecs", "target-minimal", "target-advanced"])
+ emit.add_argument("--out", required=True)
+ demo = sub.add_parser("demo", help="FECS mechanism proof + construct-gate attempt")
+ demo.add_argument("--out", help="output directory")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ if args.command == "run":
+ seed = _load_seed_file(args.seed)
+ report = run_seed(seed, image=args.image, profile_path=args.profile or None,
+ entry=args.entry, budget=args.budget, timeout=args.timeout,
+ out=args.out, record=False if args.no_record else None)
+ summary = {k: report[k] for k in
+ ("format", "seed", "entry", "status", "outcome", "stop_pc",
+ "fault", "gate", "instructions", "wall_seconds",
+ "report_json", "session") if k in report}
+ print(json.dumps(summary, indent=2, sort_keys=True))
+ return 0
+ if args.command == "validate":
+ seed = _load_seed_file(args.seed)
+ plan = load_seed(seed)
+ print(json.dumps({
+ "format": plan["format"],
+ "name": plan["name"],
+ "entry": f"{plan['entry']:#x}",
+ "objects": [{"name": item["name"], "type": item["type"],
+ "pa": f"{item['pa']:#x}", "size": item["size"]}
+ for item in plan["layout"]],
+ "unknown_fields": plan["unknown_fields"],
+ "writes": len(plan["writes"]),
+ "stubs": plan["stubs"],
+ }, indent=2, sort_keys=True))
+ return 0
+ if args.command == "emit":
+ seed = _emit_seed(args.kind)
+ os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
+ with open(args.out, "w", encoding="utf-8") as fh:
+ json.dump(seed, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ print(os.path.abspath(args.out))
+ return 0
+ if args.command == "demo":
+ result = run_demo(args.out)
+ print(json.dumps({k: result[k] for k in
+ ("format", "ok", "deterministic", "mechanism",
+ "target", "target_advanced", "artifacts")
+ if k in result}, indent=2, sort_keys=True))
+ return 0 if result["ok"] else 3
+ parser.print_help()
+ return 2
+ except StateSeedError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/valprobe.py b/debug/risc-v-gsp-rm-vm/harness/valprobe.py
new file mode 100644
index 0000000..55952b9
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/valprobe.py
@@ -0,0 +1,651 @@
+#!/usr/bin/env python3
+"""Validation-predicate probe: run a firmware predicate branch with crafted state.
+
+Why this exists
+---------------
+The live GR campaign keeps burning supervised hardware cycles to ask the same
+question class: *which control/entry shapes does the 570.144 GSP-RM firmware
+accept or refuse, and why?* The answer lives in firmware branches that are
+buried behind host traffic (B4/B5) and RM object state, so a plain rehost of
+the full handler early-outs before the predicate. This harness instead runs
+one **predicate branch** of the real image with the minimum crafted state it
+needs: registers, memory, and (where a branch calls out to a lock/table walk
+that is not the predicate) one recorded inline stub. Each run stops on a
+labelled outcome address, so the verdict is the branch the firmware took, not
+an inference.
+
+It is deliberately not a boot and not a model: the report carries the exact
+entry, seeded state, stubs and outcome map, and every limitation in `limits`.
+Fidelity is L0/L1 (docs/06); no timing claim.
+
+Bundled target: ``sweep --target fecs-promote`` reproduces the live V6 FECS
+matrix (`PM/PATCH/PRIV/UNR accepted, FECS init refused 0x1f, FECS VA-only
+accepted`) and evaluates the untested shapes offline, including the
+sysmem-aperture FECS-init variant the handoff proposed paying a hardware cycle
+for.
+
+Offline only: never touches a GPU, kernel module, mdev, guest or network.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import struct
+import sys
+import time
+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.elf import load_elf # noqa: E402
+from greeneye.errors import GreenEyeError # noqa: E402
+from greeneye.sim_backend import launch_native # noqa: E402
+from sim.devices import load_profile, registry_from_profile # noqa: E402
+from sim.gsp_boot import _apply_boot_shim, _run_with_milestones, _rung_from_run # noqa: E402
+from sim.libos import LibosSyscalls # noqa: E402
+from sim.machine import R_BP, R_BUDGET, R_ECALL, R_FAULT # noqa: E402
+
+from harness.rehost import ( # noqa: E402
+ DEFAULT_IMAGE,
+ DEFAULT_PROFILE,
+ RUN_CHUNK,
+ _parse_u64,
+ _sha256_file,
+ parse_hex_bytes,
+)
+
+#: Schema tag of a single probe run.
+PROBE_FORMAT = "greeneye-valprobe/1"
+#: Schema tag of a probe spec.
+SPEC_FORMAT = "greeneye-valprobe-spec/1"
+#: Schema tag of a matrix run (a spec with variants).
+MATRIX_FORMAT = "greeneye-valprobe-matrix/1"
+#: Schema tag of the bundled demo.
+DEMO_FORMAT = "greeneye-valprobe-demo/1"
+
+DEFAULT_BUDGET = 50_000
+DEFAULT_TIMEOUT = 20.0
+BOOT_INSTRUCTIONS = 2_000_000
+EBREAK = 0x00100073
+
+REGISTERS = {
+ "zero": 0, "ra": 1, "sp": 2, "gp": 3, "tp": 4,
+ "t0": 5, "t1": 6, "t2": 7, "s0": 8, "fp": 8, "s1": 9,
+ "a0": 10, "a1": 11, "a2": 12, "a3": 13, "a4": 14, "a5": 15,
+ "a6": 16, "a7": 17, "s2": 18, "s3": 19, "s4": 20, "s5": 21,
+ "s6": 22, "s7": 23, "s8": 24, "s9": 25, "s10": 26, "s11": 27,
+ "t3": 28, "t4": 29, "t5": 30, "t6": 31,
+}
+for _i in range(32):
+ REGISTERS.setdefault(f"x{_i}", _i)
+
+RUNG_RANK = {"B0": 0, "B1": 1, "B2": 2, "B3": 3}
+
+
+class ValprobeError(GreenEyeError):
+ """A probe spec, image or scenario is invalid."""
+
+
+def reg_index(name: str) -> int:
+ key = str(name).strip().lower()
+ if key not in REGISTERS:
+ raise ValprobeError(f"unknown register {name!r}")
+ return REGISTERS[key]
+
+
+def encode_return_stub(value: int) -> bytes:
+ """Encode ``lui a0,hi; addi a0,a0,lo; jalr zero,0(ra)`` (RV64IMAC)."""
+ value &= 0xFFFF_FFFF_FFFF_FFFF
+ hi = (value + 0x800) >> 12
+ lo = value - (hi << 12)
+ lui = ((hi & 0xF_FFFF) << 12) | (10 << 7) | 0x37
+ addi = ((lo & 0xFFF) << 20) | (10 << 15) | (10 << 7) | 0x13
+ ret = 0x0000_8067
+ return struct.pack("<III", lui, addi, ret)
+
+
+def decode_memory_spec(item: dict) -> tuple[int, bytes]:
+ """Parse one memory item: ``{addr, hex|u64|u32|u8}``."""
+ if "addr" not in item:
+ raise ValprobeError(f"memory item needs addr: {item!r}")
+ addr = _parse_u64(item["addr"])
+ for key, fmt, size in (("hex", None, None), ("u64", "<Q", 8), ("u32", "<I", 4),
+ ("u16", "<H", 2), ("u8", "<B", 1)):
+ if key in item:
+ if key == "hex":
+ return addr, parse_hex_bytes(str(item["hex"]))
+ return addr, struct.pack(fmt, _parse_u64(item[key]) & ((1 << (8 * size)) - 1))
+ raise ValprobeError(f"memory item needs one of hex/u64/u32/u16/u8: {item!r}")
+
+
+def _load_spec(path: str) -> dict:
+ if not os.path.isfile(path):
+ raise ValprobeError(f"spec not found: {path}")
+ with open(path, "r", encoding="utf-8") as fh:
+ spec = json.load(fh)
+ fmt = spec.get("format", SPEC_FORMAT)
+ if fmt != SPEC_FORMAT:
+ raise ValprobeError(f"unsupported spec format {fmt!r} (want {SPEC_FORMAT})")
+ if not spec.get("variants"):
+ raise ValprobeError("spec has no variants")
+ return spec
+
+
+def _apply_state(machine, *, registers: dict, memory, stubs, default_sp: int) -> list[dict]:
+ """Write stubs, memory and registers; return the applied stub notes."""
+ stub_notes = []
+ for stub in stubs or ():
+ addr = _parse_u64(stub["addr"])
+ if "return" not in stub:
+ raise ValprobeError(f"stub needs return: {stub!r}")
+ code = encode_return_stub(_parse_u64(stub["return"]))
+ machine.write_mem(addr, code)
+ stub_notes.append({"addr": f"{addr:#x}", "return": f"{_parse_u64(stub['return']):#x}",
+ "code_hex": code.hex()})
+ for item in memory or ():
+ addr, data = decode_memory_spec(item)
+ machine.write_mem(addr, data)
+ for name, value in (registers or {}).items():
+ machine.x[reg_index(name)] = _parse_u64(value)
+ if machine.x[2] == 0:
+ machine.x[2] = default_sp
+ return stub_notes
+
+
+def _read_outcomes(machine, reads) -> list[dict]:
+ out = []
+ for item in reads or ():
+ addr = _parse_u64(item["addr"])
+ size = int(item.get("size", 8))
+ data = bytes(machine.read_mem(addr, size))
+ record = {"addr": f"{addr:#x}", "size": size, "hex": data.hex()}
+ if item.get("label"):
+ record["label"] = item["label"]
+ if size == 8:
+ record["u64"] = int.from_bytes(data, "little")
+ elif size == 4:
+ record["u32"] = int.from_bytes(data, "little")
+ out.append(record)
+ return out
+
+
+def run_probe(
+ *,
+ image: str = DEFAULT_IMAGE,
+ profile_path: str | None = DEFAULT_PROFILE,
+ spec: dict,
+ variant: dict,
+ out: str | None = None,
+ record: bool | None = None,
+ name: str = "valprobe",
+) -> dict:
+ """Run one variant of a probe spec in a fresh machine; return its report."""
+ if not os.path.isfile(image):
+ raise ValprobeError(f"image not found: {image}")
+ image_obj = load_elf(image)
+ if not image_obj.is_riscv:
+ raise ValprobeError(f"{image}: not a RISC-V ELF image")
+
+ profile = load_profile(profile_path) if profile_path else None
+ load = (profile or {}).get("load", {})
+ ram_base = _parse_u64(load.get("ram_base", 0))
+ memory_mb = int(load.get("memory_mb", 256))
+ ram_top = ram_base + memory_mb * 1024 * 1024
+
+ entry = _parse_u64(variant.get("entry", spec.get("target")))
+ outcomes = {_parse_u64(a): label for a, label in
+ (variant.get("outcomes") or spec.get("outcomes") or {}).items()}
+ if not outcomes:
+ raise ValprobeError("probe needs at least one outcome address")
+
+ budget = int(variant.get("budget", spec.get("budget", DEFAULT_BUDGET)))
+ timeout = float(variant.get("timeout", spec.get("timeout", DEFAULT_TIMEOUT)))
+ label = variant.get("label") or f"variant-{entry:#x}"
+ boot_to = spec.get("boot_to") or variant.get("boot_to")
+
+ if record is None:
+ record = bool(out)
+ session = launch_native(
+ kernel=image,
+ memory_mb=memory_mb,
+ ram_base=ram_base,
+ record=record,
+ name=f"{name}-{label}"[:80],
+ device_map=None,
+ unknown_mmio="lenient",
+ bias=0,
+ )
+ machine = session.target.machine
+ shim_meta: dict[str, Any] = {"enabled": False, "assumptions": []}
+ libos = LibosSyscalls()
+ boot: dict | None = None
+ try:
+ if profile is not None:
+ machine.devices = registry_from_profile(profile, machine=machine)
+ machine.translator = getattr(machine.devices, "dma", None)
+ machine.syscall_handler = libos.handle
+ shim = dict(profile.get("boot_shim", {}))
+ shim["_image_path"] = image
+ _apply_boot_shim(machine, shim, image_obj, shim_meta)
+ shim_meta["enabled"] = True
+
+ if boot_to:
+ milestones: list[str] = []
+ reason, pc, _a, _b = _run_with_milestones(machine, BOOT_INSTRUCTIONS, milestones)
+ rung = _rung_from_run(machine, shim_meta, milestones, libos)
+ boot = {
+ "requested": boot_to,
+ "rung": rung,
+ "instructions": machine.insns,
+ "stop_pc": f"{pc:#x}",
+ "milestones": milestones,
+ "reached": RUNG_RANK.get(rung, -1) >= RUNG_RANK.get(str(boot_to), 99),
+ }
+
+ sentinel = (ram_top - 0x100) & ~0xF
+ default_sp = (ram_top - 0x8000) & ~0xF
+ machine.write_mem(sentinel, EBREAK.to_bytes(4, "little"))
+ breakpoints = set(outcomes)
+ breakpoints.add(sentinel)
+
+ registers = dict(spec.get("registers") or {})
+ registers.update(variant.get("registers") or {})
+ registers.setdefault("ra", sentinel)
+ memory = list(spec.get("memory") or ()) + list(variant.get("memory") or ())
+ stubs = list(spec.get("stubs") or ()) + list(variant.get("stubs") or ())
+ stubs_applied = _apply_state(
+ machine, registers=registers, memory=memory, stubs=stubs, default_sp=default_sp
+ )
+ machine.pc = entry
+
+ entered = {f"x{i}": machine.x[i] for i in range(32) if i and machine.x[i]}
+ entered["pc"] = entry
+ started = time.monotonic()
+ status = "budget"
+ stop_pc = entry
+ fault: dict | None = None
+ outcome: str | None = None
+ remaining = budget
+ while remaining > 0:
+ if time.monotonic() - started > timeout:
+ status = "timeout"
+ break
+ chunk = min(RUN_CHUNK, remaining)
+ reason, stop_pc, a, b = machine.run(chunk, breakpoints=breakpoints)
+ remaining -= chunk
+ if reason == R_BP:
+ if stop_pc == sentinel:
+ status = "return"
+ break
+ outcome = outcomes.get(stop_pc, "unlabelled")
+ status = "outcome"
+ break
+ if reason == R_FAULT:
+ status = "fault"
+ fault = {"cause": a, "tval": f"{b:#x}"}
+ break
+ if reason == R_ECALL:
+ handled, result = (machine.syscall_handler(machine, stop_pc)
+ if machine.syscall_handler else (False, None))
+ if not handled:
+ status = "ecall"
+ break
+ if result is not None:
+ machine.x[10] = result & 0xFFFF_FFFF_FFFF_FFFF
+ continue
+ if reason == R_BUDGET:
+ status = "budget"
+
+ report: dict[str, Any] = {
+ "format": PROBE_FORMAT,
+ "probe": spec.get("name"),
+ "label": label,
+ "image": os.path.abspath(image),
+ "image_sha256": _sha256_file(image),
+ "profile": os.path.basename(profile_path) if profile_path else None,
+ "entry": f"{entry:#x}",
+ "status": status,
+ "outcome": outcome,
+ "expect": variant.get("expect"),
+ "pass": (None if variant.get("expect") is None else outcome == variant.get("expect")),
+ "stop_pc": f"{stop_pc:#x}",
+ "fault": fault,
+ "retval": machine.x[10],
+ "instructions": machine.insns,
+ "wall_seconds": round(time.monotonic() - started, 4),
+ "boot": boot,
+ "stubs": stubs_applied,
+ "registers_entered": {k: f"{v:#x}" if isinstance(v, int) else v
+ for k, v in entered.items()},
+ "reads": _read_outcomes(machine, variant.get("reads") or spec.get("reads")),
+ "limits": [
+ "Predicate-branch probe, not a boot: only the seeded state exists.",
+ "Stubs (if any) replace an out-of-predicate dependency; they are listed.",
+ "No timing/cycle claim (L0/L1 fidelity, docs/06).",
+ "A fault is the first missing state, not a product defect.",
+ ],
+ }
+ if getattr(session.recorder, "dir", None) is not None:
+ session.note("valprobe", label=label, outcome=outcome, status=status)
+ report["session"] = session.recorder.dir
+ return report
+ finally:
+ session.close()
+
+
+def run_spec(
+ *,
+ spec: dict,
+ image: str | None = None,
+ profile_path: str | None = None,
+ out: str | None = None,
+ record: bool | None = None,
+ name: str | None = None,
+) -> dict:
+ """Run every variant of a spec; return a matrix report."""
+ image = image or spec.get("image") or DEFAULT_IMAGE
+ profile_path = profile_path or spec.get("profile") or DEFAULT_PROFILE
+ started = time.monotonic()
+ rows = []
+ for variant in spec["variants"]:
+ rows.append(run_probe(
+ image=image, profile_path=profile_path, spec=spec, variant=variant,
+ out=out, record=record, name=name or (spec.get("name") or "valprobe"),
+ ))
+ matrix: dict[str, Any] = {
+ "format": MATRIX_FORMAT,
+ "spec": spec.get("name"),
+ "spec_format": spec.get("format"),
+ "image": os.path.abspath(image),
+ "image_sha256": _sha256_file(image),
+ "target": spec.get("target"),
+ "boot_to": spec.get("boot_to"),
+ "variants": rows,
+ "passed": sum(1 for r in rows if r["pass"] is True),
+ "failed": sum(1 for r in rows if r["pass"] is False),
+ "evaluated": sum(1 for r in rows if r["pass"] is not None),
+ "wall_seconds": round(time.monotonic() - started, 4),
+ }
+ if out:
+ os.makedirs(out, exist_ok=True)
+ path = os.path.join(out, "valprobe-matrix.json")
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(matrix, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ matrix["report_json"] = os.path.abspath(path)
+ return matrix
+
+
+# ---------------------------------------------------------------------------
+# Bundled target: the GR PROMOTE_CTX FECS predicate (570.144 firmware).
+#
+# Firmware facts used (all verified by disassembly of the shipped image):
+# * per-buffer validation branch point: 0x5290dd4 (in FUN_ram_05290a48)
+# * bInitialize=1 and bufferId==9 -> FECS branch at 0x5290e3c
+# * FECS branch: instance = pGpu[(idx+0x37c)], idx = *(u32*)(getter+0xD28);
+# table = *(u64*)(instance+0x190); slot = table[mapped(bufferId)*8];
+# refuse (0x1f) iff slot != 0 -- regardless of physAttr/size/VA
+# * bufferId -> slot index via the firmware's own 0x531b944 jump table
+# (FECS => 6)
+# * bInitialize=0: bNonmapped!=0 => 0x1f, else accept
+#
+# The instance-index getter (0x524a5b8, a lock/table walk outside the
+# predicate) is stubbed with a recorded constant return so the branch can run
+# with crafted state; the predicate itself, the mapping table call and the
+# slot read are the firmware's own code.
+# ---------------------------------------------------------------------------
+
+PROMOTE_VALIDATION = 0x5290DD4
+FECS_INIT_BRANCH = 0x5290E3C
+INSTANCE_GETTER = 0x524A5B8
+OUTCOMES = {
+ "0x5290cd0": "accept-commit-path",
+ "0x5290ce4": "accept-skip-path",
+ "0x5290df8": "refuse-invalid-argument-0x1f",
+ "0x5290ebc": "refuse-fecs-slot-exists-0x1f",
+}
+
+#: Buffer ids (NV2080_CTRL_GPU_PROMOTE_CTX_BUFFER_ID_*), 570.144 interface order.
+BUFFER_ID = {
+ "MAIN": 0, "PM": 1, "PATCH": 2, "BUNDLE_CB": 3, "PAGEPOOL": 4,
+ "ATTRIBUTE_CB": 5, "RTV_CB_GLOBAL": 6, "GFXP_POOL": 7, "GFXP_CTRL_BLK": 8,
+ "FECS_EVENT": 9, "PRIV_ACCESS_MAP": 10, "UNRESTRICTED_PRIV_ACCESS_MAP": 11,
+ "GLOBAL_PRIV_ACCESS_MAP": 12,
+}
+
+
+#: Scratch base for crafted predicate state. Must be encodable by the
+#: ``lui+addi`` return stub (below 2^31), inside RAM, outside the image
+#: (max vaddr ~0x5bb9000), outside the 0x0..0x8000000 DMA window and below
+#: the 0x100000000 DMEM aperture the firmware uses after relocation.
+FECS_SCRATCH_BASE = 0x10000000
+
+
+def _fecs_layout() -> dict:
+ """Deterministic scratch layout (see module doc)."""
+ scratch = FECS_SCRATCH_BASE
+ return {
+ "scratch": scratch,
+ "sp": scratch + 0x4000,
+ "s0": scratch + 0x5000,
+ "pgpu": scratch + 0x1000,
+ "getter_out": scratch + 0x1800,
+ "instance": scratch + 0x3800,
+ "entry": scratch + 0x6000,
+ }
+
+
+def _fecs_variant(layout: dict, *, label: str, buffer_id: int, b_initialize: int,
+ b_nonmapped: int, phys_attr: int = 4, size: int = 0x10000,
+ phys: int = 0x1F4150000, virt: int = 0x204608000,
+ slot: int | None = None, expect: str | None = None) -> dict:
+ lay = layout
+ entry = lay["entry"]
+ # table = *(u64*)(instance+0x190) = instance+0x300; FECS maps to slot 6.
+ slot_addr = lay["instance"] + 0x300 + 6 * 8
+ memory = [
+ {"addr": f"{entry + 0x30:#x}", "u64": b_initialize and phys or 0},
+ {"addr": f"{entry + 0x38:#x}", "u64": virt},
+ {"addr": f"{entry + 0x40:#x}", "u64": size},
+ {"addr": f"{entry + 0x48:#x}", "u32": phys_attr},
+ {"addr": f"{entry + 0x4c:#x}", "u16": buffer_id},
+ {"addr": f"{entry + 0x4e:#x}", "u8": b_initialize},
+ {"addr": f"{entry + 0x4f:#x}", "u8": b_nonmapped},
+ # pGpu -> instance pointer; getter result -> idx 0; instance -> table.
+ {"addr": f"{lay['pgpu'] + 0x1be0:#x}", "u64": lay["instance"]},
+ {"addr": f"{lay['instance'] + 0x190:#x}", "u64": lay["instance"] + 0x300},
+ ]
+ if slot is not None:
+ memory.append({"addr": f"{slot_addr:#x}", "u64": slot})
+ return {
+ "label": label,
+ "registers": {
+ "s0": f"{lay['s0']:#x}",
+ "s2": f"{lay['pgpu']:#x}",
+ "s5": f"{entry:#x}",
+ "sp": f"{lay['sp']:#x}",
+ "a3": buffer_id,
+ "t1": 0xC, # loop bound set by the function before our entry point
+ },
+ "stubs": [{"addr": f"{INSTANCE_GETTER:#x}", "return": f"{lay['getter_out']:#x}"}],
+ "memory": memory,
+ "reads": [{"addr": f"{lay['s0'] - 0x1c8:#x}", "size": 4,
+ "label": "firmware-mapped-slot-index"},
+ {"addr": f"{slot_addr:#x}", "size": 8, "label": "fecs-slot-value"}],
+ "expect": expect,
+ }
+
+
+def fecs_spec(*, image: str = DEFAULT_IMAGE, profile_path: str = DEFAULT_PROFILE) -> dict:
+ """Build the FECS promote-validation matrix spec (bundled target)."""
+ layout = _fecs_layout()
+ variants = [
+ # Live m3j: FECS init (PA) refused -- slot already populated.
+ _fecs_variant(layout, label="FECS init vidmem, slot non-NULL",
+ buffer_id=9, b_initialize=1, b_nonmapped=0, slot=1,
+ expect="refuse-fecs-slot-exists-0x1f"),
+ # Predicate inverse: the same shape would be accepted on an empty slot.
+ _fecs_variant(layout, label="FECS init vidmem, slot NULL",
+ buffer_id=9, b_initialize=1, b_nonmapped=0, slot=0,
+ expect="accept-commit-path"),
+ # The handoff's untested variant (a): does a sysmem aperture change it?
+ _fecs_variant(layout, label="FECS init sysmem attr 0x6, slot non-NULL",
+ buffer_id=9, b_initialize=1, b_nonmapped=0, phys_attr=6, slot=1,
+ expect="refuse-fecs-slot-exists-0x1f"),
+ _fecs_variant(layout, label="FECS init sysmem attr 0x6, slot NULL",
+ buffer_id=9, b_initialize=1, b_nonmapped=0, phys_attr=6, slot=0,
+ expect="accept-commit-path"),
+ # Size/PA content must not matter to the predicate.
+ _fecs_variant(layout, label="FECS init size 0x1000, slot non-NULL",
+ buffer_id=9, b_initialize=1, b_nonmapped=0, size=0x1000, slot=1,
+ expect="refuse-fecs-slot-exists-0x1f"),
+ # Live m7: FECS VA-only accepted (bInitialize=0 path).
+ _fecs_variant(layout, label="FECS no-init mapped, slot non-NULL",
+ buffer_id=9, b_initialize=0, b_nonmapped=0, slot=1,
+ expect="accept-skip-path"),
+ _fecs_variant(layout, label="FECS no-init nonmapped (bookkeeping refusal)",
+ buffer_id=9, b_initialize=0, b_nonmapped=1, slot=1,
+ expect="refuse-invalid-argument-0x1f"),
+ # Live m3j: PM and PATCH init accepted.
+ _fecs_variant(layout, label="PM init",
+ buffer_id=1, b_initialize=1, b_nonmapped=0,
+ expect="accept-commit-path"),
+ _fecs_variant(layout, label="PATCH init",
+ buffer_id=2, b_initialize=1, b_nonmapped=0,
+ expect="accept-commit-path"),
+ _fecs_variant(layout, label="GFXP_CTRL_BLK init (id 8)",
+ buffer_id=8, b_initialize=1, b_nonmapped=0,
+ expect="accept-skip-path"),
+ _fecs_variant(layout, label="BUNDLE_CB init (id 3)",
+ buffer_id=3, b_initialize=1, b_nonmapped=0,
+ expect="refuse-invalid-argument-0x1f"),
+ _fecs_variant(layout, label="PRIV_ACCESS_MAP init (id 10)",
+ buffer_id=10, b_initialize=1, b_nonmapped=0,
+ expect="accept-skip-path"),
+ _fecs_variant(layout, label="UNRESTRICTED_PRIV init (id 11)",
+ buffer_id=11, b_initialize=1, b_nonmapped=0,
+ expect="accept-skip-path"),
+ _fecs_variant(layout, label="GLOBAL_PRIV_ACCESS_MAP init (id 12)",
+ buffer_id=12, b_initialize=1, b_nonmapped=0,
+ expect="refuse-invalid-argument-0x1f"),
+ ]
+ # id > 0xc is refused one block earlier (0x5290cc4, still in the per-entry
+ # loop); enter there with the loop's a4/t1 state.
+ unknown_id = _fecs_variant(layout, label="unknown buffer id 13",
+ buffer_id=13, b_initialize=1, b_nonmapped=0,
+ expect="refuse-invalid-argument-0x1f")
+ unknown_id["entry"] = "0x5290cc0"
+ unknown_id["registers"]["a4"] = 13
+ variants.append(unknown_id)
+ return {
+ "format": SPEC_FORMAT,
+ "name": "fecs-promote-validation",
+ "target": f"{PROMOTE_VALIDATION:#x}",
+ "outcomes": OUTCOMES,
+ "budget": 20_000,
+ "timeout": 20.0,
+ "image": image,
+ "profile": profile_path,
+ "note": (
+ "570.144 per-buffer PROMOTE_CTX validation; instance-index getter "
+ f"({INSTANCE_GETTER:#x}, lock/table walk outside the predicate) stubbed "
+ "to a recorded constant; the predicate, the id->slot mapping call and "
+ "the memDesc slot read are the firmware's own code."
+ ),
+ "variants": variants,
+ }
+
+
+def run_demo(out: str | None = None) -> dict:
+ """Run the bundled FECS matrix on the real image, twice for determinism."""
+ spec = fecs_spec()
+ first = run_spec(spec=spec, out=out, record=False, name="valprobe-fecs")
+ second = run_spec(spec=spec, record=False, name="valprobe-fecs")
+ verdicts_a = [(r["label"], r["outcome"]) for r in first["variants"]]
+ verdicts_b = [(r["label"], r["outcome"]) for r in second["variants"]]
+ result = {
+ "format": DEMO_FORMAT,
+ "ok": first["failed"] == 0 and verdicts_a == verdicts_b,
+ "evaluated": first["evaluated"],
+ "failed": first["failed"],
+ "deterministic": verdicts_a == verdicts_b,
+ "verdicts": [{"label": label, "outcome": outcome} for label, outcome in verdicts_a],
+ }
+ if out:
+ os.makedirs(out, exist_ok=True)
+ path = os.path.join(out, "valprobe-demo.json")
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(result, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ result["artifacts"] = {"demo_json": os.path.abspath(path)}
+ return result
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="valprobe.py",
+ description=(
+ "Run one firmware validation-predicate branch with crafted state "
+ "(offline; never touches hardware)."
+ ),
+ )
+ sub = parser.add_subparsers(dest="command")
+ sweep = sub.add_parser("sweep", help="run a bundled target matrix")
+ sweep.add_argument("--target", default="fecs-promote", choices=["fecs-promote"])
+ sweep.add_argument("--image", default=None)
+ sweep.add_argument("--profile", default=None)
+ sweep.add_argument("--boot-to", default=None, help="boot the image to a rung first (e.g. B3)")
+ sweep.add_argument("--out", help="output directory (matrix JSON + sessions)")
+ sweep.add_argument("--no-record", action="store_true", help="skip session recording")
+ run = sub.add_parser("run", help="run a probe spec file")
+ run.add_argument("--spec", required=True)
+ run.add_argument("--image", default=None)
+ run.add_argument("--profile", default=None)
+ run.add_argument("--out", help="output directory (matrix JSON + sessions)")
+ run.add_argument("--no-record", action="store_true", help="skip session recording")
+ demo = sub.add_parser("demo", help="bundled FECS matrix, run twice (determinism)")
+ demo.add_argument("--out", help="output directory")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ if args.command == "sweep":
+ spec = fecs_spec(image=args.image or DEFAULT_IMAGE,
+ profile_path=args.profile or DEFAULT_PROFILE)
+ if args.boot_to:
+ spec["boot_to"] = args.boot_to
+ matrix = run_spec(spec=spec, image=args.image, profile_path=args.profile,
+ out=args.out, record=False if args.no_record else None)
+ elif args.command == "run":
+ spec = _load_spec(args.spec)
+ matrix = run_spec(spec=spec, image=args.image, profile_path=args.profile,
+ out=args.out, record=False if args.no_record else None)
+ elif args.command == "demo":
+ matrix = run_demo(args.out)
+ else:
+ parser.print_help()
+ return 2
+ except ValprobeError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+ if matrix.get("format") == MATRIX_FORMAT:
+ for row in matrix["variants"]:
+ mark = {True: "PASS", False: "FAIL", None: "----"}[row["pass"]]
+ print(f"{mark} {row['label']:<48} {row['outcome'] or row['status']}")
+ print(json.dumps({k: matrix[k] for k in
+ ("format", "spec", "evaluated", "passed", "failed",
+ "wall_seconds", "report_json") if k in matrix},
+ indent=2, sort_keys=True))
+ return 3 if matrix["failed"] else 0
+ print(json.dumps(matrix, indent=2, sort_keys=True))
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())