summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm/sim/devices
diff options
context:
space:
mode:
Diffstat (limited to 'debug/risc-v-gsp-rm-vm/sim/devices')
-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
11 files changed, 1333 insertions, 0 deletions
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))