summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm/harness/port_new_device.py
diff options
context:
space:
mode:
Diffstat (limited to 'debug/risc-v-gsp-rm-vm/harness/port_new_device.py')
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/port_new_device.py726
1 files changed, 726 insertions, 0 deletions
diff --git a/debug/risc-v-gsp-rm-vm/harness/port_new_device.py b/debug/risc-v-gsp-rm-vm/harness/port_new_device.py
new file mode 100644
index 0000000..56522ec
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/port_new_device.py
@@ -0,0 +1,726 @@
+#!/usr/bin/env python3
+"""New-device port scaffold (roadmap H4.1/R6, docs/07 P2, docs/22 §3, docs/28).
+
+``--device <name>`` creates or uses a profile JSON holding the user-supplied
+device facts (PCI IDs, BARs, FB geometry, engine inventory, interrupt topology,
+ROM/VBIOS notes) and then drives, on the local QEMU backend only:
+
+1. probe-first-touch on the device's firmware (first unmapped access, bounded);
+2. a symbol inventory (ELF .symtab + optional Ghidra/green-eye DB sources);
+3. a portability matrix run of the device against a reference firmware;
+4. a port checklist report against the docs/07 evidence checklist.
+
+``--dry-run`` is the default: it prints the plan (profile action, missing
+profile fields, steps, expected artifacts) and exits 0 without creating files.
+``--run`` writes an evidence bundle under ``--out`` with a sha256 manifest.
+The RM 610 gate from ``version_diff.py`` (docs/23) is enforced before any file
+is opened, in dry-run too.
+
+This tool never touches hardware: no /dev, no kernel module, no mdev, no
+guest, no physical GPU. Physical validation is a separate supervised step
+(docs/06-portability/hardware-software-matrix.md §5.1).
+
+Run from the tool root:
+
+ python3 harness/port_new_device.py --device rtx4090 --profile profiles/rtx4090.json
+ python3 harness/port_new_device.py --run --device rtx4090 \\
+ --firmware ../../firmware/57X-GSP/570.144/gsp-570.144.fwimage.bin \\
+ --reference firmware/uproc_demo/build/uproc_demo.elf --out dumps/port-rtx4090
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+from dataclasses import dataclass
+
+try:
+ from .common import (
+ MAX_LOG_BYTES,
+ SymbolDB,
+ ensure_dir,
+ qemu_available,
+ sha256_file,
+ write_json,
+ write_text,
+ )
+ from .portability_matrix import (
+ MatrixOptions,
+ TargetSpec,
+ render_markdown as render_matrix_markdown,
+ run_matrix,
+ )
+ from .version_diff import (
+ RM610_TOKEN,
+ Rm610GateError,
+ VersionDiffOptions,
+ check_rm610_gate,
+ load_any_target,
+ load_symbol_sources,
+ run_probe,
+ )
+except ImportError: # direct script execution (python3 harness/port_new_device.py)
+ from common import ( # type: ignore[no-redef]
+ MAX_LOG_BYTES,
+ SymbolDB,
+ ensure_dir,
+ qemu_available,
+ sha256_file,
+ write_json,
+ write_text,
+ )
+ from portability_matrix import ( # type: ignore[no-redef]
+ MatrixOptions,
+ TargetSpec,
+ render_markdown as render_matrix_markdown,
+ run_matrix,
+ )
+ from version_diff import ( # type: ignore[no-redef]
+ RM610_TOKEN,
+ Rm610GateError,
+ VersionDiffOptions,
+ check_rm610_gate,
+ load_any_target,
+ load_symbol_sources,
+ run_probe,
+ )
+
+FORMAT = "greeneye-device-port/1"
+PLAN_FORMAT = "greeneye-device-port-plan/1"
+PROFILE_FORMAT = "greeneye-device-profile/1"
+BUNDLE_FORMAT = "greeneye-release-bundle/1"
+DEVICE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
+
+#: Where each user-supplied profile section comes from (docs/22 §3.1).
+PROFILE_HELP = {
+ "pci": "lspci / vendor docs, config/hardware/*.yaml is a design target only",
+ "bars": "guest lspci -vvv, host sysfs",
+ "fb": "host oracle: rmtool mem stats --format=json",
+ "engines": "host oracle: rmtool engine list --format=json; validate per driver profile",
+ "interrupts": "MSI-X tables, guest lspci -vvv",
+ "rom": "ROM/VBIOS read; laptop/Surface overrides",
+ "firmware": "extracted GSP .fwimage or re-hosted ELF (user-supplied; never downloaded)",
+ "pairing": "verify image family + FMC manifest + version string before debugging (docs/07 P2.2)",
+ "physical": "supervised physical validation only; this tool is simulator-only (docs/22 §3.5)",
+}
+
+
+@dataclass
+class DeviceOptions:
+ device: str
+ profile: str | None = None
+ firmware: str | None = None
+ reference: str | None = None
+ symbols: str | None = None
+ ghidra_exports: str | None = None
+ ghidra_bias: int = 0
+ out: str | None = None
+ run: bool = False
+ approval_token: str | None = None
+ probe_instructions: int = 2000
+ probe_duration: float = 0.5
+ scenario_duration: float = 0.4
+ max_stops: int = 5000
+ max_rows: int = 40
+ step_timeout: float = 600.0
+ log_bytes: int = MAX_LOG_BYTES
+
+ @property
+ def dry_run(self) -> bool:
+ return not self.run
+
+
+# ---------------------------------------------------------------------------
+# profile
+# ---------------------------------------------------------------------------
+
+
+def scaffold_profile(name: str) -> dict:
+ """Empty user-supplied profile; every field documents where it comes from."""
+ return {
+ "format": PROFILE_FORMAT,
+ "name": name,
+ "vendor": "",
+ "device": "",
+ "pci": {"vendor_id": "0x10de", "device_id": "", "subsystem_vendor_id": "",
+ "subsystem_device_id": ""},
+ "bars": [],
+ "fb": {"vram_mib": None, "bar1_mib": None, "ecc": None, "notes": ""},
+ "engines": [],
+ "interrupts": {"msix_vectors": None, "notes": ""},
+ "rom": {"vblos_sha256": "", "notes": ""},
+ "firmware": "",
+ "firmware_sha256": "",
+ "firmware_family": "",
+ "firmware_version": "",
+ "reference": "",
+ "physical_validation": {"status": "pending", "date": "", "by": "", "evidence": "", "notes": ""},
+ "notes": "",
+ "where_to_fill": dict(PROFILE_HELP),
+ "references": [
+ "docs/07-PORTABILITY-PLAYBOOKS.md P2",
+ "docs/22-PORTING-CUDA-AND-DEVICES.md 3",
+ "docs/06-portability/hardware-software-matrix.md 5.1",
+ ],
+ }
+
+
+def load_profile(path: str) -> tuple[dict, bool]:
+ """Return ``(profile, created)``; an unreadable profile is a clean error."""
+ if not os.path.isfile(path):
+ return {}, False
+ with open(path, "r", encoding="utf-8") as fh:
+ data = json.load(fh)
+ if not isinstance(data, dict):
+ raise ValueError(f"profile {path}: expected a JSON object")
+ return data, True
+
+
+def profile_missing_fields(profile: dict) -> list[str]:
+ missing: list[str] = []
+ pci = profile.get("pci") or {}
+ if not any(str(pci.get(key, "")).strip() for key in ("vendor_id", "device_id", "subsystem_device_id")):
+ missing.append("pci")
+ if not profile.get("bars"):
+ missing.append("bars")
+ fb = profile.get("fb") or {}
+ if not any(fb.get(key) not in (None, "", 0) for key in ("vram_mib", "bar1_mib", "notes")):
+ missing.append("fb")
+ if not profile.get("engines"):
+ missing.append("engines")
+ interrupts = profile.get("interrupts") or {}
+ if not any(interrupts.get(key) not in (None, "", 0) for key in ("msix_vectors", "notes")):
+ missing.append("interrupts")
+ rom = profile.get("rom") or {}
+ if not any(str(rom.get(key, "")).strip() for key in ("vblos_sha256", "notes")):
+ missing.append("rom")
+ return missing
+
+
+def profile_labels(profile: dict, device: str, profile_path: str | None) -> list[str]:
+ labels = [device, os.path.basename(profile_path or "")]
+ for key in ("name", "device", "firmware", "reference", "firmware_family", "firmware_version"):
+ value = profile.get(key)
+ if isinstance(value, str) and value:
+ labels.append(os.path.basename(value))
+ return labels
+
+
+def merge_profile(profile: dict, options: DeviceOptions) -> dict:
+ merged = json.loads(json.dumps(profile))
+ if options.firmware:
+ merged["firmware"] = options.firmware
+ if options.reference:
+ merged["reference"] = options.reference
+ return merged
+
+
+# ---------------------------------------------------------------------------
+# plan (dry run)
+# ---------------------------------------------------------------------------
+
+
+def _expected_artifacts(out: str, device: str) -> list[str]:
+ base = os.path.join(out, device)
+ return [
+ os.path.join(base, "device-port.json"),
+ os.path.join(base, "device-port.md"),
+ os.path.join(base, "port-checklist.json"),
+ os.path.join(base, "port-checklist.md"),
+ os.path.join(base, "symbols", f"{device}.json"),
+ os.path.join(base, "sessions"),
+ os.path.join(base, "portability-matrix", "portability-report.json"),
+ os.path.join(base, "portability-matrix", "portability-report.md"),
+ os.path.join(base, "bundle-manifest.json"),
+ ]
+
+
+def build_plan(options: DeviceOptions) -> dict:
+ profile_path = options.profile or (os.path.join(options.out or "<out>", "profiles",
+ f"{options.device}.json"))
+ profile, created = load_profile(profile_path) if os.path.isfile(profile_path) else ({}, False)
+ merged = merge_profile(profile if created else scaffold_profile(options.device), options)
+ firmware = merged.get("firmware") or ""
+ reference = merged.get("reference") or ""
+ check_rm610_gate(
+ profile_labels(profile if created else {}, options.device, profile_path)
+ + [os.path.basename(firmware), os.path.basename(reference)],
+ approval_token=options.approval_token,
+ )
+ out = os.path.abspath(options.out) if options.out else "<out>"
+ return {
+ "format": PLAN_FORMAT,
+ "tool": "harness/port_new_device.py",
+ "dry_run": True,
+ "rm610_gate": {"status": "passed", "labels": sorted(set(
+ profile_labels(profile if created else {}, options.device, profile_path)
+ + [os.path.basename(firmware), os.path.basename(reference)]
+ ))},
+ "device": options.device,
+ "profile": {
+ "path": profile_path,
+ "action": "use" if created else "would-create-scaffold",
+ "missing_fields": profile_missing_fields(merged),
+ "where_to_fill": PROFILE_HELP,
+ },
+ "firmware": {"path": firmware or None, "found": bool(firmware) and os.path.isfile(firmware)},
+ "reference": {"path": reference or None,
+ "found": bool(reference) and os.path.isfile(reference)},
+ "out": out,
+ "steps": [
+ {"name": "probe_first_touch", "target": firmware or None,
+ "out": os.path.join(out, options.device, "sessions", f"{options.device}-probe")},
+ {"name": "symbol_inventory", "sources": [firmware or None, options.symbols]},
+ {"name": "portability_matrix", "targets": [reference or None, firmware or None]},
+ {"name": "port_checklist", "out": os.path.join(out, options.device, "port-checklist.json")},
+ ],
+ "expected_artifacts": _expected_artifacts(out, options.device),
+ "interpretation": (
+ "dry-run: fill the profile fields, then pass --run; physical validation "
+ "is a separate supervised step and is never done by this tool"
+ ),
+ }
+
+
+# ---------------------------------------------------------------------------
+# steps
+# ---------------------------------------------------------------------------
+
+
+def _run_step(name: str, fn, *args, **kwargs) -> dict:
+ try:
+ detail = fn(*args, **kwargs)
+ except Exception as exc: # a failed step must not hide the others
+ return {"name": name, "status": "error", "reason": f"{type(exc).__name__}: {exc}"}
+ entry = {"name": name}
+ entry.update(detail)
+ return entry
+
+
+def step_probe(loaded, db: SymbolDB, options: DeviceOptions, out_dir: str, qemu_ok: bool) -> dict:
+ vd = VersionDiffOptions(
+ old=loaded.path,
+ new=loaded.path,
+ out=out_dir,
+ probe_instructions=options.probe_instructions,
+ probe_duration=options.probe_duration,
+ log_bytes=options.log_bytes,
+ approval_token=options.approval_token,
+ )
+ result = run_probe(loaded, db, vd, out_dir, qemu_ok)
+ return {
+ "status": result["status"],
+ "reason": result.get("reason"),
+ "session": result.get("session"),
+ "stopped_pc": result.get("stopped_pc"),
+ "stopped_symbol": result.get("stopped_symbol"),
+ "fault_count": result.get("fault_count"),
+ "first_fault": result.get("first_fault"),
+ }
+
+
+def step_symbols(loaded, db: SymbolDB, sources: list[str], notes: list[str],
+ options: DeviceOptions, out_dir: str) -> dict:
+ records = [
+ {"name": symbol.name, "addr": symbol.addr, "size": symbol.size, "source": symbol.source}
+ for symbol in db
+ ]
+ rel = None
+ if records:
+ rel = os.path.join("symbols", f"{options.device}.json")
+ path = os.path.join(out_dir, rel)
+ ensure_dir(os.path.dirname(path))
+ db.save_json(path, meta={
+ "device": options.device,
+ "firmware": os.path.basename(loaded.path),
+ "bias": loaded.bias,
+ "sources": sources,
+ "notes": notes,
+ })
+ return {
+ "status": "ok" if records else "skipped",
+ "reason": None if records else "no symbols: no ELF .symtab and no Ghidra/JSON sources supplied",
+ "count": len(records),
+ "sources": sources,
+ "notes": notes,
+ "db": rel,
+ }
+
+
+def step_matrix(firmware: str, reference: str | None, options: DeviceOptions, out_dir: str) -> dict:
+ specs = []
+ if reference and os.path.isfile(reference):
+ specs.append(TargetSpec(path=os.path.abspath(reference), name="reference"))
+ specs.append(TargetSpec(path=os.path.abspath(firmware), name=options.device))
+ matrix = run_matrix(
+ specs,
+ MatrixOptions(
+ out=out_dir,
+ probe_instructions=options.probe_instructions,
+ probe_duration=options.probe_duration,
+ scenario_duration=options.scenario_duration,
+ max_stops=options.max_stops,
+ max_rows=options.max_rows,
+ log_bytes=options.log_bytes,
+ ),
+ )
+ write_json(os.path.join(out_dir, "portability-report.json"), matrix)
+ write_text(
+ os.path.join(out_dir, "portability-report.md"),
+ render_matrix_markdown(matrix, MatrixOptions(out=out_dir, max_rows=options.max_rows)),
+ )
+ statuses = {row["name"]: row["status"] for row in matrix["targets"]}
+ return {
+ "status": "error" if "error" in statuses.values() else "ok",
+ "report": "portability-matrix/portability-report.json",
+ "targets": statuses,
+ "comparisons": [
+ {"a": row["a"], "b": row["b"], "status": row["status"], "all_zero": row["all_zero"]}
+ for row in matrix["comparisons"]
+ ],
+ }
+
+
+def build_checklist(profile: dict, firmware: str, reference: str | None, results: dict) -> dict:
+ """docs/07 evidence checklist + docs/22 §3.1 profile items."""
+ missing = profile_missing_fields(profile)
+ pairing_ok = bool(str(profile.get("firmware_family", "")).strip()
+ and str(profile.get("firmware_version", "")).strip())
+ physical = profile.get("physical_validation") or {}
+ physical_status = str(physical.get("status", "pending")).lower()
+ probe = results.get("probe") or {}
+ symbols = results.get("symbols") or {}
+ matrix = results.get("matrix") or {}
+ items = [
+ {
+ "id": "profile",
+ "item": "device profile fields recorded (PCI IDs, BARs, FB, engines, interrupts, ROM)",
+ "status": "ok" if not missing else "pending",
+ "detail": "missing: " + ", ".join(missing) if missing else "all profile sections populated",
+ },
+ {
+ "id": "pairing",
+ "item": "firmware hash + family/version pairing recorded before debugging (docs/07 P2.2)",
+ "status": "ok" if pairing_ok else "pending",
+ "detail": f"family={profile.get('firmware_family') or '-'} version={profile.get('firmware_version') or '-'}",
+ },
+ {
+ "id": "hash",
+ "item": "firmware sha256 recorded",
+ "status": "ok" if firmware and os.path.isfile(firmware) else "missing",
+ "detail": sha256_file(firmware) if firmware and os.path.isfile(firmware) else "firmware not found",
+ },
+ {
+ "id": "probe",
+ "item": "probe first-touch captured and explained as profile vs logic",
+ "status": "ok" if probe.get("status") == "ok" else "pending",
+ "detail": f"stopped_pc={probe.get('stopped_pc')} faults={probe.get('fault_count')}",
+ },
+ {
+ "id": "symbols",
+ "item": "symbol inventory built with provenance",
+ "status": "ok" if symbols.get("status") == "ok" else "pending",
+ "detail": f"count={symbols.get('count')} sources={symbols.get('sources')}",
+ },
+ {
+ "id": "matrix",
+ "item": "portability matrix run vs the reference firmware",
+ "status": "ok" if matrix.get("status") == "ok" and reference else "pending",
+ "detail": f"targets={matrix.get('targets')}",
+ },
+ {
+ "id": "docs07",
+ "item": "docs/07 evidence checklist attachable (hash, probe, symbols, sessions, compare, M1-M6)",
+ "status": "ok" if firmware else "missing",
+ "detail": "bundle-manifest.json hashes every artifact",
+ },
+ {
+ "id": "physical",
+ "item": "physical/supervised validation done (outside this simulator-only tool)",
+ "status": "ok" if physical_status in ("passed", "ok", "done") else "pending",
+ "detail": f"status={physical_status}; docs/22 §3.5: simulator-only evidence never justifies the support claim",
+ },
+ ]
+ required_pending = [row["id"] for row in items
+ if row["status"] != "ok" and row["id"] != "physical"]
+ if required_pending:
+ recommendation = "hold: " + ", ".join(required_pending) + " not complete"
+ elif physical_status in ("passed", "ok", "done"):
+ recommendation = "go: simulator evidence complete and physical validation recorded"
+ else:
+ recommendation = "simulator-evidence-complete; physical validation pending (supervised step)"
+ return {
+ "format": "greeneye-device-checklist/1",
+ "tool": "harness/port_new_device.py",
+ "device": profile.get("name") or "-",
+ "firmware": firmware,
+ "reference": reference,
+ "profile_missing_fields": missing,
+ "items": items,
+ "recommendation": recommendation,
+ "safety": "simulator-only: no /dev, no kernel module, no mdev, no guest, no physical GPU",
+ }
+
+
+def render_checklist_markdown(checklist: dict) -> str:
+ lines = [
+ f"# green-eye port checklist: {checklist['device']}",
+ "",
+ "> TOOL IN DEVELOPMENT - NOT READY YET! Generated by `harness/port_new_device.py`.",
+ "",
+ f"* firmware: `{checklist['firmware']}`",
+ f"* reference: `{checklist['reference']}`",
+ f"* recommendation: **{checklist['recommendation']}**",
+ f"* safety: {checklist['safety']}",
+ "",
+ "| id | item | status | detail |",
+ "|---|---|---|---|",
+ ]
+ for row in checklist["items"]:
+ lines.append(f"| {row['id']} | {row['item']} | {row['status']} | {row['detail']} |")
+ lines += ["", "Evidence: `port-checklist.json`, `bundle-manifest.json`, `portability-matrix/`.", ""]
+ return "\n".join(lines)
+
+
+def build_bundle(out_dir: str) -> dict:
+ files: list[dict] = []
+ for root, dirs, names in os.walk(out_dir):
+ dirs.sort()
+ for name in sorted(names):
+ path = os.path.join(root, name)
+ rel = os.path.relpath(path, out_dir)
+ if rel == "bundle-manifest.json":
+ continue
+ files.append({"path": rel, "bytes": os.path.getsize(path), "sha256": sha256_file(path)})
+ files.sort(key=lambda row: row["path"])
+ return {"format": BUNDLE_FORMAT, "tool": "harness/port_new_device.py", "files": files}
+
+
+def run_device_port(options: DeviceOptions) -> dict:
+ if options.out is None:
+ raise ValueError("--out is required unless --dry-run is used")
+ out_root = os.path.abspath(options.out)
+ device_dir = os.path.join(out_root, options.device)
+ profile_path = options.profile or os.path.join(out_root, "profiles", f"{options.device}.json")
+ profile, created = load_profile(profile_path)
+ created_now = not created
+ if not created:
+ profile = scaffold_profile(options.device)
+ profile = merge_profile(profile, options)
+ labels = profile_labels(profile, options.device, profile_path)
+ firmware = profile.get("firmware") or ""
+ reference = profile.get("reference") or ""
+ check_rm610_gate(labels + [os.path.basename(firmware), os.path.basename(reference)],
+ approval_token=options.approval_token)
+ if created_now:
+ ensure_dir(os.path.dirname(profile_path))
+ write_json(profile_path, profile)
+ if not firmware:
+ raise ValueError(
+ "no device firmware: pass --firmware PATH or set \"firmware\" in the profile "
+ f"({profile_path}); see docs/07 P2"
+ )
+ loaded = load_any_target(firmware)
+ if not loaded.ok:
+ raise ValueError(f"firmware not usable: {loaded.path}: {loaded.reason or loaded.kind}")
+ if created_now:
+ profile["firmware_sha256"] = loaded.sha256
+ write_json(profile_path, profile)
+
+ ensure_dir(device_dir)
+ db, sources, notes = load_symbol_sources(
+ loaded,
+ symbols_json=options.symbols,
+ ghidra_exports=options.ghidra_exports,
+ ghidra_bias=options.ghidra_bias,
+ )
+ qemu_ok = qemu_available()
+ results: dict = {}
+ steps = [
+ _run_step("probe_first_touch", step_probe, loaded, db, options, device_dir, qemu_ok),
+ _run_step("symbol_inventory", step_symbols, loaded, db, sources, notes, options, device_dir),
+ _run_step("portability_matrix", step_matrix, firmware, reference, options,
+ os.path.join(device_dir, "portability-matrix")),
+ ]
+ results["probe"] = steps[0]
+ results["symbols"] = steps[1]
+ results["matrix"] = steps[2]
+ checklist = build_checklist(profile, firmware, reference, results)
+ write_json(os.path.join(device_dir, "port-checklist.json"), checklist)
+ write_text(os.path.join(device_dir, "port-checklist.md"), render_checklist_markdown(checklist))
+ checklist_step = {
+ "name": "port_checklist",
+ "status": "ok",
+ "report": "port-checklist.json",
+ "recommendation": checklist["recommendation"],
+ }
+ steps.append(checklist_step)
+ failed = [step["name"] for step in steps if step["status"] == "error"]
+ report = {
+ "format": FORMAT,
+ "tool": "harness/port_new_device.py",
+ "dry_run": False,
+ "rm610_gate": {"status": "passed", "labels": sorted(set(labels))},
+ "device": options.device,
+ "profile": {
+ "path": os.path.relpath(profile_path, out_root),
+ "created": created_now,
+ "missing_fields": profile_missing_fields(profile),
+ },
+ "firmware": {"path": firmware, "kind": loaded.kind, "sha256": loaded.sha256},
+ "reference": {"path": reference, "found": bool(reference) and os.path.isfile(reference)},
+ "qemu_available": qemu_ok,
+ "steps": steps,
+ "checklist": checklist,
+ "failed": failed,
+ "manifest": "bundle-manifest.json",
+ }
+ write_json(os.path.join(device_dir, "device-port.json"), report)
+ write_text(os.path.join(device_dir, "device-port.md"), render_report_markdown(report))
+ write_json(os.path.join(device_dir, "bundle-manifest.json"), build_bundle(device_dir))
+ return report
+
+
+def render_report_markdown(report: dict) -> str:
+ lines = [
+ f"# green-eye device port: {report['device']}",
+ "",
+ "> TOOL IN DEVELOPMENT - NOT READY YET! Generated by `harness/port_new_device.py`.",
+ "",
+ f"* format: `{report['format']}`",
+ f"* firmware: `{report['firmware']['path']}` ({report['firmware']['kind']}, "
+ f"sha256 `{report['firmware']['sha256'][:16]}`)",
+ f"* reference: `{report['reference']['path'] or '-'}`",
+ f"* qemu available: {report['qemu_available']}",
+ "",
+ "| step | status | report | reason |",
+ "|---|---|---|---|",
+ ]
+ for step in report["steps"]:
+ lines.append(f"| {step['name']} | {step['status']} | {step.get('report') or '-'} "
+ f"| {step.get('reason') or '-'} |")
+ lines += [
+ "",
+ f"* checklist recommendation: **{report['checklist']['recommendation']}**",
+ f"* manifest: `{report['manifest']}`",
+ "",
+ ]
+ return "\n".join(lines)
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+
+_HELP_EPILOG = """\
+invocation:
+ dry-run first (default; nothing is created or touched):
+ python3 harness/port_new_device.py --device rtx4090 --profile profiles/rtx4090.json
+ execute (scaffolds the profile if absent; simulator only):
+ python3 harness/port_new_device.py --run --device rtx4090 \\
+ --firmware ../../firmware/57X-GSP/570.144/gsp-570.144.fwimage.bin \\
+ --reference firmware/uproc_demo/build/uproc_demo.elf --out dumps/port-rtx4090
+
+agent (MCP, docs/20): read the dry-run plan, fill the profile, then --run;
+read device-port.json and port-checklist.json from the bundle.
+RM 610 (docs/23): rm610-labelled device/firmware labels are refused without
+--approval-token RM610-APPROVED or GREENEYE_RM610_APPROVED=1.
+Safety: no /dev, no kernel module, no mdev, no guest, no physical GPU.
+"""
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="port_new_device",
+ description=__doc__.splitlines()[0],
+ epilog=_HELP_EPILOG,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ )
+ parser.add_argument("--device", required=True, help="device name (profile key, e.g. rtx4090)")
+ parser.add_argument("--profile", help="profile JSON path (default: <out>/profiles/<device>.json)")
+ parser.add_argument("--firmware", help="device GSP firmware image or re-hosted ELF (overrides profile)")
+ parser.add_argument("--reference", help="reference firmware for the portability matrix")
+ parser.add_argument("--symbols", help="green-eye symbol DB JSON for the device firmware")
+ parser.add_argument("--ghidra-exports", help="Ghidra exports TSV for the device firmware")
+ parser.add_argument("--ghidra-bias", type=lambda value: int(value, 0), default=0)
+ parser.add_argument("--out", help="evidence bundle directory (required for --run)")
+ parser.add_argument("--run", dest="run", action="store_true",
+ help="execute (default is the dry-run plan)")
+ parser.add_argument("--dry-run", dest="run", action="store_false",
+ help="print the plan and exit 0 (default)")
+ parser.set_defaults(run=False)
+ parser.add_argument("--approval-token", help=f"explicit RM610 token ({RM610_TOKEN})")
+ parser.add_argument("--probe-instructions", type=int, default=2000)
+ parser.add_argument("--probe-duration", type=float, default=0.5)
+ parser.add_argument("--scenario-duration", type=float, default=0.4)
+ parser.add_argument("--max-stops", type=int, default=5000)
+ parser.add_argument("--max-rows", type=int, default=40)
+ parser.add_argument("--step-timeout", type=float, default=600.0)
+ parser.add_argument("--log-bytes", type=int, default=MAX_LOG_BYTES)
+ return parser
+
+
+def options_from_args(args: argparse.Namespace) -> DeviceOptions:
+ if not DEVICE_RE.match(args.device):
+ raise ValueError(f"invalid device name: {args.device!r}")
+ return DeviceOptions(
+ device=args.device,
+ profile=args.profile,
+ firmware=args.firmware,
+ reference=args.reference,
+ symbols=args.symbols,
+ ghidra_exports=args.ghidra_exports,
+ ghidra_bias=args.ghidra_bias,
+ out=args.out,
+ run=args.run,
+ approval_token=args.approval_token,
+ probe_instructions=args.probe_instructions,
+ probe_duration=args.probe_duration,
+ scenario_duration=args.scenario_duration,
+ max_stops=args.max_stops,
+ max_rows=args.max_rows,
+ step_timeout=args.step_timeout,
+ log_bytes=args.log_bytes,
+ )
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ try:
+ options = options_from_args(args)
+ except ValueError as exc:
+ parser.error(str(exc))
+ try:
+ check_rm610_gate(
+ [options.device, os.path.basename(options.profile or ""),
+ os.path.basename(options.firmware or ""), os.path.basename(options.reference or "")],
+ approval_token=options.approval_token,
+ )
+ if options.dry_run:
+ plan = build_plan(options)
+ print(json.dumps(plan, indent=2, sort_keys=True))
+ print(f"[device] dry-run: profile {plan['profile']['action']}, "
+ f"firmware found={plan['firmware']['found']}; nothing created or touched")
+ return 0
+ report = run_device_port(options)
+ except Rm610GateError as exc:
+ print(f"[device] RM610 gate refused: {exc}", file=sys.stderr)
+ return 3
+ except (ValueError, FileNotFoundError, json.JSONDecodeError) as exc:
+ print(f"[device] error: {exc}", file=sys.stderr)
+ return 2
+ for step in report["steps"]:
+ reason = f" reason={step['reason']}" if step.get("reason") else ""
+ print(f"[device] {step['name']}: status={step['status']}{reason}")
+ print(f"[device] checklist: {report['checklist']['recommendation']}")
+ print(f"[device] wrote {os.path.join(os.path.abspath(options.out), options.device, 'device-port.json')}")
+ print(f"[device] wrote {os.path.join(os.path.abspath(options.out), options.device, 'bundle-manifest.json')}")
+ return 1 if report["failed"] else 0
+
+
+if __name__ == "__main__": # pragma: no cover
+ raise SystemExit(main())