summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm/sim
diff options
context:
space:
mode:
authorArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-03 14:05:38 -0400
committerArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-03 14:05:38 -0400
commit32b551d43781dc7b8ac4587ca2ba1554b0cbd5e5 (patch)
treed1cab6bcc35e461610cd5dfcf3f51ba679a576fc /debug/risc-v-gsp-rm-vm/sim
parentfa71f1b53b524e5f6c6c1fbff1088d610b9f3236 (diff)
downloadopen-iov-32b551d43781dc7b8ac4587ca2ba1554b0cbd5e5.tar.gz
open-iov-32b551d43781dc7b8ac4587ca2ba1554b0cbd5e5.zip
vgsp: host CUDA bridge + sema release recovery; greeneye simulator wave
- kernel: gsp_cuda_bridge (QMD launch capture, debugfs job/out/done, param+output resolution cached from inline writes), sema drain fixes (never hold/starve a channel), GR ring autopin + counter autoreg, per-page push reads, counter-derive gate; module builds clean - tools/cuda-bridge: CUDA driver-API daemon executing mail/vadd kernels on the physical GPU, CUBB2/CBD2 v2 protocol, layout check + self-tests - tools/guest-diag + tools/cuda-ab: scanner/pin fixes, victory push-only mode - debug/risc-v-gsp-rm-vm: Phase S simulator wave (GSP device model, boot ladder, triage/re-demo harnesses, docs 30-41), GPGPU-sim scaffolding - STATE-CURRENT/docs updates
Diffstat (limited to 'debug/risc-v-gsp-rm-vm/sim')
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/__init__.py14
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/cycles.py247
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/__init__.py163
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/base.py263
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/csb.py89
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/dma.py117
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/falcon.py228
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py80
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/irq.py58
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/pmc.py90
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/pmu.py46
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/priscv.py113
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/timer.py86
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/gsp_boot.py548
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/gsp_profile.py317
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/machine.py185
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/profiles/tu104-570.144.json106
17 files changed, 2746 insertions, 4 deletions
diff --git a/debug/risc-v-gsp-rm-vm/sim/__init__.py b/debug/risc-v-gsp-rm-vm/sim/__init__.py
index ad20893..c083bb7 100644
--- a/debug/risc-v-gsp-rm-vm/sim/__init__.py
+++ b/debug/risc-v-gsp-rm-vm/sim/__init__.py
@@ -5,8 +5,17 @@ RV64IMAC from a 256 MiB RAM region with an NS16550A UART; the debugger-facing
adapter lives in ``greeneye.sim_backend``. A machine can be given a
``greeneye.device_map.DeviceMap`` and ``unknown_mmio="lenient"`` to serve
unmodelled MMIO accesses from harvested first-touch data instead of faulting.
+:mod:`sim.cycles` adds a modeled, table-driven cycle counter alongside the
+instruction count (not a timing claim; see ``docs/32-GSP-CYCLE-MODEL.md``).
"""
from .decode import decode16, decode32 # noqa: F401
+from .cycles import ( # noqa: F401
+ CYCLE_FIDELITY,
+ CYCLE_MODEL_NAME,
+ DEFAULT_MODEL,
+ NOTE,
+ CycleModel,
+)
from .machine import ( # noqa: F401
R_BP,
R_BUDGET,
@@ -32,6 +41,11 @@ __all__ = [
"decode32",
"Trace",
"make_trace",
+ "CycleModel",
+ "DEFAULT_MODEL",
+ "CYCLE_MODEL_NAME",
+ "CYCLE_FIDELITY",
+ "NOTE",
"R_BUDGET",
"R_BP",
"R_WATCH",
diff --git a/debug/risc-v-gsp-rm-vm/sim/cycles.py b/debug/risc-v-gsp-rm-vm/sim/cycles.py
new file mode 100644
index 0000000..b841c6c
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/cycles.py
@@ -0,0 +1,247 @@
+"""Modeled RV64IMAC instruction-class cycle accounting for the native core.
+
+**Every number in this module is a modeled default - NOT a measurement.**
+The tables encode deliberately simple, public assumptions about an in-order
+single-issue RISC-V core: one cycle for plain ALU work, a multi-cycle cost for
+the M extension, a fixed modeled latency added when a memory access completes,
+and a larger one for MMIO. They are *not* derived from the GSP core, not from
+silicon, and not from vendor data.
+
+The simulator's fidelity statement is unchanged (docs/06-ACCURACY.md):
+instruction semantics (L0) and memory behaviour (L1) are modelled; L2
+(time/concurrency) is **not**. A cycle count from this module is a
+deterministic accounting artifact under an explicit model - never a timing,
+latency, throughput or silicon claim.
+
+The model is deterministic by construction: cost depends only on the decoded
+instruction class (``sim.decode`` kind/sub) and whether a completed access was
+RAM or MMIO. No host clock, no entropy, no caches, no pipeline hazards.
+"""
+from __future__ import annotations
+
+from .decode import (
+ A_DIV,
+ A_DIVU,
+ A_MUL,
+ A_MULH,
+ A_MULHSU,
+ A_MULHU,
+ A_REM,
+ A_REMU,
+ K_AMO,
+ K_AUIPC,
+ K_BRANCH,
+ K_CSR,
+ K_EBREAK,
+ K_ECALL,
+ K_FENCE,
+ K_ILLEGAL,
+ K_JAL,
+ K_JALR,
+ K_LOAD,
+ K_LUI,
+ K_MRET,
+ K_OP,
+ K_OP32,
+ K_OPI,
+ K_OPI32,
+ K_STORE,
+)
+
+#: Model identifier written into session metadata and reports.
+CYCLE_MODEL_NAME = "rv64imac-modeled/1"
+
+#: Fidelity label that must travel with every cycle number from this module.
+CYCLE_FIDELITY = "L2-not-modelled; cycles are modeled defaults, not measured"
+
+#: One-line provenance/label, repeated wherever the model is described.
+NOTE = (
+ "modeled defaults sourced from public in-order RISC-V pipeline assumptions; "
+ "not measured, not a silicon timing claim"
+)
+
+# Instruction classes used for reporting and per-class reasoning.
+CLASS_ALU = "alu"
+CLASS_BRANCH = "branch"
+CLASS_JUMP = "jump"
+CLASS_MUL = "mul"
+CLASS_DIV = "div"
+CLASS_LOAD = "load"
+CLASS_STORE = "store"
+CLASS_AMO = "amo"
+CLASS_CSR = "csr"
+CLASS_TRAP = "trap"
+CLASS_FENCE = "fence"
+CLASS_ILLEGAL = "illegal"
+
+_CLASS_BY_KIND = {
+ K_LUI: CLASS_ALU,
+ K_AUIPC: CLASS_ALU,
+ K_JAL: CLASS_JUMP,
+ K_JALR: CLASS_JUMP,
+ K_BRANCH: CLASS_BRANCH,
+ K_LOAD: CLASS_LOAD,
+ K_STORE: CLASS_STORE,
+ K_OPI: CLASS_ALU,
+ K_OPI32: CLASS_ALU,
+ K_FENCE: CLASS_FENCE,
+ K_CSR: CLASS_CSR,
+ K_ECALL: CLASS_TRAP,
+ K_EBREAK: CLASS_TRAP,
+ K_MRET: CLASS_TRAP,
+ K_AMO: CLASS_AMO,
+ K_ILLEGAL: CLASS_ILLEGAL,
+}
+
+#: Sentinel in ``base_costs``: the cost lives in ``op_costs`` (M extension).
+_SENTINEL = -1
+
+_M_EXT_KINDS = (K_OP, K_OP32)
+
+
+class CycleModel:
+ """Table-driven, deterministic instruction-class cycle costs.
+
+ All keyword values are **modeled defaults** (see the module docstring);
+ they are parameters so a future calibration can replace them without
+ touching the execution loop. ``memory_latency``/``mmio_latency`` are the
+ extra cycles added when a load/store/AMO access completes (RAM vs
+ modelled-MMIO respectively); they are *not* cache models.
+ """
+
+ name = CYCLE_MODEL_NAME
+
+ def __init__(
+ self,
+ *,
+ alu: int = 1, # plain integer ALU / shift / lui / auipc / op-imm
+ branch: int = 1, # conditional branch (taken penalty deliberately not modelled)
+ jump: int = 1, # jal / jalr
+ mul: int = 3, # mul / mulh / mulhsu / mulhu (conservative)
+ div: int = 20, # div / divu / rem / remu (conservative, data-independent)
+ load: int = 1, # load base; + memory_latency/mmio_latency on completion
+ store: int = 1, # store base; + memory_latency/mmio_latency on completion
+ amo: int = 2, # amoswap/lr/sc base; + one memory latency on completion
+ csr: int = 2, # CSR read/modify/write
+ trap: int = 3, # ecall / ebreak / mret
+ fence: int = 1, # fence / fence.i (no ordering model; nominal)
+ illegal: int = 1, # decode failure (execution stops immediately)
+ memory_latency: int = 3, # extra cycles for a completed RAM access
+ mmio_latency: int = 12, # extra cycles for a completed unmodelled-MMIO access
+ ):
+ for label, value in (
+ ("alu", alu),
+ ("branch", branch),
+ ("jump", jump),
+ ("mul", mul),
+ ("div", div),
+ ("load", load),
+ ("store", store),
+ ("amo", amo),
+ ("csr", csr),
+ ("trap", trap),
+ ("fence", fence),
+ ("illegal", illegal),
+ ("memory_latency", memory_latency),
+ ("mmio_latency", mmio_latency),
+ ):
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
+ raise ValueError(f"cycle model: {label} must be a non-negative int, got {value!r}")
+ if alu < 1 or branch < 1 or jump < 1:
+ raise ValueError("cycle model: alu/branch/jump must be >= 1 for a clean per-instruction model")
+ self.alu = alu
+ self.branch = branch
+ self.jump = jump
+ self.mul = mul
+ self.div = div
+ self.load = load
+ self.store = store
+ self.amo = amo
+ self.csr = csr
+ self.trap = trap
+ self.fence = fence
+ self.illegal = illegal
+ self.memory_latency = memory_latency
+ self.mmio_latency = mmio_latency
+
+ base = [0] * 18
+ base[K_LUI] = alu
+ base[K_AUIPC] = alu
+ base[K_JAL] = jump
+ base[K_JALR] = jump
+ base[K_BRANCH] = branch
+ base[K_LOAD] = load
+ base[K_STORE] = store
+ base[K_OPI] = alu
+ base[K_OP] = _SENTINEL
+ base[K_OPI32] = alu
+ base[K_OP32] = _SENTINEL
+ base[K_FENCE] = fence
+ base[K_CSR] = csr
+ base[K_ECALL] = trap
+ base[K_EBREAK] = trap
+ base[K_MRET] = trap
+ base[K_AMO] = amo
+ base[K_ILLEGAL] = illegal
+
+ op = [alu] * 18
+ for sub in (A_MUL, A_MULH, A_MULHSU, A_MULHU):
+ op[sub] = mul
+ for sub in (A_DIV, A_DIVU, A_REM, A_REMU):
+ op[sub] = div
+ # Immutable fast-path tables consumed by Machine.run's hot loop.
+ self.base_costs = tuple(base)
+ self.op_costs = tuple(op)
+
+ # -- queries -----------------------------------------------------------
+
+ def instruction_cost(self, kind: int, sub: int = 0) -> int:
+ """Modeled cycles for one decoded instruction (memory latency excluded)."""
+ cost = self.base_costs[kind]
+ if cost == _SENTINEL:
+ return self.op_costs[sub]
+ return cost
+
+ def memory_cost(self, is_mmio: bool = False) -> int:
+ """Modeled extra cycles when a load/store/AMO access completes."""
+ return self.mmio_latency if is_mmio else self.memory_latency
+
+ @staticmethod
+ def classify(kind: int, sub: int = 0) -> str:
+ """Human-readable class for a decoded instruction (reporting only)."""
+ if kind in _M_EXT_KINDS:
+ if sub >= A_DIV:
+ return CLASS_DIV
+ if sub >= A_MUL:
+ return CLASS_MUL
+ return CLASS_ALU
+ return _CLASS_BY_KIND.get(kind, "other")
+
+ def describe(self) -> dict:
+ """The full table, for session metadata and reports."""
+ return {
+ "name": self.name,
+ "note": NOTE,
+ "fidelity": CYCLE_FIDELITY,
+ "classes": {
+ CLASS_ALU: self.alu,
+ CLASS_BRANCH: self.branch,
+ CLASS_JUMP: self.jump,
+ CLASS_MUL: self.mul,
+ CLASS_DIV: self.div,
+ CLASS_LOAD: self.load,
+ CLASS_STORE: self.store,
+ CLASS_AMO: self.amo,
+ CLASS_CSR: self.csr,
+ CLASS_TRAP: self.trap,
+ CLASS_FENCE: self.fence,
+ CLASS_ILLEGAL: self.illegal,
+ },
+ "memory_latency": self.memory_latency,
+ "mmio_latency": self.mmio_latency,
+ }
+
+
+#: Shared default model. Machine() uses it unless told otherwise; it is
+#: immutable in practice, so one instance is safe for every target.
+DEFAULT_MODEL = CycleModel()
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py b/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py
new file mode 100644
index 0000000..caf22b3
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py
@@ -0,0 +1,163 @@
+"""GSP device model: registry + per-block modules.
+
+Public surface:
+
+* :class:`~sim.devices.base.DeviceRegistry` -- address-range lookup, access
+ log and serialisable state.
+* :func:`registry_from_profile` -- build a registry from a versioned
+ ``sim/profiles/*.json`` document.
+* per-block devices: :mod:`sim.devices.pmc`, :mod:`sim.devices.falcon`,
+ :mod:`sim.devices.priscv`, :mod:`sim.devices.timer`, :mod:`sim.devices.csb`,
+ :mod:`sim.devices.fwsec`, :mod:`sim.devices.pmu`, plus the
+ :mod:`sim.devices.dma` translation seam and :mod:`sim.devices.irq` router.
+
+Evidence and limits for every modelled register are documented in the module
+docstrings and in ``docs/36-GSP-DEVICE-MODEL.md``; unmodelled offsets always
+stay unhandled so the machine's unknown-MMIO log remains the source of truth
+for what is still missing.
+"""
+from __future__ import annotations
+
+import json
+
+from .base import Device, DeviceRegistry, SNAPSHOT_SCHEMA, Window
+from .csb import CsbWindow
+from .dma import DmaTranslator
+from .falcon import FalconBlock
+from .fwsec import Wpr2Block
+from .irq import IrqRouter
+from .pmc import PmcBlock
+from .pmu import PmuStub
+from .priscv import PriscvBlock
+from .timer import PtimerBlock
+
+PROFILE_SCHEMA = "greeneye-sim-profile/1"
+
+__all__ = [
+ "Device",
+ "DeviceRegistry",
+ "Window",
+ "SNAPSHOT_SCHEMA",
+ "PROFILE_SCHEMA",
+ "PmcBlock",
+ "FalconBlock",
+ "PriscvBlock",
+ "PtimerBlock",
+ "CsbWindow",
+ "Wpr2Block",
+ "PmuStub",
+ "DmaTranslator",
+ "IrqRouter",
+ "load_profile",
+ "registry_from_profile",
+]
+
+
+def load_profile(path) -> dict:
+ """Load and minimally validate a ``greeneye-sim-profile/1`` document."""
+ with open(path, "r", encoding="utf-8") as fh:
+ data = json.load(fh)
+ if data.get("schema") != PROFILE_SCHEMA:
+ raise ValueError(f"{path}: expected schema {PROFILE_SCHEMA!r}")
+ if "devices" not in data or not isinstance(data["devices"], dict):
+ raise ValueError(f"{path}: missing 'devices' object")
+ return data
+
+
+def _int(value, default=None):
+ """Profile numbers may be JSON integers or ``0x``/decimal strings."""
+ if value is None:
+ return default
+ if isinstance(value, str):
+ return int(value, 0)
+ return int(value)
+
+
+def _windows(section: dict, default_size: int = 0x1000):
+ out = []
+ for win in section.get("windows", ()):
+ out.append((_int(win["base"]), _int(win.get("size", default_size))))
+ return out
+
+
+def registry_from_profile(profile: dict, *, machine=None,
+ max_access_log: int = 100_000) -> DeviceRegistry:
+ """Build a :class:`DeviceRegistry` from a profile document.
+
+ ``machine`` (optional) supplies RAM to the DMA translation seam; without
+ it DMA transfers are still modelled structurally but refuse to move data
+ (recorded, never faked).
+ """
+ if profile.get("schema") != PROFILE_SCHEMA:
+ raise ValueError(f"expected schema {PROFILE_SCHEMA!r}")
+ sections = profile.get("devices", {})
+ reg = DeviceRegistry(max_access_log=max_access_log)
+
+ dma_section = sections.get("dma", {})
+ dma_windows = [
+ {"pa": _int(w["pa"]), "sim": _int(w["sim"]), "size": _int(w["size"])}
+ for w in dma_section.get("windows", ())
+ ]
+ if machine is not None:
+ dma = DmaTranslator(
+ ram=machine.ram,
+ ram_base=machine.ram_base,
+ ram_size=machine.ram_size,
+ windows=dma_windows,
+ )
+ else:
+ dma = DmaTranslator(ram=bytearray(), ram_base=0, ram_size=0,
+ windows=())
+
+ falcon = None
+ priscv = None
+
+ for name, section in sections.items():
+ if not isinstance(section, dict):
+ continue
+ if name == "dma":
+ continue
+ if name == "pmc":
+ dev = PmcBlock(
+ chip_id=_int(section.get("chip_id", 0x164)),
+ major=_int(section.get("major", 0)),
+ minor=_int(section.get("minor", 0)),
+ minor_ext=_int(section.get("minor_ext", 0)),
+ boot_42=(_int(section["boot_42"]) if "boot_42" in section else None),
+ )
+ elif name == "falcon":
+ shims = section.get("shims", {})
+ dev = FalconBlock(
+ dma=dma,
+ debuginfo_host_consume=bool(shims.get("debuginfo_host_consume", False)),
+ debuginfo_stage_mask=_int(section.get("debuginfo_stage_mask", 0x700000)),
+ debuginfo_stage_shift=_int(section.get("debuginfo_stage_shift", 20)),
+ )
+ falcon = dev
+ elif name == "priscv":
+ dev = PriscvBlock()
+ priscv = dev
+ elif name == "timer":
+ if not section.get("enabled", True):
+ continue
+ dev = PtimerBlock(
+ ns_per_insn=float(section.get("ns_per_insn", 1.0)),
+ clock=(machine.insn_count if machine is not None else None),
+ )
+ elif name == "csb":
+ dev = CsbWindow(registers=section.get("registers", {}))
+ elif name == "fwsec":
+ dev = Wpr2Block(
+ wpr2_start=(_int(section["wpr2_start"]) if "wpr2_start" in section else None),
+ wpr2_end=(_int(section["wpr2_end"]) if "wpr2_end" in section else None),
+ )
+ elif name == "pmu":
+ dev = PmuStub()
+ else:
+ raise ValueError(f"unknown device section {name!r}")
+ for base, size in _windows(section):
+ reg.add(dev, base, size)
+
+ reg.add_service(IrqRouter(falcon=falcon, priscv=priscv))
+ reg.dma = dma
+ return reg
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/base.py b/debug/risc-v-gsp-rm-vm/sim/devices/base.py
new file mode 100644
index 0000000..877109c
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/base.py
@@ -0,0 +1,263 @@
+"""Device-model core: address-range registry and device base classes.
+
+The registry decodes a guest MMIO address to at most one modelled device
+window (``base <= addr < base + size``) and asks that device for read/write
+semantics. Modeled windows take precedence over the lenient device-map /
+unknown fallback in :class:`sim.machine.Machine`; accesses inside a known
+window that the device does not model are *not* silently zeroed here -- they
+return ``unhandled`` so the machine still logs them as unknowns.
+
+Every modelled access is recorded in :attr:`DeviceRegistry.accesses` with the
+PC, width, direction and value so a session can explain exactly what the
+device model served. All state is exposed as JSON-serialisable data
+(:meth:`Device.state` / :meth:`DeviceRegistry.snapshot`) so snapshot support
+can be layered on later without changing this API.
+
+Evidence rule (SIMULATOR-WORK-BOUNDARY / docs/35): a register is only given
+semantics when it is backed by at least one of
+
+* a hardware register/field header (offset, width, access, reset/field docs);
+* a firmware/source use (the code that reads or writes it);
+* an observed access pattern (address, width, direction, PC, value sequence).
+
+Each device class documents the evidence kind for every register it models.
+Anything else stays ``unhandled`` and is reported by the machine's unknown
+log; we never invent a read-back value.
+"""
+from __future__ import annotations
+
+import json
+
+#: Schema tag for :meth:`DeviceRegistry.snapshot`.
+SNAPSHOT_SCHEMA = "greeneye-sim-devices/1"
+
+
+class Device:
+ """One modelled register block at one or more address windows.
+
+ Subclasses implement :meth:`read`/:meth:`write` (guest side, side effects
+ allowed) and :meth:`peek` (debugger side, no side effects). Returning
+ ``None`` from ``read``/``peek`` or ``False`` from ``write`` means "not
+ modelled"; the registry then reports the access as unhandled so it is
+ still logged by the caller.
+ """
+
+ name = "device"
+
+ def __init__(self, base: int = 0, size: int = 0):
+ self.base = base
+ self.size = size
+
+ # -- guest-visible semantics ------------------------------------------
+
+ def read(self, offset: int, size: int) -> int | None:
+ return None
+
+ def write(self, offset: int, size: int, value: int) -> bool:
+ return False
+
+ # -- debugger-visible semantics (no side effects) ----------------------
+
+ def peek(self, offset: int, size: int) -> int | None:
+ return self.read(offset, size)
+
+ # -- serialisable state ------------------------------------------------
+
+ def state(self) -> dict:
+ return {}
+
+ def load_state(self, data: dict) -> None:
+ pass
+
+ def reset(self) -> None:
+ self.load_state({})
+
+ # -- helpers -----------------------------------------------------------
+
+ @staticmethod
+ def _mask(size: int) -> int:
+ return (1 << (8 * size)) - 1
+
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
+ return f"{type(self).__name__}({self.name} @ {self.base:#x}+{self.size:#x})"
+
+
+class Window:
+ """One (base, size) -> :class:`Device` mapping."""
+
+ __slots__ = ("base", "size", "device")
+
+ def __init__(self, base: int, size: int, device: Device):
+ self.base = base
+ self.size = size
+ self.device = device
+
+ def contains(self, addr: int, size: int) -> bool:
+ return self.base <= addr and addr + size <= self.base + self.size
+
+ def to_dict(self) -> dict:
+ return {
+ "base": self.base,
+ "size": self.size,
+ "device": self.device.name,
+ }
+
+
+class DeviceRegistry:
+ """Ordered set of device windows plus an access log.
+
+ Lookup is last-registered-first so a narrow window may override a wider
+ one without hiding it: when the last matching window does not implement the
+ access (``read`` returns ``None`` / ``write`` returns ``False``), the
+ registry continues to the next matching window (safe fallback). If every
+ matching window refuses, the access is recorded once as unhandled.
+ Accesses are matched by exact containment (``addr + size`` inside the
+ window) so a straddling access is never half-served.
+ """
+
+ def __init__(self, *, max_access_log: int = 100_000):
+ self.windows: list[Window] = []
+ self.devices: list[Device] = []
+ self.services: list[object] = []
+ self.dma = None # optional DmaTranslator aperture seam
+ self.accesses: list[dict] = []
+ self.dropped_accesses = 0
+ self.unhandled = 0
+ self.max_access_log = max(0, int(max_access_log))
+
+ def add_service(self, service) -> object:
+ """Attach a non-window service (e.g. an IRQ router) for state export."""
+ self.services.append(service)
+ return service
+
+ # -- construction ------------------------------------------------------
+
+ def add(self, device: Device, base: int | None = None, size: int | None = None) -> Device:
+ """Register ``device`` for one window (defaults: device.base/size)."""
+ b = device.base if base is None else base
+ s = device.size if size is None else size
+ if s <= 0:
+ raise ValueError(f"device window size must be positive, got {s}")
+ if device not in self.devices:
+ self.devices.append(device)
+ self.windows.append(Window(b, s, device))
+ return device
+
+ def device_by_name(self, name: str) -> Device | None:
+ for dev in self.devices:
+ if dev.name == name:
+ return dev
+ return None
+
+ # -- lookup ------------------------------------------------------------
+
+ def _candidates(self, addr: int, size: int):
+ """Matching windows, last-registered-first (fallback order)."""
+ for win in reversed(self.windows):
+ if win.contains(addr, size):
+ yield win
+
+ def _record(self, addr: int, size: int, write: bool, value: int | None,
+ handled: bool, pc: int) -> None:
+ entry = {
+ "addr": addr,
+ "size": size,
+ "rw": "w" if write else "r",
+ "handled": bool(handled),
+ "pc": pc,
+ }
+ if write and value is not None:
+ entry["value"] = value & ((1 << (8 * size)) - 1)
+ if handled and not write:
+ entry["value"] = value
+ if len(self.accesses) < self.max_access_log:
+ self.accesses.append(entry)
+ else:
+ self.dropped_accesses += 1
+
+ def read(self, addr: int, size: int, pc: int = 0) -> tuple[bool, int]:
+ """Guest read: ``(handled, value)``. Unhandled accesses are logged.
+
+ The last matching window is asked first; a window that does not model
+ the offset defers to the next matching window.
+ """
+ matched = False
+ for win in self._candidates(addr, size):
+ matched = True
+ value = win.device.read(addr - win.base, size)
+ if value is not None:
+ self._record(addr, size, False, value, True, pc)
+ return True, value
+ self.unhandled += 1
+ if matched:
+ self._record(addr, size, False, None, False, pc)
+ return False, 0
+
+ def write(self, addr: int, size: int, value: int, pc: int = 0) -> bool:
+ """Guest write: True when handled. Unhandled accesses are logged.
+
+ The last matching window is asked first; a window that does not model
+ the offset defers to the next matching window.
+ """
+ matched = False
+ for win in self._candidates(addr, size):
+ matched = True
+ if win.device.write(addr - win.base, size, value):
+ self._record(addr, size, True, value, True, pc)
+ return True
+ self.unhandled += 1
+ if matched:
+ self._record(addr, size, True, value, False, pc)
+ return False
+
+ def peek(self, addr: int, size: int) -> bytes | None:
+ """Debugger read of device space: bytes, or None when unmapped."""
+ for win in self._candidates(addr, size):
+ value = win.device.peek(addr - win.base, size)
+ if value is not None:
+ return int(value).to_bytes(size, "little")
+ return None
+
+ # -- state -------------------------------------------------------------
+
+ def state(self) -> dict:
+ return {
+ "schema": SNAPSHOT_SCHEMA,
+ "devices": [{"name": d.name, "base": d.base, "size": d.size,
+ "state": d.state()} for d in self.devices],
+ "services": [{"name": getattr(s, "name", type(s).__name__),
+ "state": s.state()} for s in self.services],
+ "counters": {
+ "accesses": len(self.accesses),
+ "dropped_accesses": self.dropped_accesses,
+ "unhandled": self.unhandled,
+ },
+ }
+
+ def load_state(self, data: dict) -> None:
+ if data.get("schema") != SNAPSHOT_SCHEMA:
+ raise ValueError(f"not a {SNAPSHOT_SCHEMA} snapshot")
+ for entry in data.get("devices", []):
+ dev = self.device_by_name(entry.get("name", ""))
+ if dev is not None:
+ dev.load_state(entry.get("state", {}))
+ counters = data.get("counters", {})
+ self.dropped_accesses = int(counters.get("dropped_accesses", 0))
+ self.unhandled = int(counters.get("unhandled", 0))
+
+ def snapshot_json(self) -> str:
+ return json.dumps(self.state(), sort_keys=True)
+
+ def reset(self) -> None:
+ self.accesses.clear()
+ self.dropped_accesses = 0
+ self.unhandled = 0
+ for dev in self.devices:
+ dev.reset()
+ for service in self.services:
+ reset = getattr(service, "reset", None)
+ if callable(reset):
+ reset()
+
+ def describe(self) -> list[dict]:
+ return [w.to_dict() for w in self.windows]
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/csb.py b/debug/risc-v-gsp-rm-vm/sim/devices/csb.py
new file mode 100644
index 0000000..818a263
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/csb.py
@@ -0,0 +1,89 @@
+"""CSB / TRANSCOM message-window seam (B3 landing point).
+
+Evidence kind: **hardware register/field header + observed access pattern**
+(once the firmware reaches the message loop). At the time of writing the
+570.144 boot has not been observed polling the GSP message-queue CSB window,
+so this module deliberately models **no register semantics by default**: a
+window is only served when a profile declares it *and* lists the individual
+registers with their widths, reset values and semantics (``rw``, ``w1c``,
+``set``, ``clear``). Undeclared offsets stay unhandled and are logged.
+
+The CSB/TRANSCOM window carries the host<->GSP message queues and doorbells
+(head/tail pointers, interrupt set/clear); B3 requires the first such poll to
+return the documented read-back, which will be added here together with the
+observed access evidence and a test.
+"""
+from __future__ import annotations
+
+from .base import Device
+
+#: Per-register semantics accepted by a profile.
+SEMANTICS = ("rw", "ro", "wo", "w1c", "set", "clear")
+
+
+class CsbWindow(Device):
+ """Profile-declared CSB/mailbox register window (empty by default)."""
+
+ name = "csb"
+
+ def __init__(self, *, base: int = 0, size: int = 0x1000, registers=None):
+ super().__init__(base=base, size=size)
+ # registers: {offset: {"width": 4, "value": int, "semantics": "rw"}}
+ self.registers: dict[int, dict] = {}
+ for off, spec in (registers or {}).items():
+ self.declare(int(off), spec)
+ self.reset()
+
+ def declare(self, offset: int, spec: dict) -> None:
+ semantics = spec.get("semantics", "rw")
+ if semantics not in SEMANTICS:
+ raise ValueError(f"unknown CSB semantics {semantics!r}")
+ width = int(spec.get("width", 4))
+ if width not in (1, 2, 4, 8):
+ raise ValueError(f"unsupported CSB register width {width}")
+ self.registers[offset] = {
+ "width": width,
+ "reset": int(spec.get("value", 0)),
+ "semantics": semantics,
+ }
+
+ def reset(self) -> None:
+ self.values = {off: spec["reset"] for off, spec in self.registers.items()}
+
+ def _spec(self, offset: int, size: int) -> dict | None:
+ spec = self.registers.get(offset)
+ if spec is None or spec["width"] != size:
+ return None
+ return spec
+
+ def read(self, offset: int, size: int) -> int | None:
+ spec = self._spec(offset, size)
+ if spec is None or spec["semantics"] == "wo":
+ return None
+ return self.values.get(offset, 0)
+
+ def write(self, offset: int, size: int, value: int) -> bool:
+ spec = self._spec(offset, size)
+ if spec is None or spec["semantics"] == "ro":
+ return False
+ mask = (1 << (8 * size)) - 1
+ value &= mask
+ sem = spec["semantics"]
+ if sem == "w1c":
+ self.values[offset] = self.values.get(offset, 0) & ~value & mask
+ elif sem == "set":
+ self.values[offset] = (self.values.get(offset, 0) | value) & mask
+ elif sem == "clear":
+ self.values[offset] = (self.values.get(offset, 0) & ~value) & mask
+ else:
+ self.values[offset] = value
+ return True
+
+ def state(self) -> dict:
+ return {"values": {str(k): v for k, v in sorted(self.values.items())},
+ "declared": sorted(self.registers)}
+
+ def load_state(self, data: dict) -> None:
+ values = {int(k): int(v) for k, v in data.get("values", {}).items()}
+ self.values = {off: values.get(off, spec["reset"])
+ for off, spec in self.registers.items()}
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/dma.py b/debug/risc-v-gsp-rm-vm/sim/devices/dma.py
new file mode 100644
index 0000000..45d03e5
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/dma.py
@@ -0,0 +1,117 @@
+"""DMA / address-aperture translation seam.
+
+Evidence kind: **hardware address-map header + firmware use + observed
+access pattern (through the falcon DMA registers).**
+
+The GSP RISC-V address map (Turing) defines apertures the firmware and its
+DMA engine address in GPU physical space:
+
+* ``IMEM`` ``0x0_0000_0000`` (4 GiB window) -- local instruction memory
+* ``DMEM`` ``0x1_0000_0000`` (4 GiB window) -- local data memory
+* ``EMEM`` ``0x2_0000_0000`` (4 GiB window) -- external memory window
+* ``FBGPA`` ``0x6180_0000_0000_0000`` -- framebuffer aperture
+* ``SYSGPA````0x8180_0000_0000_0000`` -- system-memory aperture
+* ``PRIV`` ``0x2000_0000_0000_0000`` -- register aperture
+
+The simulator keeps local memory in one flat RAM region; framebuffer and
+system memory only exist as *profile-declared windows* that map a GPU
+physical aperture onto a simulator RAM address. A window not declared in the
+profile is not modelled: the transfer is refused and recorded (never
+silently zero-filled). This class is the single place where that mapping
+lives, so a future IOMMU model can replace it without touching devices.
+"""
+from __future__ import annotations
+
+
+class DmaTranslator:
+ """Translate GPU physical addresses to simulator RAM through windows."""
+
+ def __init__(self, *, ram, ram_base: int, ram_size: int, windows=None):
+ self.ram = ram
+ self.ram_base = ram_base
+ self.ram_size = ram_size
+ self.windows: list[tuple[int, int, int]] = [] # (pa_base, sim_base, size)
+ self.transfers = 0
+ self.refused = 0
+ self.events: list[dict] = []
+ for win in windows or ():
+ self.add_window(int(win["pa"]), int(win["sim"]), int(win["size"]))
+
+ def add_window(self, pa_base: int, sim_base: int, size: int) -> None:
+ if size <= 0:
+ raise ValueError("DMA window size must be positive")
+ self.windows.append((pa_base, sim_base, size))
+
+ def translate(self, pa: int) -> int | None:
+ """GPU physical address -> simulator address, or None if unmodelled."""
+ for pa_base, sim_base, size in reversed(self.windows):
+ if pa_base <= pa < pa_base + size:
+ return sim_base + (pa - pa_base)
+ return None
+
+ def read(self, pa: int, size: int) -> bytes | None:
+ sim = self.translate(pa)
+ if sim is None:
+ self._refuse(pa, size, "r")
+ return None
+ return self._read_sim(sim, size)
+
+ def write(self, pa: int, data: bytes) -> bool:
+ sim = self.translate(pa)
+ if sim is None:
+ self._refuse(pa, len(data), "w")
+ return False
+ self._write_sim(sim, data)
+ return True
+
+ def _in_ram(self, addr: int, size: int) -> bool:
+ off = addr - self.ram_base
+ return 0 <= off and off + size <= self.ram_size
+
+ def _read_sim(self, sim: int, size: int) -> bytes | None:
+ if not self._in_ram(sim, size):
+ self._refuse(sim, size, "r-sim")
+ return None
+ off = sim - self.ram_base
+ return bytes(self.ram[off:off + size])
+
+ def _write_sim(self, sim: int, data: bytes) -> bool:
+ if not self._in_ram(sim, len(data)):
+ self._refuse(sim, len(data), "w-sim")
+ return False
+ off = sim - self.ram_base
+ self.ram[off:off + len(data)] = data
+ return True
+
+ def copy(self, src_pa: int, dst_pa: int, size: int) -> bool:
+ """Copy ``size`` bytes between GPU physical addresses.
+
+ A transfer is counted only when both the source read and the
+ destination write complete; a refused destination write leaves the
+ transfer in :attr:`refused` and not in :attr:`transfers`.
+ """
+ data = self.read(src_pa, size)
+ if data is None:
+ return False
+ ok = self.write(dst_pa, data)
+ if ok:
+ self.transfers += 1
+ return ok
+
+ def _refuse(self, pa: int, size: int, kind: str) -> None:
+ self.refused += 1
+ if len(self.events) < 4096:
+ self.events.append({"pa": pa, "size": size, "kind": kind})
+
+ def state(self) -> dict:
+ return {
+ "windows": [{"pa": p, "sim": s, "size": n} for p, s, n in self.windows],
+ "transfers": self.transfers,
+ "refused": self.refused,
+ "events": list(self.events),
+ }
+
+ def reset(self) -> None:
+ self.transfers = 0
+ self.refused = 0
+ self.events.clear()
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/falcon.py b/debug/risc-v-gsp-rm-vm/sim/devices/falcon.py
new file mode 100644
index 0000000..8841906
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/falcon.py
@@ -0,0 +1,228 @@
+"""GSP falcon register block (boot polling + handshake + DMA).
+
+Evidence kind: **hardware register/field header + firmware use + observed
+access pattern.**
+
+The block is reached through the PRIV aperture (``PRIV + 0x110000``) and, in
+the libos kernel's private mapping, at virtual base ``0x3_0000_0000`` (the
+firmware accesses both forms during boot). Modelled registers, all with
+header offsets relative to the block:
+
+====================================== ====== ===== =========================
+register offset access semantics
+====================================== ====== ===== =========================
+``FALCON_IRQSSET`` 0x000 W ``irq_status |= value``
+``FALCON_IRQSCLR`` 0x004 W ``irq_status &= ~value``
+``FALCON_IRQSTAT`` 0x008 R ``irq_status``
+``FALCON_MAILBOX0`` 0x040 RW storage
+``FALCON_MAILBOX1`` 0x044 RW storage
+``FALCON_OS`` 0x080 RW storage
+``FALCON_DEBUGINFO`` 0x094 RW storage (+ host shim)
+``FALCON_CPUCTL`` 0x100 RW storage
+``FALCON_BOOTVEC`` 0x104 RW storage
+``FALCON_DMACTL`` 0x10c RW storage
+``FALCON_DMATRFBASE`` 0x110 RW storage
+``FALCON_DMATRFMOFFS`` 0x114 RW storage
+``FALCON_DMATRFCMD`` 0x118 RW transfer on write
+``FALCON_DMATRFFBOFFS`` 0x11c RW storage
+``FALCON_DMATRFBASE1`` 0x128 RW storage
+``FBIF_TRANSCFG(0..7)`` 0x600 RW storage
+``FBIF_CTL`` 0x624 RW storage
+====================================== ====== ===== =========================
+
+Observed firmware use (570.144): the boot code writes MAILBOX0 (progress
+code), MAILBOX1, DEBUGINFO (boot-stage marker ``0x11DEAD``) and IRQSSET
+``HALT`` (bit 4), then polls DEBUGINFO bits ``22:20`` until they clear; it
+reads back DMACTL/DMATRFCMD/DMATRFBASE/DMATRFBASE1/DMATRFMOFFS/
+DMATRFFBOFFS and IRQSTAT. The DMA transfer semantics (size encoding, IMEM/
+WRITE direction, ``((base1<<32|base)<<8)+fboffs`` addressing) follow the
+register header fields and are exercised only through the translation seam
+(:mod:`sim.devices.dma`); an unmapped transfer is refused and recorded, never
+zero-filled.
+
+The DEBUGINFO "host consume" behaviour is a **boot shim**, not a device
+claim: the firmware's own write-then-poll loop has no self-clearing path, and
+the documented debug channel is write-then-wait-for-zero (consumer is the
+host side). It is enabled per profile and recorded as an assumption.
+"""
+from __future__ import annotations
+
+from .base import Device
+
+# Register offsets (header values, relative to the falcon block base).
+IRQSSET = 0x000
+IRQSCLR = 0x004
+IRQSTAT = 0x008
+MAILBOX0 = 0x040
+MAILBOX1 = 0x044
+OS = 0x080
+DEBUGINFO = 0x094
+CPUCTL = 0x100
+BOOTVEC = 0x104
+DMACTL = 0x10C
+DMATRFBASE = 0x110
+DMATRFMOFFS = 0x114
+DMATRFCMD = 0x118
+DMATRFFBOFFS = 0x11C
+DMATRFBASE1 = 0x128
+FBIF_TRANSCFG0 = 0x600
+FBIF_TRANSCFG_STRIDE = 4
+FBIF_TRANSCFG_COUNT = 8
+FBIF_CTL = 0x624
+
+# DMATRFCMD fields (header).
+CMD_FULL = 1 << 0
+CMD_IDLE = 1 << 1
+CMD_IMEM = 1 << 4
+CMD_WRITE = 1 << 5
+CMD_SIZE_SHIFT = 8
+CMD_SIZE_MASK = 0x7
+CMD_CTXDMA_SHIFT = 12
+CMD_CTXDMA_MASK = 0x7
+CMD_ERROR = 1 << 20
+
+#: IRQSSET/IRQSCLR bit 4 is HALT in the header's field list; the firmware's
+#: boot handshake sets it after writing MAILBOX0/DEBUGINFO.
+IRQ_HALT = 1 << 4
+
+# IMEM/DMEM apertures of the GSP RISC-V address map (header constants).
+AMAP_IMEM_START = 0x0000000000000000
+AMAP_DMEM_START = 0x0000000100000000
+
+_RW_REGISTERS = {
+ MAILBOX0, MAILBOX1, OS, DEBUGINFO, CPUCTL, BOOTVEC, DMACTL, DMATRFBASE,
+ DMATRFMOFFS, DMATRFCMD, DMATRFFBOFFS, DMATRFBASE1, FBIF_CTL,
+}
+
+
+class FalconBlock(Device):
+ """Falcon/GSP register block with boot-handshake and DMA semantics."""
+
+ name = "falcon"
+
+ def __init__(self, *, base: int = 0, size: int = 0x1000, dma=None,
+ debuginfo_host_consume: bool = False,
+ debuginfo_stage_mask: int = 0x700000,
+ debuginfo_stage_shift: int = 20):
+ super().__init__(base=base, size=size)
+ self.dma = dma
+ self.debuginfo_host_consume = bool(debuginfo_host_consume)
+ self.debuginfo_stage_mask = debuginfo_stage_mask
+ self.debuginfo_stage_shift = debuginfo_stage_shift
+ self.reset()
+
+ def reset(self) -> None:
+ self.irq_status = 0
+ self.regs: dict[int, int] = {off: 0 for off in _RW_REGISTERS}
+ self.regs[DMATRFCMD] = CMD_IDLE
+ self.transcfg = [0] * FBIF_TRANSCFG_COUNT
+ self.dma_transfers = 0
+ self.dma_refused = 0
+ self.debuginfo_consumed = 0
+
+ # -- access ------------------------------------------------------------
+
+ def read(self, offset: int, size: int) -> int | None:
+ if size != 4:
+ return None
+ if offset == IRQSTAT:
+ return self.irq_status
+ if offset == DEBUGINFO:
+ value = self.regs.get(DEBUGINFO, 0)
+ if self.debuginfo_host_consume:
+ stage = (value & self.debuginfo_stage_mask) >> self.debuginfo_stage_shift
+ if stage:
+ # Boot shim: the host consumes the stage marker (documented
+ # write-then-wait-for-zero debug channel); the rest of the
+ # register is preserved.
+ self.regs[DEBUGINFO] = value & ~self.debuginfo_stage_mask
+ self.debuginfo_consumed += 1
+ return value
+ if offset in self.regs:
+ return self.regs[offset]
+ if FBIF_TRANSCFG0 <= offset < FBIF_TRANSCFG0 + 4 * FBIF_TRANSCFG_COUNT:
+ return self.transcfg[(offset - FBIF_TRANSCFG0) // 4]
+ return None
+
+ def write(self, offset: int, size: int, value: int) -> bool:
+ if size != 4:
+ return False
+ value &= 0xFFFFFFFF
+ if offset == IRQSSET:
+ self.irq_status = (self.irq_status | value) & 0xFFFFFFFF
+ return True
+ if offset == IRQSCLR:
+ self.irq_status = (self.irq_status & ~value) & 0xFFFFFFFF
+ return True
+ if offset == IRQSTAT:
+ return False # read-only
+ if offset == DMATRFCMD:
+ self.regs[DMATRFCMD] = value
+ self._dma_transfer(value)
+ return True
+ if offset in self.regs:
+ self.regs[offset] = value
+ return True
+ if FBIF_TRANSCFG0 <= offset < FBIF_TRANSCFG0 + 4 * FBIF_TRANSCFG_COUNT:
+ self.transcfg[(offset - FBIF_TRANSCFG0) // 4] = value
+ return True
+ return False
+
+ def peek(self, offset: int, size: int) -> int | None:
+ # No shim side effects for debugger reads.
+ if size != 4:
+ return None
+ if offset == IRQSTAT:
+ return self.irq_status
+ if offset in self.regs:
+ return self.regs[offset]
+ if FBIF_TRANSCFG0 <= offset < FBIF_TRANSCFG0 + 4 * FBIF_TRANSCFG_COUNT:
+ return self.transcfg[(offset - FBIF_TRANSCFG0) // 4]
+ return None
+
+ # -- DMA ---------------------------------------------------------------
+
+ def _dma_transfer(self, cmd: int) -> None:
+ """Execute one falcon DMA transfer (header field semantics)."""
+ if self.dma is None:
+ self.dma_refused += 1
+ return
+ size = 1 << (((cmd >> CMD_SIZE_SHIFT) & CMD_SIZE_MASK) + 2)
+ tcm = AMAP_IMEM_START if (cmd & CMD_IMEM) else AMAP_DMEM_START
+ base = ((self.regs.get(DMATRFBASE1, 0) & 0x1FF) << 32) | self.regs.get(DMATRFBASE, 0)
+ fb = (base << 8) + self.regs.get(DMATRFFBOFFS, 0)
+ tcm_off = self.regs.get(DMATRFMOFFS, 0) & 0xFFFF
+ if cmd & CMD_WRITE:
+ ok = self.dma.copy(tcm + tcm_off, fb, size)
+ else:
+ ok = self.dma.copy(fb, tcm + tcm_off, size)
+ if ok:
+ self.dma_transfers += 1
+ self.regs[DMATRFCMD] = (cmd | CMD_IDLE) & ~CMD_ERROR
+ else:
+ self.dma_refused += 1
+ self.regs[DMATRFCMD] = (cmd & ~CMD_IDLE) | CMD_ERROR
+
+ # -- state -------------------------------------------------------------
+
+ def state(self) -> dict:
+ return {
+ "irq_status": self.irq_status,
+ "regs": {str(k): v for k, v in sorted(self.regs.items())},
+ "transcfg": list(self.transcfg),
+ "dma_transfers": self.dma_transfers,
+ "dma_refused": self.dma_refused,
+ "debuginfo_consumed": self.debuginfo_consumed,
+ "debuginfo_host_consume": self.debuginfo_host_consume,
+ }
+
+ def load_state(self, data: dict) -> None:
+ self.irq_status = int(data.get("irq_status", 0))
+ regs = {int(k): int(v) for k, v in data.get("regs", {}).items()}
+ self.regs = {off: regs.get(off, 0) for off in _RW_REGISTERS}
+ self.transcfg = [int(v) for v in data.get("transcfg", [0] * FBIF_TRANSCFG_COUNT)]
+ self.dma_transfers = int(data.get("dma_transfers", 0))
+ self.dma_refused = int(data.get("dma_refused", 0))
+ self.debuginfo_consumed = int(data.get("debuginfo_consumed", 0))
+ self.debuginfo_host_consume = bool(
+ data.get("debuginfo_host_consume", self.debuginfo_host_consume))
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py b/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py
new file mode 100644
index 0000000..8dcf633
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py
@@ -0,0 +1,80 @@
+"""WPR2 / FWSEC state registers.
+
+Evidence kind: **hardware register/field header + firmware use** (the boot
+flow locates the firmware write-protected region from these registers on
+pre-Hopper parts; the host/booter writes them, the firmware reads them).
+
+Modelled registers (offsets in the FB/PFB register aperture):
+
+* ``NV_PFB_PRI_MMU_WPR2_ADDR_LO`` ``0x1FA824``: WPR2 start, bits ``31:4``
+ shifted by ``NV_PFB_PRI_MMU_WPR2_ADDR_LO_ALIGNMENT = 12`` (4 KiB).
+* ``NV_PFB_PRI_MMU_WPR2_ADDR_HI`` ``0x1FA828``: points at the start of the
+ last 128 KiB block of the WPR, same alignment; the end is
+ ``(value << 12) + 0x20000``.
+
+A profile that wants the firmware to see a WPR declares ``wpr2_start`` /
+``wpr2_end``; without a declaration the window is unhandled and logged.
+FWSEC-specific state is not modelled (no proven register use yet).
+"""
+from __future__ import annotations
+
+from .base import Device
+
+WPR2_ADDR_LO = 0x1FA824
+WPR2_ADDR_HI = 0x1FA828
+WPR2_ALIGNMENT = 12
+WPR2_BLOCK = 0x20000
+
+
+class Wpr2Block(Device):
+ """WPR2 address pair as seen by the firmware (host/booter-written)."""
+
+ name = "fwsec"
+
+ def __init__(self, *, base: int = 0, size: int = 0x1000,
+ wpr2_start: int | None = None, wpr2_end: int | None = None):
+ super().__init__(base=base, size=size)
+ self.reset()
+ if wpr2_start is not None and wpr2_end is not None:
+ self.set_range(wpr2_start, wpr2_end)
+
+ def set_range(self, start: int, end: int) -> None:
+ if end <= start:
+ raise ValueError("WPR2 end must be greater than start")
+ self.regs[WPR2_ADDR_LO] = (start >> WPR2_ALIGNMENT) & 0xFFFFFFFF
+ self.regs[WPR2_ADDR_HI] = ((end - WPR2_BLOCK) >> WPR2_ALIGNMENT) & 0xFFFFFFFF
+
+ @property
+ def start(self) -> int | None:
+ """WPR2 start derived from ADDR_LO (``None`` when unset)."""
+ lo = self.regs.get(WPR2_ADDR_LO)
+ return None if lo is None else lo << WPR2_ALIGNMENT
+
+ @property
+ def end(self) -> int | None:
+ """WPR2 end derived from ADDR_HI (``None`` when unset)."""
+ hi = self.regs.get(WPR2_ADDR_HI)
+ return None if hi is None else (hi << WPR2_ALIGNMENT) + WPR2_BLOCK
+
+ def reset(self) -> None:
+ self.regs: dict[int, int] = {}
+
+ def read(self, offset: int, size: int) -> int | None:
+ if size == 4 and offset in self.regs:
+ return self.regs[offset]
+ return None
+
+ def write(self, offset: int, size: int, value: int) -> bool:
+ if size == 4 and offset in (WPR2_ADDR_LO, WPR2_ADDR_HI):
+ self.regs[offset] = value & 0xFFFFFFFF
+ return True
+ return False
+
+ def state(self) -> dict:
+ return {"regs": {str(k): v for k, v in sorted(self.regs.items())},
+ "start": self.start, "end": self.end}
+
+ def load_state(self, data: dict) -> None:
+ # start/end are derived from regs; serialised copies are ignored so a
+ # restored block can never report a stale range.
+ self.regs = {int(k): int(v) for k, v in data.get("regs", {}).items()}
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/irq.py b/debug/risc-v-gsp-rm-vm/sim/devices/irq.py
new file mode 100644
index 0000000..7ce63fb
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/irq.py
@@ -0,0 +1,58 @@
+"""IRQ routing seam.
+
+Evidence kind: **hardware register/field header + observed access pattern.**
+
+The GSP boot handshake sets the falcon ``IRQSSET`` HALT bit (bit 4) after
+writing MAILBOX0/DEBUGINFO, and the boot code reads ``FALCON_IRQSTAT``; the
+libos kernel owns the RISC-V-local ``RISCV_IRQMSET``/``IRQMCLR``/``IRQMASK``
+group. This module aggregates those two sources behind one object so a
+future core-interrupt model can consume a single ``pending()`` surface.
+
+It does **not** deliver interrupts to the machine's hart: mstatus/mie/mip/
+mtvec trap entry is not modelled (documented unknown). It only makes the
+routing state explicit and serialisable.
+"""
+from __future__ import annotations
+
+# Falcon IRQSSET/IRQSCLR bit positions used by the boot handshake (header
+# field list; only the ones with proven firmware use are named).
+FALCON_IRQ_GPTMR = 1 << 0
+FALCON_IRQ_WDTMR = 1 << 1
+FALCON_IRQ_MTHD = 1 << 2
+FALCON_IRQ_CTXSW = 1 << 3
+FALCON_IRQ_HALT = 1 << 4
+FALCON_IRQ_EXTERR = 1 << 5
+FALCON_IRQ_SWGEN0 = 1 << 6
+FALCON_IRQ_SWGEN1 = 1 << 7
+FALCON_IRQ_DMA = 1 << 16
+FALCON_IRQ_SHA = 1 << 17
+
+
+class IrqRouter:
+ """Aggregate falcon + RISC-V-local interrupt state."""
+
+ name = "irq"
+
+ def __init__(self, *, falcon=None, priscv=None):
+ self.falcon = falcon
+ self.priscv = priscv
+
+ def falcon_status(self) -> int:
+ return self.falcon.irq_status if self.falcon is not None else 0
+
+ def riscv_mask(self) -> int:
+ return self.priscv.irq_mask if self.priscv is not None else 0
+
+ def pending(self) -> dict:
+ """Raw pending state; delivery is deliberately not modelled."""
+ return {
+ "falcon_irq_status": self.falcon_status(),
+ "falcon_halt": bool(self.falcon_status() & FALCON_IRQ_HALT),
+ "riscv_irq_mask": self.riscv_mask(),
+ }
+
+ def state(self) -> dict:
+ return self.pending()
+
+ def reset(self) -> None:
+ pass
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/pmc.py b/debug/risc-v-gsp-rm-vm/sim/devices/pmc.py
new file mode 100644
index 0000000..1ccf1cd
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/pmc.py
@@ -0,0 +1,90 @@
+"""PMC / master register block (first-touch device).
+
+Evidence kind: **hardware register/field header + firmware use + observed
+access pattern.**
+
+* ``NV_PMC_BOOT_42`` lives at offset ``0xA00`` of the master (PMC) block and is
+ read by the GSP boot code through the PRIV window at
+ ``0x2000_0000_0000_0A00`` (observed 4-byte read, first MMIO of the real
+ 570.144 image; the QEMU baseline faults there).
+* The firmware extracts bits ``28:20`` (``NV_PMC_BOOT_42_CHIP_ID``) and
+ compares against the TU10x IDs ``0x162..0x168``; only a match continues the
+ boot (observed branch chain at firmware vaddr ``0x4009020``).
+* ``NV_PMC_BOOT_42`` field layout: ARCHITECTURE ``28:24`` (TU100 = ``0x16``),
+ IMPLEMENTATION ``23:20``, MAJOR_REVISION ``19:16``, MINOR_REVISION
+ ``15:12``, MINOR_EXTENDED_REVISION ``11:8``, CHIP_ID ``28:20``.
+
+Only ``0xA00`` is modelled; every other offset in the window stays unhandled
+and is logged by the machine.
+"""
+from __future__ import annotations
+
+from .base import Device
+
+BOOT_42_OFFSET = 0xA00
+CHIP_ID_SHIFT = 20
+CHIP_ID_MASK = 0x1FF
+ARCHITECTURE_MASK = 0x1F
+IMPLEMENTATION_MASK = 0xF
+MAJOR_MASK = 0xF
+MINOR_MASK = 0xF
+MINOR_EXT_MASK = 0xF
+
+#: Chip IDs seen in the firmware's comparison chain (header field values).
+#: TU105 (0x165) is kept because it appears in the observed chain, but its
+#: product mapping is unverified. The former ``TU107 = 0x167`` entry was a
+#: transcription error (0x167 is TU117) and has been removed.
+TU102 = 0x162
+TU104 = 0x164
+TU105 = 0x165 # unverified product mapping (observed in the chain only)
+TU106 = 0x166
+TU116 = 0x168
+TU117 = 0x167
+
+
+def boot_42_value(chip_id: int, *, major: int = 0, minor: int = 0,
+ minor_ext: int = 0) -> int:
+ """Assemble an ``NV_PMC_BOOT_42`` value from its documented fields.
+
+ ARCHITECTURE and IMPLEMENTATION are derived from ``chip_id`` (they are the
+ upper/lower nibbles of the same bits ``28:20``), matching the header's
+ ``CHIP_ID_TU104 = 0x164`` / ``ARCHITECTURE_TU100 = 0x16`` pair.
+ """
+ chip_id &= CHIP_ID_MASK
+ arch = (chip_id >> 4) & ARCHITECTURE_MASK
+ impl = chip_id & IMPLEMENTATION_MASK
+ value = (chip_id << CHIP_ID_SHIFT)
+ value |= (arch << 24) | (impl << 20)
+ value |= (major & MAJOR_MASK) << 16
+ value |= (minor & MINOR_MASK) << 12
+ value |= (minor_ext & MINOR_EXT_MASK) << 8
+ return value
+
+
+class PmcBlock(Device):
+ """Master/PMC block: ``NV_PMC_BOOT_42`` chip identification."""
+
+ name = "pmc"
+
+ def __init__(self, *, chip_id: int = TU104, major: int = 0, minor: int = 0,
+ minor_ext: int = 0, boot_42: int | None = None,
+ base: int = 0, size: int = 0x1000):
+ super().__init__(base=base, size=size)
+ self.chip_id = chip_id
+ self.boot_42 = boot_42 if boot_42 is not None else boot_42_value(
+ chip_id, major=major, minor=minor, minor_ext=minor_ext)
+
+ def read(self, offset: int, size: int) -> int | None:
+ if offset == BOOT_42_OFFSET and size == 4:
+ return self.boot_42
+ return None
+
+ def peek(self, offset: int, size: int) -> int | None:
+ return self.read(offset, size)
+
+ def write(self, offset: int, size: int, value: int) -> bool:
+ # NV_PMC_BOOT_42 is read-only (R--4R).
+ return False
+
+ def state(self) -> dict:
+ return {"chip_id": self.chip_id, "boot_42": self.boot_42}
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/pmu.py b/debug/risc-v-gsp-rm-vm/sim/devices/pmu.py
new file mode 100644
index 0000000..b241c7e
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/pmu.py
@@ -0,0 +1,46 @@
+"""PMU stub.
+
+Evidence kind: **none yet** (no PMU register has been observed in the
+570.144 boot runs, and no PMU register semantics are needed to reach the
+rungs achieved so far). The class exists so a profile can attach a PMU
+window without inventing behaviour: every access stays unhandled and is
+reported by the machine's unknown log, and the stub records the accesses it
+saw for later identification.
+
+When PMU accesses are observed, each register is added here with its header
+field evidence and a test, exactly like the falcon block.
+"""
+from __future__ import annotations
+
+from .base import Device
+
+
+class PmuStub(Device):
+ """A PMU window with no proven semantics: observe, never fake."""
+
+ name = "pmu"
+
+ def __init__(self, *, base: int = 0, size: int = 0x1000):
+ super().__init__(base=base, size=size)
+ self.reset()
+
+ def reset(self) -> None:
+ self.seen: dict[str, int] = {}
+
+ def read(self, offset: int, size: int) -> int | None:
+ self._note(offset, size, "r")
+ return None
+
+ def write(self, offset: int, size: int, value: int) -> bool:
+ self._note(offset, size, "w")
+ return False
+
+ def peek(self, offset: int, size: int) -> int | None:
+ return None
+
+ def _note(self, offset: int, size: int, rw: str) -> None:
+ key = f"{rw}:{offset:#x}:{size}"
+ self.seen[key] = self.seen.get(key, 0) + 1
+
+ def state(self) -> dict:
+ return {"seen": dict(sorted(self.seen.items()))}
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/priscv.py b/debug/risc-v-gsp-rm-vm/sim/devices/priscv.py
new file mode 100644
index 0000000..62a8fea
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/priscv.py
@@ -0,0 +1,113 @@
+"""RISC-V local (PRISCV) register block.
+
+Evidence kind: **hardware register/field header + observed access pattern.**
+
+Reached through the PRIV aperture at ``PRIV + 0x111000`` and through the libos
+kernel's private mapping at ``0x3_0000_1000`` (the firmware reads the trace
+and error-status registers there during boot). Modelled registers:
+
+================================ ====== ===== ==============================
+register offset access semantics
+================================ ====== ===== ==============================
+``RISCV_IRQMSET`` 0x2ac W ``irq_mask |= value``
+``RISCV_IRQMCLR`` 0x2b0 W ``irq_mask &= ~value``
+``RISCV_IRQMASK`` 0x2b4 R ``irq_mask``
+``RISCV_IRQDEST`` 0x2b8 RW storage
+``RISCV_IRQTYPE`` 0x2bc RW storage
+``RISCV_TRACECTL`` 0x344 RW storage
+``RISCV_TRACE_RDIDX`` 0x348 RW storage
+``RISCV_TRACE_WTIDX`` 0x34c RW storage
+``RISCV_PRIV_ERR_STAT`` 0x360 RW storage
+``RISCV_HUB_ERR_STAT`` 0x36c RW storage
+================================ ====== ===== ==============================
+
+The IRQ mask semantics (set/clear/read) follow the header access types and the
+observed boot reads; core interrupt *delivery* (mtvec/mip) is not part of this
+device and remains unmodelled (documented unknown).
+"""
+from __future__ import annotations
+
+from .base import Device
+
+IRQMSET = 0x2AC
+IRQMCLR = 0x2B0
+IRQMASK = 0x2B4
+IRQDEST = 0x2B8
+IRQTYPE = 0x2BC
+TRACECTL = 0x344
+TRACE_RDIDX = 0x348
+TRACE_WTIDX = 0x34C
+PRIV_ERR_STAT = 0x360
+PRIV_ERR_INFO = 0x364
+PRIV_ERR_ADDR = 0x368
+HUB_ERR_STAT = 0x36C
+
+_RW = {IRQDEST, IRQTYPE, TRACECTL, TRACE_RDIDX, TRACE_WTIDX,
+ PRIV_ERR_STAT, HUB_ERR_STAT}
+#: Read-only error capture registers (header R-I4R); the boot panic path
+#: reads them after a trap (observed reads at PC 0x40106BC).
+_RO = {PRIV_ERR_INFO, PRIV_ERR_ADDR}
+
+
+class PriscvBlock(Device):
+ """RISC-V-local control/trace/error registers."""
+
+ name = "priscv"
+
+ def __init__(self, *, base: int = 0, size: int = 0x1000):
+ super().__init__(base=base, size=size)
+ self.reset()
+
+ def reset(self) -> None:
+ self.irq_mask = 0
+ self.regs: dict[int, int] = {off: 0 for off in _RW}
+ self.error: dict[int, int] = {off: 0 for off in _RO}
+
+ def read(self, offset: int, size: int) -> int | None:
+ if size != 4:
+ return None
+ if offset == IRQMASK:
+ return self.irq_mask
+ if offset in self.regs:
+ return self.regs[offset]
+ if offset in self.error:
+ return self.error[offset]
+ return None
+
+ def write(self, offset: int, size: int, value: int) -> bool:
+ if size != 4:
+ return False
+ value &= 0xFFFFFFFF
+ if offset == IRQMSET:
+ self.irq_mask = (self.irq_mask | value) & 0xFFFFFFFF
+ return True
+ if offset == IRQMCLR:
+ self.irq_mask = (self.irq_mask & ~value) & 0xFFFFFFFF
+ return True
+ if offset == IRQMASK:
+ return False # read-only
+ if offset in self.regs:
+ self.regs[offset] = value
+ return True
+ return False
+
+ def peek(self, offset: int, size: int) -> int | None:
+ if size != 4:
+ return None
+ if offset == IRQMASK:
+ return self.irq_mask
+ if offset in self.error:
+ return self.error[offset]
+ return self.regs.get(offset)
+
+ def state(self) -> dict:
+ return {"irq_mask": self.irq_mask,
+ "regs": {str(k): v for k, v in sorted(self.regs.items())},
+ "error": {str(k): v for k, v in sorted(self.error.items())}}
+
+ def load_state(self, data: dict) -> None:
+ self.irq_mask = int(data.get("irq_mask", 0))
+ regs = {int(k): int(v) for k, v in data.get("regs", {}).items()}
+ self.regs = {off: regs.get(off, 0) for off in _RW}
+ err = {int(k): int(v) for k, v in data.get("error", {}).items()}
+ self.error = {off: err.get(off, 0) for off in _RO}
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/timer.py b/debug/risc-v-gsp-rm-vm/sim/devices/timer.py
new file mode 100644
index 0000000..c57ee06
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/timer.py
@@ -0,0 +1,86 @@
+"""PTIMER block (modeled virtual time; not a silicon timing claim).
+
+Evidence kind: **hardware register/field header** (offsets and field layout)
+plus a *modeled* time source. The firmware has not yet been observed reading
+this block in the 570.144 boot runs (the observed time-like reads were on
+falcon MAILBOX1); the device is therefore opt-in: it is registered only when
+a profile declares a ``timer`` section, and a section with
+``"enabled": false`` is skipped. Its values are explicitly labelled
+*modeled*.
+
+Header facts used:
+
+* ``NV_PTIMER`` block base ``0x9000`` in the register aperture.
+* ``NV_PTIMER_TIME_0`` at aperture offset ``0x9400``: nanoseconds bits
+ ``31:5``.
+* ``NV_PTIMER_TIME_1`` at aperture offset ``0x9410``: nanoseconds bits
+ ``28:0``.
+* Reads of TIME_0 latch TIME_1 (paired read), as on the hardware register
+ pair; the simulator derives nanoseconds from the machine's instruction
+ count times the profile's ``ns_per_insn`` (default 1.0, i.e. 1 ns/insn --
+ a placeholder, not calibrated).
+
+Register offsets below are **relative to the block window base** the profile
+declares (``PRIV + 0x9000``), i.e. aperture offset minus ``PTIMER_BASE``:
+``0x9400 - 0x9000 = 0x400``. Using the aperture-absolute numbers here would
+place the registers outside the declared window and serve nothing.
+"""
+from __future__ import annotations
+
+from .base import Device
+
+PTIMER_BASE = 0x9000
+#: Block-relative offsets of NV_PTIMER_TIME_0/1 (aperture 0x9400/0x9410).
+TIME_0 = 0x400
+TIME_1 = 0x410
+
+
+class PtimerBlock(Device):
+ """Two-register nanosecond timer with a modeled tick source."""
+
+ name = "timer"
+
+ def __init__(self, *, base: int = 0, size: int = 0x1000,
+ ns_per_insn: float = 1.0, clock=None):
+ super().__init__(base=base, size=size)
+ self.ns_per_insn = float(ns_per_insn)
+ self.clock = clock # callable returning virtual instruction count
+ self._latch = 0
+ self.reads = 0
+
+ def _now_ns(self) -> int:
+ insns = self.clock() if self.clock is not None else 0
+ return int(insns * self.ns_per_insn) & 0xFFFFFFFFFFFFFFFF
+
+ def read(self, offset: int, size: int) -> int | None:
+ if size != 4:
+ return None
+ if offset == TIME_0:
+ self._latch = self._now_ns()
+ self.reads += 1
+ return (self._latch >> 5) & 0xFFFFFFFF # NSEC field 31:5
+ if offset == TIME_1:
+ self.reads += 1
+ return self._latch & 0x1FFFFFFF # NSEC field 28:0
+ return None
+
+ def peek(self, offset: int, size: int) -> int | None:
+ if size != 4:
+ return None
+ if offset == TIME_0:
+ return (self._now_ns() >> 5) & 0xFFFFFFFF
+ if offset == TIME_1:
+ return self._now_ns() & 0x1FFFFFFF
+ return None
+
+ def write(self, offset: int, size: int, value: int) -> bool:
+ return False
+
+ def state(self) -> dict:
+ return {"ns_per_insn": self.ns_per_insn, "latch": self._latch,
+ "reads": self.reads}
+
+ def load_state(self, data: dict) -> None:
+ self.ns_per_insn = float(data.get("ns_per_insn", self.ns_per_insn))
+ self._latch = int(data.get("latch", 0))
+ self.reads = int(data.get("reads", 0))
diff --git a/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py b/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py
new file mode 100644
index 0000000..c586ab3
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py
@@ -0,0 +1,548 @@
+"""Real-firmware GSP boot runner (device model + versioned profile + shim).
+
+Loads ``gsp-570.144.fwimage.bin`` at its **link addresses** (RAM base 0), builds
+the :mod:`sim.devices` registry from a ``greeneye-sim-profile/1`` document, runs
+bounded (<= 2M instructions per attempt), and records a normal
+``greeneye-session/1`` directory carrying the device state, unknown-MMIO
+summary and the boot-shim assumptions.
+
+Boot ladder rungs (docs/35-GREENEYE-SIMULATOR-DESIGN.md §3.3) reported by
+:class:`GspBootResult`:
+
+* ``B0`` -- flat image loads, first MMIO observed at ``0x2000_0000_0000_0A00``.
+* ``B1`` -- first fault past the QEMU point, explained by the device model
+ (the QEMU fault is the PMC ``BOOT_42`` chip-ID read; modelled by
+ :class:`sim.devices.pmc.PmcBlock`).
+* ``B2`` -- boot-stage progress: vector/MPU/TCM setup and bootargs consumption
+ exercised with the profile.
+* ``B3`` -- first CSB/TRANSCOM message-window poll served with read-back.
+* ``B4`` -- message loop reached (waits on a host message).
+
+The boot shim supplies reset-time state that the real SEC2 booter/host would
+provide (bootargs and the register arguments); every shim value is an
+explicit, documented assumption in the profile and the session metadata. The
+device model itself never fakes a register read.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import sys
+from dataclasses import dataclass, field
+
+from greeneye.device_map import firmware_sha256
+from greeneye.elf import load_elf
+from greeneye.errors import GreenEyeError
+from sim.devices import load_profile, registry_from_profile
+from sim.machine import (
+ R_BP,
+ R_BUDGET,
+ R_EBREAK,
+ R_ECALL,
+ R_FAULT,
+ R_STEP,
+ R_WATCH,
+)
+
+TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DEFAULT_IMAGE = os.path.normpath(
+ 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")
+
+#: Hard cap for one attempt (task rule: <= 2M instructions per attempt).
+MAX_ATTEMPT_INSTRUCTIONS = 2_000_000
+#: Default bounded budget for a boot attempt.
+DEFAULT_INSTRUCTIONS = 2_000_000
+
+STOP_REASONS = {
+ R_BUDGET: "budget",
+ R_BP: "breakpoint",
+ R_WATCH: "watch",
+ R_EBREAK: "ebreak",
+ R_FAULT: "fault",
+ R_STEP: "step",
+ R_ECALL: "ecall",
+}
+
+# GSP RISC-V address-map aperture starts (header constants).
+SYSGPA_START = 0x8180000000000000
+FBGPA_START = 0x6180000000000000
+PRIV_START = 0x2000000000000000
+
+# GspFwWprMeta field constants (header).
+GSP_FW_WPR_META_MAGIC = 0xdc3aae21371a60b3
+GSP_FW_WPR_META_REVISION = 1
+GSP_FW_WPR_META_VERIFIED = 0xa0a0a0a0a0a0a0a0
+WPR_META_SIZE = 256
+
+# NV_RISCV_BOOTLDR_PARAMS / NV_RISCV_BOOT_PARAMS layout (header).
+BOOTLDR_BOOT_TYPE_RM = 0x4E56524D # 'NVRM'
+BOOTLDR_VERSION = 1
+BOOTARGS_DEFAULT_SIZE = 0x78 # NV_RISCV_BOOT_PARAMS (120 bytes)
+LIBOS_MEMORY_REGION_CONTIGUOUS = 1
+LIBOS_MEMORY_REGION_LOC_FB = 2
+LIBOS_MEMORY_REGION_LOC_SYSMEM = 1
+WPRM_ID8 = 0x5750524D # 'WPRM'
+
+
+def _int(value, default=None):
+ if value is None:
+ return default
+ if isinstance(value, str):
+ return int(value, 0)
+ return int(value)
+
+
+def _write_u32(machine, addr: int, value: int) -> None:
+ machine.write_mem(addr, (value & 0xFFFFFFFF).to_bytes(4, "little"))
+
+
+def _write_u64(machine, addr: int, value: int) -> None:
+ machine.write_mem(addr, (value & 0xFFFFFFFFFFFFFFFF).to_bytes(8, "little"))
+
+
+def build_bootargs(machine, *, bootargs_pa: int, core_dump_pa: int,
+ core_dump_size: int, wpr_meta_pa: int,
+ wpr_meta_size: int = WPR_META_SIZE,
+ bootargs_size: int = BOOTARGS_DEFAULT_SIZE) -> None:
+ """Write ``NV_RISCV_BOOT_PARAMS`` and its ``WPRM`` init arg.
+
+ Assumption (profile-recorded): the real SEC2 booter/host fills these; we
+ reconstruct the documented structure and one ``WPRM`` libos init argument
+ pointing at the ``GspFwWprMeta`` written by :func:`build_wpr_meta`.
+ """
+ _write_u32(machine, bootargs_pa + 0x00, BOOTLDR_BOOT_TYPE_RM)
+ machine.write_mem(bootargs_pa + 0x04, (bootargs_size & 0xFFFF).to_bytes(2, "little"))
+ machine.write_mem(bootargs_pa + 0x06, bytes([BOOTLDR_VERSION]))
+ machine.write_mem(bootargs_pa + 0x07, b"\x00")
+ _write_u64(machine, bootargs_pa + 0x08, core_dump_pa)
+ _write_u32(machine, bootargs_pa + 0x10, core_dump_size)
+ # libos.initArgs[0]: WPRM in FB, contiguous.
+ base = bootargs_pa + 0x18
+ _write_u64(machine, base + 0x00, WPRM_ID8)
+ _write_u64(machine, base + 0x08, wpr_meta_pa)
+ _write_u64(machine, base + 0x10, wpr_meta_size)
+ machine.write_mem(base + 0x18, bytes([LIBOS_MEMORY_REGION_CONTIGUOUS,
+ LIBOS_MEMORY_REGION_LOC_FB]))
+ # initArgs[1..2] stay zero (id8 == 0 terminates the array).
+
+
+def build_wpr_meta(machine, *, wpr_meta_pa: int, elf_pa: int, elf_size: int,
+ wpr_start: int, wpr_end: int,
+ boot_bin_offset: int, gsp_fw_offset: int,
+ fb_size: int) -> None:
+ """Write a ``GspFwWprMeta`` (header layout) into RAM.
+
+ Assumption (profile-recorded): the booter/CPU-RM produces this structure;
+ we place it with a self-consistent layout that satisfies the documented
+ validation (magic/revision/verified, monotonic FB ranges). Values are
+ declared in the profile, never derived from silicon.
+ """
+ machine.write_mem(wpr_meta_pa, bytes(WPR_META_SIZE))
+ _write_u64(machine, wpr_meta_pa + 0x00, GSP_FW_WPR_META_MAGIC)
+ _write_u64(machine, wpr_meta_pa + 0x08, GSP_FW_WPR_META_REVISION)
+ _write_u64(machine, wpr_meta_pa + 0x10, elf_pa)
+ _write_u64(machine, wpr_meta_pa + 0x18, elf_size)
+ _write_u64(machine, wpr_meta_pa + 0x20, 0) # sysmemAddrOfBootloader
+ _write_u64(machine, wpr_meta_pa + 0x28, 0) # sizeOfBootloader
+ _write_u64(machine, wpr_meta_pa + 0x30, 0) # bootloaderCodeOffset
+ _write_u64(machine, wpr_meta_pa + 0x38, 0) # bootloaderDataOffset
+ _write_u64(machine, wpr_meta_pa + 0x40, 0) # bootloaderManifestOffset
+ _write_u64(machine, wpr_meta_pa + 0x48, 0) # sysmemAddrOfSignature
+ _write_u64(machine, wpr_meta_pa + 0x50, 0) # sizeOfSignature
+ _write_u64(machine, wpr_meta_pa + 0x58, wpr_start) # gspFwRsvdStart
+ _write_u64(machine, wpr_meta_pa + 0x60, wpr_start) # nonWprHeapOffset
+ _write_u64(machine, wpr_meta_pa + 0x68, 0x10000) # nonWprHeapSize
+ _write_u64(machine, wpr_meta_pa + 0x70, wpr_start) # gspFwWprStart
+ _write_u64(machine, wpr_meta_pa + 0x78, wpr_start) # gspFwHeapOffset
+ _write_u64(machine, wpr_meta_pa + 0x80, wpr_end - wpr_start) # heap size
+ _write_u64(machine, wpr_meta_pa + 0x88, gsp_fw_offset) # gspFwOffset
+ _write_u64(machine, wpr_meta_pa + 0x90, boot_bin_offset) # bootBinOffset
+ _write_u64(machine, wpr_meta_pa + 0x98, 0) # frtsOffset
+ _write_u64(machine, wpr_meta_pa + 0xA0, 0) # frtsSize
+ _write_u64(machine, wpr_meta_pa + 0xA8, wpr_end) # gspFwWprEnd
+ _write_u64(machine, wpr_meta_pa + 0xB0, fb_size) # fbSize
+ _write_u64(machine, wpr_meta_pa + 0xB8, wpr_end) # vgaWorkspaceOffset
+ _write_u64(machine, wpr_meta_pa + 0xC0, 0x100000) # vgaWorkspaceSize
+ _write_u64(machine, wpr_meta_pa + 0xC8, 0) # bootCount
+ _write_u64(machine, wpr_meta_pa + 0xD0, 0) # partitionRpcAddr
+ _write_u64(machine, wpr_meta_pa + 0xF8, GSP_FW_WPR_META_VERIFIED)
+
+
+#: Observed boot milestones (firmware vaddrs) used to classify the rung.
+#: Evidence: disassembly of the 570.144 image (bootargs consumer reads
+#: NV_RISCV_BOOT_PARAMS; the relocation loop copies from the DMEM aperture
+#: into the 0x400E000 runtime region).
+MILESTONES = (
+ ("bootargs_consumer", 0x4007A30),
+ ("relocation_copy", 0x4007B7C),
+)
+
+
+@dataclass
+class GspBootResult:
+ ran: bool
+ reason: str
+ image: str
+ profile: str | None = None
+ profile_name: str | None = None
+ image_sha256: str | None = None
+ session_dir: str | None = None
+ instructions: int = 0
+ cycles: int = 0
+ cycle_model: str | None = None
+ stop_reason: str | None = None
+ stop_pc: int | None = None
+ stop_pc_fw: int | None = None
+ rung: str = "B0"
+ first_unknown_mmio: dict | None = None
+ mmio_unknown: int = 0
+ device_unhandled: int = 0
+ device_accesses: int = 0
+ device_state_sha256: str | None = None
+ registers: dict = field(default_factory=dict)
+ shim: dict = field(default_factory=dict)
+ milestones: list = field(default_factory=list)
+ device_map: object | None = field(default=None, repr=False)
+
+ def to_dict(self) -> dict:
+ return {
+ "schema": "greeneye-gsp-boot/1",
+ "ran": self.ran,
+ "reason": self.reason,
+ "image": self.image,
+ "image_sha256": self.image_sha256,
+ "profile": self.profile,
+ "profile_name": self.profile_name,
+ "session": self.session_dir,
+ "rung": self.rung,
+ "instructions_executed": self.instructions,
+ "cycles": self.cycles,
+ "cycle_model": self.cycle_model,
+ "stop": {"reason": self.stop_reason, "pc": self.stop_pc,
+ "pc_fw": self.stop_pc_fw},
+ "first_unknown_mmio": self.first_unknown_mmio,
+ "mmio_unknown": self.mmio_unknown,
+ "device_unhandled": self.device_unhandled,
+ "device_accesses": self.device_accesses,
+ "device_state_sha256": self.device_state_sha256,
+ "registers": self.registers,
+ "boot_shim": self.shim,
+ "milestones": list(self.milestones),
+ }
+
+
+def _fw_pc(pc: int) -> int:
+ return pc
+
+
+def classify_rung(machine, *, shim: dict) -> str:
+ """Best rung supported by the observed run (never inferred, only observed)."""
+ if machine.mmio_unknown and machine.mmio_log:
+ first = machine.mmio_log[0]
+ if first["addr"] == 0x2000000000000A00:
+ return "B0"
+ if shim.get("applied"):
+ # The firmware consumed the boot shim and advanced past the first
+ # unknown; B2 is claimed only when the run went past the chip gate and
+ # exercised the MPU/bootargs path (the runner checks the PC).
+ return shim.get("rung", "B1")
+ return "B1"
+
+
+def run_boot(
+ *,
+ image: str = DEFAULT_IMAGE,
+ profile_path: str = DEFAULT_PROFILE,
+ instructions: int = DEFAULT_INSTRUCTIONS,
+ record=None,
+ name: str = "gsp-boot",
+ use_shim: bool = True,
+ shim_overrides: dict | None = None,
+) -> GspBootResult:
+ """Run one bounded boot attempt and record a session."""
+ if instructions <= 0 or instructions > MAX_ATTEMPT_INSTRUCTIONS:
+ raise GreenEyeError(
+ f"instructions must be in 1..{MAX_ATTEMPT_INSTRUCTIONS}, got {instructions}")
+ if not os.path.isfile(image):
+ return GspBootResult(ran=False, reason=f"image absent: {image}", image=image)
+ if not os.path.isfile(profile_path):
+ return GspBootResult(ran=False, reason=f"profile absent: {profile_path}",
+ image=image)
+
+ profile = load_profile(profile_path)
+ image_obj = load_elf(image)
+ if not image_obj.is_riscv:
+ raise GreenEyeError(f"{image}: not a RISC-V ELF image")
+ sha = firmware_sha256(image)
+
+ load = profile.get("load", {})
+ ram_base = _int(load.get("ram_base", 0))
+ memory_mb = _int(load.get("memory_mb", 12288))
+
+ from greeneye.sim_backend import launch_native # deferred
+
+ session = launch_native(
+ kernel=image,
+ memory_mb=memory_mb,
+ ram_base=ram_base,
+ record=True if record is None else record,
+ name=name,
+ device_map=None,
+ unknown_mmio="lenient",
+ bias=0,
+ )
+ machine = session.target.machine
+ machine.devices = registry_from_profile(profile, machine=machine)
+ machine.translator = getattr(machine.devices, "dma", None)
+
+ shim_meta = {"enabled": bool(use_shim), "assumptions": []}
+ if use_shim:
+ shim = dict(profile.get("boot_shim", {}))
+ if shim_overrides:
+ shim.update(shim_overrides)
+ shim["_image_path"] = image
+ _apply_boot_shim(machine, shim, image_obj, shim_meta)
+
+ milestones_hit: list[str] = []
+ try:
+ reason, pc, a, b = _run_with_milestones(machine, instructions, milestones_hit)
+ model = machine.cycle_model
+ stop_reason = STOP_REASONS.get(reason, str(reason))
+ first = machine.mmio_log[0] if machine.mmio_log else None
+ reg = machine.devices
+ state = reg.state() if reg is not None else None
+ state_json = json.dumps(state, sort_keys=True, default=str) if state else ""
+ state_sha = hashlib.sha256(state_json.encode()).hexdigest() if state else None
+ registers = {f"x{i}": machine.x[i] for i in range(32) if i and machine.x[i]}
+ registers["pc"] = pc
+ rung = _rung_from_run(machine, shim_meta, milestones_hit)
+ shim_meta["rung"] = rung
+ meta = {
+ "kind": "gsp-boot",
+ "profile": os.path.basename(profile_path),
+ "profile_name": profile.get("name"),
+ "firmware_sha256": sha,
+ "ram_base": ram_base,
+ "instructions_executed": machine.insns,
+ "cycles": machine.cycles,
+ "cycle_model": model.name if model is not None else None,
+ "stop_reason": stop_reason,
+ "stop_pc": pc,
+ "rung": rung,
+ "milestones": list(milestones_hit),
+ "mmio_unknown": machine.mmio_unknown,
+ "first_unknown_mmio": first,
+ "device_unhandled": getattr(reg, "unhandled", 0),
+ "device_accesses": len(getattr(reg, "accesses", ())),
+ "device_state_sha256": state_sha,
+ "boot_shim": shim_meta,
+ }
+ if getattr(session.recorder, "dir", None) is not None:
+ if state is not None:
+ rel = session.recorder.blob(
+ "device-state.json",
+ json.dumps(state, indent=1, sort_keys=True, default=str).encode(),
+ )
+ meta["device_state_blob"] = rel
+ session.recorder.meta.update(meta)
+ session.note(
+ "gsp_boot",
+ rung=rung,
+ instructions=machine.insns,
+ stop_reason=stop_reason,
+ stop_pc=pc,
+ )
+ session_dir = getattr(session.recorder, "dir", None)
+ finally:
+ session.close()
+
+ return GspBootResult(
+ ran=True,
+ reason="ok",
+ image=image,
+ profile=profile_path,
+ profile_name=profile.get("name"),
+ image_sha256=sha,
+ session_dir=session_dir,
+ instructions=machine.insns,
+ cycles=machine.cycles,
+ cycle_model=model.name if model is not None else None,
+ stop_reason=stop_reason,
+ stop_pc=pc,
+ stop_pc_fw=_fw_pc(pc),
+ rung=rung,
+ first_unknown_mmio=first,
+ mmio_unknown=machine.mmio_unknown,
+ device_unhandled=getattr(reg, "unhandled", 0),
+ device_accesses=len(getattr(reg, "accesses", ())),
+ device_state_sha256=state_sha,
+ registers=registers,
+ shim=shim_meta,
+ milestones=list(milestones_hit),
+ )
+
+
+def _run_with_milestones(machine, budget: int, hit: list[str]):
+ """Run the budget, stopping at each milestone once to record it.
+
+ Milestone hits are evidence of boot-stage progress (they are firmware
+ vaddrs reached), not inferred from timing or heuristics.
+ """
+ remaining = budget
+ while remaining > 0:
+ pending = {addr for name, addr in MILESTONES if name not in hit}
+ reason, pc, a, b = machine.run(remaining, breakpoints=pending)
+ remaining = budget - machine.insns
+ if reason == R_BP:
+ for name, addr in MILESTONES:
+ if addr == pc and name not in hit:
+ hit.append(name)
+ break
+ continue
+ return reason, pc, a, b
+ return R_BUDGET, machine.pc, 0, 0
+
+
+def _rung_from_run(machine, shim_meta: dict, milestones_hit) -> str:
+ """Observed rung from milestones and the unknown-MMIO surface.
+
+ B0: the first unmodelled access is the PMC ``BOOT_42`` chip-ID read.
+ B1: the device model served the whole run (no unknown MMIO) and the boot
+ chip gate was passed; the run may stop at the bootargs panic/halt.
+ B2: the boot shim was consumed and the firmware reached its relocation
+ stage (milestone ``relocation_copy``) -- boot-stage progress beyond
+ the bootargs consumer.
+ """
+ first_unknown = machine.mmio_log[0] if machine.mmio_log else None
+ if first_unknown is not None and first_unknown["addr"] == 0x2000000000000A00:
+ return "B0"
+ if "relocation_copy" in milestones_hit:
+ return "B2"
+ return "B1"
+
+
+def _rung_note(rung: str) -> str:
+ return {
+ "B0": "first MMIO is the unmodelled PMC BOOT_42 chip-ID read",
+ "B1": "device model serves the boot path; bootargs consumer reached",
+ "B2": "boot-stage progress past the bootargs consumer (relocation)",
+ }.get(rung, rung)
+
+
+def _apply_boot_shim(machine, shim: dict, image_obj, shim_meta: dict) -> None:
+ """Write profile-declared reset state (documented assumption)."""
+ registers = {k: (int(v, 0) if isinstance(v, str) else int(v))
+ for k, v in shim.get("registers", {}).items()}
+ machine.apply_reset_regs(registers)
+ bootargs_pa = shim.get("bootargs_pa")
+ if bootargs_pa is not None:
+ bootargs_pa = int(bootargs_pa, 0) if isinstance(bootargs_pa, str) else int(bootargs_pa)
+ core_dump_pa = _int(shim.get("core_dump_pa", 0x40000))
+ core_dump_size = _int(shim.get("core_dump_size", 0x10000))
+ build_bootargs(
+ machine,
+ bootargs_pa=bootargs_pa,
+ core_dump_pa=core_dump_pa,
+ core_dump_size=core_dump_size,
+ wpr_meta_pa=_int(shim.get("wpr_meta_pa", 0x90000)),
+ wpr_meta_size=_int(shim.get("wpr_meta_size", WPR_META_SIZE)),
+ bootargs_size=_int(shim.get("bootargs_size", BOOTARGS_DEFAULT_SIZE)),
+ )
+ shim_meta["assumptions"].append(
+ "NV_RISCV_BOOT_PARAMS reconstructed at 0x%x from the documented "
+ "layout (header); real value is host/booter-provided" % bootargs_pa)
+ if shim.get("wpr_meta_pa") is not None and shim.get("wpr"):
+ wpr = shim["wpr"]
+ build_wpr_meta(
+ machine,
+ wpr_meta_pa=_int(shim["wpr_meta_pa"]),
+ elf_pa=_int(wpr.get("elf_pa", image_obj.entry)),
+ elf_size=_int(wpr.get("elf_size", image_obj.max_vaddr - 0x4000000)),
+ wpr_start=_int(wpr.get("wpr_start", 0x2000000)),
+ wpr_end=_int(wpr.get("wpr_end", 0x6000000)),
+ boot_bin_offset=_int(wpr.get("boot_bin_offset", 0x2000000)),
+ gsp_fw_offset=_int(wpr.get("gsp_fw_offset", 0x4000000)),
+ fb_size=_int(wpr.get("fb_size", 0x200000000)),
+ )
+ shim_meta["assumptions"].append(
+ "GspFwWprMeta reconstructed from the documented layout (header); "
+ "real value is booter-provided")
+ shim_meta["applied"] = True
+ shim_meta["registers"] = {k: registers[k] for k in sorted(registers)}
+ image_at = shim.get("image_at")
+ if image_at is not None:
+ addr = _int(image_at)
+ with open(shim["_image_path"], "rb") as fh:
+ blob = fh.read()
+ machine.write_mem(addr, blob)
+ shim_meta["assumptions"].append(
+ "raw firmware image mirrored at 0x%x (DMEM) so the app's own "
+ "self-relocation copy is self-consistent; real value is "
+ "booter-DMA-provided" % addr)
+ shim_meta["image_at"] = addr
+ shim_meta["image_bytes"] = len(blob)
+
+
+def _print_result(result: GspBootResult, out=sys.stdout) -> None:
+ print(f"image: {result.image}", file=out)
+ print(f"profile: {result.profile_name} ({result.profile})", file=out)
+ print(f"session: {result.session_dir}", file=out)
+ print(f"rung: {result.rung}", file=out)
+ print(f"instructions: {result.instructions}", file=out)
+ print(f"cycles (modeled): {result.cycles}", file=out)
+ print(f"stop: {result.stop_reason} pc={result.stop_pc:#x}"
+ if result.stop_pc is not None else f"stop: {result.stop_reason}", file=out)
+ print(f"unknown MMIO accesses: {result.mmio_unknown}", file=out)
+ print(f"device accesses: {result.device_accesses} "
+ f"(unhandled in windows: {result.device_unhandled})", file=out)
+ if result.first_unknown_mmio:
+ first = result.first_unknown_mmio
+ print(f"first unknown MMIO: pc={first['pc']:#x} addr={first['addr']:#x} "
+ f"size={first['size']} rw={first['rw']}", file=out)
+ print("note: device semantics carry their evidence in docs/36; "
+ "boot-shim values are declared assumptions", file=out)
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="python3 -m sim.gsp_boot",
+ description=("Bounded real-firmware GSP boot attempt with the device model "
+ "and versioned profile (read-only image; no hardware)."))
+ parser.add_argument("--image", default=DEFAULT_IMAGE)
+ parser.add_argument("--profile", default=DEFAULT_PROFILE)
+ parser.add_argument("--instructions", type=int, default=DEFAULT_INSTRUCTIONS)
+ parser.add_argument("--record", help="session directory (default: auto)")
+ parser.add_argument("--name", default="gsp-boot")
+ parser.add_argument("--no-shim", action="store_true",
+ help="run without the boot shim (pure device model)")
+ parser.add_argument("--json", action="store_true")
+ args = parser.parse_args(argv)
+ try:
+ result = run_boot(
+ image=args.image,
+ profile_path=args.profile,
+ instructions=args.instructions,
+ record=args.record,
+ name=args.name,
+ use_shim=not args.no_shim,
+ )
+ except GreenEyeError as exc:
+ print(f"[gsp-boot] error: {exc}", file=sys.stderr)
+ return 1
+ if not result.ran:
+ print(f"[gsp-boot] skipped: {result.reason}", file=sys.stderr)
+ return 2
+ if args.json:
+ print(json.dumps(result.to_dict(), indent=1, sort_keys=True))
+ else:
+ _print_result(result)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/debug/risc-v-gsp-rm-vm/sim/gsp_profile.py b/debug/risc-v-gsp-rm-vm/sim/gsp_profile.py
new file mode 100644
index 0000000..6d87389
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/gsp_profile.py
@@ -0,0 +1,317 @@
+"""GSP cycle-0 profile: bounded, deterministic real-firmware run with cycles.
+
+Loads the TU104 570.144 GSP flat image (read-only), maps it the same way the
+device-map/probe path does (``bias=0x80000000``, ``greeneye.elf`` PT_LOAD plan,
+lenient MMIO from a :class:`greeneye.device_map.DeviceMap`), executes a bounded
+instruction budget, and writes a normal ``greeneye-session/1`` directory whose
+metadata carries the modeled instruction/cycle totals and the stop reason.
+
+**Fidelity:** this is a bounded lenient first-touch profile, not a boot. The
+cycle figures come from :mod:`sim.cycles` - modeled defaults, not silicon
+timings (the simulator's L2 timing is *not* modelled; docs/06-ACCURACY.md,
+docs/32-GSP-CYCLE-MODEL.md). The run is deterministic: two identical
+invocations execute the same instructions and report the same cycle total.
+
+CLI::
+
+ python3 -m sim.gsp_profile --instructions 200000
+ python3 -m sim.gsp_profile --record dumps/gsp-cycle0 --json
+
+The image is loaded read-only; nothing is written back to it, and no hardware,
+GPU, module, guest or network is touched.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from dataclasses import dataclass, field as _field
+
+from greeneye.device_map import DeviceMap, firmware_sha256
+from greeneye.elf import load_elf
+from greeneye.errors import GreenEyeError
+from sim.machine import (
+ R_BP,
+ R_BUDGET,
+ R_EBREAK,
+ R_ECALL,
+ R_FAULT,
+ R_STEP,
+ R_WATCH,
+)
+
+#: Tool root (the directory containing ``sim/`` and ``greeneye/``).
+TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+
+#: The TU104 570.144 GSP firmware, as laid out in the workspace tree.
+DEFAULT_IMAGE = os.path.normpath(
+ os.path.join(
+ TOOL_ROOT, "..", "..", "firmware", "57X-GSP", "570.144", "gsp-570.144.fwimage.bin"
+ )
+)
+
+#: GPU physical addresses are relocated into simulator RAM at this bias
+#: (same convention as the probe/device-map path).
+DEFAULT_BIAS = 0x80000000
+
+#: Small default budget: enough to pass the documented first-touch fault.
+DEFAULT_INSTRUCTIONS = 200_000
+
+#: Machine stop reasons -> stable session labels.
+STOP_REASONS = {
+ R_BUDGET: "budget",
+ R_BP: "breakpoint",
+ R_WATCH: "watch",
+ R_EBREAK: "ebreak",
+ R_FAULT: "fault",
+ R_STEP: "step",
+ R_ECALL: "ecall",
+}
+
+
+@dataclass
+class GspProfileResult:
+ """Outcome of one :func:`run_profile` invocation.
+
+ ``ran`` is False (with ``reason`` explaining why) when the image is absent
+ and the caller should skip cleanly; ``device_map`` is the in-memory map
+ used/learned during the run (never serialised by :meth:`to_dict`).
+ """
+
+ ran: bool
+ reason: str
+ image: str
+ image_sha256: str | None = None
+ session_dir: str | None = None
+ instructions: int = 0
+ cycles: int = 0
+ cycle_model: str | None = None
+ stop_reason: str | None = None
+ stop_pc: int | None = None
+ stop_pc_fw: int | None = None
+ first_unknown_mmio: dict | None = None
+ mmio_unknown: int = 0
+ device_map: DeviceMap | None = _field(default=None, repr=False)
+
+ def to_dict(self) -> dict:
+ data = {
+ "schema": "greeneye-gsp-cycle-profile/1",
+ "ran": self.ran,
+ "reason": self.reason,
+ "image": self.image,
+ "image_sha256": self.image_sha256,
+ "session": self.session_dir,
+ "instructions_executed": self.instructions,
+ "cycles": self.cycles,
+ "cycle_model": self.cycle_model,
+ "fidelity": "modeled cycles; L2 (timing) not modelled (docs/06)",
+ "stop": {"reason": self.stop_reason, "pc": self.stop_pc, "pc_fw": self.stop_pc_fw},
+ "first_unknown_mmio": self.first_unknown_mmio,
+ "mmio_unknown": self.mmio_unknown,
+ }
+ if self.device_map is not None:
+ data["device_map_entries"] = len(self.device_map)
+ return data
+
+
+def _fw_pc(pc: int, bias: int) -> int:
+ if not bias or pc == 0 or pc < bias:
+ return pc
+ return pc - bias
+
+
+def _memory_mb(image, bias: int, ram_base: int = DEFAULT_BIAS) -> int:
+ """RAM size that covers the relocated image, as the probe path computes it."""
+ end = image.max_vaddr + bias
+ need = end - ram_base + (1 << 20)
+ return max(256, (need + (1 << 20) - 1) // (1 << 20))
+
+
+def run_profile(
+ *,
+ image: str = DEFAULT_IMAGE,
+ instructions: int = DEFAULT_INSTRUCTIONS,
+ bias: int = DEFAULT_BIAS,
+ record=None,
+ device_map=None,
+ learn_mmio: bool = False,
+ name: str = "gsp-cycle-profile",
+ cycle_model=None,
+) -> GspProfileResult:
+ """Run the bounded cycle profile; skip cleanly when the image is absent.
+
+ ``record`` is a session directory (or True for an auto-named one under
+ ``dumps/``); the default ``None`` still records, so a session is always
+ written. ``device_map`` may be a :class:`~greeneye.device_map.DeviceMap`,
+ a path to one, or None for an empty lenient map.
+ """
+ if instructions <= 0:
+ raise GreenEyeError(f"instructions must be positive, got {instructions}")
+ if not os.path.isfile(image):
+ return GspProfileResult(ran=False, reason=f"image absent: {image}", image=image)
+
+ image_obj = load_elf(image)
+ if not image_obj.is_riscv:
+ raise GreenEyeError(f"{image}: not a RISC-V ELF image")
+ sha = firmware_sha256(image)
+
+ if isinstance(device_map, (str, os.PathLike)):
+ dm = DeviceMap.load(os.fspath(device_map))
+ elif device_map is None:
+ dm = DeviceMap(
+ meta={
+ "source": "gsp-profile",
+ "firmware": os.path.basename(image),
+ "firmware_sha256": sha,
+ "bias": bias,
+ }
+ )
+ else:
+ dm = device_map
+
+ from greeneye.sim_backend import launch_native # deferred: keeps sim/ standalone
+
+ session = launch_native(
+ kernel=image,
+ memory_mb=_memory_mb(image_obj, bias),
+ record=True if record is None else record,
+ name=name,
+ device_map=dm,
+ unknown_mmio="lenient",
+ learn_mmio=learn_mmio,
+ bias=bias,
+ cycle_model=cycle_model,
+ )
+ machine = session.target.machine
+ try:
+ reason, pc, _a, _b = machine.run(instructions)
+ first = machine.mmio_log[0] if machine.mmio_log else None
+ model = machine.cycle_model
+ stop_reason = STOP_REASONS.get(reason, str(reason))
+ stop_pc_fw = _fw_pc(pc, bias)
+ meta = {
+ "kind": "gsp-cycle-profile",
+ "firmware_sha256": sha,
+ "bias": bias,
+ "instructions_executed": machine.insns,
+ "cycles": machine.cycles,
+ "cycle_model": model.name if model is not None else None,
+ "cycle_fidelity": "modeled cycles, not measured (docs/32-GSP-CYCLE-MODEL.md)",
+ "stop_reason": stop_reason,
+ "stop_pc": pc,
+ "stop_pc_fw": stop_pc_fw,
+ "mmio_unknown": machine.mmio_unknown,
+ "first_unknown_mmio": first,
+ }
+ if model is not None:
+ meta["cycle_model_table"] = model.describe()
+ if getattr(session.recorder, "dir", None) is not None:
+ session.recorder.meta.update(meta)
+ session.note(
+ "gsp_cycle_profile",
+ instructions=machine.insns,
+ cycles=machine.cycles,
+ stop_reason=stop_reason,
+ stop_pc_fw=stop_pc_fw,
+ )
+ session_dir = getattr(session.recorder, "dir", None)
+ finally:
+ session.close()
+
+ return GspProfileResult(
+ ran=True,
+ reason="ok",
+ image=image,
+ image_sha256=sha,
+ session_dir=session_dir,
+ instructions=machine.insns,
+ cycles=machine.cycles,
+ cycle_model=model.name if model is not None else None,
+ stop_reason=stop_reason,
+ stop_pc=pc,
+ stop_pc_fw=stop_pc_fw,
+ first_unknown_mmio=first,
+ mmio_unknown=machine.mmio_unknown,
+ device_map=dm,
+ )
+
+
+def _print_result(result: GspProfileResult, out=sys.stdout) -> None:
+ print(f"image: {result.image}", file=out)
+ print(f"sha256: {result.image_sha256}", file=out)
+ print(f"session: {result.session_dir}", file=out)
+ print(f"instructions: {result.instructions}", file=out)
+ print(f"cycles (modeled): {result.cycles}", file=out)
+ print(f"cycle model: {result.cycle_model}", file=out)
+ if result.instructions:
+ print(f"cycles/instruction: {result.cycles / result.instructions:.3f}", file=out)
+ print(
+ f"stop: {result.stop_reason} pc={result.stop_pc:#x} (fw {result.stop_pc_fw:#x})",
+ file=out,
+ )
+ print(f"unknown MMIO accesses: {result.mmio_unknown}", file=out)
+ if result.first_unknown_mmio:
+ first = result.first_unknown_mmio
+ print(
+ "first unknown MMIO: "
+ f"pc={first['pc']:#x} addr={first['addr']:#x} "
+ f"size={first['size']} rw={first['rw']}",
+ file=out,
+ )
+ print(
+ "note: cycles are modeled (sim/cycles.py defaults); no silicon timing claim",
+ file=out,
+ )
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="python3 -m sim.gsp_profile",
+ description=(
+ "Bounded deterministic GSP cycle-0 profile (modeled cycles; no silicon claim). "
+ "Loads the real firmware read-only, runs under lenient MMIO and records a session."
+ ),
+ )
+ parser.add_argument("--image", default=DEFAULT_IMAGE, help="flat GSP firmware image")
+ parser.add_argument(
+ "--instructions", type=int, default=DEFAULT_INSTRUCTIONS, help="instruction budget"
+ )
+ parser.add_argument("--bias", type=lambda s: int(s, 0), default=DEFAULT_BIAS)
+ parser.add_argument("--record", help="session directory (default: auto under dumps/)")
+ parser.add_argument("--device-map", help="existing greeneye-device-map/1 JSON to serve MMIO")
+ parser.add_argument("--learn", action="store_true", help="learn observed MMIO into the map")
+ parser.add_argument("--learn-out", help="save the learned/used map here")
+ parser.add_argument("--name", default="gsp-cycle-profile")
+ parser.add_argument("--json", action="store_true", help="emit the result as JSON")
+ args = parser.parse_args(argv)
+
+ try:
+ result = run_profile(
+ image=args.image,
+ instructions=args.instructions,
+ bias=args.bias,
+ record=args.record,
+ device_map=args.device_map,
+ learn_mmio=args.learn,
+ name=args.name,
+ )
+ except GreenEyeError as exc:
+ print(f"[gsp-profile] error: {exc}", file=sys.stderr)
+ return 1
+
+ if not result.ran:
+ print(f"[gsp-profile] skipped: {result.reason}", file=sys.stderr)
+ return 2
+ if args.learn_out and result.device_map is not None:
+ result.device_map.save(args.learn_out)
+ print(f"[gsp-profile] device map ({len(result.device_map)} entries) -> {args.learn_out}")
+ if args.json:
+ print(json.dumps(result.to_dict(), indent=1, sort_keys=True))
+ else:
+ _print_result(result)
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/debug/risc-v-gsp-rm-vm/sim/machine.py b/debug/risc-v-gsp-rm-vm/sim/machine.py
index 8d77dd8..9908753 100644
--- a/debug/risc-v-gsp-rm-vm/sim/machine.py
+++ b/debug/risc-v-gsp-rm-vm/sim/machine.py
@@ -5,12 +5,20 @@ The execution loop is a monolithic dispatch over the cached decode tuples from
image and the instruction budget handed to ``run``: no host clock, no threads,
no entropy. RAM is an anonymous ``mmap`` (lazily paged, so a 256 MiB region is
cheap); all other addresses resolve to the UART or fault.
+
+Alongside the instruction count (virtual time, ``insns``) the machine keeps a
+``cycles`` counter advanced by :mod:`sim.cycles`, a table-driven **modeled**
+instruction-class cost. It changes no execution behaviour: with the default
+model enabled, ``insns``, stops and register/memory state are exactly as
+before; ``cycle_model=False`` leaves ``cycles`` at 0. See
+``docs/32-GSP-CYCLE-MODEL.md``.
"""
from __future__ import annotations
import mmap
import struct
+from .cycles import DEFAULT_MODEL
from .decode import (
A_ADD,
A_AND,
@@ -22,7 +30,6 @@ from .decode import (
A_MULHU,
A_OR,
A_REM,
- A_REMU,
A_SLL,
A_SLT,
A_SLTU,
@@ -38,8 +45,6 @@ from .decode import (
I_SLLIW,
I_SLTI,
I_SLTIU,
- I_SRAI,
- I_SRAIW,
I_SRLI,
I_SRLIW,
I_XORI,
@@ -50,7 +55,6 @@ from .decode import (
K_EBREAK,
K_ECALL,
K_FENCE,
- K_ILLEGAL,
K_JAL,
K_JALR,
K_LOAD,
@@ -182,6 +186,12 @@ class Machine:
``learn_mmio=True``, writes the observation back into the map. Nothing is
logged or counted when lenient mode is off, and debugger ``read_mem``/
``write_mem`` stay strict.
+
+ ``cycle_model`` selects the modeled cycle accounting (:mod:`sim.cycles`):
+ ``None`` (default) uses :data:`sim.cycles.DEFAULT_MODEL`, a
+ :class:`~sim.cycles.CycleModel` instance uses that table, and ``False``
+ disables accounting (``cycles`` stays 0). Accounting never changes
+ execution.
"""
def __init__(
@@ -192,14 +202,36 @@ class Machine:
unknown_mmio: str = "fault",
learn_mmio: bool = False,
bias: int = 0,
+ cycle_model=None,
+ devices=None,
+ reset_regs=None,
+ translator=None,
):
if unknown_mmio not in UNKNOWN_MMIO_MODES:
raise ValueError(f"unknown_mmio must be one of {UNKNOWN_MMIO_MODES}, got {unknown_mmio!r}")
+ if cycle_model is False:
+ cycle_model = None
+ elif cycle_model is None:
+ cycle_model = DEFAULT_MODEL
+ self.cycle_model = cycle_model
+ self.cycles = 0
+ if cycle_model is None:
+ self._cycle_base = None
+ self._cycle_op = None
+ self._cycle_mem = 0
+ self._cycle_io = 0
+ else:
+ self._cycle_base = cycle_model.base_costs
+ self._cycle_op = cycle_model.op_costs
+ self._cycle_mem = cycle_model.memory_latency
+ self._cycle_io = cycle_model.mmio_latency
self.ram_base = ram_base
self.ram_size = memory_mb * 1024 * 1024
self.ram = mmap.mmap(-1, self.ram_size)
self.uart = Uart16550()
self.device_map = device_map
+ self.devices = devices
+ self.translator = translator # aperture -> RAM seam (sim.devices.dma)
self.unknown_mmio = unknown_mmio
self.learn_mmio = bool(learn_mmio)
self.bias = bias # PC relocation for learned-map firmware addresses
@@ -219,6 +251,34 @@ class Machine:
self._u16p = self._u16.pack_into
self._u32p = self._u32.pack_into
self._u64p = self._u64.pack_into
+ if reset_regs:
+ self.apply_reset_regs(reset_regs)
+
+ # -- reset state (boot shim support) -----------------------------------
+
+ def apply_reset_regs(self, regs: dict) -> None:
+ """Set initial register values (boot shim: a0-a5 bootargs, etc.).
+
+ Accepts integer indices (0-31) or register names (``"a0"``..``"a7"``,
+ ``"ra"``, ``"sp"``, ``"tp"``, ``"s0"``.., ``"t0"``..). x0 is ignored.
+ """
+ names = {"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 key, value in regs.items():
+ index = key if isinstance(key, int) else names.get(str(key))
+ if index is None:
+ raise ValueError(f"unknown register name {key!r}")
+ self.set_reg(int(index), int(value))
+
+ def insn_count(self) -> int:
+ """Virtual time (instruction count) for modeled clock devices."""
+ return self.insns
# -- image loading -----------------------------------------------------
@@ -262,6 +322,21 @@ class Machine:
out.append(self.uart.read(addr - UART_BASE))
addr += 1
size -= 1
+ elif self.translator is not None and self.translator.translate(addr) is not None:
+ sim = self.translator.translate(addr)
+ off = sim - self.ram_base
+ if off < 0 or off + size > self.ram_size:
+ raise Fault(5, addr)
+ out += self.ram[off : off + size]
+ addr += size
+ size = 0
+ elif self.devices is not None:
+ peeked = self.devices.peek(addr, size)
+ if peeked is None:
+ raise Fault(5, addr)
+ out += peeked
+ addr += size
+ size = 0
else:
raise Fault(5, addr)
return bytes(out)
@@ -280,6 +355,17 @@ class Machine:
elif UART_BASE <= here < UART_BASE + UART_SIZE:
self.uart.write(here - UART_BASE, data[offset])
offset += 1
+ elif self.translator is not None and self.translator.translate(here) is not None:
+ sim = self.translator.translate(here)
+ off = sim - self.ram_base
+ if off < 0 or off + remaining > self.ram_size:
+ raise Fault(7, here)
+ self.ram[off : off + remaining] = data[offset:]
+ offset = len(data)
+ elif self.devices is not None and self.devices.write(
+ here, remaining, int.from_bytes(data[offset:], "little")
+ ):
+ offset = len(data)
else:
raise Fault(7, here)
@@ -308,9 +394,57 @@ class Machine:
self.device_map = DeviceMap(meta={"source": "learn"})
self.device_map.record(addr, size, rw, value, pc=pc, bias=self.bias, source="trace")
+ def _aperture_load(self, addr: int, size: int) -> int | None:
+ """Serve a load from a translated memory aperture (FBGPA/SYSGPA)."""
+ if self.translator is None:
+ return None
+ sim = self.translator.translate(addr)
+ if sim is None:
+ return None
+ off = sim - self.ram_base
+ if off < 0 or off + size > self.ram_size:
+ return None
+ if size == 8:
+ return self._u64.unpack_from(self.ram, off)[0]
+ if size == 4:
+ return self._u32.unpack_from(self.ram, off)[0]
+ if size == 2:
+ return self._u16.unpack_from(self.ram, off)[0]
+ if size == 1:
+ return self.ram[off]
+ return None
+
+ def _aperture_store(self, addr: int, size: int, value: int) -> bool:
+ """Serve a store into a translated memory aperture (FBGPA/SYSGPA)."""
+ if self.translator is None:
+ return False
+ sim = self.translator.translate(addr)
+ if sim is None:
+ return False
+ off = sim - self.ram_base
+ if off < 0 or off + size > self.ram_size:
+ return False
+ if size == 8:
+ self._u64p(self.ram, off, value & 0xFFFFFFFFFFFFFFFF)
+ elif size == 4:
+ self._u32p(self.ram, off, value & 0xFFFFFFFF)
+ elif size == 2:
+ self._u16p(self.ram, off, value & 0xFFFF)
+ else:
+ self.ram[off] = value & 0xFF
+ self.invalidate()
+ return True
+
def _mmio_load(self, addr: int, size: int, pc: int = 0) -> int:
if UART_BASE <= addr < UART_BASE + UART_SIZE:
return self.uart.read(addr - UART_BASE)
+ value = self._aperture_load(addr, size)
+ if value is not None:
+ return value
+ if self.devices is not None:
+ handled, value = self.devices.read(addr, size, pc=pc)
+ if handled:
+ return value
handled, value = self._mapped_mmio(addr, size, False)
if handled:
return value
@@ -323,6 +457,10 @@ class Machine:
if UART_BASE <= addr < UART_BASE + UART_SIZE:
self.uart.write(addr - UART_BASE, value)
return
+ if self._aperture_store(addr, size, value):
+ return
+ if self.devices is not None and self.devices.write(addr, size, value, pc=pc):
+ return
handled, _ = self._mapped_mmio(addr, size, True)
if handled:
if self.learn_mmio and self.device_map is not None:
@@ -368,6 +506,13 @@ class Machine:
a = b = 0
traced = trace is not None
active = False
+ # Modeled cycle accounting (sim.cycles): pure table lookups, no timing.
+ cycle_base = self._cycle_base
+ cycle_op = self._cycle_op
+ cycle_mem = self._cycle_mem
+ cycle_io = self._cycle_io
+ counting = cycle_base is not None
+ cycle_sum = 0
while n < budget:
if has_bps and pc in breakpoints:
@@ -394,6 +539,9 @@ class Machine:
cache[pc] = d
kind, sub, rd, r1, r2, imm, nxt = d
n += 1
+ if counting:
+ kc = cycle_base[kind]
+ cycle_sum += cycle_op[sub] if kc < 0 else kc
if traced:
insn_pc = pc
active = trace.begin(insn_pc, self.insns + n)
@@ -434,6 +582,8 @@ class Machine:
else:
if rd:
x[rd] = i16(ram, o)[0]
+ if counting:
+ cycle_sum += cycle_mem
pc = nxt
else:
try:
@@ -447,6 +597,8 @@ class Machine:
x[rd] = v
if active:
trace.mem(insn_pc, ea, size, False, self.insns + n)
+ if counting:
+ cycle_sum += cycle_io
pc = nxt
continue
@@ -473,6 +625,15 @@ class Machine:
u16p(ram, o, value & 0xFFFF)
else:
ram[o] = value & 0xFF
+ # Self-modifying code: drop any cached decode overlapping
+ # the written range (firmware patches code at runtime).
+ p = ea & ~1
+ end = ea + size
+ while p < end:
+ cache.pop(p, None)
+ p += 2
+ if counting:
+ cycle_sum += cycle_mem
pc = nxt
else:
try:
@@ -484,6 +645,8 @@ class Machine:
break
if active:
trace.mem(insn_pc, ea, size, True, self.insns + n)
+ if counting:
+ cycle_sum += cycle_io
pc = nxt
continue
@@ -755,6 +918,8 @@ class Machine:
trace.mem(insn_pc, ea, width, True, self.insns + n)
if rd:
x[rd] = (_sext32(old) & MASK64) if width == 4 else old
+ if counting:
+ cycle_sum += cycle_mem if 0 <= ea - base <= limit - width else cycle_io
pc = nxt
except Fault as f:
reason = R_FAULT
@@ -796,10 +961,17 @@ class Machine:
self.pc = pc
self.insns += n
+ self.cycles += cycle_sum
if single_step and reason == R_BUDGET:
reason = R_STEP
return (reason, pc, a, b)
+ def device_state(self) -> dict | None:
+ """Serialisable state of the attached device registry (or None)."""
+ if self.devices is None or not hasattr(self.devices, "state"):
+ return None
+ return self.devices.state()
+
# -- guest memory helpers (AMO) ----------------------------------------
def _guest_load(self, addr: int, width: int, pc: int = 0) -> int:
@@ -817,6 +989,11 @@ class Machine:
self._u64p(self.ram, off, value)
else:
self._u32p(self.ram, off, value & 0xFFFFFFFF)
+ p = addr & ~1
+ end = addr + width
+ while p < end:
+ self._cache.pop(p, None)
+ p += 2
return
self._mmio_store(addr, width, value, pc)
diff --git a/debug/risc-v-gsp-rm-vm/sim/profiles/tu104-570.144.json b/debug/risc-v-gsp-rm-vm/sim/profiles/tu104-570.144.json
new file mode 100644
index 0000000..919dffe
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/profiles/tu104-570.144.json
@@ -0,0 +1,106 @@
+{
+ "schema": "greeneye-sim-profile/1",
+ "name": "tu104-570.144",
+ "description": "Turing TU104 GSP 570.144: device model windows, load layout and boot-shim assumptions. New firmware version = new profile, not a code fork.",
+ "firmware": {
+ "file": "firmware/57X-GSP/570.144/gsp-570.144.fwimage.bin",
+ "sha256": "2d1bb5c60b208cfd115f015b17dd04a6fece05fd2c0dcba3625d9d48cce3ceea",
+ "entry": "0x4009000",
+ "link_base": "0x4000000",
+ "max_vaddr": "0x5bb9000",
+ "size": 28528288
+ },
+ "load": {
+ "ram_base": "0x0",
+ "memory_mb": 12288,
+ "note": "firmware executes at its link addresses; PRIV/PMC is at 0x2000000000000000, local memory covers IMEM/DMEM/EMEM (0x0..0x300000000)."
+ },
+ "devices": {
+ "pmc": {
+ "chip_id": "0x164",
+ "major": 0,
+ "minor": 0,
+ "minor_ext": 0,
+ "evidence": "NV_PMC_BOOT_42 at PMC+0xA00; CHIP_ID bits 28:20 compared against TU10x IDs by the boot code; TU104 = 0x164 (header field).",
+ "windows": [
+ {"base": "0x2000000000000000", "size": "0x1000"}
+ ]
+ },
+ "falcon": {
+ "shims": {
+ "debuginfo_host_consume": true
+ },
+ "debuginfo_stage_mask": "0x700000",
+ "debuginfo_stage_shift": 20,
+ "evidence": "Falcon register header offsets; boot writes MAILBOX0/MAILBOX1/DEBUGINFO/IRQSSET(HALT) and polls DEBUGINFO bits 22:20; DMA field layout and transfer semantics from the register header. DEBUGINFO host-consume is a boot shim, not a device claim.",
+ "windows": [
+ {"base": "0x2000000000110000", "size": "0x1000"},
+ {"base": "0x300000000", "size": "0x1000"}
+ ]
+ },
+ "priscv": {
+ "evidence": "RISC-V-local register header; boot reads TRACECTL/TRACE_RDIDX/TRACE_WTIDX/PRIV_ERR_STAT/IRQMASK through the libos private mapping at 0x300001000.",
+ "windows": [
+ {"base": "0x2000000000111000", "size": "0x1000"},
+ {"base": "0x300001000", "size": "0x1000"}
+ ]
+ },
+ "timer": {
+ "ns_per_insn": 1.0,
+ "evidence": "PTIMER register header (NV_PTIMER_TIME_0 0x9400, TIME_1 0x9410). Values are MODELED from the instruction count; the firmware has not been observed reading this block in the 570.144 boot runs.",
+ "windows": [
+ {"base": "0x2000000000009000", "size": "0x1000"}
+ ]
+ },
+ "fwsec": {
+ "wpr2_start": "0x2000000",
+ "wpr2_end": "0x6000000",
+ "evidence": "PFB WPR2 ADDR_LO/HI register header (0x1FA824/0x1FA828, 4KiB alignment, HI points at the last 128KiB block); values are boot-shim supplied (host/booter-written on real hardware).",
+ "windows": [
+ {"base": "0x20000000001FA000", "size": "0x1000"}
+ ]
+ },
+ "csb": {
+ "evidence": "CSB/TRANSCOM message-window seam. No register semantics are modelled until a message-window poll is observed (B3); a profile may declare individual registers with width/value/semantics.",
+ "registers": {},
+ "windows": []
+ },
+ "pmu": {
+ "evidence": "PMU stub: no PMU register has been observed; every access stays unhandled and logged.",
+ "windows": []
+ },
+ "dma": {
+ "evidence": "GSP RISC-V address map: SYSGPA/FBGPA apertures. In this flat model both apertures alias simulator RAM (documented assumption) so booter-style DMA and firmware aperture reads resolve; unmapped transfers are refused and recorded.",
+ "windows": [
+ {"pa": "0x8180000000000000", "sim": "0x0", "size": "0x8000000"},
+ {"pa": "0x6180000000000000", "sim": "0x0", "size": "0x8000000"}
+ ]
+ }
+ },
+ "boot_shim": {
+ "note": "Reset-time state the real SEC2 booter/host provides. Every value here is an assumption recorded in the session; the device model never fakes a register read.",
+ "registers": {
+ "a0": "0x80000",
+ "a1": "0x80000",
+ "a2": "0x4000000",
+ "a3": "0x1b34ea0",
+ "a4": "0x4000000",
+ "a5": "0x0"
+ },
+ "bootargs_pa": "0x80000",
+ "bootargs_size": 120,
+ "core_dump_pa": "0x40000",
+ "core_dump_size": "0x10000",
+ "wpr_meta_pa": "0x90000",
+ "image_at": "0x100000000",
+ "wpr": {
+ "elf_pa": "0x4000000",
+ "elf_size": "0x1b34ea0",
+ "wpr_start": "0x2000000",
+ "wpr_end": "0x6000000",
+ "boot_bin_offset": "0x3000000",
+ "gsp_fw_offset": "0x4000000",
+ "fb_size": "0x200000000"
+ }
+ }
+}