summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm/sim
diff options
context:
space:
mode:
authorArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-02 10:58:21 -0400
committerArthur Hanson Rasmusson <arthur@vgpu.io>2026-10-02 10:58:21 -0400
commit002e3d7d13c414ea874abbee8d82803dd3a72fe5 (patch)
tree6627c12fa07f6a1cf73e5aaeb7390e698dd3a896 /debug/risc-v-gsp-rm-vm/sim
parent2f249817df327f5dcccf0464ad517fa60d6071d3 (diff)
downloadopen-iov-002e3d7d13c414ea874abbee8d82803dd3a72fe5.tar.gz
open-iov-002e3d7d13c414ea874abbee8d82803dd3a72fe5.zip
debug: green-eye wave 3 - RM harness, local simulator, device map, MCP plugin, value-validation demos, CI pipeline, toolchain docs; add host RM tools rmtool/rmtop
Diffstat (limited to 'debug/risc-v-gsp-rm-vm/sim')
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/__init__.py43
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/decode.py399
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/machine.py825
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/trace.py145
4 files changed, 1412 insertions, 0 deletions
diff --git a/debug/risc-v-gsp-rm-vm/sim/__init__.py b/debug/risc-v-gsp-rm-vm/sim/__init__.py
new file mode 100644
index 0000000..5d7ab1f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/__init__.py
@@ -0,0 +1,43 @@
+"""green-eye native RV64IMAC simulator (M1).
+
+Pure-Python, deterministic, stdlib-only. :class:`sim.machine.Machine` executes
+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.
+"""
+from .decode import decode16, decode32 # noqa: F401
+from .machine import ( # noqa: F401
+ R_BP,
+ R_BUDGET,
+ R_EBREAK,
+ R_ECALL,
+ R_FAULT,
+ R_STEP,
+ R_WATCH,
+ UNKNOWN_MMIO_MODES,
+ Fault,
+ Machine,
+ SimMachine,
+ Uart16550,
+)
+from .trace import Trace, make_trace # noqa: F401
+
+__all__ = [
+ "Machine",
+ "SimMachine",
+ "Uart16550",
+ "Fault",
+ "decode16",
+ "decode32",
+ "Trace",
+ "make_trace",
+ "R_BUDGET",
+ "R_BP",
+ "R_WATCH",
+ "R_EBREAK",
+ "R_FAULT",
+ "R_STEP",
+ "R_ECALL",
+ "UNKNOWN_MMIO_MODES",
+]
diff --git a/debug/risc-v-gsp-rm-vm/sim/decode.py b/debug/risc-v-gsp-rm-vm/sim/decode.py
new file mode 100644
index 0000000..0f3ef3a
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/decode.py
@@ -0,0 +1,399 @@
+"""RV64IMAC instruction decoder (pure Python, stdlib only).
+
+The decoder canonicalises every compressed instruction into the equivalent
+32-bit operation, so breakpoints, single-step and watchpoints behave uniformly
+regardless of encoding. ``decode16``/``decode32`` return a 7-tuple
+
+ (kind, sub, rd, rs1, rs2, imm, next_pc)
+
+where ``next_pc`` is the fall-through (or branch/jump target for the
+unconditional control transfers), ``imm`` is already sign/zero-normalised for
+its use, and branch/jump targets are absolute virtual addresses. Results are
+cached per PC by the Machine; the cache is dropped when the debugger writes
+memory (M1 does not model self-modifying code).
+"""
+from __future__ import annotations
+
+MASK64 = (1 << 64) - 1
+
+# Instruction kinds.
+(
+ K_LUI,
+ K_AUIPC,
+ K_JAL,
+ K_JALR,
+ K_BRANCH,
+ K_LOAD,
+ K_STORE,
+ K_OPI,
+ K_OP,
+ K_OPI32,
+ K_OP32,
+ K_FENCE,
+ K_CSR,
+ K_ECALL,
+ K_EBREAK,
+ K_MRET,
+ K_AMO,
+ K_ILLEGAL,
+) = range(18)
+
+# ALU ops shared by K_OP and (width-truncated) K_OP32.
+(
+ A_ADD,
+ A_SLL,
+ A_SLT,
+ A_SLTU,
+ A_XOR,
+ A_SRL,
+ A_OR,
+ A_AND,
+ A_SUB,
+ A_SRA,
+ A_MUL,
+ A_MULH,
+ A_MULHSU,
+ A_MULHU,
+ A_DIV,
+ A_DIVU,
+ A_REM,
+ A_REMU,
+) = range(18)
+
+# K_OPI / K_OPI32 immediate ops (funct3 values, with srai/sraiw offset).
+I_ADDI, I_SLLI, I_SLTI, I_SLTIU, I_XORI, I_SRLI, I_ORI, I_ANDI = range(8)
+I_SRAI = 0x15
+I_ADDIW, I_SLLIW, I_SRLIW, I_SRAIW = 0, 1, 5, 0x15
+
+# LOAD/STORE funct3.
+LOAD_SIZE = (1, 2, 4, 8, 1, 2, 4)
+STORE_SIZE = (1, 2, 4, 8)
+
+_ILLEGAL = (K_ILLEGAL, 0, 0, 0, 0, 0, 0)
+
+
+def sext(value: int, bits: int) -> int:
+ sign = 1 << (bits - 1)
+ return (value & (sign - 1)) - (value & sign)
+
+
+def _bits(value: int, hi: int, lo: int) -> int:
+ return (value >> lo) & ((1 << (hi - lo + 1)) - 1)
+
+
+def _op_alu(sub: int) -> int:
+ """Map an R-type funct3 to the shared ALU op code (non-M)."""
+ return {
+ 0: A_ADD,
+ 1: A_SLL,
+ 2: A_SLT,
+ 3: A_SLTU,
+ 4: A_XOR,
+ 5: A_SRL,
+ 6: A_OR,
+ 7: A_AND,
+ }[sub]
+
+
+def decode32(pc: int, insn: int):
+ opcode = insn & 0x7F
+ rd = (insn >> 7) & 0x1F
+ funct3 = (insn >> 12) & 7
+ rs1 = (insn >> 15) & 0x1F
+ rs2 = (insn >> 20) & 0x1F
+ funct7 = (insn >> 25) & 0x7F
+ nxt = pc + 4
+
+ if opcode == 0x37: # LUI
+ return (K_LUI, 0, rd, 0, 0, (insn & 0xFFFFF000) & MASK64, nxt)
+ if opcode == 0x17: # AUIPC
+ return (K_AUIPC, 0, rd, 0, 0, sext(insn & 0xFFFFF000, 32) & MASK64, nxt)
+ if opcode == 0x6F: # JAL
+ imm = sext(
+ (_bits(insn, 31, 31) << 20)
+ | (_bits(insn, 30, 21) << 1)
+ | (_bits(insn, 20, 20) << 11)
+ | (_bits(insn, 19, 12) << 12),
+ 21,
+ )
+ return (K_JAL, 0, rd, 0, 0, (pc + imm) & MASK64, nxt)
+ if opcode == 0x67: # JALR
+ return (K_JALR, funct3, rd, rs1, 0, sext(_bits(insn, 31, 20), 12), nxt)
+ if opcode == 0x63: # BRANCH
+ imm = sext(
+ (_bits(insn, 31, 31) << 12)
+ | (_bits(insn, 30, 25) << 5)
+ | (_bits(insn, 11, 8) << 1)
+ | (_bits(insn, 7, 7) << 11),
+ 13,
+ )
+ return (K_BRANCH, funct3, 0, rs1, rs2, (pc + imm) & MASK64, nxt)
+ if opcode == 0x03: # LOAD
+ if funct3 == 7:
+ return _ILLEGAL
+ return (K_LOAD, funct3, rd, rs1, 0, sext(_bits(insn, 31, 20), 12), nxt)
+ if opcode == 0x23: # STORE
+ if funct3 > 3:
+ return _ILLEGAL
+ imm = sext((_bits(insn, 31, 25) << 5) | _bits(insn, 11, 7), 12)
+ return (K_STORE, funct3, 0, rs1, rs2, imm, nxt)
+ if opcode == 0x13: # OP-IMM
+ shamt = (insn >> 20) & 0x3F
+ top = (insn >> 26) & 0x3F
+ if funct3 == 1:
+ if top:
+ return _ILLEGAL
+ return (K_OPI, I_SLLI, rd, rs1, 0, shamt, nxt)
+ if funct3 == 5:
+ if top == 0:
+ sub = I_SRLI
+ elif top == 0x10:
+ sub = I_SRAI
+ else:
+ return _ILLEGAL
+ return (K_OPI, sub, rd, rs1, 0, shamt, nxt)
+ return (K_OPI, funct3, rd, rs1, 0, sext(_bits(insn, 31, 20), 12), nxt)
+ if opcode == 0x33: # OP
+ if funct7 == 0x01: # M extension
+ return (K_OP, A_MUL + funct3, rd, rs1, rs2, 0, nxt)
+ if funct7 == 0x00:
+ sub = _op_alu(funct3)
+ elif funct7 == 0x20 and funct3 == 0:
+ sub = A_SUB
+ elif funct7 == 0x20 and funct3 == 5:
+ sub = A_SRA
+ else:
+ return _ILLEGAL
+ return (K_OP, sub, rd, rs1, rs2, 0, nxt)
+ if opcode == 0x1B: # OP-IMM-32
+ if funct3 == 0:
+ return (K_OPI32, I_ADDIW, rd, rs1, 0, sext(_bits(insn, 31, 20), 12), nxt)
+ if funct3 == 1:
+ if (insn >> 25) & 0x7F:
+ return _ILLEGAL
+ return (K_OPI32, I_SLLIW, rd, rs1, 0, (insn >> 20) & 0x1F, nxt)
+ if funct3 == 5:
+ top = (insn >> 25) & 0x7F
+ if top == 0:
+ sub = I_SRLIW
+ elif top == 0x20:
+ sub = I_SRAIW
+ else:
+ return _ILLEGAL
+ return (K_OPI32, sub, rd, rs1, 0, (insn >> 20) & 0x1F, nxt)
+ return _ILLEGAL
+ if opcode == 0x3B: # OP-32
+ if funct7 == 0x00:
+ if funct3 == 0:
+ sub = A_ADD
+ elif funct3 == 1:
+ sub = A_SLL
+ elif funct3 == 5:
+ sub = A_SRL
+ else:
+ return _ILLEGAL
+ elif funct7 == 0x20:
+ if funct3 == 0:
+ sub = A_SUB
+ elif funct3 == 5:
+ sub = A_SRA
+ else:
+ return _ILLEGAL
+ elif funct7 == 0x01:
+ if funct3 == 0:
+ sub = A_MUL
+ elif funct3 == 4:
+ sub = A_DIV
+ elif funct3 == 5:
+ sub = A_DIVU
+ elif funct3 == 6:
+ sub = A_REM
+ elif funct3 == 7:
+ sub = A_REMU
+ else:
+ return _ILLEGAL
+ else:
+ return _ILLEGAL
+ return (K_OP32, sub, rd, rs1, rs2, 0, nxt)
+ if opcode == 0x0F: # FENCE / FENCE.I: ordering is not modelled (single core)
+ if funct3 in (0, 1):
+ return (K_FENCE, 0, 0, 0, 0, 0, nxt)
+ return _ILLEGAL
+ if opcode == 0x73: # SYSTEM
+ if funct3 == 0:
+ imm12 = insn >> 20
+ if imm12 == 0:
+ return (K_ECALL, 0, 0, 0, 0, 0, nxt)
+ if imm12 == 1:
+ return (K_EBREAK, 0, 0, 0, 0, 0, nxt)
+ if imm12 in (0x302, 0x102): # mret / sret (treated alike, M1)
+ return (K_MRET, 0, 0, 0, 0, 0, nxt)
+ return _ILLEGAL
+ return (K_CSR, funct3, rd, rs1, 0, insn >> 20, nxt)
+ if opcode == 0x2F: # AMO
+ if funct3 not in (2, 3):
+ return _ILLEGAL
+ funct5 = funct7 >> 2
+ if funct5 not in (0x00, 0x01, 0x02, 0x03, 0x04, 0x08, 0x0C, 0x10, 0x14, 0x18, 0x1C):
+ return _ILLEGAL
+ sub = funct5 | (0x40 if funct3 == 3 else 0)
+ return (K_AMO, sub, rd, rs1, rs2, 0, nxt)
+ return _ILLEGAL
+
+
+def _rvc_j_imm(insn: int) -> int:
+ return sext(
+ (_bits(insn, 12, 12) << 11)
+ | (_bits(insn, 11, 11) << 4)
+ | (_bits(insn, 10, 9) << 8)
+ | (_bits(insn, 8, 8) << 10)
+ | (_bits(insn, 7, 7) << 6)
+ | (_bits(insn, 6, 6) << 7)
+ | (_bits(insn, 5, 3) << 1)
+ | (_bits(insn, 2, 2) << 5),
+ 12,
+ )
+
+
+def _rvc_b_imm(insn: int) -> int:
+ return sext(
+ (_bits(insn, 12, 12) << 8)
+ | (_bits(insn, 11, 10) << 3)
+ | (_bits(insn, 6, 5) << 6)
+ | (_bits(insn, 4, 3) << 1)
+ | (_bits(insn, 2, 2) << 5),
+ 9,
+ )
+
+
+def decode16(pc: int, insn: int):
+ op = insn & 3
+ funct3 = (insn >> 13) & 7
+ nxt = pc + 2
+ rd = (insn >> 7) & 0x1F
+
+ if op == 0:
+ if funct3 == 0: # c.addi4spn
+ imm = (
+ ((insn >> 7) & 0x30)
+ | ((insn >> 1) & 0x3C0)
+ | ((insn >> 4) & 0x4)
+ | ((insn >> 2) & 0x8)
+ )
+ if imm == 0:
+ return _ILLEGAL
+ return (K_OPI, I_ADDI, 8 + ((insn >> 2) & 7), 2, 0, imm, nxt)
+ if funct3 == 2: # c.lw
+ imm = ((insn >> 7) & 0x38) | ((insn >> 4) & 0x4) | ((insn << 1) & 0x40)
+ return (K_LOAD, 2, 8 + ((insn >> 2) & 7), 8 + ((insn >> 7) & 7), 0, imm, nxt)
+ if funct3 == 3: # c.ld
+ imm = ((insn >> 7) & 0x38) | ((insn << 1) & 0xC0)
+ return (K_LOAD, 3, 8 + ((insn >> 2) & 7), 8 + ((insn >> 7) & 7), 0, imm, nxt)
+ if funct3 == 6: # c.sw
+ imm = ((insn >> 7) & 0x38) | ((insn >> 4) & 0x4) | ((insn << 1) & 0x40)
+ return (K_STORE, 2, 0, 8 + ((insn >> 7) & 7), 8 + ((insn >> 2) & 7), imm, nxt)
+ if funct3 == 7: # c.sd
+ imm = ((insn >> 7) & 0x38) | ((insn << 1) & 0xC0)
+ return (K_STORE, 3, 0, 8 + ((insn >> 7) & 7), 8 + ((insn >> 2) & 7), imm, nxt)
+ return _ILLEGAL
+
+ if op == 1:
+ if funct3 == 0: # c.addi / c.nop
+ imm = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6)
+ return (K_OPI, I_ADDI, rd, rd, 0, imm, nxt)
+ if funct3 == 1: # c.addiw
+ if rd == 0:
+ return _ILLEGAL
+ imm = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6)
+ return (K_OPI32, I_ADDIW, rd, rd, 0, imm, nxt)
+ if funct3 == 2: # c.li
+ imm = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6)
+ return (K_OPI, I_ADDI, rd, 0, 0, imm, nxt)
+ if funct3 == 3:
+ if rd == 2: # c.addi16sp
+ imm = sext(
+ ((insn >> 3) & 0x200)
+ | ((insn >> 2) & 0x10)
+ | ((insn << 1) & 0x40)
+ | ((insn << 4) & 0x180)
+ | ((insn << 3) & 0x20),
+ 10,
+ )
+ if imm == 0:
+ return _ILLEGAL
+ return (K_OPI, I_ADDI, 2, 2, 0, imm, nxt)
+ imm6 = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6)
+ if rd == 0 or imm6 == 0: # c.lui
+ return _ILLEGAL
+ return (K_LUI, 0, rd, 0, 0, (imm6 << 12) & MASK64, nxt)
+ if funct3 == 4:
+ sub3 = (insn >> 10) & 3
+ rd_p = 8 + ((insn >> 7) & 7)
+ if sub3 == 0: # c.srli
+ shamt = (_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2)
+ return (K_OPI, I_SRLI, rd_p, rd_p, 0, shamt, nxt)
+ if sub3 == 1: # c.srai
+ shamt = (_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2)
+ return (K_OPI, I_SRAI, rd_p, rd_p, 0, shamt, nxt)
+ if sub3 == 2: # c.andi
+ imm = sext((_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2), 6)
+ return (K_OPI, I_ANDI, rd_p, rd_p, 0, imm, nxt)
+ f2 = (insn >> 5) & 3
+ if _bits(insn, 12, 12) == 0:
+ sub = (A_SUB, A_XOR, A_OR, A_AND)[f2]
+ return (K_OP, sub, rd_p, rd_p, 8 + ((insn >> 2) & 7), 0, nxt)
+ if f2 == 0: # c.subw
+ return (K_OP32, A_SUB, rd_p, rd_p, 8 + ((insn >> 2) & 7), 0, nxt)
+ if f2 == 1: # c.addw
+ return (K_OP32, A_ADD, rd_p, rd_p, 8 + ((insn >> 2) & 7), 0, nxt)
+ return _ILLEGAL
+ if funct3 == 5: # c.j
+ return (K_JAL, 0, 0, 0, 0, (pc + _rvc_j_imm(insn)) & MASK64, nxt)
+ if funct3 == 6: # c.beqz
+ return (K_BRANCH, 0, 0, 8 + ((insn >> 7) & 7), 0, (pc + _rvc_b_imm(insn)) & MASK64, nxt)
+ if funct3 == 7: # c.bnez
+ return (K_BRANCH, 1, 0, 8 + ((insn >> 7) & 7), 0, (pc + _rvc_b_imm(insn)) & MASK64, nxt)
+ return _ILLEGAL
+
+ if op == 2:
+ if funct3 == 0: # c.slli
+ if rd == 0:
+ return _ILLEGAL
+ shamt = (_bits(insn, 12, 12) << 5) | _bits(insn, 6, 2)
+ return (K_OPI, I_SLLI, rd, rd, 0, shamt, nxt)
+ if funct3 == 2: # c.lwsp
+ if rd == 0:
+ return _ILLEGAL
+ imm = ((insn >> 7) & 0x20) | ((insn >> 2) & 0x1C) | ((insn << 4) & 0xC0)
+ return (K_LOAD, 2, rd, 2, 0, imm, nxt)
+ if funct3 == 3: # c.ldsp
+ if rd == 0:
+ return _ILLEGAL
+ imm = ((insn >> 7) & 0x20) | ((insn >> 2) & 0x18) | ((insn << 4) & 0x1C0)
+ return (K_LOAD, 3, rd, 2, 0, imm, nxt)
+ if funct3 == 4:
+ rs2 = (insn >> 2) & 0x1F
+ if _bits(insn, 12, 12) == 0:
+ if rs2 == 0: # c.jr
+ if rd == 0:
+ return _ILLEGAL
+ return (K_JALR, 0, 0, rd, 0, 0, nxt)
+ if rd == 0: # c.mv
+ return _ILLEGAL
+ return (K_OP, A_ADD, rd, 0, rs2, 0, nxt)
+ if rs2 == 0 and rd == 0: # c.ebreak
+ return (K_EBREAK, 0, 0, 0, 0, 0, nxt)
+ if rs2 == 0: # c.jalr
+ if rd == 0:
+ return _ILLEGAL
+ return (K_JALR, 0, 1, rd, 0, 0, nxt)
+ return (K_OP, A_ADD, rd, rd, rs2, 0, nxt) # c.add
+ if funct3 == 6: # c.swsp
+ imm = ((insn >> 7) & 0x3C) | ((insn >> 1) & 0xC0)
+ return (K_STORE, 2, 0, 2, (insn >> 2) & 0x1F, imm, nxt)
+ if funct3 == 7: # c.sdsp
+ imm = ((insn >> 7) & 0x38) | ((insn >> 1) & 0x1C0)
+ return (K_STORE, 3, 0, 2, (insn >> 2) & 0x1F, imm, nxt)
+ return _ILLEGAL
+
+ return _ILLEGAL
diff --git a/debug/risc-v-gsp-rm-vm/sim/machine.py b/debug/risc-v-gsp-rm-vm/sim/machine.py
new file mode 100644
index 0000000..8d77dd8
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/machine.py
@@ -0,0 +1,825 @@
+"""Deterministic RV64IMAC machine: RAM, NS16550A UART, debugger-visible state.
+
+The execution loop is a monolithic dispatch over the cached decode tuples from
+``sim.decode``. Everything guest-visible is a pure function of the loaded
+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.
+"""
+from __future__ import annotations
+
+import mmap
+import struct
+
+from .decode import (
+ A_ADD,
+ A_AND,
+ A_DIV,
+ A_DIVU,
+ A_MUL,
+ A_MULH,
+ A_MULHSU,
+ A_MULHU,
+ A_OR,
+ A_REM,
+ A_REMU,
+ A_SLL,
+ A_SLT,
+ A_SLTU,
+ A_SRA,
+ A_SRL,
+ A_SUB,
+ A_XOR,
+ I_ADDI,
+ I_ADDIW,
+ I_ANDI,
+ I_ORI,
+ I_SLLI,
+ I_SLLIW,
+ I_SLTI,
+ I_SLTIU,
+ I_SRAI,
+ I_SRAIW,
+ I_SRLI,
+ I_SRLIW,
+ I_XORI,
+ 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,
+ LOAD_SIZE,
+ MASK64,
+ STORE_SIZE,
+ decode16,
+ decode32,
+)
+
+# Stop reasons returned by Machine.run().
+R_BUDGET = 0
+R_BP = 1
+R_WATCH = 2
+R_EBREAK = 3
+R_FAULT = 4
+R_STEP = 5
+R_ECALL = 6
+
+#: Accepted ``unknown_mmio`` policies for accesses outside RAM/UART/the map.
+UNKNOWN_MMIO_MODES = ("fault", "lenient")
+
+UART_BASE = 0x10000000
+UART_SIZE = 0x100
+
+# RV64I M A C advertised in misa (MXL=2, I=bit8, M=bit12, A=bit0, C=bit2).
+MISA_RV64IMAC = 0x8000000000001105
+
+
+class Fault(Exception):
+ """Guest-visible trap: unmapped access or illegal instruction."""
+
+ def __init__(self, cause: int, tval: int = 0):
+ super().__init__(f"cause={cause:#x} tval={tval:#x}")
+ self.cause = cause
+ self.tval = tval
+
+
+class Uart16550:
+ """Behavioural NS16550A: TX is captured, LSR always reports THR empty."""
+
+ def __init__(self):
+ self.tx = bytearray()
+ self.ier = 0
+ self.lcr = 0
+ self.mcr = 0
+ self.scr = 0
+ self.dll = 0
+ self.dlm = 0
+
+ def read(self, off: int, size: int = 1) -> int:
+ if off == 0:
+ return self.dll # RBR (no RX modelled) / DLL when DLAB=1
+ if off == 1:
+ return self.ier
+ if off == 2:
+ return 0x01 # IIR: no pending interrupt
+ if off == 3:
+ return self.lcr
+ if off == 4:
+ return self.mcr
+ if off == 5:
+ return 0x60 # LSR: THRE | TEMT
+ if off == 6:
+ return 0xB0 # MSR: CTS|DSR|DCD
+ if off == 7:
+ return self.scr
+ return 0
+
+ def write(self, off: int, value: int, size: int = 1) -> None:
+ value &= 0xFF
+ if off == 0:
+ if self.lcr & 0x80:
+ self.dll = value
+ else:
+ self.tx.append(value)
+ elif off == 1:
+ self.ier = value
+ elif off == 3:
+ self.lcr = value
+ elif off == 4:
+ self.mcr = value
+ elif off == 7:
+ self.scr = value
+ # FCR (2) and reserved offsets are write-only/no-ops.
+
+ def text(self) -> str:
+ return self.tx.decode("utf-8", "replace")
+
+
+def _signed64(value: int) -> int:
+ return value - 0x10000000000000000 if value & 0x8000000000000000 else value
+
+
+def _signed32(value: int) -> int:
+ value &= 0xFFFFFFFF
+ return value - 0x100000000 if value & 0x80000000 else value
+
+
+def _sext32(value: int) -> int:
+ value &= 0xFFFFFFFF
+ return (value ^ 0x80000000) - 0x80000000
+
+
+def _watch_match(watchers, ea: int, size: int, rw_bit: int):
+ end = ea + size
+ for wa, we, wk, mask in watchers:
+ if (mask & rw_bit) and ea < we and wa < end:
+ return wk
+ return None
+
+
+class Machine:
+ """One deterministic RV64IMAC hart plus RAM and UART.
+
+ ``device_map`` is a :class:`greeneye.device_map.DeviceMap` (or any object
+ with the same ``lookup``/``record`` surface) describing modelled MMIO.
+ ``unknown_mmio`` selects what happens to an access that is neither RAM,
+ UART nor in the map: ``"fault"`` (default, M1 parity) stops as before;
+ ``"lenient"`` returns the mapped behaviour or zero, records the access in
+ :attr:`mmio_log`, counts it in :attr:`mmio_unknown`, and, with
+ ``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.
+ """
+
+ def __init__(
+ self,
+ memory_mb: int = 256,
+ ram_base: int = 0x80000000,
+ device_map=None,
+ unknown_mmio: str = "fault",
+ learn_mmio: bool = False,
+ bias: int = 0,
+ ):
+ if unknown_mmio not in UNKNOWN_MMIO_MODES:
+ raise ValueError(f"unknown_mmio must be one of {UNKNOWN_MMIO_MODES}, got {unknown_mmio!r}")
+ 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.unknown_mmio = unknown_mmio
+ self.learn_mmio = bool(learn_mmio)
+ self.bias = bias # PC relocation for learned-map firmware addresses
+ self.mmio_log: list[dict] = []
+ self.mmio_unknown = 0
+ self.x = [0] * 32
+ self.pc = ram_base
+ self.insns = 0
+ self.csrs: dict[int, int] = {0x301: MISA_RV64IMAC}
+ self._cache: dict[int, tuple] = {}
+ self._u16 = struct.Struct("<H")
+ self._u32 = struct.Struct("<I")
+ self._i8 = struct.Struct("<b")
+ self._i16 = struct.Struct("<h")
+ self._i32 = struct.Struct("<i")
+ self._u64 = struct.Struct("<Q")
+ self._u16p = self._u16.pack_into
+ self._u32p = self._u32.pack_into
+ self._u64p = self._u64.pack_into
+
+ # -- image loading -----------------------------------------------------
+
+ def load_segments(self, plan, entry: int) -> None:
+ """Place ``(addr, bytes)`` PT_LOAD results and set the PC."""
+ for addr, data in plan:
+ offset = addr - self.ram_base
+ if offset < 0 or offset + len(data) > self.ram_size:
+ raise Fault(1, addr)
+ self.ram[offset : offset + len(data)] = data
+ self.pc = entry & MASK64
+ self.invalidate()
+
+ def invalidate(self) -> None:
+ """Drop the decode cache after debugger-visible code changes."""
+ self._cache.clear()
+
+ # -- register access (debugger surface) --------------------------------
+
+ def reg(self, index: int) -> int:
+ if index == 0:
+ return 0
+ return self.x[index]
+
+ def set_reg(self, index: int, value: int) -> None:
+ if index != 0:
+ self.x[index] = value & MASK64
+
+ # -- raw memory access (debugger surface) ------------------------------
+
+ def read_mem(self, addr: int, size: int) -> bytes:
+ out = bytearray()
+ while size > 0:
+ if self.ram_base <= addr < self.ram_base + self.ram_size:
+ take = min(size, self.ram_base + self.ram_size - addr)
+ off = addr - self.ram_base
+ out += self.ram[off : off + take]
+ addr += take
+ size -= take
+ elif UART_BASE <= addr < UART_BASE + UART_SIZE:
+ out.append(self.uart.read(addr - UART_BASE))
+ addr += 1
+ size -= 1
+ else:
+ raise Fault(5, addr)
+ return bytes(out)
+
+ def write_mem(self, addr: int, data: bytes) -> None:
+ self.invalidate()
+ offset = 0
+ while offset < len(data):
+ here = addr + offset
+ remaining = len(data) - offset
+ if self.ram_base <= here < self.ram_base + self.ram_size:
+ take = min(remaining, self.ram_base + self.ram_size - here)
+ ro = here - self.ram_base
+ self.ram[ro : ro + take] = data[offset : offset + take]
+ offset += take
+ elif UART_BASE <= here < UART_BASE + UART_SIZE:
+ self.uart.write(here - UART_BASE, data[offset])
+ offset += 1
+ else:
+ raise Fault(7, here)
+
+ # -- MMIO (guest side) -------------------------------------------------
+
+ def _mapped_mmio(self, addr: int, size: int, write: bool) -> tuple[bool, int]:
+ """Consult the device map; ``(handled, value)`` for a modelled access."""
+ dm = self.device_map
+ if dm is None:
+ return False, 0
+ spec = dm.lookup(addr, size, write)
+ if spec == "unimplemented":
+ return False, 0
+ if spec.startswith("value:"):
+ return True, int(spec[6:], 16)
+ return True, 0 # "zero": mapped but no read value observed
+
+ def _log_unknown(self, pc: int, addr: int, size: int, rw: str, value: int = 0) -> None:
+ """Lenient fallback: log, count, and optionally learn one access."""
+ self.mmio_unknown += 1
+ self.mmio_log.append({"pc": pc, "addr": addr, "size": size, "rw": rw})
+ if self.learn_mmio:
+ if self.device_map is None:
+ from greeneye.device_map import DeviceMap # lazy: sim stays usable alone
+
+ self.device_map = DeviceMap(meta={"source": "learn"})
+ self.device_map.record(addr, size, rw, value, pc=pc, bias=self.bias, source="trace")
+
+ 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)
+ handled, value = self._mapped_mmio(addr, size, False)
+ if handled:
+ return value
+ if self.unknown_mmio == "lenient":
+ self._log_unknown(pc, addr, size, "r")
+ return 0
+ raise Fault(5, addr)
+
+ def _mmio_store(self, addr: int, size: int, value: int, pc: int = 0) -> None:
+ if UART_BASE <= addr < UART_BASE + UART_SIZE:
+ self.uart.write(addr - UART_BASE, value)
+ return
+ handled, _ = self._mapped_mmio(addr, size, True)
+ if handled:
+ if self.learn_mmio and self.device_map is not None:
+ self.device_map.record(addr, size, "w", value, pc=pc, bias=self.bias, source="trace")
+ return
+ if self.unknown_mmio == "lenient":
+ self._log_unknown(pc, addr, size, "w", value)
+ return
+ raise Fault(7, addr)
+
+ # -- execution ---------------------------------------------------------
+
+ def step(self, watchers=(), trace=None):
+ return self.run(1, breakpoints=(), watchers=watchers, single_step=True, trace=trace)
+
+ def run(self, budget: int, breakpoints=(), watchers=(), single_step: bool = False,
+ trace=None):
+ """Execute up to ``budget`` instructions.
+
+ Returns ``(reason, pc, a, b)``; ``a``/``b`` carry the watched address
+ and kind (R_WATCH) or the trap cause and tval (R_FAULT). When
+ ``trace`` is a :class:`sim.trace.Trace`, every executed instruction
+ and every completed memory access in its address range is recorded.
+ """
+ x = self.x
+ ram = self.ram
+ base = self.ram_base
+ limit = self.ram_size
+ cache = self._cache
+ i8 = self._i8.unpack_from
+ i16 = self._i16.unpack_from
+ i32 = self._i32.unpack_from
+ u16 = self._u16.unpack_from
+ u32 = self._u32.unpack_from
+ u64 = self._u64.unpack_from
+ u16p = self._u16p
+ u32p = self._u32p
+ u64p = self._u64p
+ has_bps = bool(breakpoints)
+ pc = self.pc
+ n = 0
+ reason = R_BUDGET
+ a = b = 0
+ traced = trace is not None
+ active = False
+
+ while n < budget:
+ if has_bps and pc in breakpoints:
+ reason = R_BP
+ a = pc
+ break
+
+ off = pc - base
+ if off < 0 or off + 4 > limit:
+ reason = R_FAULT
+ a = 1
+ b = pc
+ break
+ hw = ram[off] | (ram[off + 1] << 8)
+ if hw & 3 == 3:
+ d = cache.get(pc)
+ if d is None:
+ d = decode32(pc, hw | (ram[off + 2] << 16) | (ram[off + 3] << 24))
+ cache[pc] = d
+ else:
+ d = cache.get(pc)
+ if d is None:
+ d = decode16(pc, hw)
+ cache[pc] = d
+ kind, sub, rd, r1, r2, imm, nxt = d
+ n += 1
+ if traced:
+ insn_pc = pc
+ active = trace.begin(insn_pc, self.insns + n)
+
+ # -- loads / stores (most frequent in firmware hot loops) ------
+ if kind == K_LOAD:
+ ea = (x[r1] + imm) & MASK64
+ size = LOAD_SIZE[sub]
+ if watchers:
+ hit = _watch_match(watchers, ea, size, 2)
+ if hit is not None:
+ reason = R_WATCH
+ a = ea
+ b = hit
+ break
+ o = ea - base
+ if 0 <= o <= limit - size:
+ if active:
+ trace.mem(insn_pc, ea, size, False, self.insns + n)
+ if sub == 3:
+ if rd:
+ x[rd] = u64(ram, o)[0]
+ elif sub == 2:
+ if rd:
+ x[rd] = i32(ram, o)[0]
+ elif sub == 4:
+ if rd:
+ x[rd] = ram[o]
+ elif sub == 0:
+ if rd:
+ x[rd] = i8(ram, o)[0]
+ elif sub == 5:
+ if rd:
+ x[rd] = u16(ram, o)[0]
+ elif sub == 6:
+ if rd:
+ x[rd] = u32(ram, o)[0]
+ else:
+ if rd:
+ x[rd] = i16(ram, o)[0]
+ pc = nxt
+ else:
+ try:
+ v = self._mmio_load(ea, size, pc)
+ except Fault as f:
+ reason = R_FAULT
+ a = f.cause
+ b = f.tval
+ break
+ if rd:
+ x[rd] = v
+ if active:
+ trace.mem(insn_pc, ea, size, False, self.insns + n)
+ pc = nxt
+ continue
+
+ if kind == K_STORE:
+ ea = (x[r1] + imm) & MASK64
+ size = STORE_SIZE[sub]
+ if watchers:
+ hit = _watch_match(watchers, ea, size, 1)
+ if hit is not None:
+ reason = R_WATCH
+ a = ea
+ b = hit
+ break
+ o = ea - base
+ value = x[r2]
+ if 0 <= o <= limit - size:
+ if active:
+ trace.mem(insn_pc, ea, size, True, self.insns + n)
+ if sub == 3:
+ u64p(ram, o, value)
+ elif sub == 2:
+ u32p(ram, o, value & 0xFFFFFFFF)
+ elif sub == 1:
+ u16p(ram, o, value & 0xFFFF)
+ else:
+ ram[o] = value & 0xFF
+ pc = nxt
+ else:
+ try:
+ self._mmio_store(ea, size, value, pc)
+ except Fault as f:
+ reason = R_FAULT
+ a = f.cause
+ b = f.tval
+ break
+ if active:
+ trace.mem(insn_pc, ea, size, True, self.insns + n)
+ pc = nxt
+ continue
+
+ # -- control flow ----------------------------------------------
+ if kind == K_BRANCH:
+ va = x[r1]
+ vb = x[r2]
+ if sub == 0:
+ take = va == vb
+ elif sub == 1:
+ take = va != vb
+ elif sub == 4:
+ take = _signed64(va) < _signed64(vb)
+ elif sub == 5:
+ take = _signed64(va) >= _signed64(vb)
+ elif sub == 6:
+ take = va < vb
+ else:
+ take = va >= vb
+ pc = imm if take else nxt
+ elif kind == K_JAL:
+ if rd:
+ x[rd] = nxt
+ pc = imm
+ elif kind == K_JALR:
+ target = (x[r1] + imm) & MASK64
+ target &= ~1
+ if rd:
+ x[rd] = nxt
+ pc = target
+ elif kind == K_AUIPC:
+ if rd:
+ x[rd] = (pc + imm) & MASK64
+ pc = nxt
+ elif kind == K_LUI:
+ if rd:
+ x[rd] = imm
+ pc = nxt
+
+ # -- integer ALU -----------------------------------------------
+ elif kind == K_OPI:
+ va = x[r1] if r1 else 0
+ if sub == I_ADDI:
+ rv = (va + imm) & MASK64
+ elif sub == I_SLLI:
+ rv = (va << (imm & 63)) & MASK64
+ elif sub == I_SLTI:
+ rv = 1 if _signed64(va) < imm else 0
+ elif sub == I_SLTIU:
+ rv = 1 if va < (imm & MASK64) else 0
+ elif sub == I_XORI:
+ rv = va ^ (imm & MASK64)
+ elif sub == I_ORI:
+ rv = va | (imm & MASK64)
+ elif sub == I_ANDI:
+ rv = va & (imm & MASK64)
+ elif sub == I_SRLI:
+ rv = va >> (imm & 63)
+ else: # I_SRAI
+ rv = (_signed64(va) >> (imm & 63)) & MASK64
+ if rd:
+ x[rd] = rv
+ pc = nxt
+
+ elif kind == K_OP:
+ va = x[r1]
+ vb = x[r2]
+ if sub == A_ADD:
+ rv = (va + vb) & MASK64
+ elif sub == A_SLL:
+ rv = (va << (vb & 63)) & MASK64
+ elif sub == A_SLT:
+ rv = 1 if _signed64(va) < _signed64(vb) else 0
+ elif sub == A_SLTU:
+ rv = 1 if va < vb else 0
+ elif sub == A_XOR:
+ rv = va ^ vb
+ elif sub == A_SRL:
+ rv = va >> (vb & 63)
+ elif sub == A_OR:
+ rv = va | vb
+ elif sub == A_AND:
+ rv = va & vb
+ elif sub == A_SUB:
+ rv = (va - vb) & MASK64
+ elif sub == A_SRA:
+ rv = (_signed64(va) >> (vb & 63)) & MASK64
+ elif sub == A_MUL:
+ rv = (va * vb) & MASK64
+ elif sub == A_MULH:
+ rv = ((_signed64(va) * _signed64(vb)) >> 64) & MASK64
+ elif sub == A_MULHSU:
+ rv = ((_signed64(va) * vb) >> 64) & MASK64
+ elif sub == A_MULHU:
+ rv = ((va * vb) >> 64) & MASK64
+ elif sub == A_DIV:
+ sa, sb = _signed64(va), _signed64(vb)
+ if sb == 0:
+ rv = MASK64
+ elif sa == -(1 << 63) and sb == -1:
+ rv = 1 << 63
+ else:
+ rv = (abs(sa) // abs(sb)) & MASK64
+ if (sa < 0) != (sb < 0):
+ rv = (-rv) & MASK64
+ elif sub == A_DIVU:
+ rv = MASK64 if vb == 0 else va // vb
+ elif sub == A_REM:
+ sa, sb = _signed64(va), _signed64(vb)
+ if sb == 0 or (sa == -(1 << 63) and sb == -1):
+ rv = 0
+ else:
+ rv = (abs(sa) % abs(sb)) & MASK64
+ if sa < 0:
+ rv = (-rv) & MASK64
+ else: # A_REMU
+ rv = va if vb == 0 else va % vb
+ if rd:
+ x[rd] = rv
+ pc = nxt
+
+ elif kind == K_OPI32:
+ va = x[r1] if r1 else 0
+ if sub == I_ADDIW:
+ rv = (va + imm) & 0xFFFFFFFF
+ elif sub == I_SLLIW:
+ rv = (va << (imm & 31)) & 0xFFFFFFFF
+ elif sub == I_SRLIW:
+ rv = (va & 0xFFFFFFFF) >> (imm & 31)
+ else: # I_SRAIW
+ rv = (_signed32(va) >> (imm & 31)) & 0xFFFFFFFF
+ if rd:
+ x[rd] = _sext32(rv) & MASK64
+ pc = nxt
+
+ elif kind == K_OP32:
+ va = x[r1]
+ vb = x[r2]
+ if sub == A_ADD:
+ rv = (va + vb) & 0xFFFFFFFF
+ elif sub == A_SUB:
+ rv = (va - vb) & 0xFFFFFFFF
+ elif sub == A_SLL:
+ rv = (va << (vb & 31)) & 0xFFFFFFFF
+ elif sub == A_SRL:
+ rv = (va & 0xFFFFFFFF) >> (vb & 31)
+ elif sub == A_SRA:
+ rv = (_signed32(va) >> (vb & 31)) & 0xFFFFFFFF
+ elif sub == A_MUL:
+ rv = (va * vb) & 0xFFFFFFFF
+ elif sub == A_DIV:
+ sa, sb = _signed32(va), _signed32(vb)
+ if sb == 0:
+ rv = 0xFFFFFFFF
+ elif sa == -(1 << 31) and sb == -1:
+ rv = 1 << 31
+ else:
+ rv = (abs(sa) // abs(sb)) & 0xFFFFFFFF
+ if (sa < 0) != (sb < 0):
+ rv = (-rv) & 0xFFFFFFFF
+ elif sub == A_DIVU:
+ b32 = vb & 0xFFFFFFFF
+ rv = 0xFFFFFFFF if b32 == 0 else (va & 0xFFFFFFFF) // b32
+ elif sub == A_REM:
+ sa, sb = _signed32(va), _signed32(vb)
+ if sb == 0 or (sa == -(1 << 31) and sb == -1):
+ rv = 0
+ else:
+ rv = (abs(sa) % abs(sb)) & 0xFFFFFFFF
+ if sa < 0:
+ rv = (-rv) & 0xFFFFFFFF
+ else: # A_REMU
+ b32 = vb & 0xFFFFFFFF
+ rv = (va & 0xFFFFFFFF) if b32 == 0 else (va & 0xFFFFFFFF) % b32
+ if rd:
+ x[rd] = _sext32(rv) & MASK64
+ pc = nxt
+
+ # -- system / atomics / traps ----------------------------------
+ elif kind == K_FENCE:
+ pc = nxt
+
+ elif kind == K_CSR:
+ csr = imm
+ old = self.csrs.get(csr, 0)
+ if sub in (5, 6, 7):
+ src = r1 # zimm
+ if sub == 5:
+ if rd:
+ x[rd] = old
+ self.csrs[csr] = src
+ else:
+ if rd:
+ x[rd] = old
+ if src:
+ self.csrs[csr] = (old | src) if sub == 6 else (old & ~src)
+ else:
+ src = x[r1]
+ if sub == 1:
+ if rd:
+ x[rd] = old
+ self.csrs[csr] = src & MASK64
+ elif sub == 2:
+ if rd:
+ x[rd] = old
+ if r1:
+ self.csrs[csr] = (old | src) & MASK64
+ elif sub == 3:
+ if rd:
+ x[rd] = old
+ if r1:
+ self.csrs[csr] = (old & ~src) & MASK64
+ else:
+ reason = R_FAULT
+ a = 2
+ b = csr
+ break
+ pc = nxt
+
+ elif kind == K_AMO:
+ width = 8 if sub & 0x40 else 4
+ op5 = sub & 0x3F
+ ea = x[r1] & MASK64
+ if watchers:
+ hit = _watch_match(watchers, ea, width, 1)
+ if hit is not None:
+ reason = R_WATCH
+ a = ea
+ b = hit
+ break
+ try:
+ old = self._guest_load(ea, width, pc)
+ if active:
+ trace.mem(insn_pc, ea, width, False, self.insns + n)
+ if op5 == 0x02: # lr
+ if rd:
+ x[rd] = old
+ elif op5 == 0x03: # sc
+ self._guest_store(ea, width, x[r2], pc)
+ if active:
+ trace.mem(insn_pc, ea, width, True, self.insns + n)
+ if rd:
+ x[rd] = 0
+ else:
+ rhs = x[r2]
+ mask = 0xFFFFFFFF if width == 4 else MASK64
+ ov = old & mask
+ rv = rhs & mask
+ if op5 == 0x00:
+ new = (ov + rv) & mask
+ elif op5 == 0x01:
+ new = rv
+ elif op5 == 0x04:
+ new = ov ^ rv
+ elif op5 == 0x08:
+ new = ov | rv
+ elif op5 == 0x0C:
+ new = ov & rv
+ elif op5 == 0x10:
+ new = ov if _signed64(ov) < _signed64(rv) else rv
+ elif op5 == 0x14:
+ new = ov if _signed64(ov) > _signed64(rv) else rv
+ elif op5 == 0x18:
+ new = ov if ov < rv else rv
+ else: # 0x1C
+ new = ov if ov > rv else rv
+ self._guest_store(ea, width, new, pc)
+ if active:
+ trace.mem(insn_pc, ea, width, True, self.insns + n)
+ if rd:
+ x[rd] = (_sext32(old) & MASK64) if width == 4 else old
+ pc = nxt
+ except Fault as f:
+ reason = R_FAULT
+ a = f.cause
+ b = f.tval
+ break
+
+ elif kind == K_EBREAK:
+ if single_step:
+ pc = nxt
+ else:
+ reason = R_EBREAK
+ a = pc
+ break
+
+ elif kind == K_ECALL:
+ if single_step:
+ pc = nxt
+ else:
+ reason = R_ECALL
+ a = pc
+ break
+
+ elif kind == K_MRET:
+ target = self.csrs.get(0x341, 0) & MASK64
+ if target:
+ pc = target
+ else:
+ reason = R_FAULT
+ a = 2
+ b = 0
+ break
+
+ else: # K_ILLEGAL
+ reason = R_FAULT
+ a = 2
+ b = 0
+ break
+
+ self.pc = pc
+ self.insns += n
+ if single_step and reason == R_BUDGET:
+ reason = R_STEP
+ return (reason, pc, a, b)
+
+ # -- guest memory helpers (AMO) ----------------------------------------
+
+ def _guest_load(self, addr: int, width: int, pc: int = 0) -> int:
+ off = addr - self.ram_base
+ if 0 <= off <= self.ram_size - width:
+ if width == 8:
+ return self._u64.unpack_from(self.ram, off)[0]
+ return self._u32.unpack_from(self.ram, off)[0]
+ return self._mmio_load(addr, width, pc)
+
+ def _guest_store(self, addr: int, width: int, value: int, pc: int = 0) -> None:
+ off = addr - self.ram_base
+ if 0 <= off <= self.ram_size - width:
+ if width == 8:
+ self._u64p(self.ram, off, value)
+ else:
+ self._u32p(self.ram, off, value & 0xFFFFFFFF)
+ return
+ self._mmio_store(addr, width, value, pc)
+
+
+#: Task-facing alias: the native simulator machine is :class:`Machine`.
+SimMachine = Machine
diff --git a/debug/risc-v-gsp-rm-vm/sim/trace.py b/debug/risc-v-gsp-rm-vm/sim/trace.py
new file mode 100644
index 0000000..04373b1
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/trace.py
@@ -0,0 +1,145 @@
+"""Native execution/access trace (roadmap R4): the in-process equivalent of
+``qemu/greeneye_trace.c``.
+
+The QEMU TCG plugin records every translated instruction and every memory
+access as JSONL. This module does the same from inside
+:class:`sim.machine.Machine`, with the same knobs and line shape:
+
+* ``exec=on|off`` - emit one ``exec`` line per executed instruction;
+* ``start=``/``end=`` - only instrument PCs in the inclusive range (matching
+ the plugin, which filters at translation time, so memory accesses are also
+ filtered by the *instruction* PC);
+* a bounded ring budget - once full, further lines are counted as ``dropped``
+ instead of growing without limit (deterministically).
+
+Lines are JSON objects in the TCG plugin's schema (``t``/``pc``/``addr``/
+``size``, hex strings), plus a ``vt`` virtual-time field (the machine's
+instruction count). Nothing here reads the host clock, so the payload is
+byte-identical for identical execution.
+"""
+from __future__ import annotations
+
+#: Default cap on buffered trace lines (~50 bytes each in memory).
+DEFAULT_MAX_EVENTS = 1_000_000
+
+
+class Trace:
+ """Collects compact exec/access lines for one native session."""
+
+ def __init__(self, *, exec_enabled: bool = True, start: int | None = None,
+ end: int | None = None, max_events: int | None = None):
+ self.exec_enabled = bool(exec_enabled)
+ self.start = start
+ self.end = end
+ self.max_events = DEFAULT_MAX_EVENTS if max_events is None else max(0, int(max_events))
+ self.execs = 0
+ self.reads = 0
+ self.writes = 0
+ self.events = 0
+ self.dropped = 0
+ self._lines: list[str] = []
+ self._flushed = False
+
+ # -- configuration -----------------------------------------------------
+
+ def in_range(self, pc: int) -> bool:
+ if self.start is not None and pc < self.start:
+ return False
+ if self.end is not None and pc > self.end:
+ return False
+ return True
+
+ def config(self) -> dict:
+ return {
+ "exec": self.exec_enabled,
+ "start": self.start,
+ "end": self.end,
+ "max_events": self.max_events,
+ }
+
+ def counts(self) -> dict:
+ return {
+ "exec": self.execs,
+ "read": self.reads,
+ "write": self.writes,
+ "events": self.events,
+ "dropped": self.dropped,
+ }
+
+ # -- collection (called from Machine.run's hot loop) --------------------
+
+ def begin(self, pc: int, vt: int) -> bool:
+ """Called once per executed instruction; returns access-capture."""
+ if not self.in_range(pc):
+ return False
+ if self.exec_enabled:
+ self._append(f'{{"t":"exec","vcpu":0,"pc":"0x{pc:x}","vt":{vt}}}')
+ self.execs += 1
+ return True
+
+ def mem(self, pc: int, addr: int, size: int, is_write: bool, vt: int) -> None:
+ kind = "write" if is_write else "read"
+ if is_write:
+ self.writes += 1
+ else:
+ self.reads += 1
+ self._append(
+ f'{{"t":"{kind}","vcpu":0,"addr":"0x{addr:x}","size":{size},'
+ f'"pc":"0x{pc:x}","vt":{vt}}}'
+ )
+
+ def _append(self, line: str) -> None:
+ self.events += 1
+ if self.max_events and len(self._lines) >= self.max_events:
+ self.dropped += 1
+ return
+ self._lines.append(line)
+
+ # -- output ------------------------------------------------------------
+
+ def payload(self) -> bytes:
+ """The complete JSONL document, including the plugin's summary line."""
+ body = "\n".join(self._lines)
+ if body:
+ body += "\n"
+ summary = (
+ f'{{"t":"summary","insns":{self.execs},"mem":{self.reads + self.writes},'
+ f'"events":{self.events},"dropped":{self.dropped},'
+ f'"exec":{str(self.exec_enabled).lower()},'
+ f'"start":{_json_addr(self.start)},"end":{_json_addr(self.end)}}}'
+ )
+ return (body + summary + "\n").encode("utf-8")
+
+ def flush(self, recorder) -> str | None:
+ """Write the trace to ``recorder`` as ``blobs/trace.jsonl`` (once)."""
+ if self._flushed:
+ return None
+ self._flushed = True
+ if recorder is None or getattr(recorder, "dir", None) is None:
+ return None
+ rel = recorder.blob("trace.jsonl", self.payload())
+ recorder.meta["trace_blob"] = rel
+ recorder.meta["trace"] = {**self.config(), **self.counts()}
+ return rel
+
+
+def make_trace(spec):
+ """Normalise a trace spec (None/False, True, dict or Trace) to a Trace."""
+ if spec is None or spec is False:
+ return None
+ if isinstance(spec, Trace):
+ return spec
+ if spec is True:
+ return Trace()
+ if isinstance(spec, dict):
+ return Trace(
+ exec_enabled=spec.get("exec", spec.get("exec_enabled", True)),
+ start=spec.get("start"),
+ end=spec.get("end"),
+ max_events=spec.get("max_events", spec.get("max")),
+ )
+ raise ValueError(f"trace spec must be None, bool, dict or Trace, not {type(spec).__name__}")
+
+
+def _json_addr(value: int | None) -> str:
+ return "null" if value is None else f"\"0x{value:x}\""