summaryrefslogtreecommitdiffstats
path: root/debug
diff options
context:
space:
mode:
Diffstat (limited to 'debug')
-rw-r--r--debug/risc-v-gsp-rm-vm/.gitignore5
-rw-r--r--debug/risc-v-gsp-rm-vm/README.md7
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md12
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md8
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/35-GREENEYE-SIMULATOR-DESIGN.md20
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/36-GSP-DEVICE-MODEL.md127
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/37-SIM-QEMU-LINE.md47
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/39-RE-WORKFLOW-REVIEW.md2
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/40-NEW-VERSION-TRIAGE.md8
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/41-STABILITY-AND-TIME-TO-VALUE.md45
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/42-GMMU-FBIF-OBSERVABILITY.md203
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/43-GMMU-PTE-DECODER.md206
-rw-r--r--debug/risc-v-gsp-rm-vm/docs/44-GMMU-ORACLE-AND-WORKFLOW.md254
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py924
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py585
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json80
-rw-r--r--debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md46
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/__init__.py26
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/base.py16
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py21
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/devices/observe.py372
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/gmmu_pte.py988
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/gsp_boot.py179
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/libos.py217
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/machine.py80
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/mpu.py154
-rw-r--r--debug/risc-v-gsp-rm-vm/sim/profiles/tu104-570.144.json815
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_gmmu_debug.py484
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_gmmu_fbif_trace.py381
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_gmmu_oracle.py397
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_gmmu_pte.py534
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_mpu.py177
-rw-r--r--debug/risc-v-gsp-rm-vm/tests/test_sim_devices.py28
33 files changed, 7280 insertions, 168 deletions
diff --git a/debug/risc-v-gsp-rm-vm/.gitignore b/debug/risc-v-gsp-rm-vm/.gitignore
index f400d55..54dd9aa 100644
--- a/debug/risc-v-gsp-rm-vm/.gitignore
+++ b/debug/risc-v-gsp-rm-vm/.gitignore
@@ -22,3 +22,8 @@ gpgpu-sim/**/*.so
# S4 evaluation evidence: generated sessions are never tracked.
harness/qemu_line/sessions/
harness/re_demo/sessions/
+
+# G3 GMMU/FBIF harness: generated fixtures/reports are never tracked.
+harness/gmmu_debug_fixture/
+harness/gmmu_debug_reports/
+harness/gmmu_oracle_reports/
diff --git a/debug/risc-v-gsp-rm-vm/README.md b/debug/risc-v-gsp-rm-vm/README.md
index 1868a58..d730660 100644
--- a/debug/risc-v-gsp-rm-vm/README.md
+++ b/debug/risc-v-gsp-rm-vm/README.md
@@ -116,14 +116,17 @@ SIMULATOR-WORK-BOUNDARY.txt scope contract for the 2026-10-03 simulator wave
| [31-TURING-TU104-PARAMETERS](docs/31-TURING-TU104-PARAMETERS.md) | Turing TU104 parameter dossier (public sources) |
| [32-GSP-CYCLE-MODEL](docs/32-GSP-CYCLE-MODEL.md) | modeled per-instruction cycle accounting (not silicon timing) |
| [33-AMPERE-PARAMETERS](docs/33-AMPERE-PARAMETERS.md) | Ampere parameter dossier (public sources) |
-| [34-BLACKWELL-SOURCES](docs/34-BLACKWELL-SOURCES.md) | Blackwell source classification (public/private leads) |
+| [34-BLACKWELL-SOURCES](docs/34-BLACKWELL-SOURCES.md) | Blackwell source classification (public sources and open questions) |
| [35-GREENEYE-SIMULATOR-DESIGN](docs/35-GREENEYE-SIMULATOR-DESIGN.md) | standalone GSP simulator design; Phase S/G scope and gates |
-| [36-GSP-DEVICE-MODEL](docs/36-GSP-DEVICE-MODEL.md) | S1 device model, versioned profiles, boot ladder B0–B2 |
+| [36-GSP-DEVICE-MODEL](docs/36-GSP-DEVICE-MODEL.md) | S1 device model, versioned profiles, boot ladder B0–B3 (XMPU + libos services) |
| [37-SIM-QEMU-LINE](docs/37-SIM-QEMU-LINE.md) | simulator-vs-QEMU A/B harness and measured line verdict |
| [38-GSP-EM-API](docs/38-GSP-EM-API.md) | TLV boundary between the GSP emulator and a system simulator (design) |
| [39-RE-WORKFLOW-REVIEW](docs/39-RE-WORKFLOW-REVIEW.md) | reverse-engineering workflow review: time sinks and upgrades |
| [40-NEW-VERSION-TRIAGE](docs/40-NEW-VERSION-TRIAGE.md) | one-command new-version triage (`harness/triage.py`, `./risc-v-gsp-rm-debugger.sh triage`) |
| [41-STABILITY-AND-TIME-TO-VALUE](docs/41-STABILITY-AND-TIME-TO-VALUE.md) | formal stability/value report: phased improvements, proof, adversarial review, conditions |
+| [42-GMMU-FBIF-OBSERVABILITY](docs/42-GMMU-FBIF-OBSERVABILITY.md) | observe-only GMMU/FBIF windows and `greeneye-gmmu-fbif-trace/1` (in development) |
+| [43-GMMU-PTE-DECODER](docs/43-GMMU-PTE-DECODER.md) | clean-room TU104 GMMU PTE/PDE decoder (in development) |
+| [44-GMMU-ORACLE-AND-WORKFLOW](docs/44-GMMU-ORACLE-AND-WORKFLOW.md) | oracle-vs-simulator comparison and static<->dynamic register join (in development) |
| [RISC-V-GSP-RM-DEBUGGER-ROADMAP](RISC-V-GSP-RM-DEBUGGER-ROADMAP.md) | the long-term program (H1–H7) |
Scope contract for the current simulator wave:
diff --git a/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md b/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md
index 50b4428..a5f14ab 100644
--- a/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md
+++ b/debug/risc-v-gsp-rm-vm/docs/00-OVERVIEW.md
@@ -44,12 +44,12 @@ whole stack scriptable, and QEMU is just the first execution backend.
| Plugin loader + example plugin | working |
| Native RV64IMAC simulator (M1, `--backend native`) | working; QEMU-parity differential test |
| Native exec/access trace (M1.5, R4): `--trace`, analyzer coverage | working; trace blob + determinism test |
-| Test suite | full suite green on the 2026-10-03 review host (298 tests incl. live QEMU + native differential; count varies with local prerequisites) |
+| Test suite | full suite green on the 2026-10-03 review host (417 tests incl. live QEMU + native differential; count varies with local prerequisites) |
| Ghidra bridge | scripts + docs |
| Real GSP `.fwimage` probe (first-fault report) | working (first fault + lenient continuation) |
-| GSP device model + versioned profiles (S1) | in development: `sim/devices/`, `sim/profiles/`, `sim/gsp_boot.py`; B0–B2 on the real 570.144 image with 0 unknown MMIO (36) |
+| GSP device model + versioned profiles (S1) | in development: `sim/devices/`, `sim/profiles/`, `sim/gsp_boot.py`, XMPU model `sim/mpu.py`, libos service model `sim/libos.py`; B0–B3 on the real 570.144 image with 0 unknown MMIO (36) |
| Modeled cycle accounting | increment 0: `sim/cycles.py`; modeled defaults, not silicon timing (32) |
-| Simulator-vs-QEMU A/B + new-version triage | working: frozen thresholds, verdict `above` on the S1 path per docs/37 §4.3; `harness/triage.py` (37, 40) |
+| Simulator-vs-QEMU A/B + new-version triage | working: frozen thresholds, verdict `above` on the S1 path per docs/37 §4.4; `harness/triage.py` (37, 40) |
| GPGPU-Sim overlay | Phase 0 scaffold + CUDA-12 build port record; Phase G gated (30) |
| TCG full-memory tracing plugin | source + optional build (verified live) |
| Automation harness (mutation/portability/version diff) + value demos | working (18/19/21/25) |
@@ -63,7 +63,7 @@ The in-development notices were removed on 2026-10-02 after the tool audit
end-to-end s01–s10 value demonstrations
([25-VALUE-VALIDATION](25-VALUE-VALIDATION.md)). Open items stay listed as
open: docs 28/29 are unadmitted pilots, the device model is partial (S1,
-B0–B2; docs/36) and custom-simulator M2+ remains roadmap, and nothing here is
+B0–B3; docs/36) and custom-simulator M2+ remains roadmap, and nothing here is
a stability guarantee beyond the audited v0.1 surface.
## Quick start
@@ -127,6 +127,10 @@ Interactively:
| [38-GSP-EM-API](38-GSP-EM-API.md) | GSP-emulator/system-simulator TLV boundary (design) |
| [39-RE-WORKFLOW-REVIEW](39-RE-WORKFLOW-REVIEW.md) | reverse-engineering workflow review |
| [40-NEW-VERSION-TRIAGE](40-NEW-VERSION-TRIAGE.md) | one-command new-version triage |
+| [41-STABILITY-AND-TIME-TO-VALUE](41-STABILITY-AND-TIME-TO-VALUE.md) | formal stability and value report |
+| [42-GMMU-FBIF-OBSERVABILITY](42-GMMU-FBIF-OBSERVABILITY.md) | observe-only GMMU/FBIF windows and trace (in development) |
+| [43-GMMU-PTE-DECODER](43-GMMU-PTE-DECODER.md) | TU104 GMMU PTE/PDE decoder (in development) |
+| [44-GMMU-ORACLE-AND-WORKFLOW](44-GMMU-ORACLE-AND-WORKFLOW.md) | GMMU/FBIF oracle comparison and static<->dynamic join (in development) |
| [RISC-V-GSP-RM-DEBUGGER-ROADMAP](../RISC-V-GSP-RM-DEBUGGER-ROADMAP.md) | long-term program H1–H7 |
## Where this fits in the Open-IOV workflow
diff --git a/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md b/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md
index a9a91aa..1a85ec5 100644
--- a/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md
+++ b/debug/risc-v-gsp-rm-vm/docs/06-ACCURACY.md
@@ -124,9 +124,11 @@ Before trusting a risc-v-gsp-rm-debugger session:
## 8. Known accuracy gaps (living list)
1. Partial TU104 device model (S1, docs/36): the real 570.144 image is now
- served past the first unmapped access to B2 (relocation copy, 0 unknown
- MMIO) with a recorded boot shim; B3/B4 (CSB poll, message loop) are not
- reached, and the model is uncalibrated and partial.
+ served past the first unmapped access to B3 (libos init complete; the
+ message-loop task blocks in the port wait, 0 unknown MMIO) with the XMPU
+ model, a recorded boot shim and the libos service model; B4/B5
+ (host-traffic-triggered CSB poll, scripted message) are not reached, and
+ the model is uncalibrated and partial.
2. No time/concurrency model; multi-core and partition-switch behaviour is
approximated. Cycles are modeled defaults, not measured (docs/32).
3. PMU/SEC2/ACR/Booter behaviour is message-level only.
diff --git a/debug/risc-v-gsp-rm-vm/docs/35-GREENEYE-SIMULATOR-DESIGN.md b/debug/risc-v-gsp-rm-vm/docs/35-GREENEYE-SIMULATOR-DESIGN.md
index 83954d4..d5bf4a4 100644
--- a/debug/risc-v-gsp-rm-vm/docs/35-GREENEYE-SIMULATOR-DESIGN.md
+++ b/debug/risc-v-gsp-rm-vm/docs/35-GREENEYE-SIMULATOR-DESIGN.md
@@ -129,11 +129,20 @@ reproducible twice identically and have its device requirements listed.
|---|---|---|
| B0 | flat image loads; first MMIO observed at `0x2000000000000A00` | done (native, lenient) |
| B1 | device model serves the whole boot path: 0 unknown MMIO, 0 unhandled in windows; deterministic 2M-instruction run stopping at the boot-halt/report loop (`0x4010928`) | done (S1 device model; sessions `dumps/gsp-boot-b1-run1/2`) |
-| B2 | boot stage progress: vector table/TCM/MPU/trap handling exercised with the shim; passes the bootargs consumer and reaches the relocation copy, then stops on the known DMEM-runspace unknown (`fault @ 0x400EA0C`; docs/36 §4) | done (shim) |
-| B3 | first CSB/TRANSCOM mailbox poll with correct read-back semantics | target |
-| B4 | message loop reached (`appMain`/equivalent); waits on host message deterministically | target |
+| B2 | boot stage progress: vector table/TCM/MPU/trap handling exercised with the shim; passes the bootargs consumer and reaches the relocation copy | done (S1 device model + XMPU model + bootstrap-args shim; reaches B3) |
+| B3 | libos init completes (TCM registration, pagetable registration, DMA flush, external-interrupt arming) and the message-loop task blocks in the port wait | done (sessions `dumps/gsp-boot-b3-run1/2`: 474 043 insns, stop `ecall` at `0x4018038`, two-run identical) |
+| B4 | first CSB/TRANSCOM mailbox poll served with correct read-back semantics (host-traffic-triggered doorbell) | target |
| B5 | one scripted host-side RM message served end-to-end with a recorded reply | target (gated by S3 injection) |
+**Ladder revision (2026-10-03, B3).** The original B3 wording assumed the
+firmware polls CSB/TRANSCOM during a silent boot. The 570.144 image does not:
+after arming interrupts and completing its libos services it blocks in the
+message-loop port wait, and the CSB/TRANSCOM poll is *triggered by host
+traffic*. B3 is therefore anchored on the observed wait (a real, reproducible
+milestone) and the CSB poll moved to B4, where a scripted doorbell will
+produce it. The pre-revision text is kept in history; this note is the
+operative definition.
+
The ladder is deliberately versioned-data driven: a new firmware version is a
new profile + shim record, not a code fork.
@@ -211,7 +220,7 @@ flags; every call attributable to a session.
| Probe/record/plugins | yes | — | backend peer | yes |
| Ghidra symbol/control bridges | yes | watch/heat-map/nav | protocol-compatible | full (S2/S3) |
| Injection SDK | demo | RM-core interception H1.3 | demo backend | services vs GSP model (S3) |
-| Device model / real boot | no (faults) | H2.2/H2.3/H2.4 planned | ladder B0–B2 (S1, device model + shim) | B4/B5 (S1/S3) |
+| Device model / real boot | no (faults) | H2.2/H2.3/H2.4 planned | ladder B0–B3 (S1, device model + XMPU + libos services) | B4/B5 (S1/S3) |
| Determinism/replay | wall-clock | H2.4 budgets | instruction-count | event replay (S2) |
| MCP | yes (QEMU) | — | native sim tools + backend selection (20) | wider GSP-model tools (S3) |
| Tiering | — | — | modeled cycles | calibrated bands (S4) |
@@ -277,12 +286,13 @@ The comparison and decision are recorded in the private handoff.
| Wave | Date | Result |
|---|---|---|
-| Recon + master handoff | 2026-10-03 | `handoff-simulator.md`, local sources register |
+| Recon + master handoff | 2026-10-03 | master handoff; source register |
| W1 GPGPU-Sim build | 2026-10-03 | CUDA 12 port patches; `vectorAdd` functional+perf green |
| W1 TU104 config/overlay | 2026-10-03 | config scaffold + sanity 26/26 + admissions draft |
| W1 GSP cycle-0 | 2026-10-03 | modeled cycles; real image 200k instr deterministic; 298 tests green (2026-10-03 suite run; count varies with prerequisites) |
| W1 dossier | 2026-10-03 | Turing/Ampere/Blackwell parameter docs + cross-generation survey |
| W2 Phase S (S1 + S4) | 2026-10-03 | `sim/devices` + `sim/profiles` device model: B0–B2 on the real image (0 unknown MMIO), 2-run determinism; A/B harness discovers the profile and records verdict `above` on the frozen checks (docs/36, docs/37 §4.3, docs/40) |
+| W2 Phase S (4c: XMPU + libos) | 2026-10-03 | `sim/mpu.py` + `sim/libos.py` + bootstrap-args shim: B3 on the real image (474 043 instr, message-loop port wait, 0 unknown MMIO, 2-run identical); A/B re-run at `--instructions 2000000` verdict `above` (docs/37 §4.4); 417 tests green |
| W2 Phase S | launched | S2 parity, S3 MCP/injection, S4 validation |
Backlog (recorded, not started): Ampere/Blackwell models; fabric (PCIe,
diff --git a/debug/risc-v-gsp-rm-vm/docs/36-GSP-DEVICE-MODEL.md b/debug/risc-v-gsp-rm-vm/docs/36-GSP-DEVICE-MODEL.md
index 9516fd5..2741748 100644
--- a/debug/risc-v-gsp-rm-vm/docs/36-GSP-DEVICE-MODEL.md
+++ b/debug/risc-v-gsp-rm-vm/docs/36-GSP-DEVICE-MODEL.md
@@ -7,7 +7,7 @@
> `06-ACCURACY.md`. Development-only: nothing here authorises GPU hardware,
> kernel modules, mdev, guest access or reboots.
>
-> **See also:** `37-SIM-QEMU-LINE.md` (the B0–B2 device model on the frozen
+> **See also:** `37-SIM-QEMU-LINE.md` (the B0–B3 device model on the frozen
> simulator-vs-QEMU checks) and `40-NEW-VERSION-TRIAGE.md` (one-command triage
> of a new image through this ladder).
@@ -47,13 +47,15 @@ sim/devices/__init__.py registry_from_profile(profile, machine)
│ FBIF control/transcfg
├─ priscv.py RISC-V-local block: IRQ mask, trace, error regs
├─ timer.py PTIMER (modeled virtual time; opt-in)
- ├─ csb.py CSB/TRANSCOM message window seam (B3 landing)
+ ├─ csb.py CSB/TRANSCOM message window seam (B4 landing)
├─ fwsec.py WPR2 address pair (FWSEC state unmodeled)
├─ pmu.py PMU stub: observes, never fakes
├─ dma.py aperture → RAM translation seam (FBGPA/SYSGPA)
└─ irq.py falcon + RISC-V-local IRQ aggregation
▼
sim/machine.py Machine(devices=..., translator=..., reset_regs=...)
+ │ + sim/mpu.py: XMPU entry model (fetch/load/store)
+ │ + sim/libos.py: libos syscall service model
▼
sim/gsp_boot.py bounded real-firmware run + session + rung
```
@@ -80,7 +82,7 @@ noted; every unlisted offset is unhandled and logged.
| RISC-V local | `+0x111000`, alias `0x300001000` | `IRQMSET` 0x2ac, `IRQMCLR` 0x2b0, `IRQMASK` 0x2b4 (read), `IRQDEST` 0x2b8, `IRQTYPE` 0x2bc, `TRACECTL` 0x344, `TRACE_RDIDX` 0x348, `TRACE_WTIDX` 0x34c, `PRIV_ERR_STAT` 0x360, `PRIV_ERR_INFO` 0x364 (read), `PRIV_ERR_ADDR` 0x368 (read), `HUB_ERR_STAT` 0x36c | header + observed |
| PTIMER | `+0x9000` | `TIME_0` aperture 0x9400 / block `+0x400` (NSEC 31:5), `TIME_1` aperture 0x9410 / block `+0x410` (NSEC 28:0); value = instruction count × `ns_per_insn` (**modeled**, not calibrated) | header + **modeled** time |
| WPR2 | `+0x1FA000` | `WPR2_ADDR_LO` 0x1FA824, `WPR2_ADDR_HI` 0x1FA828 (4 KiB alignment; HI points at the last 128 KiB block) | header + firmware use |
-| CSB/TRANSCOM | profile-declared | none by default; a profile may declare individual registers with width/value and `rw`/`ro`/`wo`/`w1c`/`set`/`clear` semantics | none yet (B3 landing point) |
+| CSB/TRANSCOM | profile-declared | none by default; a profile may declare individual registers with width/value and `rw`/`ro`/`wo`/`w1c`/`set`/`clear` semantics | none yet (B4 landing point) |
| PMU | profile-declared | none; the stub records accesses and returns unhandled | none yet |
| DMA seam | FBGPA/SYSGPA (profile) | GPU-physical → RAM windows for falcon DMA and aperture loads/stores; unmapped transfers are refused and recorded | address-map header + firmware use |
@@ -99,10 +101,15 @@ and records each as an assumption in `session.json.boot_shim.assumptions`:
* `NV_RISCV_BOOT_PARAMS` at a RAM address (bootType `NVRM`, size 120,
version 1, core-dump buffer, one `WPRM` libos init arg);
* a `GspFwWprMeta` (magic/revision/verified and a self-consistent FB layout);
-* raw-image mirror at the DMEM aperture base so the app's own relocation copy
- is self-consistent (it is not: see §6);
-* `a0`–`a5` register arguments (bootargs PA/VA, ELF PA/size, load base, WPR
- id).
+* a `libos_bootstrap_args_t` — six `u64` fields (IMEM and DMEM
+ `base`/`size`/`bs`) the real bootloader builds from its TCM configuration
+ and passes in `a0`; the M-mode TCM configuration is declared in the
+ profile (offsets 0, 64 KiB windows). This replaces the earlier
+ raw-image mirror at the DMEM aperture: with the XMPU model (§3.2) the
+ resident-section copy reads its real backing and no image mirror is needed;
+* `a0`–`a5` register arguments (a0 = bootstrap-args pointer; the rest are
+ declared assumptions the boot path only uses where its own computation is
+ not self-sufficient).
The DEBUGINFO **host-consume** behaviour is also a shim: the firmware writes a
boot-stage marker and polls bits 22:20 until they clear, and no self-clearing
@@ -110,28 +117,86 @@ path exists in the firmware, so the host side is modelled as consuming the
stage marker on read. The device never changes a read value except for this
documented shim, and the shim is profile-enabled and session-recorded.
+### 3.2 CPU protection unit (XMPU) — core model, `sim/mpu.py`
+
+The GSP core boots in bare mode and then enables a small protection unit
+through M-mode CSRs; the firmware programs entries for its code/data ranges
+and, during init, for a scratch window it copies resident sections through.
+Evidence kind: **hardware CSR/field header + firmware use + observed access
+sequence** (the 570.144 bootstrap writes are visible in any run's device/CSR
+trace). Modelled CSRs:
+
+| CSR | value | semantics |
+|---|---|---|
+| `MMPUIDX` (0x7C8) | entry index | INDEX field 5:0 (64 entries) |
+| `MMPUVA` (0x7C9) | virtual base | BASE 63:12, `VLD` bit 0 |
+| `MMPUPA` (0x7CA) | physical base | BASE 63:12 |
+| `MMPURNG` (0x7CB) | range (a size) | RANGE 63:12 |
+| `MMPUATTR` (0x7CC) | attributes | recorded, not enforced |
+
+Translation is active only when `mstatus.VM == NVMPU` (`0x1E`; field bits
+28:24); lookup is lowest-index-first (the firmware's own bootstrap comment
+documents the precedence). An entry maps `addr` to `pa + (addr - va_base)`
+when `va_base <= addr < va_base + range`. The model applies to instruction
+fetch, loads, stores and atomics; permissions/cache attributes are recorded
+but not enforced, and **no match is treated as identity** (more lenient than
+silicon) because the simulator keeps local memory flat and the boot path's
+mappings are self-consistent. `read_mem`/`write_mem` (debugger surface)
+remain raw and untranslated.
+
+Evidence in the session: `session.json.mpu` carries the enabled flag, entry
+table and `mstatus`. The model removes the B2 ceiling: with the correct
+bootstrap args and the scratch mapping, the resident-section copy reads the
+in-place image and the run continues (no DMEM image mirror, no faked copy).
+
+### 3.3 libos syscall surface — service model, `sim/libos.py`
+
+The application is a libos task that requests kernel services with `ecall`
+(the syscall number in `t0`). The native machine stops on `ecall` unless a
+handler is installed, so the boot runner attaches a deterministic service
+model that:
+
+* records every request (number, name, arguments, pc, instruction count) in
+ `session.json.libos`;
+* acknowledges only no-ops and modelled services — task yield, cache
+ invalidation, external-interrupt arming, profiler enable/query, the
+ modeled timer read, DMA bounce-buffer registration (`TCM` region),
+ inverted-pagetable registration, and DMA copy/flush (an in-RAM copy in the
+ flat model: the real kernel drives the falcon DMA through TCM, the copy
+ effect is identical and is not faked — both sides must resolve or the run
+ stops);
+* **never fakes waits**: `processor_suspend`, `task_port_send_recv_wait`,
+ `task_panic` and every unimplemented syscall stop the run with `R_ECALL`
+ and are reported in `session.json.libos.stopped`.
+
+Task switching, interrupt delivery, pagination, partition switch and memory
+queries are not modelled (force-stopped, not invented). The B3 result is
+exactly this stop: the message-loop task blocks in `task_port_send_recv_wait`
+with `armed_mask=0xFF`.
+
## 4. Boot ladder results (observed, 2026-10-03)
| Rung | Claim | Evidence |
|---|---|---|
| **B0** | flat image loads; first MMIO is the unmodelled PMC `BOOT_42` read at `0x2000_0000_0000_0A00` | `tests/test_sim_devices.py` (B0 classification) |
| **B1** | device model serves the whole boot path: **0 unknown MMIO, 0 unhandled in windows**, deterministic 2M-instruction run stopping at the boot-halt/report loop (`0x4010928`) | `run_boot(use_shim=False)`; sessions `dumps/gsp-boot-b1-run1`, `dumps/gsp-boot-b1-run2`; test `test_no_shim_b1_device_model_serves_boot` |
-| **B2** | with the shim, the firmware passes the chip gate, MPU/vector setup, the bootargs consumer (`0x4007A30`) and reaches its **relocation copy** (`0x4007B7C`), then stops at a known unknown (DMEM runspace) | sessions `dumps/gsp-boot-b2-run1`, `dumps/gsp-boot-b2-run2`; test `test_shim_reaches_relocation_b2` |
+| **B2** | with the shim, the firmware passes the chip gate, XMPU setup, the bootstrap-args consumer and its **relocation copy** (`0x4007B7C`); the XMPU model resolves the copy's real backing and the run continues into libos init | sessions `dumps/gsp-boot-b2-run1`, `dumps/gsp-boot-b2-run2`; test `test_shim_reaches_relocation_plus_mmu` |
+| **B3** | libos init completes (TCM registration `0x5B22000`/`0x1000`, pagetable registration, DMA flush, external-interrupt arming `0xFF`) and the message-loop task blocks in the port wait | sessions `dumps/gsp-boot-b3-run1`, `dumps/gsp-boot-b3-run2`; test `test_shim_reaches_message_loop_b3` |
Reproduction (read-only; no hardware):
```bash
cd open-iov/debug/risc-v-gsp-rm-vm
-python3 -m sim.gsp_boot --instructions 2000000 --record dumps/gsp-boot-b2-run1
-python3 -m unittest tests.test_sim_devices -v
+python3 -m sim.gsp_boot --instructions 2000000 --record dumps/gsp-boot-b3-run1
+python3 -m unittest tests.test_sim_devices tests.test_mpu -v
```
Two-run determinism is asserted by `test_two_runs_identical` and was checked
-at the recorded B2 sessions: identical instruction count (14 682), stop
-reason/PC (`fault @ 0x400EA0C`), device-state SHA-256
-(`3fa264b9…`) and register file. The B1 no-shim run is likewise identical
-across runs (2 000 000 instructions, budget stop at `0x4010928`, device-state
-SHA-256 `88eb07b7…`).
+at the recorded B3 sessions: identical instruction count (474 043), stop
+reason/PC (`ecall @ 0x4018038`, syscall
+`task_port_send_recv_wait`), device-state SHA-256 (`5b3cc7e4…`) and register
+file. The B1 no-shim run is likewise identical across runs (2 000 000
+instructions, budget stop at `0x4010928`, device-state SHA-256 `88eb07b7…`).
## 5. Session evidence
@@ -139,21 +204,24 @@ Each `run_boot` invocation writes a `greeneye-session/1` directory with:
* `session.json` — firmware SHA-256, profile name, load layout, rung,
milestones, stop reason/PC, unknown-MMIO counts, device-state SHA-256,
- device-access counts and the full boot-shim assumption list;
+ device-access counts, the full boot-shim assumption list, the XMPU state
+ (`mpu`: enabled, entries, mstatus) and the libos service log (`libos`:
+ calls, counts, TCM/pagetable registrations, the stopping request);
* `blobs/device-state.json` — the `greeneye-sim-devices/1` snapshot of every
device (registers, IRQ state, DMA windows/counters, shim flags);
* `events.jsonl` — the normal session event stream (`gsp_boot` note).
## 6. Unknowns (explicitly not modeled / not verified)
-1. **DMEM runspace content.** The app's relocation copy reads the DMEM
- aperture (`0x1_0000_0000+`) and writes it to the `0x400E000` runtime
- region; the bootloader-provided content is not reconstructed, so the copy
- writes zeros and the run stops at `0x400EA0C` (illegal instruction). This
- is the B2 ceiling today.
-2. **CSB/TRANSCOM register semantics.** No message-window poll has been
- observed yet, so no register is modeled. B3 waits for the first observed
- poll; the `csb.py` seam and profile register declarations are in place.
+1. **CSB/TRANSCOM register semantics.** No message-window poll has been
+ observed yet, so no register is modeled. The 570.144 firmware blocks in
+ the message-loop port wait before any CSB access; B4 waits for the first
+ host-traffic-triggered poll; the `csb.py` seam and profile register
+ declarations are in place.
+2. **Libos services.** Task switching, interrupt delivery, pagination,
+ partition switch and memory queries are not modelled: the service model
+ records and force-stops on them (never invents progress). DMA copies are
+ modelled as in-RAM copies in the flat memory model.
3. **PMU.** No PMU register access observed; stub only.
4. **FWSEC state machine.** Only the WPR2 address pair is modeled; FWSEC
execution is not.
@@ -161,14 +229,15 @@ Each `run_boot` invocation writes a `greeneye-session/1` directory with:
not proven; only the profile's DMA windows decide what an address means.
6. **Interrupt delivery.** Falcon/RISC-V IRQ state is aggregated and
serialisable, but mstatus/mie/mip/mtvec trap entry and interrupt injection
- are not modelled.
+ are not modelled (the firmware arms external interrupts with mask `0xFF`
+ and then waits forever in the flat model).
7. **PTIMER calibration.** Values are modeled from the instruction count with
a placeholder `ns_per_insn`; no firmware PTIMER read has been observed and
no timing claim is made.
-8. **MPU translation.** The firmware programs its MPU CSRs (observed), but
- the machine does not enforce translations; the libos private mapping is
- served by explicit device aliases and the FBGPA/SYSGPA seam, not by an MPU
- model.
+8. **XMPU enforcement.** Translation, VLD gating, mstatus enable and
+ lowest-index precedence are modelled; permissions, cache/coherence
+ attributes and WPR IDs are recorded but not enforced, and an unmatched
+ access passes through as identity (lenient by design, listed here).
Nothing in this page is a silicon-accuracy claim; `06-ACCURACY.md` fidelity
levels apply (L0/L1 modelled, L2 modelled-not-calibrated, L3 partial and
diff --git a/debug/risc-v-gsp-rm-vm/docs/37-SIM-QEMU-LINE.md b/debug/risc-v-gsp-rm-vm/docs/37-SIM-QEMU-LINE.md
index 42c76cc..06fa943 100644
--- a/debug/risc-v-gsp-rm-vm/docs/37-SIM-QEMU-LINE.md
+++ b/debug/risc-v-gsp-rm-vm/docs/37-SIM-QEMU-LINE.md
@@ -29,11 +29,12 @@ and analyze/compare, probes/plugins, Ghidra bridges, trace, injection SDK and
`Session.call`/`run_until`, plus the documented first fault at firmware vaddr
`0x4009014` (4-byte read of `0x2000000000000A00`).
-The boot ladder (docs/35 §3.3) is `pre-B0` (image loads, first MMIO observed
-*as a fault*) < `B0` (first MMIO observed; lenient) < `B1` (first fault past
-the QEMU point, explained by device semantics) < `B2` (boot-stage progress) <
-`B3` (first mailbox poll) < `B4` (message loop) < `B5` (one scripted message
-served).
+The boot ladder (docs/35 §3.3, revised 2026-10-03) is `pre-B0` (image loads,
+first MMIO observed *as a fault*) < `B0` (first MMIO observed; lenient) <
+`B1` (first fault past the QEMU point, explained by device semantics) <
+`B2` (boot-stage progress: relocation copy) < `B3` (libos init complete;
+message-loop task blocks in the port wait) < `B4` (first CSB/TRANSCOM poll
+served, host-traffic-triggered) < `B5` (one scripted message served).
**"Above the QEMU line"** requires *all* of the frozen checks in §3,
including the docs/35 §1.1 requirement that the milestone be reached "with
@@ -196,6 +197,33 @@ MMIO. Frozen thresholds unchanged. Measured run on the same image
`harness/qemu_line/sim-qemu-line.json|.md`). Wall figures are host-load
dependent.
+### 4.4 Re-measurement after the XMPU/libos wave (2026-10-03, session 4c)
+
+The native side now runs the real image with the XMPU (MPU) translation model
+and the libos service model (docs/36 §3.2/§3.3), so the DMEM-runspace unknown
+is gone. Fresh run, frozen thresholds unchanged, same image
+(sha256 `2d1bb5c60b208cfd…`), `--instructions 2000000`:
+
+| side | ran | rung | instructions | cycles (modeled) | stop | wall s | evidence |
+|---|---|---|---:|---:|---|---:|---|
+| QEMU probe | yes | `pre-B0` | – | – | `fault_load` fw `0x4009014` -> `0x2000000000000A00` | 0.98 | `harness/qemu_line/sessions/qemu-probe` |
+| native run 1 | yes | `B3` | 474,043 | 871,904 | `ecall@0x4018038` (`task_port_send_recv_wait`) | 0.65 | `harness/qemu_line/sessions/native-1` |
+| native run 2 | yes | `B3` | 474,043 | 871,904 | `ecall@0x4018038` | 0.66 | `harness/qemu_line/sessions/native-2` |
+
+* all five frozen checks pass: `qemu_first_fault_reproduced`,
+ `native_above_qemu`, `identical_runs` (device-state sha256
+ `5b3cc7e4af933e01…` on both runs), `native_pair_wall_s`, and
+ `device_model_documented`; **verdict `above`**.
+* milestones observed: `bootargs_consumer`, `relocation_copy`;
+ `unknown MMIO = 0`, `device_unhandled = 0`; `armed_mask=0xFF`.
+* What this does **not** claim: B4/B5 are not reached (the message-loop task
+ blocks before any CSB access without host traffic); the shim values are
+ declared assumptions; cycles are modeled (docs/32); no timing or
+ device-environment claim.
+* Command: `python3 harness/sim_qemu_line.py --instructions 2000000`
+ (regenerates `harness/qemu_line/sim-qemu-line.json|.md`; the tracked report
+ is this run). Wall figures are host-load dependent.
+
## 5. Failure signals (when to disable, per U48/U56)
* Any frozen check false **with QEMU available and the firmware present**:
@@ -227,10 +255,11 @@ report is emitted with `unverified_qemu_absent`.
## 7. Honest limits
-* **S1 partial** — the device model reaches B2 (relocation copy) with the
- recorded boot shim; B3/B4 (CSB poll, message loop) are not reached and the
- DMEM-runspace unknown is the documented ceiling (docs/36 §6). The §4.1
- lenient record predates S1; §4.3 is the post-S1 measurement.
+* **S1 partial** — the device model reaches B3 (libos init complete;
+ message-loop port wait) with the XMPU model, the recorded boot shim and the
+ libos service model (docs/36); B4/B5 (host-traffic-triggered CSB poll and a
+ scripted message) are not reached. §4.1/§4.3 are the pre-XMPU records;
+ §4.4 is the current measurement.
* **L2/L3 partial** — cycles are modeled (`rv64imac-modeled/1`, docs/32), not
measured; the device model is partial and carries per-register evidence
(docs/36). No timing or full device-environment claim is made.
diff --git a/debug/risc-v-gsp-rm-vm/docs/39-RE-WORKFLOW-REVIEW.md b/debug/risc-v-gsp-rm-vm/docs/39-RE-WORKFLOW-REVIEW.md
index 2b94746..d21957f 100644
--- a/debug/risc-v-gsp-rm-vm/docs/39-RE-WORKFLOW-REVIEW.md
+++ b/debug/risc-v-gsp-rm-vm/docs/39-RE-WORKFLOW-REVIEW.md
@@ -83,7 +83,7 @@ but unfinished; docs 30–38 and the untracked `sim/devices`/`gpgpu-sim` trees),
| 4 | Native RV64IMAC backend (M1) | `sim/decode.py`, `sim/machine.py`, `greeneye/sim_backend.py` | deterministic in-process backend; QEMU parity | tests, demos, new sim work | differential test (`docs/16` §1.1); `sim_diff.py` `PARITY: OK` | verified useful (reference core) |
| 5 | Native exec/access trace (M1.5/R4) | `sim/trace.py` | full instruction/access trace as session blob | record/analyze, value demos | `docs/16` §1.1a; trace tests | verified useful |
| 6 | Modeled cycle counter | `sim/cycles.py` | table-driven modeled cycles (explicitly not silicon timing) | 21 offline tests | `docs/32`; `results.json` of `test_sim_cycles` | in development (increment 0) |
-| 7 | GSP device model + profiles + bounded boot runner | `sim/devices/`, `sim/profiles/`, `sim/gsp_boot.py`, `sim/gsp_profile.py` | real 570.144 image past the QEMU first fault (B0–B2) | S1 workstream | `dumps/gsp-boot-b1-run1/2`, `b2-run1/2`; 2-run determinism asserted (`docs/36` §4) | in development (S1) |
+| 7 | GSP device model + profiles + bounded boot runner | `sim/devices/`, `sim/profiles/`, `sim/gsp_boot.py`, `sim/gsp_profile.py`, `sim/mpu.py`, `sim/libos.py` | real 570.144 image past the QEMU first fault (B0–B3) | S1 workstream | `dumps/gsp-boot-b1-run1/2`, `b3-run1/2`; 2-run determinism asserted (`docs/36` §4) | in development (S1) |
| 8 | Declarative probe specs | `greeneye/probes.py`, `examples/probes/` | JSON hooks/conditions without scripts | audit smoke; documented pattern | `docs/27` §3 (3 probes fired) | verified useful |
| 9 | Plugin loader + example plugin | `greeneye/plugins.py`, `plugins/example_plugin.py` | extension point | audit smoke | `plugins` CLI lists all three | verified useful |
| 10 | MCP server | `plugins/mcp_server.py` | agent/CI machine surface (JSON-RPC over stdio/loopback) | no live agent consumer found | 55 tests (`docs/20` §9); tool catalogue 31 (`docs/20` §5) | implemented; **unused in real workflow** (F1 `partially used`) |
diff --git a/debug/risc-v-gsp-rm-vm/docs/40-NEW-VERSION-TRIAGE.md b/debug/risc-v-gsp-rm-vm/docs/40-NEW-VERSION-TRIAGE.md
index 613e966..aadae37 100644
--- a/debug/risc-v-gsp-rm-vm/docs/40-NEW-VERSION-TRIAGE.md
+++ b/debug/risc-v-gsp-rm-vm/docs/40-NEW-VERSION-TRIAGE.md
@@ -194,6 +194,14 @@ the QEMU line" next-action line no longer appears. Wall ≈2.7 s
(host-load dependent). The pre-S1 trace above is kept as the recorded
before/after evidence.
+Post-XMPU/libos wave (2026-10-03, session 4c; same command): with the default
+budget the `native_boot` step reports rung **B2** and a budget stop at
+200,000 instructions (no fault anymore — the former `0x400EA0C` wall is
+gone); with `--budget 2000000` it reports rung **B3** (474,043 instructions,
+`ecall` port wait, 0 unknown MMIO) and `ab_eval` verdict `above` (measured
+wall ≈4.1 s, 434× the frozen 1800 s manual baseline). The numbers in the
+example and the post-S1 paragraph above are the pre-wave record.
+
Baseline variant (same image both sides, an unchanged-version rehearsal with
the docs/21 path):
diff --git a/debug/risc-v-gsp-rm-vm/docs/41-STABILITY-AND-TIME-TO-VALUE.md b/debug/risc-v-gsp-rm-vm/docs/41-STABILITY-AND-TIME-TO-VALUE.md
index 77e1298..57ee33b 100644
--- a/debug/risc-v-gsp-rm-vm/docs/41-STABILITY-AND-TIME-TO-VALUE.md
+++ b/debug/risc-v-gsp-rm-vm/docs/41-STABILITY-AND-TIME-TO-VALUE.md
@@ -3,15 +3,16 @@
> **Date:** 2026-10-03. **Scope:** the simulator/debugger workstream inside
> `debug/risc-v-gsp-rm-vm/` only. **Verdict:** the minimum bar for marking the
> *tool and standalone GSP simulator* as **stable (conditional)** is passed:
-> 298 tests green twice, deterministic real-firmware runs, an independently
+> 417 tests green (298 at the first review; the XMPU/libos wave added the
+> rest), deterministic real-firmware runs, an independently
> reproduced A/B verdict "above the QEMU line", and reproducible value
> measurements. Conditions are listed in §7 — chiefly that the device model
-> currently reaches boot rung B2 (not the message loop) and cycle numbers are
+> currently reaches boot rung B3 (message-loop port wait; the CSB poll awaits
+> host traffic) and cycle numbers are
> modelled, not silicon-calibrated.
>
-> Local notes: `../../../../handoff-simulator.md` (directive ledger, top work),
-> `../LOCAL-REVIEW-NOTES.md` (local provenance). Public text describes
-> components and behaviour only.
+> Companion: `../../../../handoff-simulator.md` (directive ledger, top work).
+> Public text describes components and behaviour only.
## 1. What we were doing before (the QEMU baseline)
@@ -29,8 +30,8 @@
| Phase | Improvement | Status |
|---|---|---|
-| P1 | **GSP device model** (`sim/devices/`, `sim/profiles/`) with PMC/FALCON/PRISCV/timer/CSB/FWSEC/PMU/DMA/IRQ windows, versioned profile, boot shims | B2: 14,682 instr, fault `0x400EA0C`, 0 unknown MMIO, two-run identical |
-| P2 | **`sim.gsp_boot` boot-ladder runner** with rung classification and serialisable device state | B0–B2 (B3/B4 targets) |
+| P1 | **GSP device model** (`sim/devices/`, `sim/profiles/`) with PMC/FALCON/PRISCV/timer/CSB/FWSEC/PMU/DMA/IRQ windows, versioned profile, boot shims, **XMPU model** (`sim/mpu.py`) and **libos service model** (`sim/libos.py`) | B3: 474,043 instr, `ecall` port wait, 0 unknown MMIO, two-run identical |
+| P2 | **`sim.gsp_boot` boot-ladder runner** with rung classification and serialisable device state | B0–B3 (B4/B5 targets) |
| P3 | **One-command release triage** (`./risc-v-gsp-rm-debugger.sh triage`) orchestrating identity/QEMU probe/native boot/A-B eval/version diff/readiness | 570.144 in ~2.7 s; baseline pair ~11.4 s |
| P4 | **QEMU-line A/B evaluation harness** (`harness/sim_qemu_line.py`, frozen checks) | verdict `above`, all threshold checks computed from live runs |
| P5 | **Real RE demonstration** (`harness/re_demo.py`) answering a concrete bootstrap question with simulator tools | PASS 5/5, 0.13 s vs 601 s baseline floor |
@@ -41,7 +42,7 @@
| Scenario | Before | Now | Evidence |
|---|---|---|---|
-| What happens in GSP boot before the first wall? | QEMU: fault at `0x4009014`, stop | Device model serves the path; deterministic B2 fault `0x400EA0C`; unknown accesses 0 | `python3 -m sim.gsp_boot`; sessions `dumps/gsp-boot-b2-run1/2` |
+| What happens in GSP boot before the first wall? | QEMU: fault at `0x4009014`, stop | Device model serves the path; deterministic B3 port wait (`ecall @ 0x4018038`); unknown accesses 0 | `python3 -m sim.gsp_boot`; sessions `dumps/gsp-boot-b3-run1/2` |
| Triage a new firmware image | manual probe + reading, ~1800 s estimated | one command, **2.68 s**, JSON+MD report with hashes | `./risc-v-gsp-rm-debugger.sh triage --firmware … --version 570.144` |
| Is the emulator better than QEMU? | no mechanism | validators run both, compute rung/feature deltas; verdict `above` | `python3 harness/sim_qemu_line.py` |
| Answer a bootstrap RE question | static reading only | 5/5 recorded answers in 0.13 s | `python3 harness/re_demo.py` |
@@ -54,10 +55,10 @@ one-line command in the report/doc that produced it.
## 4. Stability evidence
-* `python3 -m unittest discover -s tests -t .` → **298 tests, OK**, twice
- (rounded identity across runs); MCP 55, triage 14, device model 34 new
- checks included.
-* Determinism: B1/B2 device-state SHA identical across runs; triage sessions
+* `python3 -m unittest discover -s tests -t .` → **417 tests, OK** (298 at
+ the first review; the XMPU/libos wave added 13 boot/MPU checks plus WPR2
+ and trace-number updates); MCP 55, triage 14, device model 35 included.
+* Determinism: B1/B3 device-state SHA identical across runs; triage sessions
hashed; A/B harness repeats native runs and compares.
* Back-compat: `Session`, `RspClient`, `SimClient` and CLI keep prior
behaviour; additions are keyword/default forms; QEMU path untouched
@@ -79,15 +80,20 @@ one-line command in the report/doc that produced it.
derivation, DMA refusal counting, PMC chip-ID duplication, overlapping
window fallback, stale doc counts, `session_launch` documentation, docs/40
measurement labels). One latent item recorded: WPR2 window offsets versus
- profile base (firmware never touches it in B0–B2).
+ profile base — left unserved because the old B2 run never reached the read;
+ the XMPU/libos wave reached it and the offset defect was fixed (relative and
+ absolute forms both accepted, docs/36 §3).
* Verdict after fixes: conditional stability as stated in §7; no unverified
"beats silicon" or "boots RM" statement anywhere.
## 6. Top work for the next session (time-to-value first)
-1. **Push the device model to B3/B4** (CSB/TRANSCOM mailbox semantics,
- DMEM runspace source, PMU/FWSEC stubs) — this is the gate to serving a
- real RM message and the largest remaining time-to-value lever.
+1. **Push the device model to B4/B5** (host-traffic-triggered CSB/TRANSCOM
+ poll semantics, one scripted message served end-to-end) — this is the gate
+ to serving a real RM message and the largest remaining time-to-value
+ lever. *Update 2026-10-03, session 4c: the XMPU model, bootstrap-args shim
+ and libos service model landed; the ladder is now B3 (was B2), with the
+ former DMEM-runspace unknown resolved.*
2. **Surgical rehost of the live GR engine-context promote/commit wall**
(docs/39 top-3): convert the manual, multi-cycle physical campaign into
device-free hook/watch/compare sessions.
@@ -99,7 +105,8 @@ one-line command in the report/doc that produced it.
## 7. Conditions on the stability claim
-1. Device model ceiling is B2; B3/B4 pending profile semantics.
+1. Device model ceiling is B3 (libos init complete; message-loop port wait);
+ B4/B5 (host-traffic-triggered CSB poll, scripted message) pending.
2. Cycle counts are modelled defaults, not calibrated bands.
3. Manual-baseline speedups use labelled estimates, not timed human studies.
4. Cross-version triage was exercised as a self-diff rehearsal (no second
@@ -111,8 +118,8 @@ one-line command in the report/doc that produced it.
```bash
cd open-iov/debug/risc-v-gsp-rm-vm
-python3 -m unittest discover -s tests -t . # 298 tests
-python3 -m sim.gsp_boot # B2
+python3 -m unittest discover -s tests -t . # 417 tests
+python3 -m sim.gsp_boot # B3 (port wait)
python3 -m sim.gsp_boot --no-shim # B1 budget stop
python3 harness/sim_qemu_line.py # verdict "above"
python3 harness/re_demo.py # PASS 5/5
diff --git a/debug/risc-v-gsp-rm-vm/docs/42-GMMU-FBIF-OBSERVABILITY.md b/debug/risc-v-gsp-rm-vm/docs/42-GMMU-FBIF-OBSERVABILITY.md
new file mode 100644
index 0000000..4a2d6ee
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/42-GMMU-FBIF-OBSERVABILITY.md
@@ -0,0 +1,203 @@
+# 42 — GMMU/FBIF observability (in development)
+
+> **Status: in development (2026-10-03, G1 work package).** This page
+> documents the observe-only GMMU and FBIF/FBPA register windows added to the
+> device model, the `greeneye-gmmu-fbif-trace/1` document and the boot-runner
+> flags that produce it. Development-only: nothing here authorises GPU
+> hardware, kernel modules, mdev, guest access or reboots, and nothing here is
+> a silicon-accuracy claim.
+>
+> **See also:** `36-GSP-DEVICE-MODEL.md` (the device model this plugs into),
+> `06-ACCURACY.md` (fidelity levels), `24-DEVICE-MAP.md` (harvested access
+> maps), `35-GREENEYE-SIMULATOR-DESIGN.md` §4.1 (the Phase G inventory this
+> observability de-risks).
+
+## 1. What this is
+
+The Phase G board model needs the GMMU and the FB interface eventually. Before
+modelling either, we need to know **what the firmware actually touches** in
+those register windows, with which width and direction, from which PC, and
+without pretending to know register behaviour we have not established.
+
+`sim/devices/observe.py` adds **observer windows**: device-model windows that
+record every access and then **decline** it, so execution follows the existing
+device-map / lenient / unknown path exactly as it did before the window
+existed. They never invent a read value.
+
+## 2. What is observed
+
+The TU104 profile (`sim/profiles/tu104-570.144.json`) declares 23 observer
+windows, all `observe-only`. Bases are runtime addresses in the GSP RISC-V
+PRIV aperture (`0x2000_0000_0000_0000` + BAR0 offset); offsets/names come from
+the hardware register/field headers (evidence kind: **header**). The B0–B2
+runs did not touch any of them; the B3 wave reaches the WPR2 read pair
+(`0x1FA824/0x1FA828`), which falls inside the `gmmu-wpr-lock` window and is
+recorded observe-only (2 accesses, 0 served; the fwsec device answers).
+
+| Window label | Runtime base | Size | Covers |
+|---|---|---|---|
+| `gmmu-pri-mmu` | `0x2000000000100C80` | `0x280` | `NV_PFB_PRI_MMU_*` cluster (`NV_PFB_PRI_MMU_CTRL` first) |
+| `gmmu-wpr-lock` | `0x20000000001FA7D8` | `0x5C` | MMU per-engine PRIV_LEVEL masks + VPR/WPR/lock address pair |
+| `gmmu-hsmmu` | `0x20000000001FAC00` | `0x400` | `NV_PFB_HSMMU_PRI_MMU_*` (secondary MMU block) |
+| `fbpa` | `0x20000000009A0000` | `0x4000` | main FBPA block (`NV_PFB_FBPA_*`) |
+| `fbpa-partition-0..15` | `0x2000000000900000` + i·`0x4000` | `0x4000` | per-partition FBPA instances |
+| `fbpa-mc-0..2` | `0x2000000000980000` + i·`0x4000` | `0x4000` | FBPA MC instances |
+
+Register names (and, for a few control/status registers, header field names
+with bit positions) are declared per window so traces are readable; offsets
+inside a window that the profile does not name are still recorded and land
+under `unclassified`. `gmmu-pri-mmu` spans the `NV_PFB_PRI_MMU_*` cluster
+`0x100C80–0x100EFC`; the few non-MMU PFB registers interleaved in that span
+(e.g. the PRI_FB clock/power-gating registers) are recorded as unclassified,
+never renamed.
+
+## 3. Mechanism: record, then decline
+
+Each `ObservedWindow` is a normal device-model window and is consulted by the
+registry like any other. Its `read`/`write` always record:
+
+```text
+addr, offset, size, rw, value-if-known, pc, vt, window, register, served
+```
+
+and then return `None`/`False` unless the window is declared `storage` **and**
+the accessed offset is a declared plain read/write register **and** (for
+reads) that register has been written before. Declining means the registry
+falls through to the next matching window and the machine's existing path
+applies unchanged:
+
+* lenient mode: the access is logged/counted as before and reads 0;
+* fault mode: the same `Fault(cause, tval)` as without the window;
+* a modelled window underneath (e.g. the PMC block) is consulted exactly as
+ before and serves the access if it models it, and the observer records that
+ it happened. (The `gmmu-wpr-lock` window overlaps the fwsec WPR2 window, so
+ it exercises this fall-through; it asserts nothing about whether the
+ underlying WPR2 path serves — that path is unchanged by the observer and
+ currently serves nothing through the registry, a pre-existing condition.)
+
+**No-fake-read guarantee.** An observe-only read records `"value": null` and
+returns `None`; a storage read before any write is declined the same way; no
+reset value is synthesised. The only read value a storage window can return
+is one this same run wrote to that register. All profile windows for
+GMMU/FBIF are `observe-only`; the `storage` mode exists for surfaces whose
+header proves plain read/write behaviour and is exercised by tests.
+
+`pc` is the faulting instruction address (the registry forwards it only to
+devices that opt in). `vt` is the machine instruction counter at the access;
+it advances at `Machine.run` boundaries, so accesses inside one bounded run
+share the run-start value — a documented granularity, not a timing claim
+(fidelity L2 is not modelled).
+
+**Size limit.** Each window's access log is capped (`max_access_log`, default
+65536 entries); accesses beyond the cap are counted, not written. The trace
+document reports `dropped_accesses` per window and in the summary and
+separates `accesses` (total observed) from `retained_accesses` (present in
+the document), so truncation is never silent.
+
+## 4. Trace document: `greeneye-gmmu-fbif-trace/1`
+
+`--trace-out <path>` writes a trace file: a path that is an existing
+directory (or ends in `/`, which makes that directory) gets the canonical
+filename `gmmu-fbif-trace.json` inside; anything else is used as the file
+name. Accesses are grouped per window and per declared register; undeclared
+offsets go to `unclassified`. Shape:
+
+```json
+{
+ "schema": "greeneye-gmmu-fbif-trace/1",
+ "image_sha256": "…",
+ "profile": "tu104-570.144.json",
+ "profile_name": "tu104-570.144",
+ "symbols": "symbols.json",
+ "summary": {"windows": 23, "windows_touched": 0, "accesses": 0,
+ "retained_accesses": 0, "dropped_accesses": 0,
+ "reads": 0, "writes": 0, "served": 0, "registers_touched": 0},
+ "windows": [
+ {"label": "gmmu-pri-mmu", "base": "0x2000000000100c80", "size": "0x280",
+ "mode": "observe-only", "evidence": "…",
+ "counts": {"r": 0, "w": 0, "served": 0, "declined": 0},
+ "retained_accesses": 0, "dropped_accesses": 0,
+ "registers": [
+ {"offset": "0x0", "name": "NV_PFB_PRI_MMU_CTRL", "access": "rw",
+ "counts": {"r": 0, "w": 1, "served": 0},
+ "accesses": [
+ {"addr": "0x2000000000100c80", "offset": "0x0", "size": 4,
+ "rw": "w", "value": "0x1", "pc": "0x4001234",
+ "pc_symbol": "rm_init+0x20", "vt": 0,
+ "window": "gmmu-pri-mmu", "register": "NV_PFB_PRI_MMU_CTRL",
+ "served": false, "decoded": {"VM_PG_SIZE": 1}}
+ ]}
+ ],
+ "unclassified": {"counts": {"r": 0, "w": 0, "served": 0},
+ "accesses": []}}
+ ]
+}
+```
+
+`pc_symbol` is `null` unless `--symbols <greeneye-symbols/1 JSON>` is given;
+`decoded` is present only when a value is known and the register declares
+fields (bit positions from the header). `value`/`addr`/`offset`/`pc` are hex
+strings; `vt` is an integer. The document is deterministic for a fixed image,
+profile, flags and stimulus.
+
+The **count summary is always in the boot report** (and session metadata under
+`gmmu_fbif`) even when `--trace-out` is off; the trace blob is added to the
+recorded session only when the flag is on.
+
+## 5. Commands
+
+```bash
+cd open-iov/debug/risc-v-gsp-rm-vm
+
+# B2 run with the observer summary, trace written to a directory:
+python3 -m sim.gsp_boot --instructions 20000 --record dumps/gsp-boot-b2-observe \
+ --trace-out dumps/gsp-boot-b2-observe
+
+# Resolve recorded PCs through a symbol database:
+python3 -m sim.gsp_boot --trace-out /tmp/trace.json --symbols symbols.json
+
+# Ad-hoc window whose base is not established yet (repeatable, no guessing):
+python3 -m sim.gsp_boot --observe-window 0x20000000009A0000:0x4000:fbpa \
+ --observe-window 0x2000000000100C80:0x280:gmmu
+```
+
+`--observe-window BASE:SIZE[:LABEL[:MODE]]` is the escape hatch: the trace
+accepts a runtime window list, so an unknown surface can be observed before
+any profile entry exists. A runtime window is `observe-only` unless
+`MODE=storage` is given (storage without declared registers still records and
+declines).
+
+## 6. What is NOT modelled
+
+* **No GMMU page walk, no PTE/PDE decode, no TLB, no fault handling, no ATS.**
+ Window accesses are recorded; their semantics are not interpreted.
+* **No FBIF transaction semantics, compression, ECC or partition hashing.**
+ FBPA register offsets/names are labels, not behaviour.
+* **No interrupt or timing (L2) claims.** `vt` is the modeled instruction
+ counter, not cycles.
+* **No device-environment (L3) claims.** The windows exist because the
+ simulator serves them; silicon behaviour is not asserted.
+* **Storage is not semantics.** A storage window only echoes values written in
+ the same run; it is not evidence that the real register stores them.
+* **No product or hardware action.** Nothing in this workstream touches
+ `vgsp`, a GPU, a module, mdev, or a guest.
+
+## 7. Evidence, limits and tests
+
+* Evidence kind for every window/offset is the hardware register/field header
+ (block ranges and register offsets); observed firmware use in B0–B2 was
+ **zero accesses** (measured at the B2 stop: 14 682 instructions, 0 unknown
+ MMIO, 1 device access — the PMC chip-ID read). The B3 wave's WPR2 reads are
+ the first observed accesses (2 reads inside `gmmu-wpr-lock`, observe-only,
+ 0 served). The mechanism is still tested end-to-end with runtime windows.
+* Boot numbers are unchanged by the observer windows (instructions, stop
+ PC/reason, unknown MMIO, unhandled-in-window, served accesses); the only
+ session difference is the added device-state entries for the observers.
+* Tests: `tests/test_gmmu_fbif_trace.py` — no-semantic-change (lenient and
+ fault paths match a registry without the window), storage read-back,
+ field decode, profile expansion, trace schema, access-log-cap reporting,
+ WPR2-overlap pass-through, B2 invariance, symbol resolution, runtime
+ windows.
+* Public artifacts carry only component register names/offsets and behaviour;
+ the window/register tables come from the tracked analysis and recorded
+ sessions.
diff --git a/debug/risc-v-gsp-rm-vm/docs/43-GMMU-PTE-DECODER.md b/debug/risc-v-gsp-rm-vm/docs/43-GMMU-PTE-DECODER.md
new file mode 100644
index 0000000..ea6d3d4
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/43-GMMU-PTE-DECODER.md
@@ -0,0 +1,206 @@
+# 43 — GMMU PTE/PDE decoder for TU104 (in development)
+
+> **Status: in development (2026-10-03, G2 work package).** This page
+> documents `sim/gmmu_pte.py`: a clean-room decoder for captured Turing
+> (TU104) GMMU page-table entries. It is decode-only tooling — no hardware,
+> no kernel module, no mdev, no guest, no RM 610. Development-only, never
+> shipped.
+>
+> **Rule: decode captured data only; validate against a capture before
+> trusting.** Every field position below is a reverse-engineering result;
+> none of it is a silicon-accuracy or timing claim (`06-ACCURACY.md`
+> fidelity levels apply).
+
+
+## 1. What this is — and is not
+
+`sim/gmmu_pte.py` turns a captured page-table value or blob into named
+fields with exact bit positions. It is **not** a page-table walker: it never
+dereferences a decoded address, never reads a target table, and never fills
+a gap with a guess. Unsupported encodings and levels raise `E_UNSUPPORTED`;
+set bits that no named field covers are reported (`reserved_bits` /
+`reserved_mask`) and never dropped.
+
+The evidence base is the Turing GMMU entry-format definitions (field names,
+bit ranges, aperture/KIND numeric IDs) together with the driver's own
+page-table encoder/decoder behaviour, which is what proves which of the
+several described encodings Turing actually uses (VER2) and how the
+page-tree geometry maps to virtual-address bits.
+
+## 2. Encodings shipped
+
+| Encoding | Size | Used for |
+|---|---|---|
+| `ver2` | 8 bytes | VER2 leaf PTE and directory PDE — the encoding the Turing driver's page-tree path programs |
+| `ver2_dual` | 16 bytes | VER2 dual PDE0 slot for mixed 64K/4K page tables: BIG half (low 64 bits) + SMALL half (high 64 bits) |
+
+Known-but-unsupported encodings (explicit `E_UNSUPPORTED`): the older
+non-VER2 PTE/PDE encoding and the "NEW"/VER1 encodings also described by the
+same register definitions. They are not shipped because the Turing driver's
+page-tree path uses VER2; adding them without a capture to validate against
+would be unverified surface. See §8.
+
+## 3. VER2 leaf PTE (8 bytes)
+
+| Field (our name) | Bits | Meaning |
+|---|---|---|
+| `valid` | 0 | entry is present |
+| `aperture` / `aperture_name` | 2:1 | 0 video, 1 peer, 2 system coherent, 3 system non-coherent |
+| `volatile` | 3 | cache/volatility bit: 0 = cached, 1 = volatile (the format's cache-related field) |
+| `encrypted` | 4 | encrypted memory |
+| `privilege` | 5 | privileged access |
+| `read_only` (`writable`) | 6 | write protection |
+| `atomic_disable` | 7 | atomics disabled |
+| `address_sys` | 53:8 | system-aperture address bits (PA 53:12) |
+| `address_vid` | 32:8 | video/peer address bits (PA 36:12) |
+| `peer_id` | 35:33 | peer/fabric id (peer aperture) |
+| `comptagline` | 55:36 | compression tag; overloaded as high address bits for the documented 47-bit peer case |
+| `kind` / `kind_name` | 63:56 | memory kind id (see §6); unknown ids are reported, not guessed |
+
+## 4. VER2 directory PDE (8 bytes)
+
+| Field (our name) | Bits | Meaning |
+|---|---|---|
+| `is_pde_pointer` / `is_leaf` | 0 | 0 = pointer to a lower table, 1 = 2 MB leaf PTE stored at PDE0 |
+| `aperture` / `aperture_name` | 2:1 | 0 invalid, 1 video, 2 system coherent, 3 system non-coherent |
+| `volatile` | 3 | as PTE |
+| `no_ats` | 5 | skip the ATS lookup for the covered range (driver sets it at depth 2) |
+| `address_sys` | 53:8 | next-table address bits (system aperture) |
+| `address_vid` | 32:8 | next-table address bits (video aperture) |
+| `peer_id` | 35:33 | peer/fabric id |
+
+Note the aperture-id difference: for a leaf PTE aperture 0 is video memory;
+for a directory PDE aperture 0 is invalid.
+
+## 5. VER2 dual PDE0 (16 bytes)
+
+The BIG half is the low 64 bits, the SMALL half the high 64 bits. Bit 0
+applies to the whole slot.
+
+| Field (our name) | Bits | Meaning |
+|---|---|---|
+| `is_pde_pointer` / `is_leaf` | 0 | whole-slot discriminator |
+| `aperture_big` | 2:1 | 0 invalid, 1 video, 2 system coherent, 3 system non-coherent |
+| `volatile_big` | 3 | as PTE |
+| `no_ats` | 5 | ATS skip; lies inside the BIG address field (see §7) |
+| `address_big_sys` | 53:4 | BIG target address; stores address >> 8 (PA 57:8) |
+| `address_big_vid` | 32:4 | BIG target address, video aperture |
+| `peer_id_big` | 35:33 | BIG peer/fabric id |
+| `aperture_small` | 66:65 | as `aperture_big` |
+| `volatile_small` | 67 | as PTE |
+| `address_small_sys` | 117:72 | SMALL target address; stores address >> 12 (PA 57:12) |
+| `address_small_vid` | 96:72 | SMALL target address, video aperture |
+| `peer_id_small` | 99:97 | SMALL peer/fabric id |
+
+Bits 54–64, 68–71 and 118–127 are unnamed by the format definitions and are
+reported as reserved when set.
+
+## 6. Page-tree geometry and KIND ids
+
+Turing's page-tree path inherits the Pascal/Volta geometry and uses the VER2
+entry encoding. Depths are root (0) to leaf (4):
+
+| Depth | HW level | VA bits | Index bits | Entry size | Role |
+|---|---|---|---|---|---|
+| 0 | PDE3 | 48:47 | 2 | 8 | pointer PDE |
+| 1 | PDE2 | 46:38 | 9 | 8 | pointer PDE |
+| 2 | PDE1 | 37:29 | 9 | 8 | pointer PDE (NO_ATS here) |
+| 3 | PDE0 | 28:21 | 8 | 16 (dual) / 8 (2 MB leaf) | dual 64K/4K PDE, or a 2 MB leaf PTE |
+| 4 | PTE | 20:12 (4 KB) / 20:16 (64 KB) | 9 (4 KB) / 5 (64 KB) | 8 | leaf PTE |
+
+Supported page sizes: 2 MB (depth 3), 64 KB and 4 KB (depth 4); VA width 49
+bits. A leaf entry alone does not tell 4 KB from 64 KB — the table's depth
+and page-size context do, so the decoder reports `page_size` as
+`4k_or_64k` when the depth is pinned to 4.
+
+The PTE `kind` field carries numeric ids (243 named values re-derived from
+the format definitions). Common compute values: `0x00 PITCH`,
+`0x06 GENERIC_MEMORY`, `0x07 INVALID`, `0x0F SMSKED_MESSAGE`,
+`0xFE GENERIC_16BX2`. Unknown ids decode as `kind_name: null`,
+`kind_known: false` with the raw value preserved.
+
+## 7. Address decode and uncertainty
+
+* System apertures (`system_coherent_memory`, `system_non_coherent_memory`):
+ the reconstructed `address` is complete (`address_complete: true`).
+* Video/peer apertures: only the low address bits are unambiguous
+ (`address_complete: false`). `comptagline` is a compression tag in the
+ normal case and is documented as overloaded for 47-bit peer addresses; the
+ decoder therefore does not claim a complete video address.
+* Dual BIG address: the field stores `address >> 8`, so `address` is
+ reconstructed as `field << 8`. Bit 5 of the slot is named `no_ats` and also
+ lies inside the BIG address field (address bit 9). The decoder reports the
+ flag from bit 5 and excludes that bit from the reconstructed BIG address;
+ the raw `address_big_sys` / `address_big_vid` fields keep the bit visible.
+ This assumes the target table is 4 KB aligned, which is the normal GPU
+ allocation granularity — if a capture shows a non-aligned target, treat
+ that entry as an unexplained case, not as proof of a different format.
+* Reserved-bit policy: `reserved_bits` lists every set bit not covered by a
+ named field; `reserved_mask` is the same set as a mask. Nothing is
+ silently dropped.
+* The VER2 PTE has no separate cache field: `volatile` (bit 3) is the only
+ cache-related bit the format defines, so no cache encoding is invented.
+* `decode_table` is little-endian and never follows pointers; a blob whose
+ length is not a multiple of `entry_size` raises `E_TRUNCATED`, an empty
+ blob decodes to `[]`.
+
+## 8. Supported levels and explicit gaps
+
+`levels` may pin the page-tree depth (0–4) or a sequence of acceptable
+depths. `None` (default) uses the full supported set and lets each entry's
+bit-0 discriminator choose between the leaf-PTE and directory-PDE views,
+reporting `level: null` plus `level_candidates` instead of guessing a single
+depth. Any depth outside 0–4 raises `E_UNSUPPORTED`.
+
+Explicit gaps (never guessed):
+
+1. Pre-VER2 and "NEW"/VER1 encodings are not decoded (`E_UNSUPPORTED`).
+2. The decoder does not walk tables, resolve virtual addresses, or infer a
+ 4 KB vs 64 KB leaf page size from the entry alone.
+3. No capture has been validated yet: the golden vectors are constructed
+ field-by-field from the format definitions, not sampled from silicon.
+ **Validate against a real capture before trusting a decode.**
+4. No timing, device-environment, isolation or security claim is made; the
+ simulator fidelity levels in `06-ACCURACY.md` apply.
+
+## 9. Usage
+
+Python:
+
+```python
+from sim.gmmu_pte import decode_pte, decode_table
+
+pte = decode_pte(0x0600000000123456, pte_kind="ver2")
+entries = decode_table(blob, base_pa=0x2000, entry_size=8, levels=4)
+dual = decode_table(blob16, base_pa=0x2000, entry_size=16, kind="ver2_dual",
+ levels=3)
+```
+
+CLI:
+
+```bash
+# one entry (default role "auto": bit 0 picks leaf vs directory)
+python3 -m sim.gmmu_pte --value 0x0600000000123456 --role pte
+python3 -m sim.gmmu_pte --value 0x0600000000123456 --json
+
+# a captured table blob, with the blob's GPU physical base address
+python3 -m sim.gmmu_pte --blob table.bin --base-pa 0x2000
+python3 -m sim.gmmu_pte --blob table.bin --base-pa 0x2000 --levels 4 --json
+
+# a dual PDE0 slot
+python3 -m sim.gmmu_pte --blob dual.bin --base-pa 0x2000 \
+ --kind ver2_dual --entry-size 16 --levels 3 --json
+```
+
+Exit code 0 on success; 2 with an `E_*` code on stderr for unsupported
+kinds/levels, truncated blobs or bad values. JSON output is sorted-key and
+deterministic.
+
+## 10. Tests
+
+`tests/test_gmmu_pte.py` builds every vector field-by-field from the tables
+above, asserts field round-trips, reserved-bit reporting (including the
+unnamed dual bit 64 and the unnamed PDE bits 4, 6, 7, 54–63), the NO_ATS
+overlap behaviour, unknown-kind reporting, unsupported kind/level/entry-size
+errors, empty and truncated blobs, deterministic output, and CLI success and
+error paths. 44 constructed golden entry vectors across 43 tests.
diff --git a/debug/risc-v-gsp-rm-vm/docs/44-GMMU-ORACLE-AND-WORKFLOW.md b/debug/risc-v-gsp-rm-vm/docs/44-GMMU-ORACLE-AND-WORKFLOW.md
new file mode 100644
index 0000000..affd350
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/docs/44-GMMU-ORACLE-AND-WORKFLOW.md
@@ -0,0 +1,254 @@
+# 44 — GMMU/FBIF oracle and workflow (in development)
+
+> **Status: in development (2026-10-03, G3 work package).** This page
+> documents two stdlib-only harness tools:
+>
+> * `harness/gmmu_oracle.py` — offline comparison of a human oracle capture
+> against a simulator GMMU/FBIF trace (per-register agreement, coverage,
+> unmatched lists, fidelity labels);
+> * `harness/gmmu_debug.py` — static↔dynamic join: a trace plus a symbol
+> map becomes a per-function register-write report and a
+> `greeneye_hits.tsv` the existing Ghidra annotator consumes.
+>
+> Development-only: these tools read files and write reports. They never
+> touch a GPU, kernel module, mdev, guest or network, and they make no
+> cycle/timing claim. Physical work (the oracle capture) is supervised-only
+> per RUNBOOK P1/P7 and is never run by an agent.
+>
+> **See also:** `06-ACCURACY.md` (fidelity levels, M1–M6),
+> `05-GHIDRA-BRIDGE.md` (address spaces, hit annotation),
+> `17-RM-HARNESS.md` (read-only RM oracle policy),
+> `25-VALUE-VALIDATION.md` (measured-value discipline),
+> `36-GSP-DEVICE-MODEL.md` (the simulator device model whose traces feed
+> these tools).
+
+## 1. What this is and why
+
+The device model (`sim/devices/`, docs/36) records every modelled MMIO
+access with its PC, width, direction and value. Two questions follow:
+
+1. **Accuracy** — does the value the model serves agree with what a human
+ can observe on the host (read-only RM/sysfs readbacks, supervised
+ captures)? (`gmmu_oracle.py`)
+2. **Static analysis** — which firmware function programs which
+ GMMU/FBIF register, in what order, and where is it in Ghidra?
+ (`gmmu_debug.py`)
+
+Both are offline. The oracle file is produced by a human; the tools ingest
+it and compare semantically. Nothing here executes a physical command.
+
+## 2. The accuracy loop (oracle vs simulator)
+
+```
+human (supervised, read-only) simulator (agent-safe)
+ rmtool / rmtop / sysfs / debugfs greeneye-gmmu-fbif-trace/1
+ -> greeneye-gmmu-oracle/1 (from sim/ devices + trace)
+ \ /
+ \ harness/gmmu_oracle.py compare /
+ \____________________________________/
+ |
+ gmmu-oracle-report.{json,md}
+ agreement %, coverage %, unmatched lists,
+ fidelity label per row
+```
+
+Rules:
+
+* The oracle is **observed** (human capture). The simulator value is
+ **modelled** (device model / recorded trace). Every row carries that
+ distinction; when a value is missing on either side the row's fidelity
+ label says the value is **unknown** instead of guessing.
+* Agreement is computed **per register on the matched intersection** — it
+ is never a single global "accuracy %". Coverage is reported separately.
+* An oracle-only register means the simulator did not touch it under this
+ stimulus; a simulator-only register means no oracle counterpart exists
+ yet. Neither is proof of a gap.
+* No timing, ordering or silicon claim. Fidelity is L0/L1 (docs/06).
+
+### 2.1 Oracle schema `greeneye-gmmu-oracle/1`
+
+An object with a `captures` list (a bare JSON list is accepted and reported
+as `unlabeled-list`):
+
+```json
+{
+ "format": "greeneye-gmmu-oracle/1",
+ "captures": [
+ {"name": "FBIF_CTL", "window": "falcon", "offset": "0x624",
+ "value": "0x1", "source": "rm-readback", "captured_at": "2026-10-03"}
+ ]
+}
+```
+
+`name`, `window`, `offset`, `value`, `source`, `captured_at` are the
+documented fields. `offset`/`value`/`addr` accept ints or `0x…` strings.
+A different explicit `format` is a clean error.
+
+### 2.2 Simulator schema `greeneye-gmmu-fbif-trace/1`
+
+The producer (`sim/devices/observe.py:build_trace`, docs/36) emits a
+nested document: accesses are grouped per window and per declared register,
+with an `unclassified` bucket for observed offsets the profile does not
+name. The `schema` key carries the format tag:
+
+```json
+{
+ "schema": "greeneye-gmmu-fbif-trace/1",
+ "image_sha256": "…", "profile": "tu104-570.144",
+ "summary": {"windows": 1, "accesses": 2},
+ "windows": [
+ {
+ "label": "falcon-observe", "base": "0x300110000", "size": "0x1000",
+ "mode": "observe-only", "evidence": "header",
+ "registers": [
+ {"offset": "0x624", "name": "FBIF_CTL", "access": "rw",
+ "counts": {"r": 0, "w": 1, "served": 0},
+ "accesses": [
+ {"addr": "0x300110624", "offset": "0x624", "size": 4, "rw": "w",
+ "value": "0x1", "pc": "0x4002040", "vt": 123,
+ "window": "falcon-observe", "register": "FBIF_CTL",
+ "served": false, "pc_symbol": "fbif_setup"}
+ ]}
+ ],
+ "unclassified": {"counts": {"r": 1, "w": 0, "served": 0},
+ "accesses": [{"addr": "0x300110008", "offset": "0x8",
+ "rw": "r", "value": null, "pc": "0x4002050"}]}
+ }
+ ]
+}
+```
+
+`load_sim_trace` flattens this shape (and accepts a flat `events` list, a
+bare list, JSONL, and aliases) so the harness survives producer naming
+choices: `kind`/`rw`/`direction`/`type`/`t`; `name`/`register`/`reg`;
+`window`/`block`/`device`; `offset`/`reg_offset`; `addr`/`address`/`pa`;
+`value`/`data`; `pc`; `function`/`symbol`/`pc_symbol`; `handled`/`served`.
+Nested documents are flattened per window/register: per-register access
+order is preserved, but global interleaving across registers is not
+recoverable from that grouping. The producer's top-level `summary` —
+including `retained_accesses` / `dropped_accesses` — is carried into
+`inputs.trace.source.summary`, so a capped (truncated) access log stays
+visible downstream. A session directory is accepted too:
+`session.json` pointers (`gmmu_fbif_trace`, `gmmu_fbif_trace_blob`,
+`trace_blob`) or a `blobs/*.json[l]` file carrying the format tag. A raw
+`read`/`write` JSONL trace without register names still parses (registers
+are then reported by address).
+
+## 3. Static-analysis debugging loop (`gmmu_debug.py`)
+
+```
+greeneye-gmmu-fbif-trace/1 + symbols (greeneye-symbols/1 | addr->name map | TSV)
+ |
+ v
+per-function register-write report gmmu-debug-report.{json,md}
+ function, register, window, offset,
+ sequence of written values,
+ first/last pc, write/read counts
+ |
+ v
+greeneye_hits.tsv (addr hits symbol annotation)
+ |
+ v
+analyzeHeadless ... -postScript MarkGreenEyeHits.java <greeneye_hits.tsv>
+```
+
+`greeneye_hits.tsv` is byte-compatible with
+`greeneye/ghidra.py:export_hits` and `ghidra/MarkGreenEyeHits.java`: header
+`addr\thits\tsymbol\tannotation`, hex address, one row per function, `hits`
+= register accesses attributed to the function. Function attribution uses
+the symbol DB (containing symbol when sizes exist, the toolchain's
+nearest-preceding fallback otherwise); a pc outside every symbol becomes
+`sub_<pc>`. An explicit `function` field in the trace wins over resolution.
+
+## 4. Exact commands
+
+All commands run from `open-iov/debug/risc-v-gsp-rm-vm`. They are offline
+and safe for an agent.
+
+```bash
+# Accuracy loop — compare a human oracle with a simulator trace/session.
+python3 harness/gmmu_oracle.py compare \
+ --oracle oracle.json --sim dumps/<session-or-trace.json> \
+ --out dumps/gmmu-oracle-report
+
+# Print (do NOT execute) the supervised, read-only capture instructions.
+python3 harness/gmmu_oracle.py --print-capture-hints
+
+# Static↔dynamic join — report + Ghidra-ready hits TSV.
+python3 harness/gmmu_debug.py report \
+ --trace dumps/<session-or-trace.json> --symbols symbols.json \
+ --out dumps/gmmu-debug-report
+
+# Synthetic end-to-end fixture (no firmware, no hardware).
+python3 harness/gmmu_debug.py demo --out harness/gmmu_debug_fixture
+
+# Tests (synthetic only; no live oracle capture, no hardware).
+python3 -m unittest tests.test_gmmu_oracle tests.test_gmmu_debug -v
+```
+
+`--print-capture-hints` only prints. It names the read-only RM wrapper
+commands (`./rmharness/rmtool.sh gpu list/engine list/mem stats`,
+`rmtop --once`), sysfs/proc/debugfs readbacks, and the optional
+first-touch step, with the hard warning that physical work is
+supervised-only and an agent must never run those commands. The safe
+simulator observation (`python3 -m sim.gsp_boot … --record …`) is the only
+capture an agent may produce.
+
+Generated fixture/report directories under `harness/gmmu_*` are gitignored.
+
+## 5. Worked example (synthetic, measured)
+
+`python3 harness/gmmu_debug.py demo` builds a 13-event synthetic trace with
+three named functions plus one unattributed pc, runs the whole loop, and
+prints a `RESULT_JSON` line. On 2026-10-03 (this host, CPython 3.12) the
+loop measured **≈1 ms**, against a manual static-reading baseline frozen
+in `harness/gmmu_debug.py` **before** the first measurement:
+
+| field | value |
+|---|---|
+| manual baseline (frozen) | `1200 s` — a **conservative estimate**, not measured on a human |
+| threshold fraction (frozen) | `0.05` → `60 s` |
+| measured (fresh run required to quote) | ≈0.001 s |
+| verdict | pass |
+
+The baseline and fraction are constants in `harness/gmmu_debug.py`; moving
+them requires a new recorded run and a note here. The demo proves the
+**shape** of the loop (trace → per-function report → hits TSV →
+Ghidra-ready output), never a hardware fact.
+
+Synthetic oracle comparison check (three oracle items, three trace events):
+one agree, one mismatch, one oracle-only, one simulator-only → agreement
+50 % on the two-item intersection, coverage 66.67 %. These are fixture
+numbers, not an accuracy claim about any firmware.
+
+## 6. Limits and not-verified list
+
+* **No live oracle capture was taken.** The tools were exercised only with
+ synthetic fixtures. A real oracle requires a supervised human session
+ per §4 and docs/17 §6.
+* **No real firmware trace was compared.** The loader is exercised against
+ the real `sim/devices/observe.py` producer (read-only integration test)
+ and against synthetic documents; no captured firmware run was compared,
+ and no live oracle capture was taken.
+* **L0/L1 only.** The trace records execution and register accesses, not
+ time; no L2/L3 claim is made. Absence of a register in a trace is a fact
+ about that run's stimulus, not proof of dead code.
+* **Agreement is per-register on the intersection**, with coverage and both
+ unmatched lists reported; never a single global percentage.
+* **Function attribution depends on the symbol map.** Zero-size symbols use
+ the toolchain's nearest-preceding fallback; supply sized symbols
+ (`greeneye-symbols/1`) for strict boundaries.
+* **Product changes are not implied.** Any emulation change still needs the
+ accuracy loop plus physical reproduction per docs/06 §6.
+* The demo baseline is an **estimate**; no human was timed.
+
+## 7. Tests
+
+`tests/test_gmmu_oracle.py` and `tests/test_gmmu_debug.py` cover: oracle
+ingest (object/list/aliases/bad format), comparison (agreement, mismatch,
+oracle-only, simulator-only, name fallback, no-value, empty), session
+directory ingest, report shape and sequence order, explicit-function
+override, unknown-pc handling, hits-TSV compatibility with
+`MarkGreenEyeHits.java`, empty traces, the frozen-threshold demo, and the
+CLI error paths (exit 2, no traceback). All fixtures are synthetic; no
+hardware, module, guest or network is touched.
diff --git a/debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py b/debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py
new file mode 100644
index 0000000..794bb1f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/gmmu_debug.py
@@ -0,0 +1,924 @@
+#!/usr/bin/env python3
+"""GMMU/FBIF static<->dynamic join (harness/gmmu_debug.py).
+
+Answers "which firmware function programs which register, in what order"
+from a recorded simulator trace, then writes a Ghidra-ready hit file.
+
+Inputs
+------
+
+* A G1 trace: ``greeneye-gmmu-fbif-trace/1``. The producer's document is
+ nested (``windows[].registers[].accesses[]`` plus
+ ``windows[].unclassified.accesses[]``); a flat ``events`` list and field
+ aliases are also accepted so the harness survives producer naming choices
+ (see :func:`load_sim_trace`). Nested documents are flattened per
+ window/register: per-register order is preserved, global interleaving
+ across registers is not recoverable from that shape.
+* A session directory containing such a trace under ``blobs/`` or named by
+ ``session.json`` (``gmmu_fbif_trace`` / ``gmmu_fbif_trace_blob``).
+* A symbol DB/map: ``greeneye-symbols/1`` JSON, a plain ``{addr: name}``
+ JSON map, or a Ghidra ``addr<TAB>name[...]`` TSV.
+
+Outputs
+-------
+
+* ``gmmu-debug-report.json`` / ``.md``: per-firmware-function register rows
+ (function, register, window, offset, sequence of written values,
+ first/last pc, write and read counts).
+* ``greeneye_hits.tsv``: ``addr<TAB>hits<TAB>symbol<TAB>annotation``, the
+ exact shape ``ghidra/MarkGreenEyeHits.java`` consumes.
+
+The ``demo`` subcommand builds a small synthetic fixture, runs the whole
+loop, and compares the measured wall time against thresholds frozen in this
+file before the first measurement. The manual baseline is a conservative
+estimate, not a measurement on a human.
+
+Development-only: this module only reads files and writes reports. It never
+touches a GPU, module, mdev, guest or network.
+"""
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import sys
+import time
+from dataclasses import dataclass, field
+from typing import Any
+
+TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if TOOL_ROOT not in sys.path:
+ sys.path.insert(0, TOOL_ROOT)
+
+from greeneye.errors import GreenEyeError # noqa: E402
+from greeneye.ghidra import render_ghidra_command # noqa: E402
+from greeneye.symbols import Symbol, SymbolDB # noqa: E402
+
+#: Schema tag of the G1 simulator observation file.
+TRACE_FORMAT = "greeneye-gmmu-fbif-trace/1"
+#: Schema tag of the report this module emits.
+DEBUG_REPORT_FORMAT = "greeneye-gmmu-debug-report/1"
+#: Schema tag of the demo result line.
+DEMO_RESULT_FORMAT = "greeneye-gmmu-debug-demo/1"
+#: Exact header expected by ``ghidra/MarkGreenEyeHits.java``.
+HITS_HEADER = "addr\thits\tsymbol\tannotation"
+#: Cap on the per-register value sequence kept in the JSON report.
+MAX_SEQUENCE_VALUES = 32
+
+# FROZEN BEFORE THE FIRST MEASUREMENT (2026-10-03). Do not move silently:
+# a threshold change requires a new recorded run and a note in docs/44.
+#: Conservative ESTIMATE (not measured on a human) of the static path for
+#: the demo's handful of functions: open the Ghidra project, locate each
+#: function, read every register-write site by hand against the register
+#: header. ~20 minutes is deliberately generous to the manual baseline.
+MANUAL_STATIC_BASELINE_SECONDS = 1200
+MANUAL_BASELINE_LABEL = "conservative estimate (not measured on a human)"
+#: The tool path must stay at or below this fraction of the estimate.
+WORKFLOW_THRESHOLD_FRACTION = 0.05
+
+
+class GmmuInputError(GreenEyeError):
+ """A GMMU input file is absent, unreadable or not in a supported schema."""
+
+
+def sha256_file(path: str) -> str:
+ digest = hashlib.sha256()
+ with open(path, "rb") as fh:
+ for chunk in iter(lambda: fh.read(1 << 20), b""):
+ digest.update(chunk)
+ return digest.hexdigest()
+
+
+def parse_int(value: Any, default: int | None = None) -> int | None:
+ """Parse ints, decimal strings and ``0x`` strings; ``None`` on failure."""
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return int(value)
+ if isinstance(value, int):
+ return value
+ if isinstance(value, float):
+ return int(value)
+ if isinstance(value, str):
+ text = value.strip()
+ if not text:
+ return default
+ try:
+ return int(text, 0)
+ except ValueError:
+ try:
+ return int(text, 16)
+ except ValueError:
+ return default
+ return default
+
+
+def _first_str(event: dict, keys: tuple[str, ...]) -> str:
+ for key in keys:
+ value = event.get(key)
+ if isinstance(value, str) and value:
+ return value
+ if isinstance(value, int) and not isinstance(value, bool):
+ return str(value)
+ return ""
+
+
+def _first_int(event: dict, keys: tuple[str, ...]) -> int | None:
+ for key in keys:
+ if key in event:
+ parsed = parse_int(event.get(key))
+ if parsed is not None:
+ return parsed
+ return None
+
+
+def _first_bool(event: dict, keys: tuple[str, ...]) -> bool | None:
+ for key in keys:
+ value = event.get(key)
+ if isinstance(value, bool):
+ return value
+ return None
+
+
+_WRITE_WORDS = {"w", "write", "wr", "store", "st", "out"}
+_READ_WORDS = {"r", "read", "rd", "load", "ld", "in"}
+_OTHER_WORDS = {"exec", "summary", "note", "event"}
+
+
+def _normalise_kind(event: dict) -> str:
+ for key in ("kind", "rw", "direction", "access", "op", "type", "t"):
+ value = event.get(key)
+ if isinstance(value, str):
+ low = value.strip().lower()
+ if low in _WRITE_WORDS:
+ return "write"
+ if low in _READ_WORDS:
+ return "read"
+ if low in _OTHER_WORDS:
+ return "other"
+ write_flag = event.get("write")
+ if isinstance(write_flag, bool):
+ return "write" if write_flag else "read"
+ return "other"
+
+
+@dataclass
+class TraceObservation:
+ """One register access from a simulator trace."""
+
+ seq: int
+ kind: str
+ name: str = ""
+ window: str = ""
+ offset: int | None = None
+ addr: int | None = None
+ size: int | None = None
+ value: int | None = None
+ pc: int | None = None
+ function: str = ""
+ handled: bool | None = None
+ vt: int | None = None
+
+ def to_json(self) -> dict:
+ out: dict[str, Any] = {"seq": self.seq, "kind": self.kind}
+ if self.name:
+ out["name"] = self.name
+ if self.window:
+ out["window"] = self.window
+ if self.offset is not None:
+ out["offset"] = self.offset
+ if self.addr is not None:
+ out["addr"] = self.addr
+ if self.size is not None:
+ out["size"] = self.size
+ if self.value is not None:
+ out["value"] = self.value
+ if self.pc is not None:
+ out["pc"] = self.pc
+ if self.function:
+ out["function"] = self.function
+ if self.handled is not None:
+ out["handled"] = self.handled
+ if self.vt is not None:
+ out["vt"] = self.vt
+ return out
+
+
+@dataclass
+class SimTrace:
+ path: str
+ format: str
+ sha256: str
+ observations: list[TraceObservation] = field(default_factory=list)
+ source: dict = field(default_factory=dict)
+
+
+def _observation_from_event(event: dict, seq: int) -> TraceObservation:
+ return TraceObservation(
+ seq=seq,
+ kind=_normalise_kind(event),
+ name=_first_str(event, ("name", "register", "reg", "signal")),
+ window=_first_str(event, ("window", "block", "device", "bar", "region")),
+ offset=_first_int(event, ("offset", "reg_offset", "index")),
+ addr=_first_int(event, ("addr", "address", "pa", "guest_addr", "gpa")),
+ size=_first_int(event, ("size", "width", "bytes")),
+ value=_first_int(event, ("value", "data", "val")),
+ pc=_first_int(event, ("pc", "pc_addr")),
+ function=_first_str(event, ("function", "symbol", "func", "pc_symbol")),
+ handled=_first_bool(event, ("handled", "served")),
+ vt=_first_int(event, ("vt", "virtual_time")),
+ )
+
+
+_EVENT_KEYS = ("events", "trace", "accesses", "items", "observations")
+
+
+def _flatten_windows(payload: dict) -> list[dict]:
+ """Flatten the G1 nested document (windows -> registers -> accesses)."""
+ events: list[dict] = []
+ for window in payload.get("windows", []):
+ if not isinstance(window, dict):
+ continue
+ label = window.get("label") or window.get("window") or ""
+ for register in window.get("registers", []) or []:
+ if not isinstance(register, dict):
+ continue
+ name = register.get("name")
+ for access in register.get("accesses", []) or []:
+ if not isinstance(access, dict):
+ continue
+ event = dict(access)
+ if label:
+ event.setdefault("window", label)
+ if name:
+ event.setdefault("register", name)
+ events.append(event)
+ unclassified = window.get("unclassified")
+ if isinstance(unclassified, dict):
+ for access in unclassified.get("accesses", []) or []:
+ if not isinstance(access, dict):
+ continue
+ event = dict(access)
+ if label:
+ event.setdefault("window", label)
+ events.append(event)
+ return events
+
+
+def _source_metadata(payload: dict) -> dict:
+ source: dict = {}
+ if isinstance(payload.get("source"), dict):
+ source.update(payload["source"])
+ for key in ("image_sha256", "profile", "profile_name", "symbols"):
+ if payload.get(key) is not None:
+ source.setdefault(key, payload[key])
+ summary = payload.get("summary")
+ if isinstance(summary, dict):
+ source.setdefault("summary", summary)
+ return source
+
+
+def _events_from_payload(payload: Any) -> tuple[str, list | None, dict]:
+ if isinstance(payload, list):
+ return "unlabeled-list", payload, {}
+ if not isinstance(payload, dict):
+ raise GmmuInputError("trace JSON must be an object or a list of events")
+ fmt = str(payload.get("format") or payload.get("schema") or "")
+ source = _source_metadata(payload)
+ for key in _EVENT_KEYS:
+ if isinstance(payload.get(key), list):
+ return fmt, payload[key], source
+ if isinstance(payload.get("windows"), list):
+ return fmt, _flatten_windows(payload), source
+ if fmt == TRACE_FORMAT:
+ return fmt, [], source
+ return fmt, None, source
+
+
+def _parse_jsonl(text: str) -> tuple[str, list[dict], dict]:
+ fmt = ""
+ events: list[dict] = []
+ for line in text.splitlines():
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ item = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+ if not isinstance(item, dict):
+ continue
+ if item.get("format") or item.get("schema"):
+ fmt = str(item.get("format") or item.get("schema"))
+ if not any(k in item for k in ("addr", "pc", "offset", "name", "register")):
+ continue
+ if item.get("t") == "summary":
+ continue
+ events.append(item)
+ return fmt, events, {}
+
+
+def _find_session_trace(session_dir: str) -> str:
+ session_json = os.path.join(session_dir, "session.json")
+ explicit: list[str] = []
+ if os.path.isfile(session_json):
+ try:
+ with open(session_json, "r", encoding="utf-8") as fh:
+ meta = json.load(fh)
+ except (OSError, json.JSONDecodeError):
+ meta = {}
+ if isinstance(meta, dict):
+ for key in ("gmmu_fbif_trace", "gmmu_fbif_trace_blob", "trace_blob"):
+ value = meta.get(key)
+ if isinstance(value, str) and value:
+ explicit.append(os.path.join(session_dir, value))
+ for candidate in explicit:
+ if os.path.isfile(candidate):
+ return candidate
+ blobs = os.path.join(session_dir, "blobs")
+ if os.path.isdir(blobs):
+ for name in sorted(os.listdir(blobs)):
+ if not name.endswith((".json", ".jsonl")):
+ continue
+ candidate = os.path.join(blobs, name)
+ try:
+ with open(candidate, "r", encoding="utf-8", errors="replace") as fh:
+ head = fh.read(1 << 16)
+ except OSError:
+ continue
+ if TRACE_FORMAT in head:
+ return candidate
+ for name in sorted(os.listdir(blobs)):
+ if name.endswith(".jsonl"):
+ return os.path.join(blobs, name)
+ raise GmmuInputError(
+ f"{session_dir}: no {TRACE_FORMAT} trace found (looked for session.json "
+ "pointers and blobs/*.json[l])"
+ )
+
+
+def load_sim_trace(path: str) -> SimTrace:
+ """Load a G1 trace file, or a session directory containing one.
+
+ Accepted shapes: the G1 nested document (``schema`` +
+ ``windows[].registers[].accesses[]``), an object with a flat
+ ``events``/``trace``/``accesses`` list, a bare list, and JSONL. Event
+ aliases: ``kind``/``rw``/``direction``/``type``/``t``;
+ ``name``/``register``/``reg``; ``window``/``block``/``device``;
+ ``offset``/``reg_offset``; ``addr``/``address``/``pa``;
+ ``value``/``data``; ``pc``; ``function``/``symbol``/``pc_symbol``;
+ ``handled``/``served``. Unknown fields are ignored; an event with no
+ recognisable direction is kept as ``other``.
+ """
+ if os.path.isdir(path):
+ path = _find_session_trace(path)
+ if not os.path.isfile(path):
+ raise GmmuInputError(f"trace not found: {path}")
+ digest = sha256_file(path)
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
+ text = fh.read()
+ try:
+ payload = json.loads(text)
+ except json.JSONDecodeError:
+ payload = None
+ if payload is not None:
+ fmt, events, source = _events_from_payload(payload)
+ if events is None:
+ raise GmmuInputError(
+ f"{path}: no event list (expected {TRACE_FORMAT} with an 'events' key)"
+ )
+ else:
+ fmt, events, source = _parse_jsonl(text)
+ observations = [
+ _observation_from_event(event, seq)
+ for seq, event in enumerate(events)
+ if isinstance(event, dict)
+ ]
+ return SimTrace(
+ path=os.path.abspath(path),
+ format=fmt or "unlabeled",
+ sha256=digest,
+ observations=observations,
+ source=source,
+ )
+
+
+def load_symbols(path: str | None) -> SymbolDB:
+ """Load a symbol DB (JSON) or a Ghidra-style TSV/map (``addr<TAB>name``)."""
+ db = SymbolDB()
+ if not path:
+ return db
+ if not os.path.isfile(path):
+ raise GmmuInputError(f"symbol map not found: {path}")
+ if path.endswith(".json"):
+ try:
+ with open(path, "r", encoding="utf-8") as fh:
+ payload = json.load(fh)
+ except (OSError, json.JSONDecodeError) as exc:
+ raise GmmuInputError(f"cannot read symbol map {path}: {exc}") from exc
+ if isinstance(payload, dict) and (
+ "symbols" in payload or payload.get("format") == "greeneye-symbols/1"
+ ):
+ db.load_json(path)
+ return db
+ if isinstance(payload, list):
+ db.load_json(path)
+ return db
+ if isinstance(payload, dict):
+ for key, value in payload.items():
+ addr = parse_int(key)
+ if addr is None:
+ continue
+ if isinstance(value, str):
+ db.add(Symbol(addr=addr, name=value, source="map"))
+ elif isinstance(value, dict) and value.get("name"):
+ db.add(
+ Symbol(
+ addr=addr,
+ name=str(value["name"]),
+ size=parse_int(value.get("size"), 0) or 0,
+ source="map",
+ )
+ )
+ return db
+ raise GmmuInputError(f"{path}: unsupported symbol JSON shape")
+ db.load_index_tsv(path)
+ if len(db) == 0:
+ db.load_names_tsv(path)
+ if len(db) == 0:
+ raise GmmuInputError(f"{path}: no symbols parsed")
+ return db
+
+
+def _function_identity(
+ obs: TraceObservation, symbols: SymbolDB | None
+) -> tuple[tuple | None, int | None, str]:
+ """Resolve an observation to ``(group_key, entry_addr, display_name)``.
+
+ An explicit ``function`` field groups by name (all events of one named
+ function stay together even without a symbol DB); a resolved symbol
+ groups by its entry address; an unresolved pc becomes ``sub_<pc>``.
+ """
+ if obs.function:
+ symbol = symbols.get(obs.function) if symbols is not None else None
+ return (
+ ("name", obs.function),
+ symbol.addr if symbol is not None else None,
+ obs.function,
+ )
+ if obs.pc is None:
+ return None, None, ""
+ symbol = symbols.resolve(obs.pc) if symbols is not None else None
+ if symbol is not None:
+ return ("addr", symbol.addr), symbol.addr, symbol.name
+ return ("sub", obs.pc), obs.pc, f"sub_{obs.pc:#x}"
+
+
+def _register_identity(obs: TraceObservation) -> tuple:
+ if obs.offset is not None:
+ return (obs.window, obs.offset, obs.name)
+ if obs.name:
+ return (obs.window, None, obs.name)
+ return (obs.window, obs.addr, "")
+
+
+def _register_label(obs: TraceObservation) -> str:
+ if obs.name:
+ return obs.name
+ if obs.offset is not None:
+ return f"0x{obs.offset:x}"
+ if obs.addr is not None:
+ return f"0x{obs.addr:x}"
+ return "?"
+
+
+@dataclass
+class RegisterRow:
+ function: str
+ function_addr: int | None
+ register: str
+ window: str
+ offset: int | None
+ addr: int | None
+ write_count: int = 0
+ read_count: int = 0
+ first_pc: int | None = None
+ last_pc: int | None = None
+ first_seq: int | None = None
+ last_seq: int | None = None
+ values: list[int] = field(default_factory=list)
+ values_truncated: bool = False
+
+ def observe(self, obs: TraceObservation) -> None:
+ if obs.kind == "write":
+ self.write_count += 1
+ if obs.value is not None:
+ if len(self.values) < MAX_SEQUENCE_VALUES:
+ self.values.append(obs.value)
+ else:
+ self.values_truncated = True
+ elif obs.kind == "read":
+ self.read_count += 1
+ if obs.pc is not None:
+ if self.first_pc is None:
+ self.first_pc = obs.pc
+ self.last_pc = obs.pc
+ if self.first_seq is None:
+ self.first_seq = obs.seq
+ self.last_seq = obs.seq
+
+ def to_json(self) -> dict:
+ out: dict[str, Any] = {
+ "register": self.register,
+ "write_count": self.write_count,
+ "read_count": self.read_count,
+ "first_seq": self.first_seq,
+ "last_seq": self.last_seq,
+ }
+ if self.window:
+ out["window"] = self.window
+ if self.offset is not None:
+ out["offset"] = self.offset
+ if self.addr is not None:
+ out["addr"] = self.addr
+ if self.first_pc is not None:
+ out["first_pc"] = self.first_pc
+ if self.last_pc is not None:
+ out["last_pc"] = self.last_pc
+ out["values"] = self.values
+ out["values_truncated"] = self.values_truncated
+ return out
+
+
+def build_debug_report(trace: SimTrace, symbols: SymbolDB | None) -> dict:
+ """Group trace observations by firmware function and register."""
+ functions: dict[tuple, dict] = {}
+ order: list[tuple] = []
+ unknown_pc = 0
+ for obs in trace.observations:
+ fkey, addr, function = _function_identity(obs, symbols)
+ if fkey is None:
+ unknown_pc += 1
+ continue
+ entry = functions.get(fkey)
+ if entry is None:
+ entry = {
+ "function": function,
+ "addr": addr,
+ "first_pc": None,
+ "writes": 0,
+ "reads": 0,
+ "registers": {},
+ "order": [],
+ }
+ functions[fkey] = entry
+ order.append(fkey)
+ rkey = _register_identity(obs)
+ row = entry["registers"].get(rkey)
+ if row is None:
+ row = RegisterRow(
+ function=function,
+ function_addr=addr,
+ register=_register_label(obs),
+ window=obs.window,
+ offset=obs.offset,
+ addr=obs.addr,
+ )
+ entry["registers"][rkey] = row
+ entry["order"].append(rkey)
+ row.observe(obs)
+ if obs.pc is not None and entry["first_pc"] is None:
+ entry["first_pc"] = obs.pc
+ if obs.kind == "write":
+ entry["writes"] += 1
+ elif obs.kind == "read":
+ entry["reads"] += 1
+
+ def sort_key(fkey: tuple):
+ entry = functions[fkey]
+ anchor = entry["addr"] if entry["addr"] is not None else entry["first_pc"]
+ return (anchor is None, anchor or 0, entry["function"])
+
+ function_rows = []
+ for fkey in sorted(order, key=sort_key):
+ entry = functions[fkey]
+ register_rows = [entry["registers"][rkey] for rkey in entry["order"]]
+ register_rows.sort(
+ key=lambda r: (r.window, r.offset is None, r.offset or 0, r.register)
+ )
+ function_rows.append(
+ {
+ "function": entry["function"],
+ "addr": entry["addr"],
+ "first_pc": entry["first_pc"],
+ "writes": entry["writes"],
+ "reads": entry["reads"],
+ "registers": [r.to_json() for r in register_rows],
+ }
+ )
+
+ total_registers = sum(len(f["registers"]) for f in function_rows)
+ return {
+ "format": DEBUG_REPORT_FORMAT,
+ "inputs": {
+ "trace": {
+ "path": trace.path,
+ "sha256": trace.sha256,
+ "format": trace.format,
+ "events": len(trace.observations),
+ "source": trace.source,
+ }
+ },
+ "summary": {
+ "functions": len(function_rows),
+ "registers": total_registers,
+ "writes": sum(f["writes"] for f in function_rows),
+ "reads": sum(f["reads"] for f in function_rows),
+ "unknown_pc_events": unknown_pc,
+ "trace_events": len(trace.observations),
+ },
+ "functions": function_rows,
+ "limits": [
+ "L0/L1 evidence only: the trace records execution and register accesses, not timing.",
+ "Absence of a write is not proof a register is untouched outside this run's stimulus.",
+ "Synthetic demo fixture values are invented; real traces must come from the simulator.",
+ ],
+ }
+
+
+def write_hits_tsv(report: dict, path: str) -> str:
+ """Write a ``greeneye_hits.tsv`` compatible with MarkGreenEyeHits.java."""
+ os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
+ with open(path, "w", encoding="utf-8") as fh:
+ fh.write(HITS_HEADER + "\n")
+ for function in report["functions"]:
+ addr = function.get("addr")
+ if addr is None:
+ addr = function.get("first_pc")
+ if addr is None:
+ for register in function["registers"]:
+ if register.get("first_pc") is not None:
+ addr = register["first_pc"]
+ break
+ if addr is None:
+ continue
+ hits = function["writes"] + function["reads"]
+ annotation = (
+ f"gmmu: {function['writes']} write(s), {function['reads']} read(s) "
+ f"across {len(function['registers'])} register(s)"
+ )
+ fh.write(
+ f"{addr:#x}\t{hits}\t{function['function']}\t{annotation}\n"
+ )
+ return os.path.abspath(path)
+
+
+def render_debug_markdown(report: dict, hits_path: str, ghidra_command: str) -> str:
+ summary = report["summary"]
+ lines = [
+ "# GMMU/FBIF per-function register-write report",
+ "",
+ f"> Format `{report['format']}`. Development-only static<->dynamic join;",
+ "> no timing or silicon claim. Fidelity: L0/L1 (docs/06).",
+ "",
+ "## Inputs",
+ "",
+ f"* trace: `{report['inputs']['trace']['path']}` "
+ f"({report['inputs']['trace']['events']} events, "
+ f"format `{report['inputs']['trace']['format']}`, "
+ f"sha256 `{report['inputs']['trace']['sha256'][:16]}…`)",
+ "",
+ "## Summary",
+ "",
+ "| functions | registers | writes | reads | unknown-pc events |",
+ "|---|---|---|---|---|",
+ f"| {summary['functions']} | {summary['registers']} | {summary['writes']} | "
+ f"{summary['reads']} | {summary['unknown_pc_events']} |",
+ "",
+ "## Per function",
+ "",
+ ]
+ for function in report["functions"]:
+ addr = f"{function['addr']:#x}" if function["addr"] is not None else "unknown"
+ lines.append(
+ f"### `{function['function']}` @ {addr} — "
+ f"{function['writes']} write(s), {function['reads']} read(s)"
+ )
+ lines.append("")
+ lines.append("| register | window | offset | writes | reads | first pc | last pc | value sequence |")
+ lines.append("|---|---|---|---|---|---|---|---|")
+ for register in function["registers"]:
+ offset = f"{register['offset']:#x}" if register.get("offset") is not None else ""
+ first_pc = f"{register['first_pc']:#x}" if register.get("first_pc") is not None else ""
+ last_pc = f"{register['last_pc']:#x}" if register.get("last_pc") is not None else ""
+ sequence = ", ".join(f"{v:#x}" for v in register["values"])
+ if register.get("values_truncated"):
+ sequence += ", …"
+ lines.append(
+ f"| `{register['register']}` | {register.get('window', '')} | {offset} | "
+ f"{register['write_count']} | {register['read_count']} | {first_pc} | {last_pc} | "
+ f"{sequence or ''} |"
+ )
+ lines.append("")
+ lines += [
+ "## Ghidra annotation",
+ "",
+ f"Hits file: `{hits_path}`",
+ "",
+ "```bash",
+ ghidra_command,
+ "```",
+ "",
+ "## Limits",
+ "",
+ ]
+ for limit in report["limits"]:
+ lines.append(f"* {limit}")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def run_report(trace_path: str, symbols_path: str | None, out_dir: str) -> dict:
+ """Run the full trace -> report -> hits-TSV loop into ``out_dir``."""
+ trace = load_sim_trace(trace_path)
+ symbols = load_symbols(symbols_path)
+ report = build_debug_report(trace, symbols)
+ os.makedirs(out_dir, exist_ok=True)
+ report_json = os.path.join(out_dir, "gmmu-debug-report.json")
+ report_md = os.path.join(out_dir, "gmmu-debug-report.md")
+ hits_tsv = os.path.join(out_dir, "greeneye_hits.tsv")
+ with open(report_json, "w", encoding="utf-8") as fh:
+ json.dump(report, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ write_hits_tsv(report, hits_tsv)
+ ghidra_command = render_ghidra_command(
+ "<ghidra-project-dir>", "<project>", "<program>", os.path.abspath(hits_tsv)
+ )
+ with open(report_md, "w", encoding="utf-8") as fh:
+ fh.write(render_debug_markdown(report, os.path.abspath(hits_tsv), ghidra_command))
+ report["artifacts"] = {
+ "report_json": os.path.abspath(report_json),
+ "report_md": os.path.abspath(report_md),
+ "hits_tsv": os.path.abspath(hits_tsv),
+ "ghidra_command": ghidra_command,
+ }
+ return report
+
+
+DEMO_FUNCTIONS = (
+ (0x4001000, "gmmu_map_alloc", 0x120),
+ (0x4002000, "fbif_setup", 0x80),
+ (0x4003000, "gmmu_pte_write", 0x60),
+)
+
+
+def _fixture_events() -> list[dict]:
+ raw = [
+ (0x4002010, "write", "falcon", 0x600, "FBIF_TRANSCFG0", 4, 0x00000000),
+ (0x4002020, "write", "falcon", 0x604, "FBIF_TRANSCFG1", 4, 0x00000001),
+ (0x4002030, "write", "falcon", 0x608, "FBIF_TRANSCFG2", 4, 0x00000002),
+ (0x4002040, "write", "falcon", 0x624, "FBIF_CTL", 4, 0x00000001),
+ (0x4002050, "read", "falcon", 0x008, "IRQSTAT", 4, 0x00000000),
+ (0x4001010, "write", "gmmu", 0x0000, "GMMU_PDB_LO", 4, 0x00400000),
+ (0x4001020, "write", "gmmu", 0x0004, "GMMU_PDB_HI", 4, 0x00000000),
+ (0x4001030, "write", "gmmu", 0x0010, "GMMU_MODE", 4, 0x00000007),
+ (0x4001040, "write", "gmmu", 0x0010, "GMMU_MODE", 4, 0x0000000F),
+ (0x4001050, "read", "gmmu", 0x0018, "GMMU_STATUS", 4, 0x00000000),
+ (0x4003010, "write", "gmmu", 0x0200, "PTE_DATA", 8, 0x0000000000040001),
+ (0x4003020, "write", "gmmu", 0x0208, "PTE_DATA", 8, 0x0000000000040001),
+ (0x4004010, "write", "falcon", 0x110, "DMATRFBASE", 4, 0x00000000),
+ ]
+ return [
+ {
+ "seq": seq,
+ "vt": 100 + seq,
+ "pc": pc,
+ "kind": kind,
+ "window": window,
+ "offset": offset,
+ "name": name,
+ "size": size,
+ "value": value,
+ "handled": True,
+ }
+ for seq, (pc, kind, window, offset, name, size, value) in enumerate(raw)
+ ]
+
+
+def build_fixture(out_dir: str) -> dict:
+ """Write the synthetic demo fixture (symbols + trace); return the paths.
+
+ Symbols carry sizes so a pc past every function stays unattributed
+ (``sub_<pc>``) instead of being absorbed by the nearest-preceding name.
+ """
+ os.makedirs(out_dir, exist_ok=True)
+ symbols_path = os.path.join(out_dir, "symbols.json")
+ symbols_payload = {
+ "format": "greeneye-symbols/1",
+ "count": len(DEMO_FUNCTIONS),
+ "symbols": [
+ {"addr": addr, "name": name, "size": size, "source": "fixture"}
+ for addr, name, size in DEMO_FUNCTIONS
+ ],
+ }
+ with open(symbols_path, "w", encoding="utf-8") as fh:
+ json.dump(symbols_payload, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ trace_path = os.path.join(out_dir, "trace.json")
+ payload = {
+ "format": TRACE_FORMAT,
+ "source": {
+ "kind": "synthetic-fixture",
+ "note": "invented values; proves the join shape only, never a hardware claim",
+ },
+ "events": _fixture_events(),
+ }
+ with open(trace_path, "w", encoding="utf-8") as fh:
+ json.dump(payload, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ return {
+ "symbols": os.path.abspath(symbols_path),
+ "trace": os.path.abspath(trace_path),
+ "events": len(payload["events"]),
+ "functions": len(DEMO_FUNCTIONS),
+ }
+
+
+def run_demo(out_dir: str) -> dict:
+ """Build the fixture, run the loop, and check the frozen threshold."""
+ fixture = build_fixture(out_dir)
+ start = time.perf_counter()
+ report = run_report(fixture["trace"], fixture["symbols"], out_dir)
+ elapsed = time.perf_counter() - start
+ threshold = MANUAL_STATIC_BASELINE_SECONDS * WORKFLOW_THRESHOLD_FRACTION
+ result = {
+ "format": DEMO_RESULT_FORMAT,
+ "scenario": "trace -> per-function register report -> greeneye_hits.tsv -> Ghidra-ready",
+ "fixture": fixture,
+ "summary": report["summary"],
+ "measured_seconds": round(elapsed, 4),
+ "manual_baseline_seconds": MANUAL_STATIC_BASELINE_SECONDS,
+ "manual_baseline_label": MANUAL_BASELINE_LABEL,
+ "threshold_fraction": WORKFLOW_THRESHOLD_FRACTION,
+ "threshold_seconds": round(threshold, 4),
+ "pass": elapsed <= threshold,
+ "artifacts": report["artifacts"],
+ "limits": report["limits"],
+ }
+ with open(os.path.join(out_dir, "demo-result.json"), "w", encoding="utf-8") as fh:
+ json.dump(result, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ print("RESULT_JSON: " + json.dumps(result, sort_keys=True))
+ return result
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="gmmu_debug.py",
+ description="GMMU/FBIF trace -> per-function register-write report -> Ghidra hits TSV.",
+ )
+ sub = parser.add_subparsers(dest="command")
+ report = sub.add_parser("report", help="build the report from a trace and symbol map")
+ report.add_argument("--trace", required=True, help="trace file or session directory")
+ report.add_argument("--symbols", default=None, help="symbol DB/map (JSON or TSV)")
+ report.add_argument("--out", required=True, help="output directory")
+ demo = sub.add_parser("demo", help="run the synthetic end-to-end fixture")
+ demo.add_argument(
+ "--out",
+ default=os.path.join(TOOL_ROOT, "harness", "gmmu_debug_fixture"),
+ help="fixture/report directory (gitignored)",
+ )
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ if args.command == "report":
+ try:
+ report = run_report(args.trace, args.symbols, args.out)
+ except GmmuInputError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+ print(
+ json.dumps(
+ {
+ "format": report["format"],
+ "summary": report["summary"],
+ "artifacts": report["artifacts"],
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ )
+ return 0
+ if args.command == "demo":
+ result = run_demo(args.out)
+ return 0 if result["pass"] else 1
+ parser.print_help()
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py b/debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py
new file mode 100644
index 0000000..c504550
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/harness/gmmu_oracle.py
@@ -0,0 +1,585 @@
+#!/usr/bin/env python3
+"""Host-accuracy oracle comparison for GMMU/FBIF register observations.
+
+Ingests two files and compares them semantically:
+
+* an **oracle capture** (schema ``greeneye-gmmu-oracle/1``): a list of
+ ``{name, window, offset, value, source, captured_at}`` items produced by a
+ human from read-only RM queries or supervised captures;
+* a **simulator observation** (G1's ``greeneye-gmmu-fbif-trace/1``, or a
+ session directory containing one), ingested by
+ :func:`harness.gmmu_debug.load_sim_trace`.
+
+The comparison reports per-register value agreement, coverage (oracle items
+with no simulator observation), and simulator accesses with no oracle
+counterpart, with an explicit fidelity label per row (observed vs modelled
+vs unknown). It emits ``gmmu-oracle-report.json`` and ``.md``.
+
+``--print-capture-hints`` prints (and only prints) the supervised, read-only
+commands a human would run to produce an oracle. Physical work is
+supervised-only per RUNBOOK P1/P7; an agent must never run those commands.
+
+No cycle/timing claim is made anywhere. This tool only reads files and
+writes reports; it never touches a GPU, module, mdev, guest or network.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from dataclasses import dataclass, field
+from typing import Any
+
+TOOL_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+if TOOL_ROOT not in sys.path:
+ sys.path.insert(0, TOOL_ROOT)
+
+from harness.gmmu_debug import ( # noqa: E402
+ GmmuInputError,
+ SimTrace,
+ TraceObservation,
+ load_sim_trace,
+ parse_int,
+ sha256_file,
+)
+
+#: Schema tag of the human-produced oracle capture.
+ORACLE_FORMAT = "greeneye-gmmu-oracle/1"
+#: Schema tag of the report this module emits.
+REPORT_FORMAT = "greeneye-gmmu-oracle-report/1"
+
+CAPTURE_HINTS = """\
+================================================================================
+GMMU/FBIF ORACLE CAPTURE HINTS — SUPERVISED, READ-ONLY, HUMAN-RUN ONLY
+================================================================================
+This command prints instructions; it executes NOTHING.
+
+HARD RULES (RUNBOOK P1/P7, SIMULATOR-WORK-BOUNDARY.txt):
+ * Physical work is supervised-only: a named human present, one cycle at a
+ time. An agent MUST NOT run any command below.
+ * Read-only means exactly that: no module load/unload, no mdev create or
+ claim, no bind/unbind, no guest access, no /sys/fs/pstore, no reboot.
+ * The RM tools used here allocate only an RM client/device/subdevice handle
+ pair and issue allowlisted read-only controls (docs/17-RM-HARNESS.md §6).
+ * A simulator observation is not silicon truth; only a supervised capture
+ is. Product changes still need physical reproduction.
+
+STEP 1 — read-only RM inventory (record the session, human-run):
+ cd open-iov/debug/risc-v-gsp-rm-vm
+ ./rmharness/harness.sh --session dumps/real-rm-before --name gmmu-oracle \\
+ -- rmtool gpu list --format=json
+ ./rmharness/harness.sh --session dumps/real-rm-before --name gmmu-oracle \\
+ -- rmtool engine list --format=json
+ ./rmharness/harness.sh --session dumps/real-rm-before --name gmmu-oracle \\
+ -- rmtool mem stats --format=json
+ ./rmharness/harness.sh --session dumps/real-rm-before --name gmmu-oracle \\
+ -- rmtop --once
+ # allowlisted read-only control values (never a write control):
+ ./rmharness/rmtool.sh nv2080 method call --help
+
+STEP 2 — sysfs/proc/debugfs readbacks (read-only `cat`):
+ cat /proc/driver/nvidia/gpus/*/information
+ lspci -vv -s <bdf> # config space readback
+ cat /sys/bus/pci/devices/<bdf>/resource # BAR ranges/sizes
+ # vgsp debugfs (host module; read-only reads):
+ ls /sys/kernel/debug/vgsp/
+ cat /sys/kernel/debug/vgsp/gsp-phys-* # name the exact file per readback
+
+STEP 3 — optional supervised first-touch capture:
+ A simulator observation is produced without hardware (safe, agent-allowed):
+ python3 -m sim.gsp_boot --instructions 2000000 --record dumps/gmmu-sim
+ A physical first-touch capture is a supervised human step and must not be
+ scripted by an agent.
+
+STEP 4 — write the oracle file (human, from the readbacks above):
+ {
+ "format": "greeneye-gmmu-oracle/1",
+ "captures": [
+ {"name": "FBIF_CTL", "window": "falcon", "offset": "0x624",
+ "value": "0x1", "source": "rm-readback", "captured_at": "<date>"},
+ {"name": "BOOT_42", "window": "pmc", "offset": "0xa00",
+ "value": "0x164", "source": "sysfs-readback", "captured_at": "<date>"}
+ ]
+ }
+ Then compare (safe, offline):
+ python3 harness/gmmu_oracle.py compare \\
+ --oracle oracle.json --sim dumps/gmmu-sim --out dumps/gmmu-oracle-report
+================================================================================
+"""
+
+
+@dataclass
+class OracleItem:
+ index: int
+ name: str
+ window: str
+ offset: int | None
+ addr: int | None
+ value: int | None
+ source: str
+ captured_at: str
+ raw: dict = field(default_factory=dict)
+
+
+@dataclass
+class OracleCapture:
+ path: str
+ format: str
+ sha256: str
+ items: list[OracleItem] = field(default_factory=list)
+
+
+def load_oracle(path: str) -> OracleCapture:
+ """Load a ``greeneye-gmmu-oracle/1`` capture (object wrapper or bare list).
+
+ A missing ``format`` is tolerated and reported as ``unlabeled-list`` /
+ ``unlabeled-object``; a different explicit format is a clean error.
+ """
+ if not os.path.isfile(path):
+ raise GmmuInputError(f"oracle not found: {path}")
+ digest = sha256_file(path)
+ try:
+ with open(path, "r", encoding="utf-8", errors="replace") as fh:
+ payload = json.load(fh)
+ except (OSError, json.JSONDecodeError) as exc:
+ raise GmmuInputError(f"cannot read oracle {path}: {exc}") from exc
+ if isinstance(payload, list):
+ fmt = "unlabeled-list"
+ items_payload: list = payload
+ elif isinstance(payload, dict):
+ fmt = str(payload.get("format") or payload.get("schema") or "unlabeled-object")
+ if fmt not in (ORACLE_FORMAT, "unlabeled-object"):
+ raise GmmuInputError(
+ f"{path}: format {fmt!r} is not {ORACLE_FORMAT!r}"
+ )
+ found = None
+ for key in ("captures", "items", "registers", "observations", "oracle"):
+ candidate = payload.get(key)
+ if isinstance(candidate, list):
+ found = candidate
+ break
+ if found is None:
+ raise GmmuInputError(f"{path}: no capture list (expected 'captures')")
+ items_payload = found
+ else:
+ raise GmmuInputError(f"{path}: oracle must be an object or list")
+ items: list[OracleItem] = []
+ for index, raw in enumerate(items_payload):
+ if not isinstance(raw, dict):
+ continue
+ name = str(raw.get("name") or raw.get("register") or "")
+ window = str(raw.get("window") or raw.get("block") or "")
+ offset = parse_int(raw.get("offset", raw.get("reg_offset")))
+ addr = parse_int(raw.get("addr", raw.get("address")))
+ value = parse_int(raw.get("value"))
+ if not name:
+ if offset is not None:
+ name = f"0x{offset:x}"
+ elif addr is not None:
+ name = f"0x{addr:x}"
+ else:
+ name = f"item_{index}"
+ items.append(
+ OracleItem(
+ index=index,
+ name=name,
+ window=window,
+ offset=offset,
+ addr=addr,
+ value=value,
+ source=str(raw.get("source") or "unlabelled"),
+ captured_at=str(raw.get("captured_at") or ""),
+ raw=raw,
+ )
+ )
+ return OracleCapture(
+ path=os.path.abspath(path), format=fmt, sha256=digest, items=items
+ )
+
+
+def _obs_keys(obs: TraceObservation) -> list[tuple]:
+ keys: list[tuple] = []
+ if obs.offset is not None:
+ if obs.window:
+ keys.append(("window-offset", obs.window, obs.offset))
+ keys.append(("offset", obs.offset))
+ if obs.name and obs.window:
+ keys.append(("window-name", obs.window, obs.name))
+ if obs.name:
+ keys.append(("name", obs.name))
+ if obs.addr is not None:
+ keys.append(("addr", obs.addr))
+ return keys
+
+
+def _item_keys(item: OracleItem) -> list[tuple]:
+ keys: list[tuple] = []
+ if item.offset is not None:
+ if item.window:
+ keys.append(("window-offset", item.window, item.offset))
+ keys.append(("offset", item.offset))
+ if item.name and item.window:
+ keys.append(("window-name", item.window, item.name))
+ if item.name:
+ keys.append(("name", item.name))
+ if item.addr is not None:
+ keys.append(("addr", item.addr))
+ return keys
+
+
+def _group_observations(observations: list[TraceObservation]) -> tuple[list[dict], dict]:
+ """Cluster trace observations into registers; return (groups, key index)."""
+ groups: list[dict] = []
+ index: dict[tuple, int] = {}
+ for obs in observations:
+ keys = _obs_keys(obs)
+ gidx = None
+ for key in keys:
+ if key in index:
+ gidx = index[key]
+ break
+ if gidx is None:
+ gidx = len(groups)
+ groups.append({"observations": []})
+ groups[gidx]["observations"].append(obs)
+ for key in keys:
+ index.setdefault(key, gidx)
+ return groups, index
+
+
+def _group_descriptor(group: dict) -> dict:
+ observations: list[TraceObservation] = group["observations"]
+ first = observations[0]
+ last = observations[-1]
+ writes = [o for o in observations if o.kind == "write"]
+ reads = [o for o in observations if o.kind == "read"]
+ value_obs = writes[-1] if writes else (reads[-1] if reads else last)
+ out: dict[str, Any] = {
+ "register": first.name or (
+ f"0x{first.offset:x}" if first.offset is not None
+ else (f"0x{first.addr:x}" if first.addr is not None else "?")
+ ),
+ "observations": len(observations),
+ "writes": len(writes),
+ "reads": len(reads),
+ }
+ if first.window:
+ out["window"] = first.window
+ if first.offset is not None:
+ out["offset"] = first.offset
+ if first.addr is not None:
+ out["addr"] = first.addr
+ if value_obs.value is not None:
+ out["last_value"] = value_obs.value
+ out["fidelity"] = "modelled (no oracle counterpart)"
+ else:
+ out["fidelity"] = "modelled (value unknown; no oracle counterpart)"
+ pcs = [o.pc for o in observations if o.pc is not None]
+ if pcs:
+ out["first_pc"] = pcs[0]
+ out["last_pc"] = pcs[-1]
+ return out
+
+
+def compare(oracle: OracleCapture, trace: SimTrace) -> dict:
+ """Semantic oracle-vs-simulator comparison; no timing claims."""
+ groups, index = _group_observations(trace.observations)
+ matched_groups: set[int] = set()
+ rows: list[dict] = []
+ unmatched_oracle: list[dict] = []
+ for item in oracle.items:
+ gidx = None
+ matched_by = ""
+ for key in _item_keys(item):
+ if key in index:
+ gidx = index[key]
+ matched_by = key[0]
+ break
+ if gidx is None:
+ unmatched_oracle.append(
+ {
+ "register": item.name,
+ "window": item.window,
+ "offset": item.offset,
+ "oracle_value": item.value,
+ "source": item.source,
+ "captured_at": item.captured_at,
+ "fidelity": "observed (no simulator counterpart)",
+ }
+ )
+ rows.append(
+ {
+ "register": item.name,
+ "window": item.window,
+ "offset": item.offset,
+ "oracle_value": item.value,
+ "sim_value": None,
+ "status": "oracle-only",
+ "matched_by": "",
+ "fidelity": "observed (no simulator counterpart)",
+ "source": item.source,
+ "captured_at": item.captured_at,
+ }
+ )
+ continue
+ matched_groups.add(gidx)
+ group = groups[gidx]
+ observations: list[TraceObservation] = group["observations"]
+ writes = [o for o in observations if o.kind == "write"]
+ reads = [o for o in observations if o.kind == "read"]
+ value_obs = writes[-1] if writes else (reads[-1] if reads else observations[-1])
+ sim_value = value_obs.value
+ if item.value is None or sim_value is None:
+ status = "no-value"
+ fidelity = "observed vs modelled (value unknown)"
+ elif sim_value == item.value:
+ status = "agree"
+ fidelity = "observed vs modelled"
+ else:
+ status = "mismatch"
+ fidelity = "observed vs modelled"
+ value_ever = any(o.value == item.value for o in observations if o.value is not None)
+ pcs = [o.pc for o in observations if o.pc is not None]
+ row = {
+ "register": item.name,
+ "window": item.window,
+ "offset": item.offset,
+ "oracle_value": item.value,
+ "sim_value": sim_value,
+ "status": status,
+ "matched_by": matched_by,
+ "fidelity": fidelity,
+ "source": item.source,
+ "captured_at": item.captured_at,
+ "sim_observations": len(observations),
+ "sim_writes": len(writes),
+ "sim_reads": len(reads),
+ "sim_value_ever_agreed": value_ever,
+ "sim_first_pc": pcs[0] if pcs else None,
+ "sim_last_pc": pcs[-1] if pcs else None,
+ }
+ rows.append(row)
+
+ unmatched_simulator = [
+ _group_descriptor(group)
+ for gidx, group in enumerate(groups)
+ if gidx not in matched_groups
+ ]
+
+ agreed = sum(1 for row in rows if row["status"] == "agree")
+ mismatched = sum(1 for row in rows if row["status"] == "mismatch")
+ no_value = sum(1 for row in rows if row["status"] == "no-value")
+ compared = agreed + mismatched + no_value
+ oracle_items = len(oracle.items)
+ denominator = agreed + mismatched
+ agreement_pct = round(100.0 * agreed / denominator, 2) if denominator else None
+ coverage_pct = round(100.0 * compared / oracle_items, 2) if oracle_items else None
+ sim_registers = len(groups)
+ simulator_match_pct = (
+ round(100.0 * len(matched_groups) / sim_registers, 2) if sim_registers else None
+ )
+ return {
+ "format": REPORT_FORMAT,
+ "inputs": {
+ "oracle": {
+ "path": oracle.path,
+ "format": oracle.format,
+ "sha256": oracle.sha256,
+ "items": oracle_items,
+ },
+ "simulator": {
+ "path": trace.path,
+ "format": trace.format,
+ "sha256": trace.sha256,
+ "observations": len(trace.observations),
+ "registers": sim_registers,
+ "source": trace.source,
+ },
+ },
+ "summary": {
+ "oracle_items": oracle_items,
+ "simulator_registers": sim_registers,
+ "simulator_observations": len(trace.observations),
+ "compared": compared,
+ "agreed": agreed,
+ "mismatched": mismatched,
+ "no_value": no_value,
+ "oracle_only": len(unmatched_oracle),
+ "simulator_only": len(unmatched_simulator),
+ "agreement_pct": agreement_pct,
+ "coverage_pct": coverage_pct,
+ "simulator_match_pct": simulator_match_pct,
+ "claim": (
+ "per-register value agreement on the oracle/simulator intersection; "
+ "not a timing, silicon or full-coverage claim"
+ ),
+ },
+ "rows": rows,
+ "unmatched_oracle": unmatched_oracle,
+ "unmatched_simulator": unmatched_simulator,
+ "fidelity": {
+ "oracle": "observed (human read-only capture; supervised for physical work)",
+ "simulator": "modelled (device model / recorded trace)",
+ "levels": "L0/L1 only; L2 timing and L3 device environment not claimed (docs/06)",
+ },
+ "limits": [
+ "No live oracle capture was taken by this tool; it only ingests a file a human produced.",
+ "Agreement is per-register on the matched intersection, never a global accuracy percentage.",
+ "An oracle-only register means the simulator did not touch it under this stimulus; it is not proof of a device-model gap.",
+ "A simulator-only register means no oracle counterpart exists yet; it is not evidence of a wrong value.",
+ "Values are point observations, not timing or ordering claims.",
+ ],
+ }
+
+
+def render_oracle_markdown(report: dict) -> str:
+ summary = report["summary"]
+ lines = [
+ "# GMMU/FBIF oracle vs simulator report",
+ "",
+ f"> Format `{report['format']}`. Fidelity: {report['fidelity']['levels']}.",
+ "> No cycle/timing claim. Underclaim every number (docs/06, docs/44).",
+ "",
+ "## Inputs",
+ "",
+ f"* oracle: `{report['inputs']['oracle']['path']}` "
+ f"(format `{report['inputs']['oracle']['format']}`, "
+ f"{report['inputs']['oracle']['items']} items, "
+ f"sha256 `{report['inputs']['oracle']['sha256'][:16]}…`)",
+ f"* simulator: `{report['inputs']['simulator']['path']}` "
+ f"(format `{report['inputs']['simulator']['format']}`, "
+ f"{report['inputs']['simulator']['registers']} registers / "
+ f"{report['inputs']['simulator']['observations']} observations, "
+ f"sha256 `{report['inputs']['simulator']['sha256'][:16]}…`)",
+ "",
+ "## Summary",
+ "",
+ "| oracle items | compared | agreed | mismatched | no-value | oracle-only | sim-only | agreement % | coverage % |",
+ "|---|---|---|---|---|---|---|---|---|",
+ f"| {summary['oracle_items']} | {summary['compared']} | {summary['agreed']} | "
+ f"{summary['mismatched']} | {summary['no_value']} | {summary['oracle_only']} | "
+ f"{summary['simulator_only']} | "
+ f"{summary['agreement_pct'] if summary['agreement_pct'] is not None else 'n/a'} | "
+ f"{summary['coverage_pct'] if summary['coverage_pct'] is not None else 'n/a'} |",
+ "",
+ f"* {summary['claim']}",
+ "",
+ "## Per-register rows",
+ "",
+ "| register | window | offset | oracle | simulator | status | fidelity | matched by |",
+ "|---|---|---|---|---|---|---|---|",
+ ]
+ for row in report["rows"]:
+ offset = f"{row['offset']:#x}" if row.get("offset") is not None else ""
+ oracle_value = f"{row['oracle_value']:#x}" if row.get("oracle_value") is not None else "n/a"
+ sim_value = f"{row['sim_value']:#x}" if row.get("sim_value") is not None else "n/a"
+ lines.append(
+ f"| `{row['register']}` | {row.get('window', '')} | {offset} | {oracle_value} | "
+ f"{sim_value} | {row['status']} | {row['fidelity']} | {row.get('matched_by', '')} |"
+ )
+ lines += ["", "## Oracle items with no simulator observation", ""]
+ if report["unmatched_oracle"]:
+ for item in report["unmatched_oracle"]:
+ offset = f"{item['offset']:#x}" if item.get("offset") is not None else "?"
+ value = f"{item['oracle_value']:#x}" if item.get("oracle_value") is not None else "n/a"
+ lines.append(
+ f"* `{item['register']}` ({item.get('window', '')} @ {offset}) = {value} "
+ f"[{item['source']}]"
+ )
+ else:
+ lines.append("* none")
+ lines += ["", "## Simulator registers with no oracle counterpart", ""]
+ if report["unmatched_simulator"]:
+ for item in report["unmatched_simulator"]:
+ offset = f"{item['offset']:#x}" if item.get("offset") is not None else "?"
+ value = f"{item['last_value']:#x}" if item.get("last_value") is not None else "n/a"
+ lines.append(
+ f"* `{item['register']}` ({item.get('window', '')} @ {offset}) last={value} "
+ f"[{item['writes']}w/{item['reads']}r]"
+ )
+ else:
+ lines.append("* none")
+ lines += ["", "## Limits", ""]
+ for limit in report["limits"]:
+ lines.append(f"* {limit}")
+ lines.append("")
+ return "\n".join(lines)
+
+
+def run_compare(oracle_path: str, sim_path: str, out_dir: str) -> dict:
+ """Compare an oracle capture with a simulator trace; write JSON + MD."""
+ oracle = load_oracle(oracle_path)
+ trace = load_sim_trace(sim_path)
+ report = compare(oracle, trace)
+ os.makedirs(out_dir, exist_ok=True)
+ report_json = os.path.join(out_dir, "gmmu-oracle-report.json")
+ report_md = os.path.join(out_dir, "gmmu-oracle-report.md")
+ with open(report_json, "w", encoding="utf-8") as fh:
+ json.dump(report, fh, indent=2, sort_keys=True)
+ fh.write("\n")
+ with open(report_md, "w", encoding="utf-8") as fh:
+ fh.write(render_oracle_markdown(report))
+ report["artifacts"] = {
+ "report_json": os.path.abspath(report_json),
+ "report_md": os.path.abspath(report_md),
+ }
+ return report
+
+
+def print_capture_hints(out=None) -> None:
+ """Print the supervised, read-only capture instructions; execute nothing."""
+ print(CAPTURE_HINTS, file=out or sys.stdout)
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="gmmu_oracle.py",
+ description=(
+ "Compare a human oracle capture with a simulator GMMU/FBIF trace. "
+ "Offline only; never executes physical commands."
+ ),
+ )
+ parser.add_argument(
+ "--print-capture-hints",
+ action="store_true",
+ help="print the supervised read-only commands a human would run (executes nothing)",
+ )
+ sub = parser.add_subparsers(dest="command")
+ compare_cmd = sub.add_parser("compare", help="compare oracle vs simulator observation")
+ compare_cmd.add_argument("--oracle", required=True, help="greeneye-gmmu-oracle/1 file")
+ compare_cmd.add_argument("--sim", required=True, help="trace file or session directory")
+ compare_cmd.add_argument("--out", required=True, help="output directory")
+ return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+ if args.print_capture_hints:
+ print_capture_hints()
+ return 0
+ if args.command == "compare":
+ try:
+ report = run_compare(args.oracle, args.sim, args.out)
+ except GmmuInputError as exc:
+ print(f"error: {exc}", file=sys.stderr)
+ return 2
+ print(
+ json.dumps(
+ {
+ "format": report["format"],
+ "summary": report["summary"],
+ "artifacts": report["artifacts"],
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ )
+ return 0
+ parser.print_help()
+ return 2
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json b/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json
index 4df3593..f5ccc4e 100644
--- a/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json
+++ b/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.json
@@ -6,7 +6,7 @@
"detail": {
"analyze_chars": 444,
"analyze_session": "harness/qemu_line/sessions/native-1",
- "compare_chars": 526,
+ "compare_chars": 394,
"compare_headers": [
"Breakpoint profile delta (A vs B)",
"Watchpoint delta",
@@ -43,9 +43,9 @@
"dir": "harness/qemu_line/sessions/features-inject",
"expected": 48814,
"hashes": {
- "events.jsonl": "08ab0874cfad71e0e4565b569d9b0ea5251bf158eaab3cdf17798182e4c66a95",
- "session.json": "f34b677be0b0b453cdade4487118e059c3dfb5a1010d4e6c2fc8b37a867fb25e",
- "summary.md": "59a1aa4f2b616d5266d565c4df4b0257d11aab95b09cec80f380e9aca8bfc22f"
+ "events.jsonl": "ce9c81fb9663813c5eefbacfb94af69dc4050ef563fd62e302869942ae207be1",
+ "session.json": "bb203d2e551f906cc24500c4b3f2d8263e1218267ce6aa809d99dd9c964caae2",
+ "summary.md": "a6a087528c23227628faa0e3ad3a646b66bf06c06489d26fda4e82091a7bb2ac"
},
"ping_result": 48814,
"slot": 6
@@ -79,9 +79,9 @@
"watch_hit": 1
},
"hashes": {
- "events.jsonl": "a6328cb00287bdf678d9b62d496929fc59190a08af3910b059c840c6bc98462e",
- "session.json": "2372eea627f31d11f114019759d8d6dc665683bbc88e834f158f56b16fa5c0d4",
- "summary.md": "6dfca9e61a4f23374b90dbef995d86771130fcf4c2b61dc40aac6d379d4b8706"
+ "events.jsonl": "4ed32100308c465d999c78b71a232ab096e3cfcbb82561f46c5046eb34a0f462",
+ "session.json": "6c8c45b9b4be70b934c3a43b4bd00df4a1e875baafe7c8988b13d5a18dbc4813",
+ "summary.md": "9e7af234005461bd8b4395498fa3b58a3da5e589184c51e34b399f8cba37d50b"
}
},
"status": "pass"
@@ -99,8 +99,8 @@
"dir": "harness/qemu_line/sessions/features-trace",
"events": 4001,
"hashes": {
- "events.jsonl": "36b80ed88205daa8e1bd58536eb274ed4849c3d4b1a05daa0f2c44382c7b764a",
- "session.json": "a09ec38202c291a5b7a3719434ea785725e291cba2dc877d94cd0caf7fcd1a4e",
+ "events.jsonl": "649ddd072446b2184427b6ea18a7e6bad6c118a0aa63c77bfff591cf9165114d",
+ "session.json": "c6abc0bbae7539b0c5d6685e700a6105c55d64821f42451b0f8ea386e0568020",
"summary.md": "10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44"
},
"max_events": 4000
@@ -129,7 +129,7 @@
"native": {
"backend": "native",
"compare": {
- "chars": 526,
+ "chars": 394,
"headers": [
"Breakpoint profile delta (A vs B)",
"Watchpoint delta",
@@ -139,70 +139,70 @@
},
"device_model_used": true,
"identical": true,
- "instructions_budget": 200000,
- "pair_wall_s": 0.3093,
+ "instructions_budget": 2000000,
+ "pair_wall_s": 1.2722,
"profile": "sim/profiles/tu104-570.144.json",
"ran": true,
"reason": "ok",
"repeat": 2,
- "rung": "B2",
+ "rung": "B3",
"rung_basis": "observed S1 boot-ladder rung from sim.gsp_boot (milestones=['bootargs_consumer', 'relocation_copy'], mmio_unknown=0, device_unhandled=0)",
"runs": [
{
"cycle_model": "rv64imac-modeled/1",
- "cycles": 27294,
- "device_accesses": 1,
- "device_state_sha256": "3fa264b9f81081e8455a9cd26c0c2a721e3ac272e1079105a0eb191b1497f83d",
+ "cycles": 871904,
+ "device_accesses": 15,
+ "device_state_sha256": "5b3cc7e4af933e01057cc6ab7d858ecbb11f76f2d10f3f8963a7fc974e239323",
"device_unhandled": 0,
"first_unknown_mmio": null,
- "instructions": 14682,
+ "instructions": 474043,
"milestones": [
"bootargs_consumer",
"relocation_copy"
],
"mmio_unknown": 0,
- "rung": "B2",
+ "rung": "B3",
"rung_basis": "observed S1 boot-ladder rung from sim.gsp_boot (milestones=['bootargs_consumer', 'relocation_copy'], mmio_unknown=0, device_unhandled=0)",
"session": "harness/qemu_line/sessions/native-1",
"session_hashes": {
- "events.jsonl": "f1f336af84b750b0c8bf8f8e9a7a3952161eb884418f5580f08cb3e26a070713",
- "session.json": "c756f8078812aa1ed03628e097ee517320d7de3664089f1133861c96130d5b5b",
- "summary.md": "c0aa5d5b2cb306fda703a7144af455c36fe72b9d1e2c73ddcdab905d36cdc8a7"
+ "events.jsonl": "0501ee6c127c8acfeb06c6b4c36aecacd791e872e05bf341883df22054d34315",
+ "session.json": "811b076cc5d4555062ce17474f302e64d6cb29e7af9e5c05cb7d7f67825bd315",
+ "summary.md": "9ad8c39a95078b9aca1cf1ca0b83a1f7ce22b0dd33e679aaa5f8eb5068a97b75"
},
"stop": {
- "pc": 67168780,
- "pc_fw": 67168780,
- "reason": "fault"
+ "pc": 67207224,
+ "pc_fw": 67207224,
+ "reason": "ecall"
},
- "wall_s": 0.1561
+ "wall_s": 0.6548
},
{
"cycle_model": "rv64imac-modeled/1",
- "cycles": 27294,
- "device_accesses": 1,
- "device_state_sha256": "3fa264b9f81081e8455a9cd26c0c2a721e3ac272e1079105a0eb191b1497f83d",
+ "cycles": 871904,
+ "device_accesses": 15,
+ "device_state_sha256": "5b3cc7e4af933e01057cc6ab7d858ecbb11f76f2d10f3f8963a7fc974e239323",
"device_unhandled": 0,
"first_unknown_mmio": null,
- "instructions": 14682,
+ "instructions": 474043,
"milestones": [
"bootargs_consumer",
"relocation_copy"
],
"mmio_unknown": 0,
- "rung": "B2",
+ "rung": "B3",
"rung_basis": "observed S1 boot-ladder rung from sim.gsp_boot (milestones=['bootargs_consumer', 'relocation_copy'], mmio_unknown=0, device_unhandled=0)",
"session": "harness/qemu_line/sessions/native-2",
"session_hashes": {
- "events.jsonl": "d0d15816dbde8c85c30ba2576f97c9d5e3f470e6c38d9b4762d266a612cafe12",
- "session.json": "8c7f0ea958a930edb814690bc0b233be01fcab3f747332abff39989e66642c2e",
- "summary.md": "d2d6154ec22f4ee0d942543a32c29699ccb8031188df955c415d836462d04fd3"
+ "events.jsonl": "77b6fb63a1b0be8d2f5eef9e786795849c1ad74acab96cc8cfb7a37790bd804a",
+ "session.json": "626646f2bc8f8200a339d2f93a23297eaaf39160fb41de9a97867de7ad6dc131",
+ "summary.md": "1ad09477c1ba2611db4a6ff9e47a38441395fe3b198b407b659a8a8e4b28ab95"
},
"stop": {
- "pc": 67168780,
- "pc_fw": 67168780,
- "reason": "fault"
+ "pc": 67207224,
+ "pc_fw": 67207224,
+ "reason": "ecall"
},
- "wall_s": 0.1524
+ "wall_s": 0.6167
}
]
},
@@ -235,12 +235,12 @@
"rung": "pre-B0",
"session": "harness/qemu_line/sessions/qemu-probe",
"session_hashes": {
- "events.jsonl": "cdba69c63e306f4c3de220b171e2ea7c1de4bd4abf18cdba1fb51bc681d5d172",
- "session.json": "7e3f0f8b24c0403d114e59f68ed1b923e44abbfbf19f4865bd566a1b631c64ac",
+ "events.jsonl": "0f59d5d26b51be185e1a38b4540dee06303f8a2c0661519d9e1a32d2e6b10062",
+ "session.json": "f3d1eaf6dab15c79b0f4c4019f431a8032dc2b014d3acb17f63407326eca5e26",
"summary.md": "d141fa06d3e2d0603f3b99ad7603758e1fc14627aed74c6fe8a6ad22c56eb14b"
},
"stopped_pc": 0,
- "wall_s": 0.9519
+ "wall_s": 0.9578
},
"rerun_when_s1_lands": [
"python3 harness/sim_qemu_line.py",
diff --git a/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md b/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md
index 4e546b3..550f32a 100644
--- a/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md
+++ b/debug/risc-v-gsp-rm-vm/harness/qemu_line/sim-qemu-line.md
@@ -11,9 +11,9 @@
| side | ran | rung | instructions | cycles (modeled) | stop | wall s | evidence |
|---|---|---|---:|---:|---|---:|---|
-| QEMU probe | True | pre-B0 | - | - | fault | 0.9519 | `harness/qemu_line/sessions/qemu-probe` |
-| native run 1 | True | B2 | 14682 | 27294 | fault@0x400ea0c | 0.1561 | `harness/qemu_line/sessions/native-1` |
-| native run 2 | True | B2 | 14682 | 27294 | fault@0x400ea0c | 0.1524 | `harness/qemu_line/sessions/native-2` |
+| QEMU probe | True | pre-B0 | - | - | fault | 0.9578 | `harness/qemu_line/sessions/qemu-probe` |
+| native run 1 | True | B3 | 474043 | 871904 | ecall@0x4018038 | 0.6548 | `harness/qemu_line/sessions/native-1` |
+| native run 2 | True | B3 | 474043 | 871904 | ecall@0x4018038 | 0.6167 | `harness/qemu_line/sessions/native-2` |
### QEMU first fault
@@ -43,38 +43,38 @@ Thresholds (frozen 2026-10-03): native >= QEMU + 1 rung(s); 2 identical runs; pa
| session | file | sha256 |
|---|---|---|
-| qemu-probe | events.jsonl | `cdba69c63e306f4c3de220b171e2ea7c1de4bd4abf18cdba1fb51bc681d5d172` |
-| qemu-probe | session.json | `7e3f0f8b24c0403d114e59f68ed1b923e44abbfbf19f4865bd566a1b631c64ac` |
+| qemu-probe | events.jsonl | `0f59d5d26b51be185e1a38b4540dee06303f8a2c0661519d9e1a32d2e6b10062` |
+| qemu-probe | session.json | `f3d1eaf6dab15c79b0f4c4019f431a8032dc2b014d3acb17f63407326eca5e26` |
| qemu-probe | summary.md | `d141fa06d3e2d0603f3b99ad7603758e1fc14627aed74c6fe8a6ad22c56eb14b` |
-| native-1 | events.jsonl | `f1f336af84b750b0c8bf8f8e9a7a3952161eb884418f5580f08cb3e26a070713` |
-| native-1 | session.json | `c756f8078812aa1ed03628e097ee517320d7de3664089f1133861c96130d5b5b` |
-| native-1 | summary.md | `c0aa5d5b2cb306fda703a7144af455c36fe72b9d1e2c73ddcdab905d36cdc8a7` |
-| native-2 | events.jsonl | `d0d15816dbde8c85c30ba2576f97c9d5e3f470e6c38d9b4762d266a612cafe12` |
-| native-2 | session.json | `8c7f0ea958a930edb814690bc0b233be01fcab3f747332abff39989e66642c2e` |
-| native-2 | summary.md | `d2d6154ec22f4ee0d942543a32c29699ccb8031188df955c415d836462d04fd3` |
-| sessions | events.jsonl | `a6328cb00287bdf678d9b62d496929fc59190a08af3910b059c840c6bc98462e` |
-| sessions | session.json | `2372eea627f31d11f114019759d8d6dc665683bbc88e834f158f56b16fa5c0d4` |
-| sessions | summary.md | `6dfca9e61a4f23374b90dbef995d86771130fcf4c2b61dc40aac6d379d4b8706` |
-| trace | events.jsonl | `36b80ed88205daa8e1bd58536eb274ed4849c3d4b1a05daa0f2c44382c7b764a` |
-| trace | session.json | `a09ec38202c291a5b7a3719434ea785725e291cba2dc877d94cd0caf7fcd1a4e` |
+| native-1 | events.jsonl | `0501ee6c127c8acfeb06c6b4c36aecacd791e872e05bf341883df22054d34315` |
+| native-1 | session.json | `811b076cc5d4555062ce17474f302e64d6cb29e7af9e5c05cb7d7f67825bd315` |
+| native-1 | summary.md | `9ad8c39a95078b9aca1cf1ca0b83a1f7ce22b0dd33e679aaa5f8eb5068a97b75` |
+| native-2 | events.jsonl | `77b6fb63a1b0be8d2f5eef9e786795849c1ad74acab96cc8cfb7a37790bd804a` |
+| native-2 | session.json | `626646f2bc8f8200a339d2f93a23297eaaf39160fb41de9a97867de7ad6dc131` |
+| native-2 | summary.md | `1ad09477c1ba2611db4a6ff9e47a38441395fe3b198b407b659a8a8e4b28ab95` |
+| sessions | events.jsonl | `4ed32100308c465d999c78b71a232ab096e3cfcbb82561f46c5046eb34a0f462` |
+| sessions | session.json | `6c8c45b9b4be70b934c3a43b4bd00df4a1e875baafe7c8988b13d5a18dbc4813` |
+| sessions | summary.md | `9e7af234005461bd8b4395498fa3b58a3da5e589184c51e34b399f8cba37d50b` |
+| trace | events.jsonl | `649ddd072446b2184427b6ea18a7e6bad6c118a0aa63c77bfff591cf9165114d` |
+| trace | session.json | `c6abc0bbae7539b0c5d6685e700a6105c55d64821f42451b0f8ea386e0568020` |
| trace | summary.md | `10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44` |
-| injection | events.jsonl | `08ab0874cfad71e0e4565b569d9b0ea5251bf158eaab3cdf17798182e4c66a95` |
-| injection | session.json | `f34b677be0b0b453cdade4487118e059c3dfb5a1010d4e6c2fc8b37a867fb25e` |
-| injection | summary.md | `59a1aa4f2b616d5266d565c4df4b0257d11aab95b09cec80f380e9aca8bfc22f` |
+| injection | events.jsonl | `ce9c81fb9663813c5eefbacfb94af69dc4050ef563fd62e302869942ae207be1` |
+| injection | session.json | `bb203d2e551f906cc24500c4b3f2d8263e1218267ce6aa809d99dd9c964caae2` |
+| injection | summary.md | `a6a087528c23227628faa0e3ad3a646b66bf06c06489d26fda4e82091a7bb2ac` |
## Native feature matrix (demo firmware)
| feature | status | detail |
|---|---|---|
-| analyze_compare | pass | {'analyze_session': 'harness/qemu_line/sessions/native-1', 'analyze_chars': 444, 'compare_pair': ['harness/qemu_line/sessions/native-1', 'harness/qemu_line/sessions/native-2'], 'compare_chars': 526, 'compare_headers': ['Breakpoint profile delta (A vs B)', 'Watchpoint delta', 'Written pages delta']} |
+| analyze_compare | pass | {'analyze_session': 'harness/qemu_line/sessions/native-1', 'analyze_chars': 444, 'compare_pair': ['harness/qemu_line/sessions/native-1', 'harness/qemu_line/sessions/native-2'], 'compare_chars': 394, 'compare_headers': ['Breakpoint profile delta (A vs B)', 'Watchpoint delta', 'Written pages delta']} |
| breakpoints | pass | {'run_until_hit': 2147483912, 'attached_then_removed': True, 'kind': 'sw'} |
| hooks | pass | {'rpc_dispatch_hits': 1, 'cmds': [1, 2]} |
-| injection | pass | {'dir': 'harness/qemu_line/sessions/features-inject', 'blob': 'sdk/build/injected_demo.elf', 'slot': 6, 'ping_result': 48814, 'expected': 48814, 'hashes': {'session.json': 'f34b677be0b0b453cdade4487118e059c3dfb5a1010d4e6c2fc8b37a867fb25e', 'events.jsonl': '08ab0874cfad71e0e4565b569d9b0ea5251bf158eaab3cdf17798182e4c66a95', 'summary.md': '59a1aa4f2b616d5266d565c4df4b0257d11aab95b09cec80f380e9aca8bfc22f'}} |
+| injection | pass | {'dir': 'harness/qemu_line/sessions/features-inject', 'blob': 'sdk/build/injected_demo.elf', 'slot': 6, 'ping_result': 48814, 'expected': 48814, 'hashes': {'session.json': 'bb203d2e551f906cc24500c4b3f2d8263e1218267ce6aa809d99dd9c964caae2', 'events.jsonl': 'ce9c81fb9663813c5eefbacfb94af69dc4050ef563fd62e302869942ae207be1', 'summary.md': 'a6a087528c23227628faa0e3ad3a646b66bf06c06489d26fda4e82091a7bb2ac'}} |
| mcp | not_run | native MCP surface is S3's deliverable (plugins/mcp_server.py); S4 must not race that work. Re-run after S3 lands (docs/37). |
| memory | pass | {'ctrl': 2147485760, 'magic': 1196642353} |
-| sessions | pass | {'dir': 'harness/qemu_line/sessions/features', 'event_counts': {'bp_hit': 2, 'hook': 1, 'image_loaded': 1, 'mem_read': 2, 'mem_write': 4, 'session_end': 1, 'session_start': 1, 'stop': 2, 'watch': 1, 'watch_hit': 1}, 'hashes': {'session.json': '2372eea627f31d11f114019759d8d6dc665683bbc88e834f158f56b16fa5c0d4', 'events.jsonl': 'a6328cb00287bdf678d9b62d496929fc59190a08af3910b059c840c6bc98462e', 'summary.md': '6dfca9e61a4f23374b90dbef995d86771130fcf4c2b61dc40aac6d379d4b8706'}} |
+| sessions | pass | {'dir': 'harness/qemu_line/sessions/features', 'event_counts': {'bp_hit': 2, 'hook': 1, 'image_loaded': 1, 'mem_read': 2, 'mem_write': 4, 'session_end': 1, 'session_start': 1, 'stop': 2, 'watch': 1, 'watch_hit': 1}, 'hashes': {'session.json': '6c8c45b9b4be70b934c3a43b4bd00df4a1e875baafe7c8988b13d5a18dbc4813', 'events.jsonl': '4ed32100308c465d999c78b71a232ab096e3cfcbb82561f46c5046eb34a0f462', 'summary.md': '9e7af234005461bd8b4395498fa3b58a3da5e589184c51e34b399f8cba37d50b'}} |
| struct | pass | {'schema': 'gsp_ctrl.schema.json', 'fields': 14} |
-| trace | pass | {'dir': 'harness/qemu_line/sessions/features-trace', 'blob': 'harness/qemu_line/sessions/features-trace/blobs/trace.jsonl', 'events': 4001, 'max_events': 4000, 'hashes': {'session.json': 'a09ec38202c291a5b7a3719434ea785725e291cba2dc877d94cd0caf7fcd1a4e', 'events.jsonl': '36b80ed88205daa8e1bd58536eb274ed4849c3d4b1a05daa0f2c44382c7b764a', 'summary.md': '10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44'}} |
+| trace | pass | {'dir': 'harness/qemu_line/sessions/features-trace', 'blob': 'harness/qemu_line/sessions/features-trace/blobs/trace.jsonl', 'events': 4001, 'max_events': 4000, 'hashes': {'session.json': 'c6abc0bbae7539b0c5d6685e700a6105c55d64821f42451b0f8ea386e0568020', 'events.jsonl': '649ddd072446b2184427b6ea18a7e6bad6c118a0aa63c77bfff591cf9165114d', 'summary.md': '10686e9f247862f4b0f2c68a2353821f4039befdd76846f8daefa72ae79f4b44'}} |
| watch | pass | {'result_field_stores': 1, 'pcs': [2147484066]} |
## S1 integration
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py b/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py
index caf22b3..6fdce2e 100644
--- a/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/__init__.py
@@ -26,6 +26,14 @@ from .dma import DmaTranslator
from .falcon import FalconBlock
from .fwsec import Wpr2Block
from .irq import IrqRouter
+from .observe import (
+ TRACE_SCHEMA,
+ ObservedWindow,
+ build_trace,
+ collect_observations,
+ parse_window_spec,
+ windows_from_section,
+)
from .pmc import PmcBlock
from .pmu import PmuStub
from .priscv import PriscvBlock
@@ -33,12 +41,16 @@ from .timer import PtimerBlock
PROFILE_SCHEMA = "greeneye-sim-profile/1"
+#: Profile device sections that declare observe-only/log-only windows.
+OBSERVE_SECTIONS = ("gmmu", "fbif")
+
__all__ = [
"Device",
"DeviceRegistry",
"Window",
"SNAPSHOT_SCHEMA",
"PROFILE_SCHEMA",
+ "TRACE_SCHEMA",
"PmcBlock",
"FalconBlock",
"PriscvBlock",
@@ -48,8 +60,13 @@ __all__ = [
"PmuStub",
"DmaTranslator",
"IrqRouter",
+ "ObservedWindow",
"load_profile",
"registry_from_profile",
+ "windows_from_section",
+ "parse_window_spec",
+ "collect_observations",
+ "build_trace",
]
@@ -153,6 +170,15 @@ def registry_from_profile(profile: dict, *, machine=None,
)
elif name == "pmu":
dev = PmuStub()
+ elif name in OBSERVE_SECTIONS:
+ for win in windows_from_section(section):
+ reg.add(ObservedWindow(
+ base=win["base"], size=win["size"], label=win["label"],
+ mode=win["mode"], registers=win["registers"],
+ evidence=section.get("evidence", ""),
+ clock=(machine.insn_count if machine is not None else None),
+ ))
+ continue
else:
raise ValueError(f"unknown device section {name!r}")
for base, size in _windows(section):
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/base.py b/debug/risc-v-gsp-rm-vm/sim/devices/base.py
index 877109c..7254334 100644
--- a/debug/risc-v-gsp-rm-vm/sim/devices/base.py
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/base.py
@@ -44,6 +44,11 @@ class Device:
name = "device"
+ #: Opt-in hook: when True the registry forwards the faulting PC to
+ #: ``read``/``write`` as ``pc=`` (observer windows record it; every other
+ #: device keeps the original two-/three-argument call unchanged).
+ wants_pc = False
+
def __init__(self, base: int = 0, size: int = 0):
self.base = base
self.size = size
@@ -184,7 +189,10 @@ class DeviceRegistry:
matched = False
for win in self._candidates(addr, size):
matched = True
- value = win.device.read(addr - win.base, size)
+ if win.device.wants_pc:
+ value = win.device.read(addr - win.base, size, pc=pc)
+ else:
+ value = win.device.read(addr - win.base, size)
if value is not None:
self._record(addr, size, False, value, True, pc)
return True, value
@@ -202,7 +210,11 @@ class DeviceRegistry:
matched = False
for win in self._candidates(addr, size):
matched = True
- if win.device.write(addr - win.base, size, value):
+ if win.device.wants_pc:
+ handled = win.device.write(addr - win.base, size, value, pc=pc)
+ else:
+ handled = win.device.write(addr - win.base, size, value)
+ if handled:
self._record(addr, size, True, value, True, pc)
return True
self.unhandled += 1
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py b/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py
index 8dcf633..b278ec8 100644
--- a/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/fwsec.py
@@ -25,6 +25,17 @@ WPR2_ADDR_HI = 0x1FA828
WPR2_ALIGNMENT = 12
WPR2_BLOCK = 0x20000
+#: Block-relative offsets (a profile window that starts at ``+0x1FA000``
+#: reaches the pair at ``0x824``/``0x828``; the device accepts both forms).
+WPR2_ADDR_LO_REL = WPR2_ADDR_LO & 0xFFF
+WPR2_ADDR_HI_REL = WPR2_ADDR_HI & 0xFFF
+_KEYS = {
+ WPR2_ADDR_LO: WPR2_ADDR_LO,
+ WPR2_ADDR_HI: WPR2_ADDR_HI,
+ WPR2_ADDR_LO_REL: WPR2_ADDR_LO,
+ WPR2_ADDR_HI_REL: WPR2_ADDR_HI,
+}
+
class Wpr2Block(Device):
"""WPR2 address pair as seen by the firmware (host/booter-written)."""
@@ -60,13 +71,15 @@ class Wpr2Block(Device):
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]
+ key = _KEYS.get(offset)
+ if size == 4 and key is not None and key in self.regs:
+ return self.regs[key]
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
+ key = _KEYS.get(offset)
+ if size == 4 and key is not None:
+ self.regs[key] = value & 0xFFFFFFFF
return True
return False
diff --git a/debug/risc-v-gsp-rm-vm/sim/devices/observe.py b/debug/risc-v-gsp-rm-vm/sim/devices/observe.py
new file mode 100644
index 0000000..d43cfe6
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/devices/observe.py
@@ -0,0 +1,372 @@
+"""Observer-only MMIO windows: record accesses, change no semantics.
+
+Evidence rule (see ``sim/devices/base.py`` and ``docs/36``): this module
+carries **no modelled register behaviour** beyond optional read-back of
+values written to registers the header proves plain read/write. Every access
+is recorded -- address, window-relative offset, size, direction,
+value-if-known, PC, virtual time, window label and register name -- and the
+observer then **declines** the access, so the machine's existing
+device-map/lenient/unknown path serves it exactly as before. A read value is
+never invented: an observe-only read records ``value: null`` and returns
+``None``; a storage read before any write is declined the same way.
+
+Modes
+=====
+
+``observe-only`` (default)
+ Read and write are recorded and declined. Nothing changes.
+
+``storage``
+ A declared register whose header access type is plain ``rw`` (or ``wo``)
+ stores the last written value; a later read of a register that has been
+ written returns that value. Undeclared offsets, ``ro`` registers and
+ never-written storage registers are still declined.
+
+Register specs come from the profile (or a runtime window list)::
+
+ "0x24": {"name": "NV_PFB_PRI_MMU_TLB_CTRL",
+ "width": 4, "access": "rw",
+ "fields": {"L2_SIZE": [1, 0], ...}}
+
+``fields`` is optional and only used to decode known values in the trace
+(``hi``/``lo`` bit positions, header field names); no field semantics are
+implied.
+
+The access record is the unit of evidence. It lives in
+:attr:`ObservedWindow.accesses` for the live run and is exported through the
+``greeneye-gmmu-fbif-trace/1`` document built by :func:`build_trace`;
+:meth:`ObservedWindow.state` deliberately carries only counters and storage
+values so a device snapshot stays small.
+
+Nothing here is a silicon claim and nothing here authorises hardware: these
+windows are simulator-only observability for the GMMU/FBIF configuration
+surface (``docs/42-GMMU-FBIF-OBSERVABILITY.md``).
+"""
+from __future__ import annotations
+
+from .base import Device
+
+#: Accepted observer modes.
+MODES = ("observe-only", "storage")
+#: Accepted per-register access kinds (header access type, plain meaning).
+ACCESS_KINDS = ("rw", "ro", "wo")
+#: Schema tag for the GMMU/FBIF trace document.
+TRACE_SCHEMA = "greeneye-gmmu-fbif-trace/1"
+
+DEFAULT_MAX_ACCESS_LOG = 65536
+
+
+def _int(value, default=None):
+ if value is None:
+ return default
+ if isinstance(value, str):
+ return int(value, 0)
+ return int(value)
+
+
+def _normalise_spec(offset, spec) -> dict:
+ """Accept ``"NAME"`` or ``{name, width, access, fields}`` register specs."""
+ if isinstance(spec, str):
+ spec = {"name": spec}
+ if not isinstance(spec, dict) or not spec.get("name"):
+ raise ValueError(f"observer register {offset:#x}: spec needs a name")
+ access = spec.get("access", "rw")
+ if access not in ACCESS_KINDS:
+ raise ValueError(f"observer register {offset:#x}: unknown access {access!r}")
+ width = int(spec.get("width", 4))
+ if width not in (1, 2, 4, 8):
+ raise ValueError(f"observer register {offset:#x}: unsupported width {width}")
+ fields = {}
+ for name, span in (spec.get("fields") or {}).items():
+ if isinstance(span, str):
+ hi, lo = span.split(":")
+ hi, lo = int(hi, 0), int(lo, 0)
+ else:
+ hi, lo = int(span[0]), int(span[1])
+ if not 0 <= lo <= hi <= 8 * width - 1:
+ raise ValueError(
+ f"observer register {offset:#x}: bad field {name} {hi}:{lo}")
+ fields[str(name)] = (hi, lo)
+ return {"name": str(spec["name"]), "width": width,
+ "access": access, "fields": fields}
+
+
+class ObservedWindow(Device):
+ """One observe-only (or storage) MMIO window; a :class:`~sim.devices.base.Device`.
+
+ ``clock`` is an optional zero-argument callable returning virtual time
+ (``Machine.insn_count`` follows the existing ``PtimerBlock`` pattern).
+ The counter advances at :meth:`sim.machine.Machine.run` boundaries, so all
+ accesses inside one bounded run share the run-start value; that granularity
+ is documented, not hidden.
+ """
+
+ name = "observe"
+ #: Opt-in: the registry forwards ``pc=`` to devices that set this.
+ wants_pc = True
+
+ def __init__(self, *, base: int = 0, size: int = 0x1000, label: str | None = None,
+ mode: str = "observe-only", registers=None, clock=None,
+ evidence: str = "", max_access_log: int = DEFAULT_MAX_ACCESS_LOG):
+ super().__init__(base=base, size=size)
+ self.label = str(label or "observe")
+ if mode not in MODES:
+ raise ValueError(f"observer window {self.label}: unknown mode {mode!r}")
+ self.mode = mode
+ self.evidence = str(evidence)
+ self.clock = clock
+ self.registers: dict[int, dict] = {}
+ for off, spec in (registers or {}).items():
+ self.registers[_int(off)] = _normalise_spec(_int(off), spec)
+ self.max_access_log = max(0, int(max_access_log))
+ self.accesses: list[dict] = []
+ self.dropped = 0
+ self.counts = {"r": 0, "w": 0, "served": 0, "declined": 0}
+ self.written: dict[int, int] = {}
+
+ # -- recording ---------------------------------------------------------
+
+ def _record(self, offset: int, size: int, rw: str, value, served: bool,
+ pc: int) -> None:
+ spec = self.registers.get(offset)
+ entry = {
+ "addr": self.base + offset,
+ "offset": offset,
+ "size": size,
+ "rw": rw,
+ "value": value,
+ "pc": pc,
+ "vt": self.clock() if self.clock is not None else None,
+ "window": self.label,
+ "register": spec["name"] if spec is not None else None,
+ "served": bool(served),
+ }
+ if value is not None and spec is not None and spec["fields"]:
+ entry["decoded"] = {
+ name: (value >> lo) & ((1 << (hi - lo + 1)) - 1)
+ for name, (hi, lo) in spec["fields"].items()
+ }
+ self.counts[rw] += 1
+ self.counts["served" if served else "declined"] += 1
+ if len(self.accesses) < self.max_access_log:
+ self.accesses.append(entry)
+ else:
+ self.dropped += 1
+
+ # -- guest-visible semantics ------------------------------------------
+
+ def read(self, offset: int, size: int, pc: int = 0) -> int | None:
+ spec = self.registers.get(offset)
+ value = None
+ served = False
+ if (self.mode == "storage" and spec is not None
+ and spec["width"] == size and spec["access"] == "rw"
+ and offset in self.written):
+ value = self.written[offset]
+ served = True
+ self._record(offset, size, "r", value, served, pc)
+ return value
+
+ def write(self, offset: int, size: int, value: int, pc: int = 0) -> bool:
+ spec = self.registers.get(offset)
+ served = False
+ if (self.mode == "storage" and spec is not None
+ and spec["width"] == size and spec["access"] in ("rw", "wo")):
+ mask = (1 << (8 * size)) - 1
+ self.written[offset] = value & mask
+ served = True
+ self._record(offset, size, "w", value & ((1 << (8 * size)) - 1), served, pc)
+ return served
+
+ def peek(self, offset: int, size: int) -> int | None:
+ spec = self.registers.get(offset)
+ if (self.mode == "storage" and spec is not None
+ and spec["width"] == size and spec["access"] == "rw"
+ and offset in self.written):
+ return self.written[offset]
+ return None
+
+ # -- state -------------------------------------------------------------
+
+ def state(self) -> dict:
+ return {
+ "label": self.label,
+ "mode": self.mode,
+ "counts": dict(self.counts),
+ "written": {str(k): v for k, v in sorted(self.written.items())},
+ "dropped_accesses": self.dropped,
+ }
+
+ def load_state(self, data: dict) -> None:
+ counts = data.get("counts", {})
+ self.counts = {k: int(counts.get(k, 0)) for k in ("r", "w", "served", "declined")}
+ self.written = {int(k): int(v) for k, v in data.get("written", {}).items()}
+ self.dropped = int(data.get("dropped_accesses", 0))
+
+ def reset(self) -> None:
+ self.accesses.clear()
+ self.dropped = 0
+ self.counts = {"r": 0, "w": 0, "served": 0, "declined": 0}
+ self.written.clear()
+
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
+ return (f"ObservedWindow({self.label} @ {self.base:#x}+{self.size:#x}, "
+ f"{self.mode}, {len(self.registers)} registers)")
+
+
+def windows_from_section(section: dict) -> list[dict]:
+ """Expand a profile observer section into per-window declarations.
+
+ A window entry may carry ``count`` and ``stride`` (header-proven register
+ instances, e.g. the 16 FBPA partitions); labels get an ``-<i>`` suffix
+ when more than one instance is declared. Window-level ``mode`` and
+ ``registers`` override the section-level values.
+ """
+ section_regs = section.get("registers") or {}
+ section_mode = section.get("mode", "observe-only")
+ section_label = section.get("label", "observe")
+ out: list[dict] = []
+ for win in section.get("windows", ()):
+ base = _int(win["base"])
+ size = _int(win.get("size", 0x1000))
+ count = int(win.get("count", 1))
+ stride = _int(win.get("stride", size))
+ label = str(win.get("label", section_label))
+ mode = win.get("mode", section_mode)
+ regs = win.get("registers", section_regs)
+ if count < 1:
+ raise ValueError(f"observer window {label}: count must be >= 1")
+ for i in range(count):
+ out.append({
+ "base": base + i * stride,
+ "size": size,
+ "label": label if count == 1 else f"{label}-{i}",
+ "mode": mode,
+ "registers": regs,
+ })
+ return out
+
+
+def parse_window_spec(text: str) -> dict:
+ """Parse ``BASE:SIZE[:LABEL[:MODE]]`` for a runtime observer window.
+
+ Bases/sizes accept ``0x`` forms. This is the escape hatch for windows
+ whose base cannot be established from evidence yet (docs/42); it never
+ invents a value, it only records accesses.
+ """
+ parts = str(text).split(":")
+ if len(parts) < 2:
+ raise ValueError(f"observer window spec {text!r}: expected BASE:SIZE[:LABEL[:MODE]]")
+ base, size = _int(parts[0]), _int(parts[1])
+ if base < 0 or size <= 0:
+ raise ValueError(f"observer window spec {text!r}: bad base/size")
+ label = parts[2] if len(parts) > 2 and parts[2] else "runtime-observe"
+ mode = parts[3] if len(parts) > 3 and parts[3] else "observe-only"
+ if mode not in MODES:
+ raise ValueError(f"observer window spec {text!r}: unknown mode {mode!r}")
+ return {"base": base, "size": size, "label": label, "mode": mode, "registers": {}}
+
+
+def collect_observations(registry) -> list[ObservedWindow]:
+ """All observer windows in a device registry, in registration order."""
+ return [d for d in getattr(registry, "devices", ()) if isinstance(d, ObservedWindow)]
+
+
+def _counts(entries) -> dict:
+ out = {"r": 0, "w": 0, "served": 0}
+ for entry in entries:
+ out[entry["rw"]] += 1
+ if entry["served"]:
+ out["served"] += 1
+ return out
+
+
+def _decorate(entry: dict, resolve) -> dict:
+ out = dict(entry)
+ out["addr"] = hex(entry["addr"])
+ out["offset"] = hex(entry["offset"])
+ if entry["value"] is not None:
+ out["value"] = hex(entry["value"])
+ if entry["pc"] is not None:
+ out["pc"] = hex(entry["pc"])
+ out["pc_symbol"] = None
+ if resolve is not None and entry["pc"] is not None:
+ out["pc_symbol"] = resolve(entry["pc"])
+ return out
+
+
+def build_trace(registry, *, image_sha256: str | None = None,
+ profile: str | None = None, profile_name: str | None = None,
+ symbols_path: str | None = None, resolve=None) -> dict:
+ """Build the ``greeneye-gmmu-fbif-trace/1`` document.
+
+ Accesses are grouped per window and per declared register; offsets inside
+ an observed window that the profile does not name go to ``unclassified``
+ (recorded, never guessed). ``resolve`` is an optional ``pc -> str``
+ callable (``SymbolDB``-backed in :mod:`sim.gsp_boot`).
+
+ Truncation is never silent: a window's access log is capped at
+ ``ObservedWindow.max_access_log`` entries (default 65536);
+ ``dropped_accesses`` reports the accesses beyond the cap in each window
+ and in the summary, and the summary separates ``accesses`` (total
+ observed) from ``retained_accesses`` (present in this document).
+ """
+ windows = []
+ total = {"windows": 0, "windows_touched": 0, "accesses": 0,
+ "retained_accesses": 0, "dropped_accesses": 0,
+ "reads": 0, "writes": 0, "served": 0, "registers_touched": 0}
+ for dev in collect_observations(registry):
+ total["windows"] += 1
+ win = {
+ "label": dev.label,
+ "base": hex(dev.base),
+ "size": hex(dev.size),
+ "mode": dev.mode,
+ "evidence": dev.evidence,
+ "counts": dict(dev.counts),
+ "dropped_accesses": dev.dropped,
+ "retained_accesses": len(dev.accesses),
+ "registers": [],
+ "unclassified": {"counts": {"r": 0, "w": 0, "served": 0},
+ "accesses": []},
+ }
+ grouped: dict[int, list] = {}
+ unclassified: list = []
+ for entry in dev.accesses:
+ if entry["offset"] in dev.registers:
+ grouped.setdefault(entry["offset"], []).append(entry)
+ else:
+ unclassified.append(_decorate(entry, resolve))
+ for offset in sorted(grouped):
+ spec = dev.registers[offset]
+ entries = [_decorate(e, resolve) for e in grouped[offset]]
+ win["registers"].append({
+ "offset": hex(offset),
+ "name": spec["name"],
+ "access": spec["access"],
+ "counts": _counts(entries),
+ "accesses": entries,
+ })
+ win["unclassified"]["accesses"] = unclassified
+ win["unclassified"]["counts"] = _counts(unclassified)
+ observed = dev.counts["r"] + dev.counts["w"]
+ total["dropped_accesses"] += dev.dropped
+ if observed:
+ total["windows_touched"] += 1
+ total["accesses"] += observed
+ total["retained_accesses"] += len(dev.accesses)
+ total["reads"] += dev.counts["r"]
+ total["writes"] += dev.counts["w"]
+ total["served"] += dev.counts["served"]
+ total["registers_touched"] += len(grouped)
+ windows.append(win)
+ doc = {
+ "schema": TRACE_SCHEMA,
+ "image_sha256": image_sha256,
+ "profile": profile,
+ "profile_name": profile_name,
+ "symbols": symbols_path,
+ "summary": total,
+ "windows": windows,
+ }
+ return doc
diff --git a/debug/risc-v-gsp-rm-vm/sim/gmmu_pte.py b/debug/risc-v-gsp-rm-vm/sim/gmmu_pte.py
new file mode 100644
index 0000000..24381eb
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/gmmu_pte.py
@@ -0,0 +1,988 @@
+"""Clean-room decoder for the Turing (TU104) GMMU page-table entry formats.
+
+This module decodes *captured* GMMU page-table data. It is a decoder, not a
+page-table walker: it never dereferences a decoded address and it never fills
+a gap with a guess. Every named field below has an exact bit position taken
+from the Turing GMMU format definitions; the page-tree geometry (depth -> VA
+bits, index width, entry size, page sizes) is taken from the driver's own
+page-tree HAL for Turing, which uses the VER2 entry encoding.
+
+The write-up, the field tables and the uncertainty notes live in
+``docs/43-GMMU-PTE-DECODER.md``.
+
+Formats shipped (evidence: the Turing GMMU register-field definitions and the
+driver's page-table encoder behaviour):
+
+* ``ver2`` -- the 8-byte VER2 leaf PTE and directory PDE, the encoding the
+ Turing driver programs (``NV_MMU_VER2_PTE`` / ``NV_MMU_VER2_PDE``).
+* ``ver2_dual`` -- the 16-byte dual PDE0 slot used for mixed 64K/4K page
+ tables (``NV_MMU_VER2_DUAL_PDE``): a BIG half (64K) and a SMALL half (4K)
+ in one 16-byte entry. The BIG address field spans bits 53:4 and stores
+ the address shifted right by 8; the format also names bit 5 NO_ATS.
+ Depth-3 page-table allocations are 4 KB aligned, so address bit 9 (the
+ overlapping bit) is zero in a valid pointer; the decoder reports
+ ``no_ats`` from bit 5 and excludes that bit from the BIG address.
+
+Known unsupported encodings (explicit ``E_UNSUPPORTED``, never guessed): the
+older non-VER2 PTE/PDE encoding and the "NEW"/VER1 encodings that are also
+described by the same register definitions but are not used by the Turing
+driver's page-tree HAL. See ``docs/43-GMMU-PTE-DECODER.md`` section 6.
+
+Reserved-bit policy: any set bit that no named field of the decoded role
+covers is reported in ``reserved_bits`` / ``reserved_mask`` -- never silently
+dropped and never given an invented meaning.
+
+Rule: decode captured data only; validate against a capture before trusting.
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from typing import Any
+
+# ---------------------------------------------------------------------------
+# Errors
+# ---------------------------------------------------------------------------
+
+E_UNSUPPORTED = "E_UNSUPPORTED"
+E_TRUNCATED = "E_TRUNCATED"
+E_BAD_VALUE = "E_BAD_VALUE"
+
+
+class GmmuDecodeError(ValueError):
+ """A decode request that the format evidence cannot answer."""
+
+ def __init__(self, code: str, message: str):
+ super().__init__(f"{code}: {message}")
+ self.code = code
+ self.message = message
+
+
+# ---------------------------------------------------------------------------
+# Encoding families
+# ---------------------------------------------------------------------------
+
+KIND_VER2 = "ver2"
+KIND_VER2_DUAL = "ver2_dual"
+
+#: Entry-encoding families this decoder ships.
+SUPPORTED_KINDS = (KIND_VER2, KIND_VER2_DUAL)
+
+#: Page-tree depths the evidence supports, root (0) -> leaf (4).
+SUPPORTED_LEVELS = (0, 1, 2, 3, 4)
+
+#: Depth -> hardware level, VA bits, index width and entry size.
+#:
+#: Evidence: the driver's page-tree HAL for Turing inherits the Pascal/Volta
+#: geometry; the comments in that HAL state the mapping below. Depths 0-2
+#: are pointer directories, depth 3 is the PDE0 slot (a dual 64K/4K entry,
+#: or a 2 MB leaf PTE), depth 4 is the 4K/64K leaf page table.
+LEVELS = (
+ {"depth": 0, "hw": "pde3", "va_bits": (48, 47), "index_bits": 2,
+ "entry_size": 8, "role": "pde"},
+ {"depth": 1, "hw": "pde2", "va_bits": (46, 38), "index_bits": 9,
+ "entry_size": 8, "role": "pde"},
+ {"depth": 2, "hw": "pde1", "va_bits": (37, 29), "index_bits": 9,
+ "entry_size": 8, "role": "pde"},
+ {"depth": 3, "hw": "pde0", "va_bits": (28, 21), "index_bits": 8,
+ "entry_size": 16, "role": "pde_or_2m_pte"},
+ {"depth": 4, "hw": "pte", "va_bits": (20, 12), "index_bits": 9,
+ "entry_size": 8, "role": "pte"},
+)
+
+#: Page sizes the format evidence supports, and the depth their entries live at.
+PAGE_SIZES = ("4k", "64k", "2m")
+PAGE_TABLE_DEPTH = {"2m": 3, "4k": 4, "64k": 4}
+
+#: PTE aperture field (bits 2:1).
+APERTURE_NAMES_PTE = {
+ 0: "video_memory",
+ 1: "peer_memory",
+ 2: "system_coherent_memory",
+ 3: "system_non_coherent_memory",
+}
+
+#: PDE aperture field (bits 2:1). Note aperture 0 is INVALID here, while for
+#: a leaf PTE aperture 0 is video memory -- the two entry roles differ.
+APERTURE_NAMES_PDE = {
+ 0: "invalid",
+ 1: "video_memory",
+ 2: "system_coherent_memory",
+ 3: "system_non_coherent_memory",
+}
+
+#: PTE KIND field (bits 63:56), numeric IDs from the Turing GMMU format
+#: definitions. Values not listed are reported as unknown, never guessed.
+PTE_KIND_NAMES = {
+ 0x00: 'PITCH',
+ 0x01: 'Z16',
+ 0x02: 'S8',
+ 0x03: 'S8Z24',
+ 0x04: 'ZF32_X24S8',
+ 0x05: 'Z24S8',
+ 0x06: 'GENERIC_MEMORY',
+ 0x07: 'INVALID',
+ 0x08: 'GENERIC_MEMORY_COMPRESSIBLE',
+ 0x09: 'GENERIC_MEMORY_COMPRESSIBLE_DISABLE_PLC',
+ 0x0A: 'S8_COMPRESSIBLE_DISABLE_PLC',
+ 0x0B: 'Z16_COMPRESSIBLE_DISABLE_PLC',
+ 0x0C: 'S8Z24_COMPRESSIBLE_DISABLE_PLC',
+ 0x0D: 'ZF32_X24S8_COMPRESSIBLE_DISABLE_PLC',
+ 0x0E: 'Z24S8_COMPRESSIBLE_DISABLE_PLC',
+ 0x0F: 'SMSKED_MESSAGE',
+ 0x10: 'Z16_MS2_2Z',
+ 0x11: 'Z16_MS2_2C',
+ 0x12: 'S8Z24_1Z',
+ 0x13: 'S8Z24_MS2_1Z',
+ 0x14: 'S8Z24_MS4_1Z',
+ 0x15: 'S8Z24_MS8_1Z',
+ 0x16: 'S8Z24_MS16_1Z',
+ 0x17: 'S8Z24_2CZ',
+ 0x18: 'S8Z24_MS2_2CZ',
+ 0x19: 'S8Z24_MS4_2CZ',
+ 0x1A: 'S8Z24_MS8_2CZ',
+ 0x1B: 'S8Z24_MS16_2CZ',
+ 0x1C: 'S8Z24_2CS',
+ 0x1D: 'S8Z24_MS2_2CS',
+ 0x1E: 'S8Z24_MS4_2CS',
+ 0x1F: 'S8Z24_MS8_2CS',
+ 0x20: 'S8Z24_MS16_2CS',
+ 0x21: 'S8Z24_4CSZV',
+ 0x22: 'S8Z24_MS2_4CSZV',
+ 0x23: 'S8Z24_MS4_4CSZV',
+ 0x24: 'S8Z24_MS8_4CSZV',
+ 0x25: 'S8Z24_MS16_4CSZV',
+ 0x26: 'V8Z24_MS4_VC12',
+ 0x27: 'V8Z24_MS4_VC4',
+ 0x28: 'V8Z24_MS8_VC8',
+ 0x29: 'V8Z24_MS8_VC24',
+ 0x2A: 'Z16_2C',
+ 0x2B: 'S8_2S',
+ 0x2C: 'C32_MS4_4CBRA',
+ 0x2D: 'C64_MS4_4CBRA',
+ 0x2E: 'V8Z24_MS4_VC12_1ZV',
+ 0x2F: 'V8Z24_MS4_VC4_1ZV',
+ 0x30: 'V8Z24_MS8_VC8_1ZV',
+ 0x31: 'V8Z24_MS8_VC24_1ZV',
+ 0x32: 'V8Z24_MS4_VC12_2CS',
+ 0x33: 'V8Z24_MS4_VC4_2CS',
+ 0x34: 'V8Z24_MS8_VC8_2CS',
+ 0x35: 'V8Z24_MS8_VC24_2CS',
+ 0x36: 'Z16_2CZ',
+ 0x37: 'Z16_MS2_2CZ',
+ 0x38: 'Z16_MS4_2CZ',
+ 0x39: 'Z16_MS8_2CZ',
+ 0x3A: 'V8Z24_MS4_VC12_2CZV',
+ 0x3B: 'V8Z24_MS4_VC4_2CZV',
+ 0x3C: 'V8Z24_MS8_VC8_2CZV',
+ 0x3D: 'V8Z24_MS8_VC24_2CZV',
+ 0x3E: 'V8Z24_MS4_VC12_2ZV',
+ 0x3F: 'V8Z24_MS4_VC4_2ZV',
+ 0x40: 'V8Z24_MS8_VC8_2ZV',
+ 0x41: 'V8Z24_MS8_VC24_2ZV',
+ 0x42: 'V8Z24_MS4_VC12_4CSZV',
+ 0x43: 'V8Z24_MS4_VC4_4CSZV',
+ 0x44: 'V8Z24_MS8_VC8_4CSZV',
+ 0x45: 'V8Z24_MS8_VC24_4CSZV',
+ 0x46: 'Z16_MS8_2C',
+ 0x47: 'Z24S8_1Z',
+ 0x48: 'Z24S8_MS2_1Z',
+ 0x49: 'Z24S8_MS4_1Z',
+ 0x4A: 'Z24S8_MS8_1Z',
+ 0x4B: 'Z24S8_MS16_1Z',
+ 0x4C: 'Z24S8_2CS',
+ 0x4D: 'Z24S8_MS2_2CS',
+ 0x4E: 'Z24S8_MS4_2CS',
+ 0x4F: 'Z24S8_MS8_2CS',
+ 0x50: 'Z24S8_MS16_2CS',
+ 0x51: 'Z24S8_2CZ',
+ 0x52: 'Z24S8_MS2_2CZ',
+ 0x53: 'Z24S8_MS4_2CZ',
+ 0x54: 'Z24S8_MS8_2CZ',
+ 0x55: 'Z24S8_MS16_2CZ',
+ 0x56: 'Z24S8_4CSZV',
+ 0x57: 'Z24S8_MS2_4CSZV',
+ 0x58: 'Z24S8_MS4_4CSZV',
+ 0x59: 'Z24S8_MS8_4CSZV',
+ 0x5A: 'Z24S8_MS16_4CSZV',
+ 0x5B: 'Z24V8_MS4_VC12',
+ 0x5C: 'Z24V8_MS4_VC4',
+ 0x5D: 'Z24V8_MS8_VC8',
+ 0x5E: 'Z24V8_MS8_VC24',
+ 0x5F: 'Z16_MS16_2CZ',
+ 0x60: 'Z16_MS4_2Z',
+ 0x61: 'Z16_MS8_2Z',
+ 0x62: 'Z16_MS16_2Z',
+ 0x63: 'Z24V8_MS4_VC12_1ZV',
+ 0x64: 'Z24V8_MS4_VC4_1ZV',
+ 0x65: 'Z24V8_MS8_VC8_1ZV',
+ 0x66: 'Z24V8_MS8_VC24_1ZV',
+ 0x67: 'Z24V8_MS4_VC12_2CS',
+ 0x68: 'Z24V8_MS4_VC4_2CS',
+ 0x69: 'Z24V8_MS8_VC8_2CS',
+ 0x6A: 'Z24V8_MS8_VC24_2CS',
+ 0x6B: 'Z16_2Z',
+ 0x6C: 'Z16_MS16_2C',
+ 0x6F: 'Z24V8_MS4_VC12_2CZV',
+ 0x70: 'Z24V8_MS4_VC4_2CZV',
+ 0x71: 'Z24V8_MS8_VC8_2CZV',
+ 0x72: 'Z24V8_MS8_VC24_2CZV',
+ 0x73: 'Z24V8_MS4_VC12_2ZV',
+ 0x74: 'Z24V8_MS4_VC4_2ZV',
+ 0x75: 'Z24V8_MS8_VC8_2ZV',
+ 0x76: 'Z24V8_MS8_VC24_2ZV',
+ 0x77: 'Z24V8_MS4_VC12_4CSZV',
+ 0x78: 'Z24V8_MS4_VC4_4CSZV',
+ 0x79: 'Z24V8_MS8_VC8_4CSZV',
+ 0x7A: 'Z24V8_MS8_VC24_4CSZV',
+ 0x7B: 'ZF32',
+ 0x7C: 'ZF32_1Z',
+ 0x7D: 'ZF32_MS2_1Z',
+ 0x7E: 'ZF32_MS4_1Z',
+ 0x7F: 'ZF32_MS8_1Z',
+ 0x80: 'ZF32_MS16_1Z',
+ 0x81: 'ZF32_2CS',
+ 0x82: 'ZF32_MS2_2CS',
+ 0x83: 'ZF32_MS4_2CS',
+ 0x84: 'ZF32_MS8_2CS',
+ 0x85: 'ZF32_MS16_2CS',
+ 0x86: 'ZF32_2CZ',
+ 0x87: 'ZF32_MS2_2CZ',
+ 0x88: 'ZF32_MS4_2CZ',
+ 0x89: 'ZF32_MS8_2CZ',
+ 0x8A: 'ZF32_MS16_2CZ',
+ 0x8B: 'X8Z24_X16V8S8_MS4_VC12',
+ 0x8C: 'X8Z24_X16V8S8_MS4_VC4',
+ 0x8D: 'X8Z24_X16V8S8_MS8_VC8',
+ 0x8E: 'X8Z24_X16V8S8_MS8_VC24',
+ 0x8F: 'X8Z24_X16V8S8_MS4_VC12_1CS',
+ 0x90: 'X8Z24_X16V8S8_MS4_VC4_1CS',
+ 0x91: 'X8Z24_X16V8S8_MS8_VC8_1CS',
+ 0x92: 'X8Z24_X16V8S8_MS8_VC24_1CS',
+ 0x97: 'X8Z24_X16V8S8_MS4_VC12_1ZV',
+ 0x98: 'X8Z24_X16V8S8_MS4_VC4_1ZV',
+ 0x99: 'X8Z24_X16V8S8_MS8_VC8_1ZV',
+ 0x9A: 'X8Z24_X16V8S8_MS8_VC24_1ZV',
+ 0x9B: 'X8Z24_X16V8S8_MS4_VC12_1CZV',
+ 0x9C: 'X8Z24_X16V8S8_MS4_VC4_1CZV',
+ 0x9D: 'X8Z24_X16V8S8_MS8_VC8_1CZV',
+ 0x9E: 'X8Z24_X16V8S8_MS8_VC24_1CZV',
+ 0x9F: 'X8Z24_X16V8S8_MS4_VC12_2CS',
+ 0xA0: 'X8Z24_X16V8S8_MS4_VC4_2CS',
+ 0xA1: 'X8Z24_X16V8S8_MS8_VC8_2CS',
+ 0xA2: 'X8Z24_X16V8S8_MS8_VC24_2CS',
+ 0xA3: 'X8Z24_X16V8S8_MS4_VC12_2CSZV',
+ 0xA4: 'X8Z24_X16V8S8_MS4_VC4_2CSZV',
+ 0xA5: 'X8Z24_X16V8S8_MS8_VC8_2CSZV',
+ 0xA6: 'X8Z24_X16V8S8_MS8_VC24_2CSZV',
+ 0xA7: 'ZF32_X16V8S8_MS4_VC12',
+ 0xA8: 'ZF32_X16V8S8_MS4_VC4',
+ 0xA9: 'ZF32_X16V8S8_MS8_VC8',
+ 0xAA: 'ZF32_X16V8S8_MS8_VC24',
+ 0xAB: 'ZF32_X16V8S8_MS4_VC12_1CS',
+ 0xAC: 'ZF32_X16V8S8_MS4_VC4_1CS',
+ 0xAD: 'ZF32_X16V8S8_MS8_VC8_1CS',
+ 0xAE: 'ZF32_X16V8S8_MS8_VC24_1CS',
+ 0xB3: 'ZF32_X16V8S8_MS4_VC12_1ZV',
+ 0xB4: 'ZF32_X16V8S8_MS4_VC4_1ZV',
+ 0xB5: 'ZF32_X16V8S8_MS8_VC8_1ZV',
+ 0xB6: 'ZF32_X16V8S8_MS8_VC24_1ZV',
+ 0xB7: 'ZF32_X16V8S8_MS4_VC12_1CZV',
+ 0xB8: 'ZF32_X16V8S8_MS4_VC4_1CZV',
+ 0xB9: 'ZF32_X16V8S8_MS8_VC8_1CZV',
+ 0xBA: 'ZF32_X16V8S8_MS8_VC24_1CZV',
+ 0xBB: 'ZF32_X16V8S8_MS4_VC12_2CS',
+ 0xBC: 'ZF32_X16V8S8_MS4_VC4_2CS',
+ 0xBD: 'ZF32_X16V8S8_MS8_VC8_2CS',
+ 0xBE: 'ZF32_X16V8S8_MS8_VC24_2CS',
+ 0xBF: 'ZF32_X16V8S8_MS4_VC12_2CSZV',
+ 0xC0: 'ZF32_X16V8S8_MS4_VC4_2CSZV',
+ 0xC1: 'ZF32_X16V8S8_MS8_VC8_2CSZV',
+ 0xC2: 'ZF32_X16V8S8_MS8_VC24_2CSZV',
+ 0xC3: 'Z16_MS4_2C',
+ 0xC4: 'ZF32_X24S8_1CS',
+ 0xC5: 'ZF32_X24S8_MS2_1CS',
+ 0xC6: 'ZF32_X24S8_MS4_1CS',
+ 0xC7: 'ZF32_X24S8_MS8_1CS',
+ 0xC8: 'ZF32_X24S8_MS16_1CS',
+ 0xCB: 'SMHOST_MESSAGE',
+ 0xCC: 'C32_MS2_4CBRA',
+ 0xCD: 'C64_MS2_4CBRA',
+ 0xCE: 'ZF32_X24S8_2CSZV',
+ 0xCF: 'ZF32_X24S8_MS2_2CSZV',
+ 0xD0: 'ZF32_X24S8_MS4_2CSZV',
+ 0xD1: 'ZF32_X24S8_MS8_2CSZV',
+ 0xD2: 'ZF32_X24S8_MS16_2CSZV',
+ 0xD3: 'ZF32_X24S8_2CS',
+ 0xD4: 'ZF32_X24S8_MS2_2CS',
+ 0xD5: 'ZF32_X24S8_MS4_2CS',
+ 0xD6: 'ZF32_X24S8_MS8_2CS',
+ 0xD7: 'ZF32_X24S8_MS16_2CS',
+ 0xD8: 'C32_2C',
+ 0xD9: 'C32_2CBR',
+ 0xDA: 'C32_2CBA',
+ 0xDB: 'C32_2CRA',
+ 0xDC: 'C32_2BRA',
+ 0xDD: 'C32_MS2_2C',
+ 0xDE: 'C32_MS2_2CBR',
+ 0xDF: 'C32_MS4_2C',
+ 0xE0: 'C32_MS4_2CBR',
+ 0xE1: 'C32_MS4_2CBA',
+ 0xE2: 'C32_MS4_2CRA',
+ 0xE3: 'C32_MS4_2BRA',
+ 0xE4: 'C32_MS8_MS16_2C',
+ 0xE5: 'C32_MS8_MS16_2CRA',
+ 0xE6: 'C64_2C',
+ 0xE7: 'C64_2CBR',
+ 0xE8: 'C64_2CBA',
+ 0xE9: 'C64_2CRA',
+ 0xEA: 'C64_2BRA',
+ 0xEB: 'C64_MS2_2C',
+ 0xEC: 'C64_MS2_2CBR',
+ 0xED: 'C64_MS4_2C',
+ 0xEE: 'C64_MS4_2CBR',
+ 0xEF: 'C64_MS4_2CBA',
+ 0xF0: 'C64_MS4_2CRA',
+ 0xF1: 'C64_MS4_2BRA',
+ 0xF2: 'C64_MS8_MS16_2C',
+ 0xF3: 'C64_MS8_MS16_2CRA',
+ 0xF4: 'C128_2C',
+ 0xF5: 'C128_2CR',
+ 0xF6: 'C128_MS2_2C',
+ 0xF7: 'C128_MS2_2CR',
+ 0xF8: 'C128_MS4_2C',
+ 0xF9: 'C128_MS4_2CR',
+ 0xFA: 'C128_MS8_MS16_2C',
+ 0xFB: 'C128_MS8_MS16_2CR',
+ 0xFC: 'X8C24',
+ 0xFD: 'PITCH_NO_SWIZZLE',
+ 0xFE: 'GENERIC_16BX2',
+}
+
+# ---------------------------------------------------------------------------
+# Field positions (lo, hi), inclusive. A field is only listed when the
+# format definitions prove its position; there are no inferred fields.
+# ---------------------------------------------------------------------------
+
+_VER2_PTE_FIELDS = (
+ ("valid", 0, 0),
+ ("aperture", 1, 2),
+ ("volatile", 3, 3),
+ ("encrypted", 4, 4),
+ ("privilege", 5, 5),
+ ("read_only", 6, 6),
+ ("atomic_disable", 7, 7),
+ ("address_sys", 8, 53),
+ ("address_vid", 8, 32),
+ ("peer_id", 33, 35),
+ ("comptagline", 36, 55),
+ ("kind", 56, 63),
+)
+
+_VER2_PDE_FIELDS = (
+ ("is_pde_pointer", 0, 0),
+ ("aperture", 1, 2),
+ ("volatile", 3, 3),
+ ("no_ats", 5, 5),
+ ("address_sys", 8, 53),
+ ("address_vid", 8, 32),
+ ("peer_id", 33, 35),
+)
+
+# The dual PDE is a 16-byte (128-bit) entry: the BIG half is the low 64 bits,
+# the SMALL half is the high 64 bits. Bit 0 (IS_PDE) applies to the whole
+# entry and is stored in the low bits.
+_VER2_DUAL_PDE_FIELDS = (
+ ("is_pde_pointer", 0, 0),
+ ("aperture_big", 1, 2),
+ ("volatile_big", 3, 3),
+ ("no_ats", 5, 5),
+ ("address_big_sys", 4, 53),
+ ("address_big_vid", 4, 32),
+ ("peer_id_big", 33, 35),
+ ("aperture_small", 65, 66),
+ ("volatile_small", 67, 67),
+ ("address_small_sys", 72, 117),
+ ("address_small_vid", 72, 96),
+ ("peer_id_small", 97, 99),
+)
+
+# Address field shifts from the format definitions.
+_PTE_ADDRESS_SHIFT = 12
+_PDE_ADDRESS_SHIFT = 12
+_DUAL_ADDRESS_SHIFT = 12
+_DUAL_ADDRESS_BIG_SHIFT = 8
+
+
+def _field(value: int, lo: int, hi: int) -> int:
+ return (value >> lo) & ((1 << (hi - lo + 1)) - 1)
+
+
+def _named_mask(fields) -> int:
+ mask = 0
+ for _name, lo, hi in fields:
+ mask |= ((1 << (hi - lo + 1)) - 1) << lo
+ return mask
+
+
+def _reserved(value: int, fields, width: int) -> tuple[list[int], int]:
+ """Set bits not covered by any named field: (bit list, mask)."""
+ mask = _named_mask(fields)
+ leftover = value & ~mask
+ bits = [b for b in range(width) if (leftover >> b) & 1]
+ return bits, leftover
+
+
+def _check_value(value: int, width: int, what: str) -> None:
+ if not isinstance(value, int) or isinstance(value, bool):
+ raise GmmuDecodeError(E_BAD_VALUE, f"{what} must be an int")
+ if value < 0 or value >= (1 << width):
+ raise GmmuDecodeError(
+ E_BAD_VALUE, f"{what} {value:#x} does not fit in {width} bits")
+
+
+def _check_kind(kind: str, allowed, what: str) -> None:
+ if kind not in allowed:
+ raise GmmuDecodeError(
+ E_UNSUPPORTED,
+ f"{what} {kind!r} is not supported (supported: {', '.join(allowed)})")
+
+
+def _check_level(level, allowed, what: str) -> None:
+ if level is None:
+ return
+ if not isinstance(level, int) or isinstance(level, bool):
+ raise GmmuDecodeError(E_BAD_VALUE, f"{what} level must be an int or None")
+ if level not in allowed:
+ raise GmmuDecodeError(
+ E_UNSUPPORTED,
+ f"{what} level {level} is not supported "
+ f"(supported: {', '.join(str(x) for x in allowed)})")
+
+
+def _page_size_for_level(level) -> str | None:
+ if level == 3:
+ return "2m"
+ if level == 4:
+ return "4k_or_64k"
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Leaf PTE (VER2, 8 bytes)
+# ---------------------------------------------------------------------------
+
+def decode_pte(value: int, *, pte_kind: str = KIND_VER2,
+ level: int | None = None) -> dict:
+ """Decode one 8-byte VER2 leaf PTE.
+
+ ``pte_kind`` selects the entry encoding; only ``ver2`` is a leaf encoding.
+ ``level``, when given, pins the page-tree depth (3 = 2 MB leaf, 4 =
+ 4 KB/64 KB leaf) and is otherwise reported as undetermined.
+ """
+ _check_kind(pte_kind, SUPPORTED_KINDS, "pte_kind")
+ if pte_kind != KIND_VER2:
+ raise GmmuDecodeError(
+ E_UNSUPPORTED, f"{pte_kind} is a directory entry, not a leaf PTE")
+ _check_value(value, 64, "PTE value")
+ _check_level(level, (3, 4), "PTE")
+
+ valid = bool(_field(value, 0, 0))
+ aperture = _field(value, 1, 2)
+ aperture_name = APERTURE_NAMES_PTE[aperture]
+ address_sys = _field(value, 8, 53)
+ address_vid = _field(value, 8, 32)
+ peer_id = _field(value, 33, 35)
+ comptagline = _field(value, 36, 55)
+ kind = _field(value, 56, 63)
+
+ if aperture_name == "system_coherent_memory" or \
+ aperture_name == "system_non_coherent_memory":
+ address = address_sys << _PTE_ADDRESS_SHIFT
+ address_complete = True
+ elif aperture_name in ("video_memory", "peer_memory"):
+ # Bits 36:12 of the physical address are in address_vid. The high
+ # address bits are overloaded into comptagline only for the
+ # documented 47-bit peer case; otherwise comptagline is a
+ # compression tag, so a full address is not claimed here.
+ address = address_vid << _PTE_ADDRESS_SHIFT
+ address_complete = False
+ else: # pragma: no cover - all 4 aperture values are named
+ address = None
+ address_complete = False
+
+ reserved_bits, reserved_mask = _reserved(value, _VER2_PTE_FIELDS, 64)
+ return {
+ "role": "pte",
+ "level": level,
+ "level_candidates": [3, 4] if level is None else [level],
+ "page_size": _page_size_for_level(level),
+ "valid": valid,
+ "aperture": aperture,
+ "aperture_name": aperture_name,
+ "volatile": bool(_field(value, 3, 3)),
+ "encrypted": bool(_field(value, 4, 4)),
+ "privilege": bool(_field(value, 5, 5)),
+ "read_only": bool(_field(value, 6, 6)),
+ "writable": not bool(_field(value, 6, 6)),
+ "atomic_disable": bool(_field(value, 7, 7)),
+ "address": address,
+ "address_complete": address_complete,
+ "address_sys": address_sys,
+ "address_vid": address_vid,
+ "peer_id": peer_id,
+ "comptagline": comptagline,
+ "kind": kind,
+ "kind_name": PTE_KIND_NAMES.get(kind),
+ "kind_known": kind in PTE_KIND_NAMES,
+ "reserved_bits": reserved_bits,
+ "reserved_mask": reserved_mask,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Directory PDE (VER2, 8 bytes)
+# ---------------------------------------------------------------------------
+
+def decode_pde(value: int, *, pde_kind: str = KIND_VER2,
+ level: int | None = None) -> dict:
+ """Decode one 8-byte VER2 directory entry.
+
+ Bit 0 is the leaf/pointer discriminator: the driver's encoder writes 0
+ for a pointer to a lower table and 1 for a 2 MB leaf PTE. A value whose
+ bit 0 is 1 is a leaf, not a directory entry, and is rejected here; use
+ :func:`decode_pte` (or :func:`decode_entry` with ``role='auto'``).
+ """
+ _check_kind(pde_kind, SUPPORTED_KINDS, "pde_kind")
+ if pde_kind != KIND_VER2:
+ raise GmmuDecodeError(
+ E_UNSUPPORTED, f"{pde_kind} is a dual entry, not a single PDE")
+ _check_value(value, 64, "PDE value")
+ _check_level(level, (0, 1, 2, 3), "PDE")
+
+ if _field(value, 0, 0):
+ raise GmmuDecodeError(
+ E_UNSUPPORTED,
+ "PDE bit 0 is set: this is a leaf (2 MB) PTE, not a directory entry")
+
+ aperture = _field(value, 1, 2)
+ aperture_name = APERTURE_NAMES_PDE[aperture]
+ address_sys = _field(value, 8, 53)
+ address_vid = _field(value, 8, 32)
+ peer_id = _field(value, 33, 35)
+
+ if aperture_name in ("system_coherent_memory", "system_non_coherent_memory"):
+ address = address_sys << _PDE_ADDRESS_SHIFT
+ address_complete = True
+ elif aperture_name in ("video_memory", "peer_memory"):
+ address = address_vid << _PDE_ADDRESS_SHIFT
+ address_complete = False
+ else: # aperture 0 = invalid
+ address = None
+ address_complete = False
+
+ reserved_bits, reserved_mask = _reserved(value, _VER2_PDE_FIELDS, 64)
+ return {
+ "role": "pde",
+ "level": level,
+ "level_candidates": [0, 1, 2, 3] if level is None else [level],
+ "is_pde_pointer": True,
+ "is_leaf": False,
+ "aperture": aperture,
+ "aperture_name": aperture_name,
+ "volatile": bool(_field(value, 3, 3)),
+ "no_ats": bool(_field(value, 5, 5)),
+ "address": address,
+ "address_complete": address_complete,
+ "address_sys": address_sys,
+ "address_vid": address_vid,
+ "peer_id": peer_id,
+ "reserved_bits": reserved_bits,
+ "reserved_mask": reserved_mask,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Dual PDE0 slot (VER2, 16 bytes)
+# ---------------------------------------------------------------------------
+
+def decode_dual_pde(value: int, *, pde_kind: str = KIND_VER2_DUAL,
+ level: int | None = None) -> dict:
+ """Decode one 16-byte VER2 dual PDE0 slot (BIG + SMALL halves).
+
+ ``value`` is the 128-bit little-endian slot value: the BIG half is the
+ low 64 bits, the SMALL half the high 64 bits. Bit 0 belongs to the whole
+ slot. The BIG half carries 64K-page-table state, the SMALL half 4K.
+ """
+ _check_kind(pde_kind, SUPPORTED_KINDS, "pde_kind")
+ if pde_kind != KIND_VER2_DUAL:
+ raise GmmuDecodeError(
+ E_UNSUPPORTED, f"{pde_kind} is a single entry, not a dual PDE")
+ _check_value(value, 128, "dual PDE value")
+ _check_level(level, (3,), "dual PDE")
+
+ aperture_big = _field(value, 1, 2)
+ aperture_small = _field(value, 65, 66)
+ name_big = APERTURE_NAMES_PDE[aperture_big]
+ name_small = APERTURE_NAMES_PDE[aperture_small]
+
+ address_big_sys = _field(value, 4, 53)
+ address_big_vid = _field(value, 4, 32)
+ peer_id_big = _field(value, 33, 35)
+ address_small_sys = _field(value, 72, 117)
+ address_small_vid = _field(value, 72, 96)
+ peer_id_small = _field(value, 97, 99)
+
+ # Bit 5 of the entry is named NO_ATS and also lies inside the BIG
+ # address field (bits 53:4), i.e. at field bit 1 (entry bit 5). A valid
+ # depth-3 allocation is 4 KB aligned, so address bit 9 (the overlapping
+ # bit) is zero; exclude it from the reconstructed address and report it
+ # as the flag the definitions name.
+ _NO_ATS_FIELD_MASK = 1 << (5 - 4)
+ if name_big in ("system_coherent_memory", "system_non_coherent_memory"):
+ address_big = (address_big_sys & ~_NO_ATS_FIELD_MASK) << _DUAL_ADDRESS_BIG_SHIFT
+ complete_big = True
+ elif name_big in ("video_memory", "peer_memory"):
+ address_big = (address_big_vid & ~_NO_ATS_FIELD_MASK) << _DUAL_ADDRESS_BIG_SHIFT
+ complete_big = False
+ else:
+ address_big = None
+ complete_big = False
+
+ if name_small in ("system_coherent_memory", "system_non_coherent_memory"):
+ address_small = address_small_sys << _DUAL_ADDRESS_SHIFT
+ complete_small = True
+ elif name_small in ("video_memory", "peer_memory"):
+ address_small = address_small_vid << _DUAL_ADDRESS_SHIFT
+ complete_small = False
+ else:
+ address_small = None
+ complete_small = False
+
+ reserved_bits, reserved_mask = _reserved(value, _VER2_DUAL_PDE_FIELDS, 128)
+ return {
+ "role": "dual_pde",
+ "level": level,
+ "level_candidates": [3] if level is None else [level],
+ "is_pde_pointer": not bool(_field(value, 0, 0)),
+ "is_leaf": bool(_field(value, 0, 0)),
+ "no_ats": bool(_field(value, 5, 5)),
+ "big": {
+ "aperture": aperture_big,
+ "aperture_name": name_big,
+ "volatile": bool(_field(value, 3, 3)),
+ "address": address_big,
+ "address_complete": complete_big,
+ "address_sys": address_big_sys,
+ "address_vid": address_big_vid,
+ "peer_id": peer_id_big,
+ },
+ "small": {
+ "aperture": aperture_small,
+ "aperture_name": name_small,
+ "volatile": bool(_field(value, 67, 67)),
+ "address": address_small,
+ "address_complete": complete_small,
+ "address_sys": address_small_sys,
+ "address_vid": address_small_vid,
+ "peer_id": peer_id_small,
+ },
+ "reserved_bits": reserved_bits,
+ "reserved_mask": reserved_mask,
+ }
+
+
+# ---------------------------------------------------------------------------
+# Role dispatch
+# ---------------------------------------------------------------------------
+
+def decode_entry(value: int, *, kind: str = KIND_VER2, role: str = "auto",
+ level: int | None = None) -> dict:
+ """Decode one entry with an explicit or inferred role.
+
+ ``role`` is one of ``auto``, ``pte``, ``pde``, ``dual_pde``. ``auto``
+ uses the format's own bit-0 discriminator for 8-byte VER2 entries
+ (bit 0 set = leaf PTE, clear = directory PDE); the inferred role is
+ reported with ``role_inferred=True`` and the level stays undetermined
+ unless the caller pins it.
+ """
+ if role == "auto":
+ if kind == KIND_VER2_DUAL:
+ decoded = decode_dual_pde(value, pde_kind=kind, level=level)
+ decoded["role_inferred"] = level is None
+ return decoded
+ _check_value(value, 64, "entry value")
+ if _field(value, 0, 0):
+ decoded = decode_pte(value, pte_kind=kind, level=level)
+ else:
+ decoded = decode_pde(value, pde_kind=kind, level=level)
+ decoded["role_inferred"] = level is None
+ return decoded
+ if role == "pte":
+ return decode_pte(value, pte_kind=kind, level=level)
+ if role == "pde":
+ return decode_pde(value, pde_kind=kind, level=level)
+ if role == "dual_pde":
+ return decode_dual_pde(value, pde_kind=kind, level=level)
+ raise GmmuDecodeError(
+ E_UNSUPPORTED,
+ f"role {role!r} is not supported (auto, pte, pde, dual_pde)")
+
+
+# ---------------------------------------------------------------------------
+# Table decode
+# ---------------------------------------------------------------------------
+
+def _normalize_levels(levels) -> tuple[tuple[int, ...], int | None]:
+ """Return (allowed levels, pinned level or None) from ``levels``."""
+ if levels is None:
+ return tuple(SUPPORTED_LEVELS), None
+ requested: tuple[int, ...]
+ if isinstance(levels, int) and not isinstance(levels, bool):
+ requested = (levels,)
+ else:
+ try:
+ requested = tuple(int(x) for x in levels)
+ except (TypeError, ValueError):
+ raise GmmuDecodeError(
+ E_BAD_VALUE, "levels must be an int, a sequence of ints, or None")
+ if not requested:
+ raise GmmuDecodeError(E_BAD_VALUE, "levels must not be empty")
+ for level in requested:
+ if level not in SUPPORTED_LEVELS:
+ raise GmmuDecodeError(
+ E_UNSUPPORTED,
+ f"level {level} is not supported "
+ f"(supported: {', '.join(str(x) for x in SUPPORTED_LEVELS)})")
+ pinned = requested[0] if len(set(requested)) == 1 else None
+ return tuple(sorted(set(requested))), pinned
+
+
+def _infer_role(bit0: int, allowed: tuple[int, ...]) -> str:
+ plausible_leaf = [lv for lv in allowed if lv in (3, 4)]
+ plausible_pde = [lv for lv in allowed if lv in (0, 1, 2, 3)]
+ if bit0:
+ if not plausible_leaf:
+ raise GmmuDecodeError(
+ E_UNSUPPORTED,
+ f"entry bit 0 is set (leaf) but levels {allowed} have no leaf level")
+ return "pte"
+ if not plausible_pde:
+ raise GmmuDecodeError(
+ E_UNSUPPORTED,
+ f"entry bit 0 is clear (directory) but levels {allowed} have no directory level")
+ return "pde"
+
+
+def _candidates_for(role: str, allowed: tuple[int, ...]) -> list[int]:
+ if role == "pte":
+ return [lv for lv in allowed if lv in (3, 4)]
+ if role == "pde":
+ return [lv for lv in allowed if lv in (0, 1, 2, 3)]
+ return list(allowed)
+
+
+def decode_table(blob: bytes, *, base_pa: int, entry_size: int = 8,
+ levels=None, kind: str = KIND_VER2) -> list[dict]:
+ """Decode a captured page-table blob into a list of entry dicts.
+
+ ``blob`` is read little-endian, ``entry_size`` bytes per entry.
+ ``base_pa`` is the GPU physical address of the blob's first byte and is
+ reported per entry as ``entry_pa`` (it is not dereferenced).
+
+ ``kind`` selects the entry encoding: ``ver2`` (8-byte PTE/PDE) or
+ ``ver2_dual`` (16-byte dual PDE0). ``entry_size`` must match the kind.
+
+ ``levels`` optionally pins the page-tree depth of this blob (0-4) or a
+ sequence of acceptable depths; ``None`` (the default) uses the full
+ supported set and lets each entry's bit-0 discriminator choose between
+ the leaf-PTE and directory-PDE views, reporting the level as
+ undetermined (``level=None`` plus ``level_candidates``) rather than
+ guessing one. Any depth outside the supported set raises
+ ``E_UNSUPPORTED``.
+
+ An empty blob decodes to ``[]``; a blob whose length is not a multiple
+ of ``entry_size`` raises ``E_TRUNCATED`` (trailing bytes are never
+ silently dropped).
+ """
+ _check_kind(kind, SUPPORTED_KINDS, "kind")
+ expected = 8 if kind == KIND_VER2 else 16
+ if entry_size != expected:
+ raise GmmuDecodeError(
+ E_UNSUPPORTED,
+ f"kind {kind!r} uses {expected}-byte entries, got entry_size={entry_size}")
+ if not isinstance(base_pa, int) or isinstance(base_pa, bool) or base_pa < 0:
+ raise GmmuDecodeError(E_BAD_VALUE, "base_pa must be a non-negative int")
+ if not isinstance(blob, (bytes, bytearray, memoryview)):
+ raise GmmuDecodeError(E_BAD_VALUE, "blob must be bytes-like")
+ blob = bytes(blob)
+ allowed, pinned = _normalize_levels(levels)
+
+ if len(blob) == 0:
+ return []
+ if len(blob) % entry_size != 0:
+ raise GmmuDecodeError(
+ E_TRUNCATED,
+ f"blob length {len(blob)} is not a multiple of entry_size={entry_size}")
+
+ entries = []
+ for index in range(len(blob) // entry_size):
+ offset = index * entry_size
+ value = int.from_bytes(blob[offset:offset + entry_size], "little")
+
+ if kind == KIND_VER2_DUAL:
+ if pinned is not None and pinned != 3:
+ raise GmmuDecodeError(
+ E_UNSUPPORTED,
+ f"dual PDE entries live at level 3, got levels={levels}")
+ decoded = decode_dual_pde(value, level=3 if pinned == 3 else None)
+ level_reported = 3 if pinned == 3 else None
+ decoded["role_inferred"] = False
+ else:
+ bit0 = value & 1
+ if pinned is None:
+ role = _infer_role(bit0, allowed)
+ decoded = decode_entry(value, kind=kind, role=role, level=None)
+ level_reported = None
+ else:
+ level = pinned
+ if level == 4:
+ role = "pte"
+ elif level in (0, 1, 2):
+ role = "pde"
+ else: # level 3
+ role = "pte" if bit0 else "pde"
+ decoded = decode_entry(value, kind=kind, role=role, level=level)
+ level_reported = level
+ decoded["role_inferred"] = level_reported is None
+ if level_reported is None:
+ decoded["level_candidates"] = _candidates_for(
+ decoded["role"], allowed)
+
+ entry: dict[str, Any] = {
+ "index": index,
+ "entry_pa": base_pa + offset,
+ "value": value,
+ }
+ entry.update(decoded)
+ entry["encoding"] = kind
+ entries.append(entry)
+ return entries
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def _parse_int(text: str) -> int:
+ try:
+ return int(text, 0)
+ except ValueError:
+ raise argparse.ArgumentTypeError(f"not an integer: {text!r}")
+
+
+def _parse_levels(text: str):
+ if text is None:
+ return None
+ try:
+ return [int(part, 0) for part in text.split(",") if part != ""]
+ except ValueError:
+ raise argparse.ArgumentTypeError(f"not a level list: {text!r}")
+
+
+def _render_entry(entry: dict) -> str:
+ role = entry["role"]
+ head = (f"index={entry['index']} pa={entry['entry_pa']:#x} "
+ f"role={role} level={entry['level']} value={entry['value']:#x}")
+ if role == "pte":
+ fields = (f"valid={entry['valid']} aperture={entry['aperture_name']} "
+ f"volatile={entry['volatile']} encrypted={entry['encrypted']} "
+ f"privilege={entry['privilege']} read_only={entry['read_only']} "
+ f"atomic_disable={entry['atomic_disable']} "
+ f"address={entry['address'] if entry['address'] is None else hex(entry['address'])} "
+ f"address_complete={entry['address_complete']} "
+ f"peer_id={entry['peer_id']} comptagline={entry['comptagline']:#x} "
+ f"kind={entry['kind']:#x} kind_name={entry['kind_name']}")
+ elif role == "pde":
+ fields = (f"aperture={entry['aperture_name']} volatile={entry['volatile']} "
+ f"no_ats={entry['no_ats']} "
+ f"address={entry['address'] if entry['address'] is None else hex(entry['address'])} "
+ f"address_complete={entry['address_complete']}")
+ else:
+ big, small = entry["big"], entry["small"]
+ fields = (f"big=({big['aperture_name']}, "
+ f"{big['address'] if big['address'] is None else hex(big['address'])}) "
+ f"small=({small['aperture_name']}, "
+ f"{small['address'] if small['address'] is None else hex(small['address'])}) "
+ f"no_ats={entry['no_ats']}")
+ tail = (f"reserved_bits={entry['reserved_bits']} "
+ f"reserved_mask={entry['reserved_mask']:#x}")
+ return f"{head} {fields} {tail}"
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(
+ prog="python3 -m sim.gmmu_pte",
+ description=("Decode captured TU104 GMMU page-table entries (read-only, "
+ "stdlib-only; decode captured data only and validate "
+ "against a capture before trusting)."))
+ source = parser.add_mutually_exclusive_group(required=True)
+ source.add_argument("--value", type=_parse_int,
+ help="single entry value, e.g. --value 0x...")
+ source.add_argument("--blob", help="file containing a captured table blob")
+ parser.add_argument("--base-pa", type=_parse_int, default=0,
+ help="GPU physical address of the blob's first byte")
+ parser.add_argument("--kind", default=KIND_VER2, choices=SUPPORTED_KINDS,
+ help="entry encoding family (default: ver2)")
+ parser.add_argument("--entry-size", type=int, default=8,
+ help="bytes per entry (default: 8; ver2_dual uses 16)")
+ parser.add_argument("--levels", type=_parse_levels, default=None,
+ help="page-tree depth or comma-separated depths (0-4)")
+ parser.add_argument("--role", default="auto",
+ choices=("auto", "pte", "pde", "dual_pde"),
+ help="entry role for --value (default: auto)")
+ parser.add_argument("--json", action="store_true",
+ help="emit JSON (sorted keys; deterministic)")
+ args = parser.parse_args(argv)
+
+ try:
+ if args.value is not None:
+ level = None
+ if args.levels is not None:
+ if len(args.levels) != 1:
+ raise GmmuDecodeError(
+ E_BAD_VALUE, "--value accepts exactly one level")
+ level = args.levels[0]
+ entry = decode_entry(args.value, kind=args.kind, role=args.role,
+ level=level)
+ entry.setdefault("index", 0)
+ entry.setdefault("entry_pa", args.base_pa)
+ entry["value"] = args.value
+ entry["encoding"] = args.kind
+ entries = [entry]
+ else:
+ with open(args.blob, "rb") as handle:
+ blob = handle.read()
+ entries = decode_table(blob, base_pa=args.base_pa,
+ entry_size=args.entry_size,
+ levels=args.levels, kind=args.kind)
+ except OSError as exc:
+ print(f"gmmu_pte: cannot read {args.blob}: {exc}", file=sys.stderr)
+ return 2
+ except GmmuDecodeError as exc:
+ print(f"gmmu_pte: {exc}", file=sys.stderr)
+ return 2
+
+ if args.json:
+ print(json.dumps(entries, indent=2, sort_keys=True))
+ else:
+ for entry in entries:
+ print(_render_entry(entry))
+ print(f"# {len(entries)} entr{'y' if len(entries) == 1 else 'ies'} "
+ f"(kind={args.kind}, entry_size={args.entry_size})")
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py b/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py
index c586ab3..a6f2545 100644
--- a/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py
+++ b/debug/risc-v-gsp-rm-vm/sim/gsp_boot.py
@@ -22,6 +22,16 @@ 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.
+
+GMMU/FBIF observability (``--trace-out``, default off): the profile's
+``gmmu``/``fbif`` sections declare observe-only register windows
+(:mod:`sim.devices.observe`); every access in them is recorded (never served,
+never faked) and, when requested, exported as a
+``greeneye-gmmu-fbif-trace/1`` document. ``--symbols`` optionally resolves
+recorded PCs through a ``greeneye-symbols/1`` JSON database, and
+``--observe-window BASE:SIZE[:LABEL[:MODE]]`` adds a runtime window list for
+surfaces whose base is not established yet. The count summary is always part
+of the boot report; see ``docs/42-GMMU-FBIF-OBSERVABILITY.md``.
"""
from __future__ import annotations
@@ -35,7 +45,14 @@ from dataclasses import dataclass, field
from greeneye.device_map import firmware_sha256
from greeneye.elf import load_elf
from greeneye.errors import GreenEyeError
+from greeneye.symbols import SymbolDB
from sim.devices import load_profile, registry_from_profile
+from sim.libos import LibosSyscalls, PROCESSOR_SUSPEND, TASK_PORT_SEND_RECV_WAIT
+from sim.devices.observe import (
+ ObservedWindow,
+ build_trace,
+ parse_window_spec,
+)
from sim.machine import (
R_BP,
R_BUDGET,
@@ -89,6 +106,10 @@ LIBOS_MEMORY_REGION_LOC_SYSMEM = 1
WPRM_ID8 = 0x5750524D # 'WPRM'
+#: ``libos_bootstrap_args_t``: IMEM then DMEM ``base/size/bs`` u64 fields.
+BOOTSTRAP_ARGS_SIZE = 6 * 8
+
+
def _int(value, default=None):
if value is None:
return default
@@ -131,6 +152,25 @@ def build_bootargs(machine, *, bootargs_pa: int, core_dump_pa: int,
# initArgs[1..2] stay zero (id8 == 0 terminates the array).
+def build_bootstrap_args(machine, *, pa: int, imem_base_pa: int, imem_size: int,
+ imem_bs_size: int, dmem_base_pa: int, dmem_size: int,
+ dmem_bs_size: int) -> None:
+ """Write the ``libos_bootstrap_args_t`` the bootloader passes in ``a0``.
+
+ Assumption (profile-recorded): the real libos bootloader builds this
+ structure (IMEM/DMEM base+size+bs) from its TCM configuration and calls
+ the firmware entry with a pointer to it; we reconstruct the documented
+ layout. Values are declared in the profile, never derived from silicon.
+ """
+ machine.write_mem(pa, bytes(BOOTSTRAP_ARGS_SIZE))
+ _write_u64(machine, pa + 0x00, imem_base_pa)
+ _write_u64(machine, pa + 0x08, imem_size)
+ _write_u64(machine, pa + 0x10, imem_bs_size)
+ _write_u64(machine, pa + 0x18, dmem_base_pa)
+ _write_u64(machine, pa + 0x20, dmem_size)
+ _write_u64(machine, pa + 0x28, dmem_bs_size)
+
+
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,
@@ -206,7 +246,10 @@ class GspBootResult:
device_state_sha256: str | None = None
registers: dict = field(default_factory=dict)
shim: dict = field(default_factory=dict)
+ libos: dict = field(default_factory=dict)
milestones: list = field(default_factory=list)
+ trace_summary: dict | None = None
+ trace_path: str | None = None
device_map: object | None = field(default=None, repr=False)
def to_dict(self) -> dict:
@@ -232,7 +275,12 @@ class GspBootResult:
"device_state_sha256": self.device_state_sha256,
"registers": self.registers,
"boot_shim": self.shim,
+ "libos": self.libos,
"milestones": list(self.milestones),
+ "gmmu_fbif_trace": {
+ "path": self.trace_path,
+ "summary": self.trace_summary,
+ },
}
@@ -263,8 +311,18 @@ def run_boot(
name: str = "gsp-boot",
use_shim: bool = True,
shim_overrides: dict | None = None,
+ trace_out: str | None = None,
+ symbols_path: str | None = None,
+ observe_windows: list[str] | None = None,
) -> GspBootResult:
- """Run one bounded boot attempt and record a session."""
+ """Run one bounded boot attempt and record a session.
+
+ ``trace_out`` (default off) writes the ``greeneye-gmmu-fbif-trace/1``
+ observer document to that path (a directory gets ``gmmu-fbif-trace.json``
+ inside); ``symbols_path`` resolves recorded PCs through a
+ ``greeneye-symbols/1`` JSON database; ``observe_windows`` adds runtime
+ observer windows (``BASE:SIZE[:LABEL[:MODE]]``, repeatable).
+ """
if instructions <= 0 or instructions > MAX_ATTEMPT_INSTRUCTIONS:
raise GreenEyeError(
f"instructions must be in 1..{MAX_ATTEMPT_INSTRUCTIONS}, got {instructions}")
@@ -274,6 +332,15 @@ def run_boot(
return GspBootResult(ran=False, reason=f"profile absent: {profile_path}",
image=image)
+ resolve_pc = None
+ if symbols_path:
+ db = SymbolDB()
+ db.load_json(symbols_path)
+
+ def resolve_pc(pc, _db=db):
+ sym = _db.resolve(pc)
+ return sym.label(pc) if sym is not None else None
+
profile = load_profile(profile_path)
image_obj = load_elf(image)
if not image_obj.is_riscv:
@@ -299,6 +366,16 @@ def run_boot(
machine = session.target.machine
machine.devices = registry_from_profile(profile, machine=machine)
machine.translator = getattr(machine.devices, "dma", None)
+ libos = LibosSyscalls()
+ machine.syscall_handler = libos.handle
+ for spec in observe_windows or ():
+ win = parse_window_spec(spec)
+ machine.devices.add(ObservedWindow(
+ base=win["base"], size=win["size"], label=win["label"],
+ mode=win["mode"], registers=win["registers"],
+ evidence="runtime --observe-window",
+ clock=machine.insn_count,
+ ))
shim_meta = {"enabled": bool(use_shim), "assumptions": []}
if use_shim:
@@ -309,6 +386,7 @@ def run_boot(
_apply_boot_shim(machine, shim, image_obj, shim_meta)
milestones_hit: list[str] = []
+ trace_path: str | None = None
try:
reason, pc, a, b = _run_with_milestones(machine, instructions, milestones_hit)
model = machine.cycle_model
@@ -320,8 +398,19 @@ def run_boot(
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)
+ rung = _rung_from_run(machine, shim_meta, milestones_hit, libos)
shim_meta["rung"] = rung
+ trace_doc = build_trace(
+ reg,
+ image_sha256=sha,
+ profile=os.path.basename(profile_path),
+ profile_name=profile.get("name"),
+ symbols_path=symbols_path,
+ resolve=resolve_pc,
+ ) if reg is not None else None
+ trace_summary = trace_doc["summary"] if trace_doc is not None else None
+ if trace_out and trace_doc is not None:
+ trace_path = _write_trace_file(trace_out, trace_doc)
meta = {
"kind": "gsp-boot",
"profile": os.path.basename(profile_path),
@@ -341,7 +430,13 @@ def run_boot(
"device_accesses": len(getattr(reg, "accesses", ())),
"device_state_sha256": state_sha,
"boot_shim": shim_meta,
+ "mpu": machine.mpu.state(),
+ "libos": libos.state(),
}
+ if trace_summary is not None:
+ meta["gmmu_fbif"] = trace_summary
+ if trace_path is not None:
+ meta["gmmu_fbif_trace"] = trace_path
if getattr(session.recorder, "dir", None) is not None:
if state is not None:
rel = session.recorder.blob(
@@ -349,6 +444,12 @@ def run_boot(
json.dumps(state, indent=1, sort_keys=True, default=str).encode(),
)
meta["device_state_blob"] = rel
+ if trace_doc is not None and trace_out:
+ rel = session.recorder.blob(
+ "gmmu-fbif-trace.json",
+ json.dumps(trace_doc, indent=1, sort_keys=True).encode(),
+ )
+ meta["gmmu_fbif_trace_blob"] = rel
session.recorder.meta.update(meta)
session.note(
"gsp_boot",
@@ -383,7 +484,10 @@ def run_boot(
device_state_sha256=state_sha,
registers=registers,
shim=shim_meta,
+ libos=libos.state(),
milestones=list(milestones_hit),
+ trace_summary=trace_summary,
+ trace_path=trace_path,
)
@@ -408,8 +512,8 @@ def _run_with_milestones(machine, budget: int, hit: list[str]):
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.
+def _rung_from_run(machine, shim_meta: dict, milestones_hit, libos=None) -> str:
+ """Observed rung from milestones, libos services 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
@@ -417,10 +521,17 @@ def _rung_from_run(machine, shim_meta: dict, milestones_hit) -> str:
B2: the boot shim was consumed and the firmware reached its relocation
stage (milestone ``relocation_copy``) -- boot-stage progress beyond
the bootargs consumer.
+ B3: the libos init path completed its services (TCM/pagetable
+ registration, DMA flush) and the message-loop task blocks in the
+ port wait -- the CSB/TRANSCOM poll itself is host-traffic-triggered
+ and remains owed to the message-injection rung (docs/35 §3.3 rev).
"""
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"
+ stopped = getattr(libos, "stopped", None) if libos is not None else None
+ if stopped and stopped.get("num") in (TASK_PORT_SEND_RECV_WAIT, PROCESSOR_SUSPEND):
+ return "B3"
if "relocation_copy" in milestones_hit:
return "B2"
return "B1"
@@ -431,6 +542,8 @@ def _rung_note(rung: str) -> str:
"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)",
+ "B3": "init complete; message-loop task blocks in the port wait "
+ "(CSB poll awaits host traffic)",
}.get(rung, rung)
@@ -472,6 +585,24 @@ def _apply_boot_shim(machine, shim: dict, image_obj, shim_meta: dict) -> None:
shim_meta["assumptions"].append(
"GspFwWprMeta reconstructed from the documented layout (header); "
"real value is booter-provided")
+ bootstrap = shim.get("bootstrap_args")
+ if bootstrap:
+ args_pa = _int(bootstrap.get("pa", 0x81000))
+ build_bootstrap_args(
+ machine,
+ pa=args_pa,
+ imem_base_pa=_int(bootstrap.get("imem_base_pa", 0x0)),
+ imem_size=_int(bootstrap.get("imem_size", 0x10000)),
+ imem_bs_size=_int(bootstrap.get("imem_bs_size", 0x0)),
+ dmem_base_pa=_int(bootstrap.get("dmem_base_pa", 0x100000000)),
+ dmem_size=_int(bootstrap.get("dmem_size", 0x10000)),
+ dmem_bs_size=_int(bootstrap.get("dmem_bs_size", 0x0)),
+ )
+ shim_meta["assumptions"].append(
+ "libos_bootstrap_args_t (IMEM/DMEM base+size+bs) reconstructed at "
+ "0x%x from the documented layout; real value is bootloader-built "
+ "and passed in a0" % args_pa)
+ shim_meta["bootstrap_args_pa"] = args_pa
shim_meta["applied"] = True
shim_meta["registers"] = {k: registers[k] for k in sorted(registers)}
image_at = shim.get("image_at")
@@ -481,13 +612,27 @@ def _apply_boot_shim(machine, shim: dict, image_obj, shim_meta: dict) -> None:
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)
+ "raw firmware image mirrored at 0x%x (DMEM); legacy fallback for "
+ "profiles without a bootstrap-args record (no longer used by the "
+ "tu104-570.144 profile: the MMU model resolves the copy source)"
+ % addr)
shim_meta["image_at"] = addr
shim_meta["image_bytes"] = len(blob)
+def _write_trace_file(path: str, doc: dict) -> str:
+ """Write the observer trace document; a directory gets the canonical name."""
+ target = path
+ if os.path.isdir(path) or path.endswith(os.sep):
+ target = os.path.join(path, "gmmu-fbif-trace.json")
+ parent = os.path.dirname(os.path.abspath(target))
+ os.makedirs(parent, exist_ok=True)
+ with open(target, "w", encoding="utf-8") as fh:
+ json.dump(doc, fh, indent=1, sort_keys=True)
+ fh.write("\n")
+ return target
+
+
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)
@@ -504,6 +649,14 @@ def _print_result(result: GspBootResult, out=sys.stdout) -> None:
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)
+ if result.trace_summary is not None:
+ summary = result.trace_summary
+ print(f"GMMU/FBIF observers: {summary['windows']} windows, "
+ f"{summary['accesses']} accesses ({summary['reads']} r / "
+ f"{summary['writes']} w, {summary['served']} served, "
+ f"{summary['windows_touched']} windows touched)", file=out)
+ if result.trace_path is not None:
+ print(f"GMMU/FBIF trace: {result.trace_path}", file=out)
print("note: device semantics carry their evidence in docs/36; "
"boot-shim values are declared assumptions", file=out)
@@ -520,6 +673,15 @@ def main(argv: list[str] | None = None) -> int:
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("--trace-out", default=None,
+ help="write the greeneye-gmmu-fbif-trace/1 observer "
+ "document to this file (or directory); default off")
+ parser.add_argument("--symbols", default=None,
+ help="greeneye-symbols/1 JSON database for PC resolution")
+ parser.add_argument("--observe-window", action="append", default=None,
+ metavar="BASE:SIZE[:LABEL[:MODE]]",
+ help="extra runtime observer window (repeatable); "
+ "observe-only unless MODE=storage")
parser.add_argument("--json", action="store_true")
args = parser.parse_args(argv)
try:
@@ -530,6 +692,9 @@ def main(argv: list[str] | None = None) -> int:
record=args.record,
name=args.name,
use_shim=not args.no_shim,
+ trace_out=args.trace_out,
+ symbols_path=args.symbols,
+ observe_windows=args.observe_window,
)
except GreenEyeError as exc:
print(f"[gsp-boot] error: {exc}", file=sys.stderr)
diff --git a/debug/risc-v-gsp-rm-vm/sim/libos.py b/debug/risc-v-gsp-rm-vm/sim/libos.py
new file mode 100644
index 0000000..e8280fd
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/libos.py
@@ -0,0 +1,217 @@
+"""Minimal, honest libos syscall surface for the native GSP boot.
+
+Evidence kind: **firmware use + RTOS syscall table.** The GSP application is
+a libos task that requests kernel services with ``ecall`` (the syscall number
+travels in ``t0``); the native ``Machine`` stops on ``ecall`` by default, so
+booting the real image past kernel init needs a deterministic service model.
+
+Rules:
+
+* every requested syscall is recorded (number, name, arguments, pc,
+ instruction count) so the session shows exactly what the firmware asked for;
+* only *no-ops and modelled* services are acknowledged: task yield, cache
+ invalidation, external-interrupt arming, profiler enable/query, and the
+ timer read (returned from the modeled instruction count, labelled
+ modeled);
+* **waits are never faked**: ``PROCESSOR_SUSPEND``,
+ ``TASK_PORT_SEND_RECV_WAIT``, ``TASK_PANIC`` and every unimplemented
+ syscall return *not handled*, which stops the run with ``R_ECALL`` so the
+ caller can classify the stop (message-loop wait / panic / unsupported
+ service) instead of inventing progress;
+* task switching, DMA, memory queries, pagetable registration, partition
+ switch and interrupt delivery are not modelled.
+
+No timing, scheduling or security claim is made; this is development
+tooling for the boot ladder (docs/35 §3.3).
+"""
+from __future__ import annotations
+
+# Syscall numbers (RTOS ``libos_syscalls.h``).
+TASK_PORT_SEND_RECV_WAIT = 1
+TASK_YIELD = 2
+PROCESSOR_SUSPEND = 3
+DCACHE_INVALIDATE = 4
+MEMORY_QUERY = 5
+TASK_STATE_QUERY = 6
+SHUTTLE_RESET = 7
+TASK_DMA_REGISTER_TCM = 8
+TASK_DMA_COPY = 9
+TASK_DMA_FLUSH = 10
+TASK_MEMORY_READ = 11
+INIT_REGISTER_PAGETABLES = 12
+PROFILER_ENABLE = 13
+TASK_EXIT = 14
+TASK_PANIC = 15
+INTERRUPT_ARM_EXTERNAL = 16
+TIMER_GET_NS = 17
+DCACHE_INVALIDATE_ALL = 18
+PROFILER_GET_COUNTER_MASK = 19
+PARTITION_SWITCH = 20
+LIMIT_POW2 = 32
+
+NAMES = {
+ TASK_PORT_SEND_RECV_WAIT: "task_port_send_recv_wait",
+ TASK_YIELD: "task_yield",
+ PROCESSOR_SUSPEND: "processor_suspend",
+ DCACHE_INVALIDATE: "dcache_invalidate",
+ MEMORY_QUERY: "memory_query",
+ TASK_STATE_QUERY: "task_state_query",
+ SHUTTLE_RESET: "shuttle_reset",
+ TASK_DMA_REGISTER_TCM: "task_dma_register_tcm",
+ TASK_DMA_COPY: "task_dma_copy",
+ TASK_DMA_FLUSH: "task_dma_flush",
+ TASK_MEMORY_READ: "task_memory_read",
+ INIT_REGISTER_PAGETABLES: "init_register_pagetables",
+ PROFILER_ENABLE: "profiler_enable",
+ TASK_EXIT: "task_exit",
+ TASK_PANIC: "task_panic",
+ INTERRUPT_ARM_EXTERNAL: "interrupt_arm_external",
+ TIMER_GET_NS: "timer_get_ns",
+ DCACHE_INVALIDATE_ALL: "dcache_invalidate_all",
+ PROFILER_GET_COUNTER_MASK: "profiler_get_counter_mask",
+ PARTITION_SWITCH: "partition_switch",
+ LIMIT_POW2: "limit_pow2",
+}
+
+#: Services acknowledged as no-ops (result 0); everything else stops the run.
+_NOOP_SYSCALLS = frozenset({
+ TASK_YIELD, DCACHE_INVALIDATE, DCACHE_INVALIDATE_ALL, PROFILER_ENABLE,
+})
+
+
+class LibosSyscalls:
+ """Deterministic libos service model: record, acknowledge, or stop."""
+
+ name = "libos-syscalls"
+
+ def __init__(self) -> None:
+ self.calls: list[dict] = []
+ self.counts: dict[int, int] = {}
+ self.armed_mask = 0
+ #: Last registered TCM bounce buffer (``task_dma_register_tcm``).
+ self.tcm: dict | None = None
+ #: Last inverted-pagetable registration (``init_register_pagetables``).
+ self.pagetables: dict | None = None
+ self.dma_copies = 0
+ self.dma_bytes = 0
+ #: (number, a0, a1, pc, insns) of the last request.
+ self.last: tuple | None = None
+ #: The request that stopped the run (not handled), if any.
+ self.stopped: dict | None = None
+
+ def handle(self, machine, pc: int = 0):
+ """``Machine`` ecall hook: ``(handled, result)``.
+
+ Unhandled requests leave ``machine.run`` stopping with ``R_ECALL``;
+ the number is still recorded here and in ``stopped``.
+ """
+ num = machine.x[5]
+ a0, a1 = machine.x[10], machine.x[11]
+ entry = {
+ "num": num,
+ "name": NAMES.get(num, f"unknown_{num}"),
+ "a0": a0,
+ "a1": a1,
+ "pc": pc,
+ "insns": machine.insns,
+ }
+ self.calls.append(entry)
+ self.counts[num] = self.counts.get(num, 0) + 1
+ self.last = (num, a0, a1, pc, machine.insns)
+ if num == INTERRUPT_ARM_EXTERNAL:
+ self.armed_mask |= a0
+ return True, 0
+ if num == TIMER_GET_NS:
+ # Modeled time: instruction count * 1 ns (docs/36: modeled, not
+ # calibrated; no timing claim).
+ return True, machine.insns
+ if num == TASK_DMA_REGISTER_TCM:
+ # The kernel validates the VA against its pagetable and stores it
+ # as the DMA bounce buffer; without a paging model the region is
+ # recorded and the observed result (LIBOS_OK) returned.
+ self.tcm = {"va": a0, "size": a1}
+ return True, 0
+ if num == TASK_DMA_COPY:
+ return self._dma_copy(machine, a0, a1, machine.x[12], pc)
+ if num == INIT_REGISTER_PAGETABLES:
+ # The kernel builds its inverted page table from this registration
+ # and returns LIBOS_OK. The simulator does not model the
+ # inverted table (DMA resolution goes through the flat RAM + MPU
+ # + aperture seam); the registration is recorded as evidence.
+ self.pagetables = {"base": a0, "entries": a1, "probe_count": machine.x[12]}
+ return True, 0
+ if num == TASK_DMA_FLUSH:
+ return True, 0
+ if num in _NOOP_SYSCALLS:
+ return True, 0
+ if num == PROFILER_GET_COUNTER_MASK:
+ return True, 0
+ self.stopped = entry
+ return False, None
+
+ # -- DMA service model -------------------------------------------------
+
+ def _resolve(self, machine, va: int):
+ """VA -> simulator RAM offset through the MPU and aperture seam."""
+ if machine.mpu.active:
+ translated = machine.mpu.translate(va)
+ if translated is not None:
+ va = translated
+ off = va - machine.ram_base
+ if 0 <= off < machine.ram_size:
+ return off
+ translator = machine.translator
+ if translator is not None:
+ sim = translator.translate(va)
+ if sim is not None:
+ off = sim - machine.ram_base
+ if 0 <= off < machine.ram_size:
+ return off
+ return None
+
+ def _dma_copy(self, machine, dst_va: int, src_va: int, size: int, pc: int = 0):
+ """Modeled DMA copy: equivalent to a RAM memcpy in the flat model.
+
+ The real kernel drives the falcon DMA engine through TCM; the
+ simulator keeps all regions in one RAM, so the copy effect is the
+ same. Both sides must resolve or the run stops (never a faked copy).
+ """
+ if size == 0:
+ return True, 0
+ dst = self._resolve(machine, dst_va)
+ src = self._resolve(machine, src_va)
+ if dst is None or src is None:
+ self.stopped = {"num": TASK_DMA_COPY, "name": "task_dma_copy",
+ "a0": dst_va, "a1": src_va, "size": size,
+ "reason": "unresolved", "pc": pc}
+ return False, None
+ ram = machine.ram
+ if (0 <= dst <= machine.ram_size - size
+ and 0 <= src <= machine.ram_size - size):
+ ram[dst:dst + size] = bytes(ram[src:src + size])
+ else:
+ # Straddling or out-of-range: byte-wise through the bounds.
+ for i in range(size):
+ if dst + i >= machine.ram_size or src + i >= machine.ram_size:
+ self.stopped = {"num": TASK_DMA_COPY, "name": "task_dma_copy",
+ "a0": dst_va, "a1": src_va, "size": size,
+ "reason": "out-of-range", "pc": pc}
+ return False, None
+ ram[dst + i] = ram[src + i]
+ machine.invalidate()
+ self.dma_copies += 1
+ self.dma_bytes += size
+ return True, 0
+
+ def state(self) -> dict:
+ return {
+ "calls": len(self.calls),
+ "counts": {str(k): v for k, v in sorted(self.counts.items())},
+ "armed_mask": self.armed_mask,
+ "tcm": self.tcm,
+ "pagetables": self.pagetables,
+ "dma_copies": self.dma_copies,
+ "dma_bytes": self.dma_bytes,
+ "last": list(self.last) if self.last else None,
+ "stopped": self.stopped,
+ }
diff --git a/debug/risc-v-gsp-rm-vm/sim/machine.py b/debug/risc-v-gsp-rm-vm/sim/machine.py
index 9908753..7634ce1 100644
--- a/debug/risc-v-gsp-rm-vm/sim/machine.py
+++ b/debug/risc-v-gsp-rm-vm/sim/machine.py
@@ -19,6 +19,7 @@ import mmap
import struct
from .cycles import DEFAULT_MODEL
+from .mpu import MSTATUS, WATCHED_CSRS, RiscvMpu
from .decode import (
A_ADD,
A_AND,
@@ -84,6 +85,9 @@ R_ECALL = 6
#: Accepted ``unknown_mmio`` policies for accesses outside RAM/UART/the map.
UNKNOWN_MMIO_MODES = ("fault", "lenient")
+#: CSRs whose writes update the MMU model (M-mode protection unit + mstatus).
+_MPU_CSRS = frozenset(WATCHED_CSRS) | {MSTATUS}
+
UART_BASE = 0x10000000
UART_SIZE = 0x100
@@ -232,6 +236,10 @@ class Machine:
self.device_map = device_map
self.devices = devices
self.translator = translator # aperture -> RAM seam (sim.devices.dma)
+ self.mpu = RiscvMpu() # M-mode protection unit (sim.mpu)
+ #: Optional guest-ECALL service (``handle(machine) -> (handled, ret)``);
+ #: when absent (or unhandled) ``run`` stops with ``R_ECALL`` as before.
+ self.syscall_handler = None
self.unknown_mmio = unknown_mmio
self.learn_mmio = bool(learn_mmio)
self.bias = bias # PC relocation for learned-map firmware addresses
@@ -506,6 +514,10 @@ class Machine:
a = b = 0
traced = trace is not None
active = False
+ # M-mode protection unit (sim.mpu): translation is consulted only when
+ # an enabled entry exists; no match is treated as identity.
+ mpu = self.mpu
+ mpu_on = mpu.active
# Modeled cycle accounting (sim.cycles): pure table lookups, no timing.
cycle_base = self._cycle_base
cycle_op = self._cycle_op
@@ -520,7 +532,12 @@ class Machine:
a = pc
break
- off = pc - base
+ fetch_pc = pc
+ if mpu_on:
+ translated = mpu.translate(pc)
+ if translated is not None:
+ fetch_pc = translated
+ off = fetch_pc - base
if off < 0 or off + 4 > limit:
reason = R_FAULT
a = 1
@@ -558,6 +575,12 @@ class Machine:
b = hit
break
o = ea - base
+ mem_ea = ea
+ if mpu_on:
+ translated = mpu.translate(ea)
+ if translated is not None:
+ mem_ea = translated
+ o = mem_ea - base
if 0 <= o <= limit - size:
if active:
trace.mem(insn_pc, ea, size, False, self.insns + n)
@@ -587,7 +610,7 @@ class Machine:
pc = nxt
else:
try:
- v = self._mmio_load(ea, size, pc)
+ v = self._mmio_load(mem_ea, size, pc)
except Fault as f:
reason = R_FAULT
a = f.cause
@@ -613,6 +636,12 @@ class Machine:
b = hit
break
o = ea - base
+ mem_ea = ea
+ if mpu_on:
+ translated = mpu.translate(ea)
+ if translated is not None:
+ mem_ea = translated
+ o = mem_ea - base
value = x[r2]
if 0 <= o <= limit - size:
if active:
@@ -627,17 +656,20 @@ class Machine:
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
+ # Both the virtual address and its translated backing are
+ # dropped so a fetch through either mapping re-decodes.
+ for target in ((ea, mem_ea) if mem_ea != ea else (ea,)):
+ p = target & ~1
+ end = target + size
+ while p < end:
+ cache.pop(p, None)
+ p += 2
if counting:
cycle_sum += cycle_mem
pc = nxt
else:
try:
- self._mmio_store(ea, size, value, pc)
+ self._mmio_store(mem_ea, size, value, pc)
except Fault as f:
reason = R_FAULT
a = f.cause
@@ -864,6 +896,9 @@ class Machine:
a = 2
b = csr
break
+ if csr in _MPU_CSRS:
+ mpu.on_csr_write(csr, self.csrs.get(csr, 0))
+ mpu_on = mpu.active
pc = nxt
elif kind == K_AMO:
@@ -938,9 +973,20 @@ class Machine:
elif kind == K_ECALL:
if single_step:
pc = nxt
+ elif self.syscall_handler is not None:
+ handled, result = self.syscall_handler(self, pc)
+ if not handled:
+ reason = R_ECALL
+ a = pc
+ b = x[5] # syscall number (t0), for diagnostics
+ break
+ if result is not None:
+ x[10] = result & MASK64
+ pc = nxt
else:
reason = R_ECALL
a = pc
+ b = x[5]
break
elif kind == K_MRET:
@@ -975,15 +1021,25 @@ class Machine:
# -- guest memory helpers (AMO) ----------------------------------------
def _guest_load(self, addr: int, width: int, pc: int = 0) -> int:
- off = addr - self.ram_base
+ mem = addr
+ if self.mpu.active:
+ translated = self.mpu.translate(addr)
+ if translated is not None:
+ mem = translated
+ off = mem - 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)
+ return self._mmio_load(mem, width, pc)
def _guest_store(self, addr: int, width: int, value: int, pc: int = 0) -> None:
- off = addr - self.ram_base
+ mem = addr
+ if self.mpu.active:
+ translated = self.mpu.translate(addr)
+ if translated is not None:
+ mem = translated
+ off = mem - self.ram_base
if 0 <= off <= self.ram_size - width:
if width == 8:
self._u64p(self.ram, off, value)
@@ -995,7 +1051,7 @@ class Machine:
self._cache.pop(p, None)
p += 2
return
- self._mmio_store(addr, width, value, pc)
+ self._mmio_store(mem, width, value, pc)
#: Task-facing alias: the native simulator machine is :class:`Machine`.
diff --git a/debug/risc-v-gsp-rm-vm/sim/mpu.py b/debug/risc-v-gsp-rm-vm/sim/mpu.py
new file mode 100644
index 0000000..3f24358
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/sim/mpu.py
@@ -0,0 +1,154 @@
+"""RISC-V MMU (NVMPU) CSR model for the GSP core.
+
+Evidence kind: **hardware CSR/field header + firmware use + observed access
+sequence.**
+
+The GSP RISC-V core boots in *bare* mode and then enables a small protection
+unit through CSRs; the firmware maps resident code/data into TCM and uses a
+scratch window to copy it there. Without this model the flat-RAM simulator
+serves those accesses at their virtual addresses and the relocated copy reads
+the wrong backing (the B2 ceiling in ``docs/36-GSP-DEVICE-MODEL.md``).
+
+Modelled CSRs (M-mode values from the hardware CSR header; the firmware writes
+exactly these - observed at the 570.144 bootstrap ``0x4007000``..``0x4007C00``):
+
+============================================ ====== =========================
+CSR value semantics
+============================================ ====== =========================
+``MMPUIDX`` (0x7C8) entry INDEX field 5:0
+``MMPUVA`` (0x7C9) VA BASE 63:12, VLD bit 0
+``MMPUPA`` (0x7CA) PA BASE 63:12
+``MMPURNG`` (0x7CB) range RANGE 63:12 (a size)
+``MMPUATTR`` (0x7CC) attr recorded, not enforced
+``mstatus.VM`` (0x300) mode 0x1E = NVMPU (enabled)
+============================================ ====== =========================
+
+Semantics modelled:
+
+* an entry is *valid* when ``MMPUVA.VLD`` is set; writes to ``MMPUVA`` with
+ VLD clear disable the selected entry (the firmware's clear sequence);
+* translation requires ``mstatus.VM == NVMPU`` (``0x1E``, field bits 28:24);
+ before the enable write the CSR entries exist but no access is translated;
+* lookup is **lowest-index-first** (the firmware's own bootstrap comment:
+ "the MPU gives precedence to lower mappings"); a matching entry maps
+ ``addr`` to ``pa + (addr - va_base)`` when
+ ``va_base <= addr < va_base + range``;
+* **no match is not a fault here**: the machine treats "no matching entry" as
+ identity. This is deliberately more lenient than silicon (which would
+ fault under the kernel's management) because the simulator keeps local
+ memory flat and the boot path's identity mappings are self-consistent; the
+ choice is recorded in the session and in ``docs/36`` §6.
+
+Permissions and cache/coherence attributes are recorded for evidence but not
+enforced; no timing or side-channel claim is made. This module has no device
+or host dependency and is deterministic.
+"""
+from __future__ import annotations
+
+#: CSR numbers (M-mode protection unit; hardware CSR header values).
+MMPUIDX = 0x7C8
+MMPUVA = 0x7C9
+MMPUPA = 0x7CA
+MMPURNG = 0x7CB
+MMPUATTR = 0x7CC
+
+#: CSRs this model consumes when written by the guest.
+WATCHED_CSRS = (MMPUIDX, MMPUVA, MMPUPA, MMPURNG, MMPUATTR)
+
+MSTATUS = 0x300
+MSTATUS_VM_SHIFT = 24
+MSTATUS_VM_MASK = 0x1F << MSTATUS_VM_SHIFT
+#: mstatus.VM value that enables the protection unit (header addendum).
+MSTATUS_VM_NVMPU = 0x1E
+
+#: Field masks (header: BASE/RANGE fields start at bit 12, 4 KiB granularity).
+_PAGE_MASK = ~0xFFF & ((1 << 64) - 1)
+_INDEX_MASK = 0x3F
+_MAX_ENTRIES = 64
+
+
+class RiscvMpu:
+ """MMPU entry table + mstatus enable state (deterministic, serialisable)."""
+
+ def __init__(self) -> None:
+ self.index = 0
+ self.mstatus = 0
+ self.entries: dict[int, dict] = {}
+ #: Ascending [(va_base, pa, range)] of valid entries; kept in sync on
+ #: every CSR write so translation is a short loop.
+ self._active: list[tuple[int, int, int]] = []
+ self.enabled = False
+
+ # -- guest CSR writes --------------------------------------------------
+
+ def on_csr_write(self, csr: int, value: int) -> None:
+ """Apply one guest CSR write (``value`` already masked to 64 bits)."""
+ if csr == MMPUIDX:
+ self.index = value & _INDEX_MASK
+ elif csr == MMPUVA:
+ entry = self.entries.setdefault(self.index, self._blank())
+ entry["va"] = value & _PAGE_MASK
+ entry["valid"] = bool(value & 1)
+ self._refresh()
+ elif csr == MMPUPA:
+ entry = self.entries.setdefault(self.index, self._blank())
+ entry["pa"] = value & _PAGE_MASK
+ elif csr == MMPURNG:
+ entry = self.entries.setdefault(self.index, self._blank())
+ entry["range"] = value & _PAGE_MASK
+ elif csr == MMPUATTR:
+ entry = self.entries.setdefault(self.index, self._blank())
+ entry["attr"] = value
+ elif csr == MSTATUS:
+ self.mstatus = value & ((1 << 64) - 1)
+ self.enabled = ((self.mstatus & MSTATUS_VM_MASK) >> MSTATUS_VM_SHIFT
+ == MSTATUS_VM_NVMPU)
+
+ @staticmethod
+ def _blank() -> dict:
+ return {"va": 0, "pa": 0, "range": 0, "attr": 0, "valid": False}
+
+ def _refresh(self) -> None:
+ self._active = [
+ (e["va"], e["pa"], e["range"])
+ for _, e in sorted(self.entries.items())
+ if e.get("valid") and e.get("range")
+ ]
+
+ # -- translation -------------------------------------------------------
+
+ @property
+ def active(self) -> bool:
+ """True when a matching entry could translate (fast-path switch)."""
+ return self.enabled and bool(self._active)
+
+ def translate(self, addr: int) -> int | None:
+ """Virtual -> physical for ``addr``, or ``None`` when unmatched."""
+ if not self.enabled:
+ return None
+ for va, pa, rng in self._active:
+ off = addr - va
+ if 0 <= off < rng:
+ return pa + off
+ return None
+
+ # -- evidence / snapshot ----------------------------------------------
+
+ def state(self) -> dict:
+ return {
+ "enabled": self.enabled,
+ "mstatus": self.mstatus,
+ "index": self.index,
+ "entries": [
+ {"index": i, **{k: e[k] for k in ("va", "pa", "range", "attr", "valid")}}
+ for i, e in sorted(self.entries.items())
+ if e.get("valid") or e.get("pa") or e.get("range") or e.get("attr")
+ ],
+ }
+
+ def reset(self) -> None:
+ self.index = 0
+ self.mstatus = 0
+ self.entries.clear()
+ self._active = []
+ self.enabled = False
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
index 919dffe..91f4ed5 100644
--- 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
@@ -69,6 +69,808 @@
"evidence": "PMU stub: no PMU register has been observed; every access stays unhandled and logged.",
"windows": []
},
+ "gmmu": {
+ "evidence": "GMMU/PRI-MMU register surface: NV_PFB_PRI_MMU_* offsets inside the PFB block (block range 0x00100000-0x00100FFF), the WPR/VPR/lock cluster (0x1FA7D8-0x1FA830) and the HSMMU block (0x001FAC00-0x001FAFFF) from the hardware register/field headers. Observe-only: no register semantics are modelled and no read value is invented; the B0-B2 runs did not touch these windows; the B3 wave's WPR2 read pair (0x1FA824/0x1FA828) falls in the wpr-lock window and is recorded observe-only (0 served).",
+ "mode": "observe-only",
+ "windows": [
+ {"base": "0x2000000000100C80", "size": "0x280", "label": "gmmu-pri-mmu", "registers": {"0x0": {"name": "NV_PFB_PRI_MMU_CTRL", "fields": {"VM_PG_SIZE": [0, 0], "VOL_FAULT": [1, 1], "COMP_FAULT": [2, 2], "MISS_GRAN": [4, 3], "CACHE_MODE": [6, 5], "PDE_CACHE_MODE": [8, 7], "L2_SEND_MODE": [9, 9], "WORK_CREATION_DISABLE": [10, 10], "USE_PDB_BIG_PAGE_SIZE": [11, 11], "USE_FULL_COMP_TAG_LINE": [12, 12], "PRI_FIFO_ERROR_CLEAR": [13, 13], "PRI_FIFO_ERROR": [14, 14], "PRI_FIFO_EMPTY": [15, 15], "PRI_FIFO_SPACE": [23, 16], "ATOMIC_CAPABILITY_MODE": [25, 24], "STRONG_BIT_ATOMIC_CYA": [26, 26], "STRONG_BIT_OVERRIDE_ATOMIC_DISABLE": [26, 26], "ATOMIC_CAPABILITY_SYS_NCOH_MODE": [27, 27], "MMU_APERTURE": [29, 28], "MMU_VOL": [30, 30], "MMU_DISABLE": [31, 31]}}, "0x4": {"name": "NV_PFB_PRI_MMU_PM"}, "0x8": {"name": "NV_PFB_PRI_MMU_PM_ADDR_LOW"}, "0xc": {"name": "NV_PFB_PRI_MMU_PM_ADDR_HIGH"}, "0x10": {"name": "NV_PFB_PRI_MMU_PM_UNIT_MASK"}, "0x14": {"name": "NV_PFB_PRI_MMU_PM_REQ_MASK"}, "0x18": {"name": "NV_PFB_PRI_MMU_CG"}, "0x1c": {"name": "NV_PFB_PRI_MMU_CG1"}, "0x20": {"name": "NV_PFB_PRI_MMU_PG"}, "0x24": {"name": "NV_PFB_PRI_MMU_TLB_CTRL", "fields": {"L2_SIZE": [1, 0], "L2_FA_SIZE": [3, 2], "L2_RB_SIZE": [5, 4], "HUB_SIZE": [7, 6], "HUB_FA_SIZE": [9, 8], "HUB_RB_SIZE": [11, 10], "FILL_SIZE_PTE": [13, 12], "FILL_SIZE_PDE": [15, 14], "FILL_SIZE_RB": [17, 16], "L2_SET_INDEX_BIG_PG": [19, 18], "HUB_SET_INDEX_BIG_PG": [21, 20], "L2_SET_INDEX_SMALL_PG": [23, 22], "HUB_SET_INDEX_SMALL_PG": [25, 24], "HUB_LOCKED_LIMIT": [28, 26], "ATSD_PROP_FLUSH": [26, 26], "INVAL_PROP_FLUSH": [27, 27], "BIND_PROP_FLUSH": [28, 28], "HUB_RSRVD_VM": [31, 29]}}, "0x28": {"name": "NV_PFB_PRI_MMU_RSRVD"}, "0x2c": {"name": "NV_PFB_PRI_MMU_BIND_IMB"}, "0x30": {"name": "NV_PFB_PRI_MMU_BIND"}, "0x34": {"name": "NV_PFB_PRI_MMU_INVALIDATE_VADDR"}, "0x38": {"name": "NV_PFB_PRI_MMU_INVALIDATE_PDB"}, "0x3c": {"name": "NV_PFB_PRI_MMU_INVALIDATE", "fields": {"VADDR_BITS": [31, 4], "UPPER_VADDR_BITS": [19, 0], "PDB_APERTURE": [1, 1], "PDB_ADDR": [31, 4], "UPPER_PDB_ADDR": [19, 0], "PASID_VAL": [19, 0], "SIZE_VAL": [5, 0], "SIZE_GFID": [31, 26], "ALL_VA": [0, 0], "ALL_PDB": [1, 1], "HUBTLB_ONLY": [2, 2], "REPLAY": [5, 3], "SYS_MEMBAR": [6, 6], "ACK": [8, 7], "CANCEL_CLIENT_ID": [14, 9], "CANCEL_GPC_ID": [19, 15], "CANCEL_CLIENT_TYPE": [20, 20], "USE_PASID": [21, 21], "USE_SIZE": [22, 22], "PROP_FLUSH": [23, 23], "CACHE_LEVEL": [26, 24], "TRIGGER": [31, 31], "VADDR_VIRT_BITS": [31, 4], "UPPER_VADDR_VIRT_BITS": [19, 0], "PDB_VIRT_APERTURE": [1, 1], "PDB_VIRT_ADDR": [31, 4], "UPPER_PDB_VIRT_ADDR": [19, 0], "PASID_VIRT_VAL": [19, 0], "SIZE_VIRT_VAL": [5, 0], "SIZE_VIRT_GFID": [31, 26], "VIRT_ALL_VA": [0, 0], "VIRT_ALL_PDB": [1, 1], "VIRT_HUBTLB_ONLY": [2, 2], "VIRT_REPLAY": [5, 3], "VIRT_SYS_MEMBAR": [6, 6], "VIRT_ACK": [8, 7], "VIRT_CANCEL_CLIENT_ID": [14, 9], "VIRT_CANCEL_GPC_ID": [19, 15], "VIRT_CANCEL_CLIENT_TYPE": [20, 20], "VIRT_USE_PASID": [21, 21], "VIRT_USE_SIZE": [22, 22], "VIRT_PROP_FLUSH": [23, 23], "VIRT_CACHE_LEVEL": [26, 24], "VIRT_TRIGGER": [31, 31]}}, "0x40": {"name": "NV_PFB_PRI_MMU_PREFETCH"}, "0x44": {"name": "NV_PFB_PRI_MMU_DEBUG_CTRL"}, "0x48": {"name": "NV_PFB_PRI_MMU_DEBUG_WR"}, "0x4c": {"name": "NV_PFB_PRI_MMU_DEBUG_RD"}, "0x50": {"name": "NV_PFB_PRI_MMU_VPR_INFO"}, "0x54": {"name": "NV_PFB_PRI_MMU_WPR_INFO"}, "0x58": {"name": "NV_PFB_PRI_MMU_VPR_WPR_WRITE"}, "0x5c": {"name": "NV_PFB_PRI_MMU_PRIV_LEVEL_MASK", "fields": {"READ_PROTECTION": [3, 0], "READ_PROTECTION_LEVEL0": [0, 0], "READ_PROTECTION_LEVEL1": [1, 1], "READ_PROTECTION_LEVEL2": [2, 2], "READ_PROTECTION_LEVEL3": [3, 3], "WRITE_PROTECTION": [7, 4], "WRITE_PROTECTION_LEVEL0": [4, 4], "WRITE_PROTECTION_LEVEL1": [5, 5], "WRITE_PROTECTION_LEVEL2": [6, 6], "WRITE_PROTECTION_LEVEL3": [7, 7], "READ_VIOLATION": [8, 8], "WRITE_VIOLATION": [9, 9], "SOURCE_READ_CONTROL": [10, 10], "SOURCE_WRITE_CONTROL": [11, 11], "SOURCE_ENABLE": [31, 12]}}, "0x60": {"name": "NV_PFB_PRI_MMU_LOCAL_MEMORY_RANGE"}, "0x64": {"name": "NV_PFB_PRI_MMU_PHYS_SECURE"}, "0x68": {"name": "NV_PFB_PRI_MMU_INVALIDATE_UPPER_VADDR"}, "0x6c": {"name": "NV_PFB_PRI_MMU_INVALIDATE_UPPER_PDB"}, "0x70": {"name": "NV_PFB_PRI_MMU_PM_UPPER_ADDR_LOW"}, "0x74": {"name": "NV_PFB_PRI_MMU_PM_UPPER_ADDR_HIGH"}, "0x78": {"name": "NV_PFB_PRI_MMU_PAGE_FAULT_CTRL", "fields": {"PRF_FILTER": [1, 0], "VIRT_PRF_FILTER": [1, 0]}}, "0x7c": {"name": "NV_PFB_PRI_MMU_PG1"}, "0x180": {"name": "NV_PFB_PRI_MMU_ATS_ADDR_RANGE_LOCAL"}, "0x1cc": {"name": "NV_PFB_PRI_MMU_FAULT_ADDR_LO"}, "0x1d0": {"name": "NV_PFB_PRI_MMU_FAULT_ADDR_HI"}, "0x1d4": {"name": "NV_PFB_PRI_MMU_FAULT_INST_LO"}, "0x1d8": {"name": "NV_PFB_PRI_MMU_FAULT_INST_HI"}, "0x1dc": {"name": "NV_PFB_PRI_MMU_FAULT_INFO"}, "0x1e0": {"name": "NV_PFB_PRI_MMU_FAULT_STATUS", "fields": {"DROPPED_BAR1_PHYS": [0, 0], "DROPPED_BAR1_VIRT": [1, 1], "DROPPED_BAR2_PHYS": [2, 2], "DROPPED_BAR2_VIRT": [3, 3], "DROPPED_IFB_PHYS": [4, 4], "DROPPED_IFB_VIRT": [5, 5], "DROPPED_OTHER_PHYS": [6, 6], "DROPPED_OTHER_VIRT": [7, 7], "REPLAYABLE": [8, 8], "NON_REPLAYABLE": [9, 9], "REPLAYABLE_ERROR": [10, 10], "NON_REPLAYABLE_ERROR": [11, 11], "REPLAYABLE_OVERFLOW": [12, 12], "NON_REPLAYABLE_OVERFLOW": [13, 13], "REPLAYABLE_GETPTR_CORRUPTED": [14, 14], "NON_REPLAYABLE_GETPTR_CORRUPTED": [15, 15], "BUSY_TYPE": [29, 28], "BUSY": [30, 30], "VALID": [31, 31], "VIRT_DROPPED_BAR1_PHYS": [0, 0], "VIRT_DROPPED_BAR1_VIRT": [1, 1], "VIRT_DROPPED_BAR2_PHYS": [2, 2], "VIRT_DROPPED_BAR2_VIRT": [3, 3], "VIRT_DROPPED_IFB_PHYS": [4, 4], "VIRT_DROPPED_IFB_VIRT": [5, 5], "VIRT_DROPPED_OTHER_PHYS": [6, 6], "VIRT_DROPPED_OTHER_VIRT": [7, 7], "VIRT_REPLAYABLE": [8, 8], "VIRT_NON_REPLAYABLE": [9, 9], "VIRT_REPLAYABLE_ERROR": [10, 10], "VIRT_NON_REPLAYABLE_ERROR": [11, 11], "VIRT_REPLAYABLE_OVERFLOW": [12, 12], "VIRT_NON_REPLAYABLE_OVERFLOW": [13, 13], "VIRT_REPLAYABLE_GETPTR_CORRUPTED": [14, 14], "VIRT_NON_REPLAYABLE_GETPTR_CORRUPTED": [15, 15], "VIRT_BUSY_TYPE": [29, 28], "VIRT_BUSY": [30, 30], "VIRT_VALID": [31, 31]}}, "0x1e4": {"name": "NV_PFB_PRI_MMU_INVALIDATE_PASID"}, "0x1e8": {"name": "NV_PFB_PRI_MMU_INVALIDATE_SIZE"}, "0x1ec": {"name": "NV_PFB_PRI_MMU_L2TLB_ECC_CONTROL"}, "0x1f0": {"name": "NV_PFB_PRI_MMU_L2TLB_ECC_STATUS"}, "0x1f4": {"name": "NV_PFB_PRI_MMU_L2TLB_ECC_CORRECTED_ERR_COUNT"}, "0x1f8": {"name": "NV_PFB_PRI_MMU_L2TLB_ECC_UNCORRECTED_ERR_COUNT"}, "0x1fc": {"name": "NV_PFB_PRI_MMU_L2TLB_ECC_ADDRESS"}, "0x200": {"name": "NV_PFB_PRI_MMU_HUBTLB_ECC_CONTROL"}, "0x204": {"name": "NV_PFB_PRI_MMU_HUBTLB_ECC_STATUS"}, "0x208": {"name": "NV_PFB_PRI_MMU_HUBTLB_ECC_CORRECTED_ERR_COUNT"}, "0x20c": {"name": "NV_PFB_PRI_MMU_HUBTLB_ECC_UNCORRECTED_ERR_COUNT"}, "0x210": {"name": "NV_PFB_PRI_MMU_HUBTLB_ECC_ADDRESS"}, "0x214": {"name": "NV_PFB_PRI_MMU_FILLUNIT_ECC_CONTROL"}, "0x218": {"name": "NV_PFB_PRI_MMU_FILLUNIT_ECC_STATUS"}, "0x21c": {"name": "NV_PFB_PRI_MMU_FILLUNIT_ECC_CORRECTED_ERR_COUNT"}, "0x220": {"name": "NV_PFB_PRI_MMU_FILLUNIT_ECC_UNCORRECTED_ERR_COUNT"}, "0x224": {"name": "NV_PFB_PRI_MMU_FILLUNIT_ECC_ADDRESS"}, "0x228": {"name": "NV_PFB_PRI_MMU_HUBMMU_GLOBAL_ESR"}, "0x22c": {"name": "NV_PFB_PRI_MMU_ECC_PRIV_LEVEL_MASK"}, "0x234": {"name": "NV_PFB_PRI_MMU_CBC_ADJ"}, "0x238": {"name": "NV_PFB_PRI_MMU_CBC_ADJ_PRIV_LEVEL_MASK"}, "0x23c": {"name": "NV_PFB_PRI_MMU_CBC_PRIV_LEVEL_MASK"}, "0x240": {"name": "NV_PFB_PRI_MMU_NUM_ACTIVE_LTCS"}, "0x244": {"name": "NV_PFB_PRI_MMU_CBC_BASE"}, "0x248": {"name": "NV_PFB_PRI_MMU_CBC_TOP"}, "0x24c": {"name": "NV_PFB_PRI_MMU_CBC_MAX"}, "0x250": {"name": "NV_PFB_PRI_MMU_HYPERVISOR_CTL"}, "0x254": {"name": "NV_PFB_PRI_MMU_PEER_SUPPORT"}, "0x258": {"name": "NV_PFB_PRI_MMU_BIND_EXT"}, "0x25c": {"name": "NV_PFB_PRI_MMU_INT_VECTOR_ECC_ERROR"}, "0x260": {"name": "NV_PFB_PRI_MMU_INT_VECTOR_INFO_FAULT"}, "0x27c": {"name": "NV_PFB_PRI_MMU_PHYS_SECURE_PRIV_LEVEL_MASK"}}},
+ {"base": "0x20000000001FA7D8", "size": "0x5C", "label": "gmmu-wpr-lock", "registers": {"0x0": {"name": "NV_PFB_PRI_MMU_PMU_PRIV_LEVEL_MASK"}, "0x4": {"name": "NV_PFB_PRI_MMU_SEC_PRIV_LEVEL_MASK"}, "0x8": {"name": "NV_PFB_PRI_MMU_NVDEC_PRIV_LEVEL_MASK"}, "0xc": {"name": "NV_PFB_PRI_MMU_NVENC_PRIV_LEVEL_MASK"}, "0x10": {"name": "NV_PFB_PRI_MMU_NVJPG_PRIV_LEVEL_MASK"}, "0x14": {"name": "NV_PFB_PRI_MMU_GSP_PRIV_LEVEL_MASK"}, "0x18": {"name": "NV_PFB_PRI_MMU_FECS_PRIV_LEVEL_MASK"}, "0x1c": {"name": "NV_PFB_PRI_MMU_GPCCS_PRIV_LEVEL_MASK"}, "0x20": {"name": "NV_PFB_PRI_MMU_FBFALCON_PRIV_LEVEL_MASK"}, "0x28": {"name": "NV_PFB_PRI_MMU_VPR_MODE", "fields": {"VPR_PROTECTED_MODE": [4, 4], "MEMORY_LOCKED": [5, 5], "VPR_NON_COMPRESSIBLE": [6, 6], "SYSWR_WHEN_MEMORY_LOCKED": [7, 7], "MEMORY_LOCK_FUSE": [8, 8], "WPR_COMPRESSIBLE": [9, 9], "SECURE_LOCK": [12, 12], "WPR_WITH_SECURE_LOCK": [13, 13], "FORCE_SPA_EQUAL_GPA": [20, 20], "VPR_FAULT_REPORT": [29, 29], "VPR_LOCK_HWPM": [30, 30], "VPR_LOCK_MISC": [28, 28]}}, "0x2c": {"name": "NV_PFB_PRI_MMU_VPR_ADDR_LO"}, "0x30": {"name": "NV_PFB_PRI_MMU_VPR_ADDR_HI"}, "0x34": {"name": "NV_PFB_PRI_MMU_VPR_CYA_LO"}, "0x38": {"name": "NV_PFB_PRI_MMU_VPR_CYA_HI"}, "0x3c": {"name": "NV_PFB_PRI_MMU_WPR_ALLOW_READ", "fields": {"ALL": [11, 4], "WPR1": [7, 4], "WPR1_SECURE0": [4, 4], "WPR1_SECURE1": [5, 5], "WPR1_SECURE2": [6, 6], "WPR1_SECURE3": [7, 7], "WPR2": [11, 8], "WPR2_SECURE0": [8, 8], "WPR2_SECURE1": [9, 9], "WPR2_SECURE2": [10, 10], "WPR2_SECURE3": [11, 11], "WPR_OK": [27, 27], "MIS_MATCH": [31, 28], "MIS_MATCH0": [28, 28], "MIS_MATCH1": [29, 29], "MIS_MATCH2": [30, 30], "MIS_MATCH3": [31, 31]}}, "0x40": {"name": "NV_PFB_PRI_MMU_WPR_ALLOW_WRITE", "fields": {"ALL": [11, 4], "WPR1": [7, 4], "WPR1_SECURE0": [4, 4], "WPR1_SECURE1": [5, 5], "WPR1_SECURE2": [6, 6], "WPR1_SECURE3": [7, 7], "WPR2": [11, 8], "WPR2_SECURE0": [8, 8], "WPR2_SECURE1": [9, 9], "WPR2_SECURE2": [10, 10], "WPR2_SECURE3": [11, 11], "WPR_ENABLE_FUSE": [27, 27], "MIS_MATCH": [31, 28], "MIS_MATCH0": [28, 28], "MIS_MATCH1": [29, 29], "MIS_MATCH2": [30, 30], "MIS_MATCH3": [31, 31]}}, "0x44": {"name": "NV_PFB_PRI_MMU_WPR1_ADDR_LO"}, "0x48": {"name": "NV_PFB_PRI_MMU_WPR1_ADDR_HI"}, "0x4c": {"name": "NV_PFB_PRI_MMU_WPR2_ADDR_LO"}, "0x50": {"name": "NV_PFB_PRI_MMU_WPR2_ADDR_HI"}, "0x54": {"name": "NV_PFB_PRI_MMU_LOCK_ADDR_LO"}, "0x58": {"name": "NV_PFB_PRI_MMU_LOCK_ADDR_HI"}}},
+ {"base": "0x20000000001FAC00", "size": "0x400", "label": "gmmu-hsmmu", "registers": {"0x80": {"name": "NV_PFB_HSMMU_PRI_MMU_CTRL", "fields": {"VM_PG_SIZE": [0, 0], "VOL_FAULT": [1, 1], "COMP_FAULT": [2, 2], "MISS_GRAN": [4, 3], "CACHE_MODE": [6, 5], "L2_SEND_MODE": [9, 9], "USE_FULL_COMP_TAG_LINE": [12, 12], "ATOMIC_CAPABILITY_MODE": [25, 24], "STRONG_BIT_ATOMIC_CYA": [26, 26], "STRONG_BIT_OVERRIDE_ATOMIC_DISABLE": [26, 26]}}, "0x84": {"name": "NV_PFB_HSMMU_PRI_MMU_PM"}, "0x88": {"name": "NV_PFB_HSMMU_PRI_MMU_PM_ADDR_LOW"}, "0x8c": {"name": "NV_PFB_HSMMU_PRI_MMU_PM_UPPER_ADDR_LOW"}, "0x90": {"name": "NV_PFB_HSMMU_PRI_MMU_PM_ADDR_HIGH"}, "0x94": {"name": "NV_PFB_HSMMU_PRI_MMU_PM_UPPER_ADDR_HIGH"}, "0x98": {"name": "NV_PFB_HSMMU_PRI_MMU_PM_UNIT_MASK"}, "0x9c": {"name": "NV_PFB_HSMMU_PRI_MMU_PM_REQ_MASK"}, "0xa0": {"name": "NV_PFB_HSMMU_PRI_MMU_TLB_CTRL", "fields": {"UTLB_SIZE": [1, 0], "UTLB_RB_SIZE": [3, 2], "UTLB_FAST_PATH_DISABLE": [4, 4], "L1TLB_SIZE": [17, 16], "L1TLB_FA_SIZE": [19, 18], "L1TLB_RB_SIZE": [21, 20], "L1TLB_SET_INDEX_BIG_PG": [23, 22], "L1TLB_SET_INDEX_SMALL_PG": [25, 24], "L1TLB_FAST_PATH_DISABLE": [26, 26], "SECURE_LOCK": [30, 30], "FAST_BIG_PG_FAULT_DISABLE": [31, 31]}}, "0xa4": {"name": "NV_PFB_HSMMU_PRI_MMU_PREFETCH"}, "0xb0": {"name": "NV_PFB_HSMMU_PRI_MMU_CG"}, "0xb4": {"name": "NV_PFB_HSMMU_PRI_MMU_CG1"}, "0xb8": {"name": "NV_PFB_HSMMU_PRI_MMU_PG"}, "0xc0": {"name": "NV_PFB_HSMMU_PRI_MMU_PG1"}, "0xc4": {"name": "NV_PFB_HSMMU_PRI_MMU_DEBUG_CTRL"}, "0xc8": {"name": "NV_PFB_HSMMU_PRI_MMU_DEBUG_WR"}, "0xcc": {"name": "NV_PFB_HSMMU_PRI_MMU_DEBUG_RD"}}}
+ ],
+ "note": "gmmu-pri-mmu spans the NV_PFB_PRI_MMU_* cluster 0x100C80-0x100EFC (interleaved PFB PRI_FB/NISO offsets inside the span stay unclassified); gmmu-wpr-lock overlaps the fwsec WPR2 window at 0x1FA824/0x1FA828 and just declines, so the access follows exactly the same underlying path as a run without the observer (observed by tests, not an assertion that the WPR2 path serves)."
+ },
+ "fbif": {
+ "evidence": "FBIF/FBPA register surface: NV_PFB_FBPA block (0x009A0000-0x009A3FFF), the 16 per-partition FBPA instances (0x00900000 + i*0x4000) and the 3 MC instances (0x00980000 + i*0x4000) from the hardware register/field headers. Observe-only: FB access-path, compression and ECC semantics are not modelled; no read value is invented.",
+ "mode": "observe-only",
+ "registers": {
+ "0x0": {
+ "name": "NV_PFB_FBPA_PRI_FBPA_CG"
+ },
+ "0x4": {
+ "name": "NV_PFB_FBPA_PRI_FBPA_CG1"
+ },
+ "0x8": {
+ "name": "NV_PFB_FBPA_PRI_FBPA_PG"
+ },
+ "0xc": {
+ "name": "NV_PFB_FBPA_PRI_FBPA_PG1"
+ },
+ "0x50": {
+ "name": "NV_PFB_FBPA_SPARE"
+ },
+ "0x54": {
+ "name": "NV_PFB_FBPA_FALCON_MONITOR"
+ },
+ "0x60": {
+ "name": "NV_PFB_FBPA_CFG_PWRD"
+ },
+ "0x64": {
+ "name": "NV_PFB_FBPA_FALCON_CTRL_VER"
+ },
+ "0x80": {
+ "name": "NV_PFB_FBPA_DEBUG_0"
+ },
+ "0x84": {
+ "name": "NV_PFB_FBPA_DEBUG_1"
+ },
+ "0x90": {
+ "name": "NV_PFB_FBPA_TESTCMD",
+ "fields": {
+ "CKE": [
+ 0,
+ 0
+ ],
+ "CS0": [
+ 1,
+ 1
+ ],
+ "CS1": [
+ 2,
+ 2
+ ],
+ "RAS": [
+ 3,
+ 3
+ ],
+ "CAS": [
+ 4,
+ 4
+ ],
+ "WE": [
+ 5,
+ 5
+ ],
+ "RES": [
+ 6,
+ 6
+ ],
+ "BANK_4": [
+ 7,
+ 7
+ ],
+ "BANK": [
+ 11,
+ 8
+ ],
+ "ADR": [
+ 25,
+ 12
+ ],
+ "ADR_UPPER": [
+ 28,
+ 26
+ ],
+ "GO": [
+ 30,
+ 30
+ ],
+ "HOLD": [
+ 31,
+ 31
+ ],
+ "EXT_ACT": [
+ 0,
+ 0
+ ],
+ "EXT_VLD_DRAM0": [
+ 1,
+ 1
+ ],
+ "EXT_VLD_DRAM1": [
+ 2,
+ 2
+ ],
+ "EXT_ADR_DRAM1": [
+ 10,
+ 3
+ ],
+ "EXT_RW_CMD": [
+ 16,
+ 16
+ ],
+ "EXT_MRS_CMD": [
+ 17,
+ 17
+ ],
+ "EXT_DR_CMD": [
+ 18,
+ 18
+ ],
+ "EXT_4CK_CMD": [
+ 19,
+ 19
+ ],
+ "EXT_LAM_CMD": [
+ 20,
+ 20
+ ],
+ "EXT_1_ADR": [
+ 23,
+ 0
+ ],
+ "EXT_1_CSB0": [
+ 27,
+ 27
+ ],
+ "EXT_1_CSB1": [
+ 28,
+ 28
+ ],
+ "EXT_1_USE_ADR": [
+ 29,
+ 29
+ ]
+ }
+ },
+ "0x94": {
+ "name": "NV_PFB_FBPA_TESTCMD_EXT"
+ },
+ "0xa0": {
+ "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP0BYTE0"
+ },
+ "0xa4": {
+ "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP0BYTE1"
+ },
+ "0xa8": {
+ "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP0BYTE2"
+ },
+ "0xac": {
+ "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP0BYTE3"
+ },
+ "0xb0": {
+ "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP1BYTE0"
+ },
+ "0xb4": {
+ "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP1BYTE1"
+ },
+ "0xb8": {
+ "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP1BYTE2"
+ },
+ "0xbc": {
+ "name": "NV_PFB_FBPA_SWIZZLE_BIT_SUBP1BYTE3"
+ },
+ "0xc0": {
+ "name": "NV_PFB_FBPA_SWIZZLE_DBI"
+ },
+ "0xc4": {
+ "name": "NV_PFB_FBPA_SWIZZLE_BYTE"
+ },
+ "0xc8": {
+ "name": "NV_PFB_FBPA_SWIZZLE_ECC"
+ },
+ "0xcc": {
+ "name": "NV_PFB_FBPA_SPARE2"
+ },
+ "0xd0": {
+ "name": "NV_PFB_FBPA_REPLAY_RETRY_SUBP0"
+ },
+ "0xd4": {
+ "name": "NV_PFB_FBPA_REPLAY_RETRY_SUBP1"
+ },
+ "0xd8": {
+ "name": "NV_PFB_FBPA_REPLAY_CYCLES_SUBP0"
+ },
+ "0xdc": {
+ "name": "NV_PFB_FBPA_REPLAY_CYCLES_SUBP1"
+ },
+ "0x100": {
+ "name": "NV_PFB_FBPA_PM"
+ },
+ "0x148": {
+ "name": "NV_PFB_FBPA_HISEC_PRIV_LEVEL_MASK",
+ "fields": {
+ "READ_PROTECTION": [
+ 3,
+ 0
+ ],
+ "READ_PROTECTION_LEVEL0": [
+ 0,
+ 0
+ ],
+ "READ_PROTECTION_LEVEL1": [
+ 1,
+ 1
+ ],
+ "READ_PROTECTION_LEVEL2": [
+ 2,
+ 2
+ ],
+ "READ_PROTECTION_LEVEL3": [
+ 3,
+ 3
+ ],
+ "WRITE_PROTECTION": [
+ 7,
+ 4
+ ],
+ "WRITE_PROTECTION_LEVEL0": [
+ 4,
+ 4
+ ],
+ "WRITE_PROTECTION_LEVEL1": [
+ 5,
+ 5
+ ],
+ "WRITE_PROTECTION_LEVEL2": [
+ 6,
+ 6
+ ],
+ "WRITE_PROTECTION_LEVEL3": [
+ 7,
+ 7
+ ],
+ "READ_VIOLATION": [
+ 8,
+ 8
+ ],
+ "WRITE_VIOLATION": [
+ 9,
+ 9
+ ],
+ "SOURCE_READ_CONTROL": [
+ 10,
+ 10
+ ],
+ "SOURCE_WRITE_CONTROL": [
+ 11,
+ 11
+ ],
+ "SOURCE_ENABLE": [
+ 31,
+ 12
+ ]
+ }
+ },
+ "0x150": {
+ "name": "NV_PFB_FBPA_ASRCTRL_NONISO"
+ },
+ "0x154": {
+ "name": "NV_PFB_FBPA_TESTCMD_EXT_1"
+ },
+ "0x158": {
+ "name": "NV_PFB_FBPA_PTR_RESET"
+ },
+ "0x15c": {
+ "name": "NV_PFB_FBPA_CONFIG12"
+ },
+ "0x160": {
+ "name": "NV_PFB_FBPA_MISC_CTRL",
+ "fields": {
+ "INTERVAL_TIMER_PERIOD": [
+ 2,
+ 0
+ ],
+ "INTERVAL_TIMER_EN": [
+ 4,
+ 4
+ ]
+ }
+ },
+ "0x164": {
+ "name": "NV_PFB_FBPA_NUM_ACTIVE_FBPS"
+ },
+ "0x168": {
+ "name": "NV_PFB_FBPA_MEM_PRIV_LEVEL_MASK",
+ "fields": {
+ "READ_PROTECTION": [
+ 3,
+ 0
+ ],
+ "READ_PROTECTION_LEVEL0": [
+ 0,
+ 0
+ ],
+ "READ_PROTECTION_LEVEL1": [
+ 1,
+ 1
+ ],
+ "READ_PROTECTION_LEVEL2": [
+ 2,
+ 2
+ ],
+ "READ_PROTECTION_LEVEL3": [
+ 3,
+ 3
+ ],
+ "WRITE_PROTECTION": [
+ 7,
+ 4
+ ],
+ "WRITE_PROTECTION_LEVEL0": [
+ 4,
+ 4
+ ],
+ "WRITE_PROTECTION_LEVEL1": [
+ 5,
+ 5
+ ],
+ "WRITE_PROTECTION_LEVEL2": [
+ 6,
+ 6
+ ],
+ "WRITE_PROTECTION_LEVEL3": [
+ 7,
+ 7
+ ],
+ "READ_VIOLATION": [
+ 8,
+ 8
+ ],
+ "WRITE_VIOLATION": [
+ 9,
+ 9
+ ],
+ "SOURCE_READ_CONTROL": [
+ 10,
+ 10
+ ],
+ "SOURCE_WRITE_CONTROL": [
+ 11,
+ 11
+ ],
+ "SOURCE_ENABLE": [
+ 31,
+ 12
+ ]
+ }
+ },
+ "0x16c": {
+ "name": "NV_PFB_FBPA_NUM_ACTIVE_LTCS"
+ },
+ "0x170": {
+ "name": "NV_PFB_FBPA_ECC_PRIV_LEVEL_MASK",
+ "fields": {
+ "READ_PROTECTION": [
+ 3,
+ 0
+ ],
+ "READ_PROTECTION_LEVEL0": [
+ 0,
+ 0
+ ],
+ "READ_PROTECTION_LEVEL1": [
+ 1,
+ 1
+ ],
+ "READ_PROTECTION_LEVEL2": [
+ 2,
+ 2
+ ],
+ "READ_PROTECTION_LEVEL3": [
+ 3,
+ 3
+ ],
+ "WRITE_PROTECTION": [
+ 7,
+ 4
+ ],
+ "WRITE_PROTECTION_LEVEL0": [
+ 4,
+ 4
+ ],
+ "WRITE_PROTECTION_LEVEL1": [
+ 5,
+ 5
+ ],
+ "WRITE_PROTECTION_LEVEL2": [
+ 6,
+ 6
+ ],
+ "WRITE_PROTECTION_LEVEL3": [
+ 7,
+ 7
+ ],
+ "READ_VIOLATION": [
+ 8,
+ 8
+ ],
+ "WRITE_VIOLATION": [
+ 9,
+ 9
+ ],
+ "SOURCE_READ_CONTROL": [
+ 10,
+ 10
+ ],
+ "SOURCE_WRITE_CONTROL": [
+ 11,
+ 11
+ ],
+ "SOURCE_ENABLE": [
+ 31,
+ 12
+ ]
+ }
+ },
+ "0x174": {
+ "name": "NV_PFB_FBPA_WPRE0"
+ },
+ "0x178": {
+ "name": "NV_PFB_FBPA_WPRE1"
+ },
+ "0x17c": {
+ "name": "NV_PFB_FBPA_WPRE2"
+ },
+ "0x1d8": {
+ "name": "NV_PFB_FBPA_PM_CAPTURE_CTL"
+ },
+ "0x200": {
+ "name": "NV_PFB_FBPA_CFG0",
+ "fields": {
+ "BL": [
+ 1,
+ 0
+ ],
+ "EXTBANK": [
+ 2,
+ 2
+ ],
+ "X8": [
+ 4,
+ 4
+ ],
+ "CA_RETURN_TO_ZERO": [
+ 5,
+ 5
+ ],
+ "HBM_CMD_PARITY_OVERRIDE": [
+ 6,
+ 6
+ ],
+ "EDC_RATE": [
+ 7,
+ 7
+ ],
+ "GAPLESS_WTR": [
+ 8,
+ 8
+ ],
+ "DRAM_ACPD": [
+ 11,
+ 11
+ ],
+ "AUTO_ATR": [
+ 12,
+ 12
+ ],
+ "DATA_SCRAMBLE": [
+ 13,
+ 13
+ ],
+ "AUTO_SYNC": [
+ 14,
+ 14
+ ],
+ "LOW_POWER": [
+ 15,
+ 15
+ ],
+ "ABI": [
+ 16,
+ 16
+ ],
+ "BURST_LENGTH": [
+ 17,
+ 17
+ ],
+ "DM_ON_ADR": [
+ 18,
+ 18
+ ],
+ "TIMING_UNIT": [
+ 19,
+ 19
+ ],
+ "CMD_PARITY": [
+ 20,
+ 20
+ ],
+ "AUTO_ODT": [
+ 21,
+ 21
+ ],
+ "CLAMSHELL": [
+ 22,
+ 22
+ ],
+ "EDC_EARLY_VALUE": [
+ 23,
+ 23
+ ],
+ "BG_8": [
+ 24,
+ 24
+ ],
+ "AUTO_PRE": [
+ 25,
+ 25
+ ],
+ "SAME_QUSE_TWO_RANKS": [
+ 26,
+ 26
+ ],
+ "RDEDC": [
+ 27,
+ 27
+ ],
+ "WREDC": [
+ 28,
+ 28
+ ],
+ "REPLAY": [
+ 29,
+ 29
+ ],
+ "MS01_ECC": [
+ 30,
+ 30
+ ],
+ "DRAM_ACK": [
+ 31,
+ 31
+ ]
+ }
+ },
+ "0x204": {
+ "name": "NV_PFB_FBPA_CFG1",
+ "fields": {
+ "SUBP": [
+ 1,
+ 0
+ ],
+ "AP": [
+ 6,
+ 4
+ ],
+ "COL": [
+ 15,
+ 12
+ ],
+ "ROWA": [
+ 19,
+ 16
+ ],
+ "ROWB": [
+ 23,
+ 20
+ ],
+ "BANK": [
+ 25,
+ 24
+ ],
+ "BG_8_OVR": [
+ 26,
+ 26
+ ],
+ "NUM_BANKS_PER_BG": [
+ 28,
+ 27
+ ],
+ "NUM_BANKS_PER_ROW_BG": [
+ 30,
+ 29
+ ]
+ }
+ },
+ "0x20c": {
+ "name": "NV_PFB_FBPA_CSTATUS"
+ },
+ "0x210": {
+ "name": "NV_PFB_FBPA_REFCTRL",
+ "fields": {
+ "PUT": [
+ 6,
+ 0
+ ],
+ "GET": [
+ 14,
+ 8
+ ],
+ "RESET": [
+ 16,
+ 16
+ ],
+ "VALID": [
+ 31,
+ 31
+ ]
+ }
+ },
+ "0x214": {
+ "name": "NV_PFB_FBPA_ZQ"
+ },
+ "0x218": {
+ "name": "NV_PFB_FBPA_PIN"
+ },
+ "0x21c": {
+ "name": "NV_PFB_FBPA_PAD"
+ },
+ "0x220": {
+ "name": "NV_PFB_FBPA_TIMING0"
+ },
+ "0x224": {
+ "name": "NV_PFB_FBPA_TIMING1"
+ },
+ "0x228": {
+ "name": "NV_PFB_FBPA_TIMING2"
+ },
+ "0x22c": {
+ "name": "NV_PFB_FBPA_TIMING3"
+ },
+ "0x230": {
+ "name": "NV_PFB_FBPA_TIMING4"
+ },
+ "0x234": {
+ "name": "NV_PFB_FBPA_TIMING5"
+ },
+ "0x238": {
+ "name": "NV_PFB_FBPA_TIMING6"
+ },
+ "0x23c": {
+ "name": "NV_PFB_FBPA_TIMING7"
+ },
+ "0x240": {
+ "name": "NV_PFB_FBPA_TIMING8"
+ },
+ "0x244": {
+ "name": "NV_PFB_FBPA_TIMING9"
+ },
+ "0x248": {
+ "name": "NV_PFB_FBPA_TIMING10"
+ },
+ "0x24c": {
+ "name": "NV_PFB_FBPA_TIMING11"
+ },
+ "0x250": {
+ "name": "NV_PFB_FBPA_TIMING12"
+ },
+ "0x254": {
+ "name": "NV_PFB_FBPA_TIMING13"
+ },
+ "0x258": {
+ "name": "NV_PFB_FBPA_TIMING14"
+ },
+ "0x25c": {
+ "name": "NV_PFB_FBPA_TIMING15"
+ },
+ "0x260": {
+ "name": "NV_PFB_FBPA_TIMING16"
+ },
+ "0x264": {
+ "name": "NV_PFB_FBPA_TIMING17"
+ },
+ "0x268": {
+ "name": "NV_PFB_FBPA_TIMING18"
+ },
+ "0x26c": {
+ "name": "NV_PFB_FBPA_TIMING19"
+ },
+ "0x280": {
+ "name": "NV_PFB_FBPA_CG1"
+ },
+ "0x284": {
+ "name": "NV_PFB_FBPA_CG2"
+ },
+ "0x288": {
+ "name": "NV_PFB_FBPA_TIMING20_GEN"
+ },
+ "0x28c": {
+ "name": "NV_PFB_FBPA_TIMING20"
+ },
+ "0x290": {
+ "name": "NV_PFB_FBPA_CONFIG0"
+ },
+ "0x294": {
+ "name": "NV_PFB_FBPA_CONFIG1"
+ },
+ "0x298": {
+ "name": "NV_PFB_FBPA_CONFIG2"
+ },
+ "0x29c": {
+ "name": "NV_PFB_FBPA_CONFIG3"
+ },
+ "0x2a0": {
+ "name": "NV_PFB_FBPA_CONFIG4"
+ },
+ "0x2a4": {
+ "name": "NV_PFB_FBPA_CONFIG5"
+ },
+ "0x2a8": {
+ "name": "NV_PFB_FBPA_CONFIG6"
+ },
+ "0x2ac": {
+ "name": "NV_PFB_FBPA_CONFIG7"
+ },
+ "0x2b0": {
+ "name": "NV_PFB_FBPA_TIMING0_GEN"
+ },
+ "0x2b4": {
+ "name": "NV_PFB_FBPA_TIMING1_GEN"
+ },
+ "0x2b8": {
+ "name": "NV_PFB_FBPA_TIMING2_GEN"
+ },
+ "0x2bc": {
+ "name": "NV_PFB_FBPA_TIMING3_GEN"
+ },
+ "0x2c0": {
+ "name": "NV_PFB_FBPA_TIMING4_GEN"
+ },
+ "0x2c4": {
+ "name": "NV_PFB_FBPA_TIMING5_GEN"
+ },
+ "0x2c8": {
+ "name": "NV_PFB_FBPA_TIMING6_GEN"
+ },
+ "0x2cc": {
+ "name": "NV_PFB_FBPA_CONFIG8"
+ },
+ "0x2d0": {
+ "name": "NV_PFB_FBPA_TIMING7_GEN"
+ },
+ "0x2d4": {
+ "name": "NV_PFB_FBPA_TIMING8_GEN"
+ },
+ "0x2d8": {
+ "name": "NV_PFB_FBPA_TIMING9_GEN"
+ },
+ "0x2dc": {
+ "name": "NV_PFB_FBPA_TIMING15_GEN"
+ },
+ "0x2e0": {
+ "name": "NV_PFB_FBPA_TIMING16_GEN"
+ },
+ "0x2e4": {
+ "name": "NV_PFB_FBPA_TIMING17_GEN"
+ },
+ "0x2e8": {
+ "name": "NV_PFB_FBPA_CONFIG9"
+ },
+ "0x2ec": {
+ "name": "NV_PFB_FBPA_TIMING18_GEN"
+ },
+ "0x2f0": {
+ "name": "NV_PFB_FBPA_TIMING19_GEN"
+ },
+ "0x2f4": {
+ "name": "NV_PFB_FBPA_CONFIG10"
+ },
+ "0x2f8": {
+ "name": "NV_PFB_FBPA_ASR_WAKEUP"
+ },
+ "0x2fc": {
+ "name": "NV_PFB_FBPA_DRAM_ASR"
+ },
+ "0x300": {
+ "name": "NV_PFB_FBPA_GENERIC_MRS"
+ }
+ },
+ "windows": [
+ {"base": "0x20000000009A0000", "size": "0x4000", "label": "fbpa"},
+ {"base": "0x2000000000900000", "size": "0x4000", "label": "fbpa-partition", "count": 16, "stride": "0x4000"},
+ {"base": "0x2000000000980000", "size": "0x4000", "label": "fbpa-mc", "count": 3, "stride": "0x4000"}
+ ]
+ },
"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": [
@@ -80,7 +882,7 @@
"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",
+ "a0": "0x81000",
"a1": "0x80000",
"a2": "0x4000000",
"a3": "0x1b34ea0",
@@ -92,7 +894,16 @@
"core_dump_pa": "0x40000",
"core_dump_size": "0x10000",
"wpr_meta_pa": "0x90000",
- "image_at": "0x100000000",
+ "bootstrap_args": {
+ "pa": "0x81000",
+ "imem_base_pa": "0x0",
+ "imem_size": "0x10000",
+ "imem_bs_size": "0x0",
+ "dmem_base_pa": "0x100000000",
+ "dmem_size": "0x10000",
+ "dmem_bs_size": "0x0",
+ "evidence": "libos bootstrap-args layout (IMEM/DMEM base+size+bs, passed in a0 by the bootloader); the M-mode boot path (mstatus.VM=NVMPU, observed) uses the non-sep-kern TCM configuration (offsets 0, 64 KiB). Values are declared assumptions, not silicon reads."
+ },
"wpr": {
"elf_pa": "0x4000000",
"elf_size": "0x1b34ea0",
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_gmmu_debug.py b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_debug.py
new file mode 100644
index 0000000..d9046be
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_debug.py
@@ -0,0 +1,484 @@
+"""Tests for the GMMU/FBIF static<->dynamic join (harness/gmmu_debug.py, docs/44).
+
+All fixtures are synthetic: no firmware, no simulator run, no QEMU, no
+hardware. The end-to-end demo test builds its own fixture in a temporary
+directory and checks the frozen threshold result.
+
+Run::
+
+ python3 -m unittest tests.test_gmmu_debug -v
+"""
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import os
+import sys
+import tempfile
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.abspath(os.path.join(HERE, ".."))
+if TOOL_ROOT not in sys.path:
+ sys.path.insert(0, TOOL_ROOT)
+
+from harness.gmmu_debug import ( # noqa: E402
+ DEBUG_REPORT_FORMAT,
+ HITS_HEADER,
+ MANUAL_STATIC_BASELINE_SECONDS,
+ TRACE_FORMAT,
+ WORKFLOW_THRESHOLD_FRACTION,
+ GmmuInputError,
+ build_debug_report,
+ load_sim_trace,
+ load_symbols,
+ main,
+ run_demo,
+ run_report,
+ write_hits_tsv,
+)
+
+
+def _write_json(path: str, payload) -> str:
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(payload, fh)
+ return path
+
+
+def _symbols_json(path: str) -> str:
+ return _write_json(
+ path,
+ {
+ "format": "greeneye-symbols/1",
+ "symbols": [
+ {"addr": 0x4001000, "name": "gmmu_map_alloc", "size": 0x120},
+ {"addr": 0x4002000, "name": "fbif_setup", "size": 0x80},
+ ],
+ },
+ )
+
+
+def _events() -> list[dict]:
+ return [
+ {"seq": 0, "pc": "0x4002040", "kind": "write", "window": "falcon",
+ "offset": "0x624", "name": "FBIF_CTL", "value": "0x1"},
+ {"seq": 1, "pc": "0x4002050", "kind": "read", "window": "falcon",
+ "offset": "0x8", "name": "IRQSTAT", "value": "0x0"},
+ {"seq": 2, "pc": "0x4001010", "kind": "write", "window": "gmmu",
+ "offset": "0x0", "name": "GMMU_PDB_LO", "value": "0x400000"},
+ {"seq": 3, "pc": "0x4001020", "kind": "write", "window": "gmmu",
+ "offset": "0x0", "name": "GMMU_PDB_LO", "value": "0x800000"},
+ {"seq": 4, "pc": "0x4009000", "kind": "write", "window": "gmmu",
+ "offset": "0x10", "name": "GMMU_MODE", "value": "0x7"},
+ ]
+
+
+def _g1_document() -> dict:
+ """The nested shape emitted by sim/devices/observe.py:build_trace."""
+ return {
+ "schema": TRACE_FORMAT,
+ "image_sha256": "ab" * 32,
+ "profile": "tu104-570.144",
+ "profile_name": "fixture",
+ "symbols": "symbols.json",
+ "summary": {"windows": 1, "accesses": 2, "retained_accesses": 2,
+ "dropped_accesses": 0},
+ "windows": [
+ {
+ "label": "falcon-observe",
+ "base": "0x300110000",
+ "size": "0x1000",
+ "mode": "observe-only",
+ "evidence": "header",
+ "counts": {"r": 1, "w": 1, "served": 0, "declined": 2},
+ "registers": [
+ {
+ "offset": "0x624",
+ "name": "FBIF_CTL",
+ "access": "rw",
+ "counts": {"r": 0, "w": 1, "served": 0},
+ "accesses": [
+ {"addr": "0x300110624", "offset": "0x624", "size": 4,
+ "rw": "w", "value": "0x1", "pc": "0x4002040", "vt": 123,
+ "window": "falcon-observe", "register": "FBIF_CTL",
+ "served": False, "pc_symbol": "fbif_setup"},
+ ],
+ }
+ ],
+ "unclassified": {
+ "counts": {"r": 1, "w": 0, "served": 0},
+ "accesses": [
+ {"addr": "0x300110008", "offset": "0x8", "size": 4,
+ "rw": "r", "value": None, "pc": "0x4002050", "vt": 123,
+ "window": "falcon-observe", "register": None,
+ "served": False, "pc_symbol": "fbif_setup"},
+ ],
+ },
+ }
+ ],
+ }
+
+
+def _run_main(argv: list[str]) -> tuple[int, str, str]:
+ out, err = io.StringIO(), io.StringIO()
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ rc = main(argv)
+ return rc, out.getvalue(), err.getvalue()
+
+
+class TraceIngestTests(unittest.TestCase):
+ def test_trace_tagged_and_aliases(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = _write_json(
+ os.path.join(tmp, "t.json"),
+ {
+ "format": TRACE_FORMAT,
+ "events": [
+ {"pc": 0x4002040, "rw": "w", "block": "falcon",
+ "reg_offset": 0x624, "register": "FBIF_CTL", "data": 1},
+ ],
+ },
+ )
+ trace = load_sim_trace(path)
+ self.assertEqual(trace.format, TRACE_FORMAT)
+ obs = trace.observations[0]
+ self.assertEqual((obs.kind, obs.window, obs.offset, obs.name, obs.value),
+ ("write", "falcon", 0x624, "FBIF_CTL", 1))
+
+ def test_jsonl_fallback(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "t.jsonl")
+ with open(path, "w", encoding="utf-8") as fh:
+ fh.write(json.dumps({"format": TRACE_FORMAT}) + "\n")
+ fh.write(json.dumps({"t": "write", "addr": "0x624", "size": 4, "pc": "0x4002040"}) + "\n")
+ fh.write(json.dumps({"t": "summary", "insns": 1}) + "\n")
+ trace = load_sim_trace(path)
+ self.assertEqual(len(trace.observations), 1)
+ self.assertEqual(trace.observations[0].addr, 0x624)
+
+ def test_empty_event_list_is_legal(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = _write_json(os.path.join(tmp, "t.json"), {"format": TRACE_FORMAT, "events": []})
+ trace = load_sim_trace(path)
+ self.assertEqual(trace.observations, [])
+
+ def test_untagged_object_without_events_is_clean_error(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = _write_json(os.path.join(tmp, "t.json"), {"hello": "world"})
+ with self.assertRaises(GmmuInputError):
+ load_sim_trace(path)
+
+ def test_missing_trace_is_clean_error(self):
+ with self.assertRaises(GmmuInputError):
+ load_sim_trace("/does/not/exist.json")
+
+ def test_session_directory_ingest(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ blobs = os.path.join(tmp, "blobs")
+ os.makedirs(blobs)
+ _write_json(os.path.join(tmp, "session.json"), {"format": "greeneye-session/1"})
+ _write_json(
+ os.path.join(blobs, "gmmu-fbif-trace.json"),
+ {"format": TRACE_FORMAT, "events": _events()},
+ )
+ trace = load_sim_trace(tmp)
+ self.assertEqual(len(trace.observations), len(_events()))
+
+ def test_g1_nested_document_flattens(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = _write_json(os.path.join(tmp, "t.json"), _g1_document())
+ trace = load_sim_trace(path)
+ self.assertEqual(trace.format, TRACE_FORMAT)
+ self.assertEqual(len(trace.observations), 2)
+ self.assertEqual(trace.source["profile"], "tu104-570.144")
+ self.assertEqual(trace.source["summary"]["dropped_accesses"], 0)
+ self.assertEqual(trace.source["summary"]["retained_accesses"], 2)
+ write_obs = trace.observations[0]
+ self.assertEqual(write_obs.kind, "write")
+ self.assertEqual(write_obs.name, "FBIF_CTL")
+ self.assertEqual(write_obs.window, "falcon-observe")
+ self.assertEqual(write_obs.offset, 0x624)
+ self.assertEqual(write_obs.addr, 0x300110624)
+ self.assertEqual(write_obs.pc, 0x4002040)
+ self.assertEqual(write_obs.vt, 123)
+ self.assertIs(write_obs.handled, False)
+ self.assertEqual(write_obs.function, "fbif_setup")
+ read_obs = trace.observations[1]
+ self.assertEqual(read_obs.kind, "read")
+ self.assertEqual(read_obs.offset, 0x8)
+ self.assertIsNone(read_obs.value)
+
+ def test_g1_nested_document_report(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = _write_json(os.path.join(tmp, "t.json"), _g1_document())
+ report = build_debug_report(load_sim_trace(path), None)
+ self.assertEqual(report["summary"]["functions"], 1)
+ function = report["functions"][0]
+ self.assertEqual(function["function"], "fbif_setup")
+ registers = {r["register"]: r for r in function["registers"]}
+ self.assertEqual(registers["FBIF_CTL"]["write_count"], 1)
+ self.assertEqual(registers["0x8"]["read_count"], 1)
+
+
+class ObserveProducerIntegrationTests(unittest.TestCase):
+ """Ingest a document produced by the real sim/devices/observe.py module.
+
+ The producer is in-tree, so the import is unconditional: if it breaks,
+ the G1<->G3 interface has regressed and this test must fail, not skip.
+ """
+
+ def test_ingest_real_build_trace_document(self):
+ from sim.devices.observe import ObservedWindow, build_trace
+ window = ObservedWindow(
+ base=0x300110000,
+ size=0x1000,
+ label="integration",
+ registers={"0x624": {"name": "FBIF_CTL", "width": 4, "access": "rw"}},
+ )
+ self.assertFalse(window.write(0x624, 4, 1, pc=0x4002040))
+ self.assertIsNone(window.read(0x8, 4, pc=0x4002050))
+ registry = type("Registry", (), {"devices": [window]})()
+ document = build_trace(
+ registry, image_sha256="cd" * 32, profile="tu104-570.144"
+ )
+ self.assertEqual(document.get("schema"), TRACE_FORMAT)
+ with tempfile.TemporaryDirectory() as tmp:
+ path = _write_json(os.path.join(tmp, "t.json"), document)
+ trace = load_sim_trace(path)
+ self.assertEqual(trace.format, TRACE_FORMAT)
+ self.assertEqual(len(trace.observations), 2)
+ names = {obs.name for obs in trace.observations}
+ self.assertIn("FBIF_CTL", names)
+ write_obs = next(obs for obs in trace.observations if obs.kind == "write")
+ self.assertEqual(write_obs.offset, 0x624)
+ self.assertEqual(write_obs.value, 1)
+
+
+class SymbolLoadingTests(unittest.TestCase):
+ def test_symbols_json_db(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ db = load_symbols(_symbols_json(os.path.join(tmp, "s.json")))
+ self.assertEqual(db.resolve(0x4002040).name, "fbif_setup")
+
+ def test_symbols_plain_map_json(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = _write_json(os.path.join(tmp, "s.json"), {"0x4001000": "gmmu_map_alloc"})
+ db = load_symbols(path)
+ self.assertEqual(db.get("gmmu_map_alloc").addr, 0x4001000)
+
+ def test_symbols_tsv(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "s.tsv")
+ with open(path, "w", encoding="utf-8") as fh:
+ fh.write("0x4001000\tgmmu_map_alloc\n0x4002000\tfbif_setup\n")
+ db = load_symbols(path)
+ self.assertEqual(db.resolve(0x4002000).name, "fbif_setup")
+
+ def test_missing_symbols_is_clean_error(self):
+ with self.assertRaises(GmmuInputError):
+ load_symbols("/does/not/exist.tsv")
+
+
+class ReportTests(unittest.TestCase):
+ def _report(self, events=None, symbols=None):
+ with tempfile.TemporaryDirectory() as tmp:
+ trace_path = _write_json(
+ os.path.join(tmp, "t.json"),
+ {"format": TRACE_FORMAT, "events": events if events is not None else _events()},
+ )
+ trace = load_sim_trace(trace_path)
+ return build_debug_report(trace, symbols)
+
+ def test_report_shape_and_resolution(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ symbols = load_symbols(_symbols_json(os.path.join(tmp, "s.json")))
+ report = self._report(symbols=symbols)
+ self.assertEqual(report["format"], DEBUG_REPORT_FORMAT)
+ summary = report["summary"]
+ self.assertEqual(summary["functions"], 3)
+ self.assertEqual(summary["writes"], 4)
+ self.assertEqual(summary["reads"], 1)
+ names = [f["function"] for f in report["functions"]]
+ self.assertEqual(names, ["gmmu_map_alloc", "fbif_setup", "sub_0x4009000"])
+ fbif = report["functions"][1]
+ self.assertEqual(fbif["addr"], 0x4002000)
+ registers = {r["register"]: r for r in fbif["registers"]}
+ self.assertEqual(registers["FBIF_CTL"]["write_count"], 1)
+ self.assertEqual(registers["FBIF_CTL"]["first_pc"], 0x4002040)
+ self.assertEqual(registers["FBIF_CTL"]["last_pc"], 0x4002040)
+ self.assertEqual(registers["IRQSTAT"]["read_count"], 1)
+ unknown = report["functions"][2]
+ self.assertEqual(unknown["function"], "sub_0x4009000")
+ self.assertEqual(unknown["addr"], 0x4009000)
+
+ def test_sequence_order_and_last_pc(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ symbols = load_symbols(_symbols_json(os.path.join(tmp, "s.json")))
+ report = self._report(symbols=symbols)
+ alloc = report["functions"][0]
+ pdb = next(r for r in alloc["registers"] if r["register"] == "GMMU_PDB_LO")
+ self.assertEqual(pdb["write_count"], 2)
+ self.assertEqual(pdb["values"], [0x400000, 0x800000])
+ self.assertEqual(pdb["first_pc"], 0x4001010)
+ self.assertEqual(pdb["last_pc"], 0x4001020)
+
+ def test_explicit_function_field_wins(self):
+ events = [
+ {"seq": 0, "pc": "0x4009000", "kind": "write", "window": "gmmu",
+ "offset": "0x10", "name": "GMMU_MODE", "value": "0x7",
+ "function": "fbif_setup"},
+ ]
+ with tempfile.TemporaryDirectory() as tmp:
+ symbols = load_symbols(_symbols_json(os.path.join(tmp, "s.json")))
+ report = self._report(events=events, symbols=symbols)
+ self.assertEqual(report["functions"][0]["function"], "fbif_setup")
+ self.assertEqual(report["functions"][0]["addr"], 0x4002000)
+
+ def test_no_symbols_gives_sub_labels(self):
+ report = self._report(symbols=None)
+ self.assertTrue(all(f["function"].startswith("sub_") for f in report["functions"]))
+
+ def test_events_without_pc_are_counted_not_attributed(self):
+ events = [{"seq": 0, "kind": "write", "window": "gmmu", "offset": "0x10",
+ "name": "GMMU_MODE", "value": "0x7"}]
+ report = self._report(events=events, symbols=None)
+ self.assertEqual(report["summary"]["unknown_pc_events"], 1)
+ self.assertEqual(report["summary"]["functions"], 0)
+
+ def test_empty_trace(self):
+ report = self._report(events=[], symbols=None)
+ self.assertEqual(report["summary"]["functions"], 0)
+ self.assertEqual(report["summary"]["registers"], 0)
+ self.assertEqual(report["functions"], [])
+
+
+class HitsTsvTests(unittest.TestCase):
+ def test_hits_tsv_is_markgreeneye_compatible(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ symbols = load_symbols(_symbols_json(os.path.join(tmp, "s.json")))
+ trace = load_sim_trace(
+ _write_json(
+ os.path.join(tmp, "t.json"),
+ {"format": TRACE_FORMAT, "events": _events()},
+ )
+ )
+ report = build_debug_report(trace, symbols)
+ hits_path = write_hits_tsv(report, os.path.join(tmp, "greeneye_hits.tsv"))
+ with open(hits_path, "r", encoding="utf-8") as fh:
+ lines = [line.rstrip("\n") for line in fh]
+ self.assertEqual(lines[0], HITS_HEADER)
+ self.assertTrue(lines[0].startswith("addr"))
+ rows = [line.split("\t") for line in lines[1:]]
+ self.assertEqual(len(rows), 3)
+ for cols in rows:
+ self.assertGreaterEqual(len(cols), 2)
+ int(cols[0].replace("0x", ""), 16)
+ self.assertTrue(cols[1].isdigit())
+ first = rows[0]
+ self.assertEqual(first[0], "0x4001000")
+ self.assertEqual(first[2], "gmmu_map_alloc")
+ self.assertIn("write", first[3])
+ addrs = {int(cols[0], 16) for cols in rows}
+ self.assertIn(0x4009000, addrs)
+
+ def test_hits_tsv_empty_report(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ report = {"format": DEBUG_REPORT_FORMAT, "functions": []}
+ hits_path = write_hits_tsv(report, os.path.join(tmp, "greeneye_hits.tsv"))
+ with open(hits_path, "r", encoding="utf-8") as fh:
+ self.assertEqual(fh.read().strip(), HITS_HEADER)
+
+
+class ArtifactTests(unittest.TestCase):
+ def test_run_report_writes_artifacts(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ symbols_path = _symbols_json(os.path.join(tmp, "s.json"))
+ trace_path = _write_json(
+ os.path.join(tmp, "t.json"),
+ {"format": TRACE_FORMAT, "events": _events()},
+ )
+ out = os.path.join(tmp, "out")
+ report = run_report(trace_path, symbols_path, out)
+ for key in ("report_json", "report_md", "hits_tsv", "ghidra_command"):
+ self.assertIn(key, report["artifacts"])
+ self.assertTrue(os.path.isfile(report["artifacts"]["report_json"]))
+ self.assertTrue(os.path.isfile(report["artifacts"]["report_md"]))
+ self.assertTrue(os.path.isfile(report["artifacts"]["hits_tsv"]))
+ self.assertIn("MarkGreenEyeHits.java", report["artifacts"]["ghidra_command"])
+ with open(report["artifacts"]["report_json"], "r", encoding="utf-8") as fh:
+ loaded = json.load(fh)
+ self.assertEqual(loaded["summary"]["functions"], 3)
+ with open(report["artifacts"]["report_md"], "r", encoding="utf-8") as fh:
+ md = fh.read()
+ self.assertIn("Per function", md)
+ self.assertIn("Ghidra annotation", md)
+
+
+class DemoTests(unittest.TestCase):
+ def test_demo_end_to_end_and_frozen_threshold(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ out = io.StringIO()
+ with contextlib.redirect_stdout(out):
+ result = run_demo(tmp)
+ self.assertIn("RESULT_JSON:", out.getvalue())
+ self.assertTrue(result["pass"])
+ self.assertLessEqual(result["measured_seconds"], result["threshold_seconds"])
+ self.assertEqual(
+ result["threshold_seconds"],
+ round(MANUAL_STATIC_BASELINE_SECONDS * WORKFLOW_THRESHOLD_FRACTION, 4),
+ )
+ self.assertIn("estimate", result["manual_baseline_label"])
+ self.assertEqual(result["summary"]["functions"], 4)
+ self.assertEqual(result["summary"]["writes"], 11)
+ self.assertEqual(result["summary"]["reads"], 2)
+ for key in ("report_json", "report_md", "hits_tsv"):
+ self.assertTrue(os.path.isfile(result["artifacts"][key]))
+ self.assertTrue(os.path.isfile(os.path.join(tmp, "demo-result.json")))
+
+
+class CliTests(unittest.TestCase):
+ def test_report_cli(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ symbols_path = _symbols_json(os.path.join(tmp, "s.json"))
+ trace_path = _write_json(
+ os.path.join(tmp, "t.json"),
+ {"format": TRACE_FORMAT, "events": _events()},
+ )
+ out = os.path.join(tmp, "out")
+ rc, stdout, _stderr = _run_main(
+ ["report", "--trace", trace_path, "--symbols", symbols_path, "--out", out]
+ )
+ self.assertEqual(rc, 0)
+ payload = json.loads(stdout[stdout.index("{"):stdout.rindex("}") + 1])
+ self.assertEqual(payload["summary"]["functions"], 3)
+ self.assertTrue(os.path.isfile(os.path.join(out, "greeneye_hits.tsv")))
+
+ def test_report_cli_missing_trace_exits_2(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ rc, _stdout, stderr = _run_main(
+ ["report", "--trace", "/nope.json", "--out", os.path.join(tmp, "out")]
+ )
+ self.assertEqual(rc, 2)
+ self.assertIn("trace not found", stderr)
+
+ def test_empty_trace_cli_writes_report(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ trace_path = _write_json(
+ os.path.join(tmp, "t.json"), {"format": TRACE_FORMAT, "events": []}
+ )
+ out = os.path.join(tmp, "out")
+ rc, stdout, _stderr = _run_main(["report", "--trace", trace_path, "--out", out])
+ self.assertEqual(rc, 0)
+ payload = json.loads(stdout[stdout.index("{"):stdout.rindex("}") + 1])
+ self.assertEqual(payload["summary"]["functions"], 0)
+ self.assertTrue(os.path.isfile(os.path.join(out, "gmmu-debug-report.md")))
+
+ def test_no_command_prints_help(self):
+ rc, stdout, _stderr = _run_main([])
+ self.assertEqual(rc, 2)
+ self.assertIn("usage", stdout.lower())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_gmmu_fbif_trace.py b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_fbif_trace.py
new file mode 100644
index 0000000..7b43b64
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_fbif_trace.py
@@ -0,0 +1,381 @@
+"""GMMU/FBIF observer tests: no-fake-read, no semantic change, trace schema.
+
+Every assertion is tied to the evidence documented in
+``sim/devices/observe.py`` and ``docs/42-GMMU-FBIF-OBSERVABILITY.md``. The
+real-firmware runs are bounded (<= 20k instructions) and assert observed
+numbers only: adding observer windows must not change instructions, stop
+PC/reason, unknown MMIO, unhandled-in-window counts or served accesses.
+"""
+import json
+import os
+import sys
+import tempfile
+import unittest
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from sim.devices import ( # noqa: E402
+ ObservedWindow,
+ PmcBlock,
+ TRACE_SCHEMA,
+ build_trace,
+ collect_observations,
+ load_profile,
+ parse_window_spec,
+ registry_from_profile,
+ windows_from_section,
+)
+from sim.devices.base import DeviceRegistry # noqa: E402
+from sim.machine import Machine # noqa: E402
+
+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")
+
+PMC_BASE = 0x2000000000000000
+PMC_BOOT_42 = PMC_BASE + 0xA00
+
+
+class ObserveWindowUnitTests(unittest.TestCase):
+ def test_observe_only_declines_and_records(self):
+ reg = DeviceRegistry()
+ dev = ObservedWindow(base=0x1000, size=0x100, label="unit",
+ mode="observe-only",
+ registers={0x10: "REG_A"})
+ reg.add(dev)
+ value = reg.read(0x1010, 4, pc=0x4000)
+ self.assertEqual(value, (False, 0))
+ self.assertEqual(reg.unhandled, 1)
+ self.assertEqual(dev.counts["r"], 1)
+ self.assertEqual(dev.counts["declined"], 1)
+ entry = dev.accesses[0]
+ self.assertEqual(entry["addr"], 0x1010)
+ self.assertEqual(entry["offset"], 0x10)
+ self.assertEqual(entry["size"], 4)
+ self.assertEqual(entry["rw"], "r")
+ self.assertIsNone(entry["value"]) # never invent a read value
+ self.assertFalse(entry["served"])
+ self.assertEqual(entry["pc"], 0x4000)
+ self.assertEqual(entry["register"], "REG_A")
+ self.assertEqual(entry["window"], "unit")
+ self.assertIsNone(entry["vt"]) # no clock supplied
+ self.assertFalse(reg.write(0x1010, 4, 0xAB, pc=0x4004))
+ self.assertEqual(dev.accesses[1]["value"], 0xAB)
+ self.assertFalse(dev.accesses[1]["served"])
+
+ def test_storage_reads_back_written_values_only(self):
+ dev = ObservedWindow(base=0x2000, size=0x100, label="st", mode="storage",
+ registers={
+ 0x10: {"name": "SPARE", "access": "rw"},
+ 0x14: {"name": "RO", "access": "ro"},
+ })
+ reg = DeviceRegistry()
+ reg.add(dev)
+ # Never-written storage register: declined, no invented reset value.
+ self.assertEqual(reg.read(0x2010, 4), (False, 0))
+ self.assertTrue(reg.write(0x2010, 4, 0xCAFE))
+ self.assertEqual(reg.read(0x2010, 4), (True, 0xCAFE))
+ # Read-only register: writes declined, reads have no value source.
+ self.assertFalse(reg.write(0x2014, 4, 1))
+ self.assertEqual(reg.read(0x2014, 4), (False, 0))
+ # Undeclared offset inside the window stays declined.
+ self.assertEqual(reg.read(0x2018, 4), (False, 0))
+ # Wrong width is not served.
+ self.assertEqual(reg.read(0x2010, 8), (False, 0))
+ self.assertEqual(dev.written[0x10], 0xCAFE)
+ self.assertEqual(dev.counts["served"], 2) # one write + one read-back
+
+ def test_storage_field_decode_in_trace(self):
+ dev = ObservedWindow(base=0x3000, size=0x100, label="fields",
+ mode="storage",
+ registers={0x10: {"name": "CFG",
+ "fields": {"LOW": [3, 0],
+ "HIGH": [7, 4]}}})
+ reg = DeviceRegistry()
+ reg.add(dev)
+ reg.write(0x3010, 4, 0x0053, pc=0x100)
+ reg.read(0x3010, 4, pc=0x104)
+ doc = build_trace(reg, image_sha256="0" * 64, profile="p.json",
+ profile_name="p")
+ win = doc["windows"][0]
+ self.assertEqual(win["label"], "fields")
+ self.assertEqual(win["mode"], "storage")
+ reg_entry = win["registers"][0]
+ self.assertEqual(reg_entry["name"], "CFG")
+ self.assertEqual(reg_entry["counts"], {"r": 1, "w": 1, "served": 2})
+ write, read = reg_entry["accesses"]
+ self.assertEqual(write["value"], "0x53")
+ self.assertEqual(write["decoded"], {"LOW": 3, "HIGH": 5})
+ self.assertEqual(read["value"], "0x53")
+
+ def test_windows_from_section_expands_count_and_stride(self):
+ section = {
+ "mode": "observe-only",
+ "registers": {"0x0": "REG0"},
+ "windows": [
+ {"base": "0x1000", "size": "0x100", "label": "single"},
+ {"base": "0x2000", "size": "0x100", "label": "many",
+ "count": 3, "stride": "0x1000"},
+ {"base": "0x3000", "size": "0x100", "label": "override",
+ "mode": "storage"},
+ ],
+ }
+ windows = windows_from_section(section)
+ self.assertEqual([w["label"] for w in windows],
+ ["single", "many-0", "many-1", "many-2", "override"])
+ self.assertEqual([w["base"] for w in windows],
+ [0x1000, 0x2000, 0x3000, 0x4000, 0x3000])
+ self.assertEqual(windows[1]["registers"], {"0x0": "REG0"})
+ self.assertEqual(windows[-1]["mode"], "storage")
+
+ def test_parse_window_spec(self):
+ win = parse_window_spec("0x2000000000100000:0x1000:adhoc")
+ self.assertEqual((win["base"], win["size"], win["label"], win["mode"]),
+ (0x2000000000100000, 0x1000, "adhoc", "observe-only"))
+ win = parse_window_spec("0x1000:0x100:st:storage")
+ self.assertEqual(win["mode"], "storage")
+ with self.assertRaises(ValueError):
+ parse_window_spec("0x1000")
+ with self.assertRaises(ValueError):
+ parse_window_spec("0x1000:0x100:bad:wrong-mode")
+
+ def test_state_round_trip(self):
+ dev = ObservedWindow(base=0x4000, size=0x100, label="rt", mode="storage",
+ registers={0x10: "SPARE"})
+ dev.write(0x10, 4, 0x1234)
+ snap = dev.state()
+ other = ObservedWindow(base=0x4000, size=0x100, label="rt", mode="storage",
+ registers={0x10: "SPARE"})
+ other.load_state(snap)
+ self.assertEqual(other.state(), dev.state())
+ self.assertEqual(other.written[0x10], 0x1234)
+
+ def test_access_log_cap_is_reported_not_silent(self):
+ dev = ObservedWindow(base=0x6000, size=0x100, label="cap",
+ max_access_log=2, registers={0x10: "REG_A"})
+ for value in (1, 2, 3, 4):
+ dev.write(0x10, 4, value)
+ doc = build_trace(type("Registry", (), {"devices": [dev]})())
+ self.assertEqual(dev.counts["w"], 4)
+ win = doc["windows"][0]
+ self.assertEqual(win["counts"]["w"], 4)
+ self.assertEqual(win["retained_accesses"], 2)
+ self.assertEqual(win["dropped_accesses"], 2)
+ self.assertEqual(win["registers"][0]["counts"]["w"], 2)
+ summary = doc["summary"]
+ self.assertEqual(summary["accesses"], 4)
+ self.assertEqual(summary["retained_accesses"], 2)
+ self.assertEqual(summary["dropped_accesses"], 2)
+
+
+class NoSemanticChangeTests(unittest.TestCase):
+ def test_observe_only_matches_no_window_lenient(self):
+ reg_a = DeviceRegistry()
+ reg_a.add(ObservedWindow(base=0x50000000, size=0x1000, label="obs"))
+ reg_b = DeviceRegistry()
+ a = Machine(memory_mb=1, ram_base=0, devices=reg_a, unknown_mmio="lenient")
+ b = Machine(memory_mb=1, ram_base=0, devices=reg_b, unknown_mmio="lenient")
+ self.assertEqual(a._mmio_load(0x50000010, 4, pc=0x40),
+ b._mmio_load(0x50000010, 4, pc=0x40))
+ self.assertEqual(a.mmio_unknown, b.mmio_unknown)
+ self.assertEqual(a.mmio_log, b.mmio_log)
+ a._mmio_store(0x50000010, 4, 7, pc=0x44)
+ b._mmio_store(0x50000010, 4, 7, pc=0x44)
+ self.assertEqual(a.mmio_unknown, b.mmio_unknown)
+ self.assertEqual(a.mmio_log, b.mmio_log)
+
+ def test_observe_only_matches_no_window_strict(self):
+ from sim.machine import Fault
+
+ reg_a = DeviceRegistry()
+ reg_a.add(ObservedWindow(base=0x50000000, size=0x1000, label="obs"))
+ a = Machine(memory_mb=1, ram_base=0, devices=reg_a, unknown_mmio="fault")
+ b = Machine(memory_mb=1, ram_base=0, devices=DeviceRegistry(),
+ unknown_mmio="fault")
+ with self.assertRaises(Fault) as ca:
+ a._mmio_load(0x50000010, 4, pc=0x40)
+ with self.assertRaises(Fault) as cb:
+ b._mmio_load(0x50000010, 4, pc=0x40)
+ self.assertEqual(ca.exception.cause, cb.exception.cause)
+ self.assertEqual(ca.exception.tval, cb.exception.tval)
+
+ def test_observer_does_not_shadow_a_modelled_window(self):
+ reg = DeviceRegistry()
+ pmc = PmcBlock(chip_id=0x164, base=PMC_BASE, size=0x1000)
+ reg.add(pmc)
+ obs = ObservedWindow(base=PMC_BOOT_42, size=0x10, label="overlay")
+ reg.add(obs)
+ machine = Machine(memory_mb=1, ram_base=0, devices=reg,
+ unknown_mmio="lenient")
+ value = machine._mmio_load(PMC_BOOT_42, 4, pc=0x400)
+ self.assertEqual(value, pmc.read(0xA00, 4))
+ self.assertEqual(reg.unhandled, 0)
+ self.assertEqual(len(obs.accesses), 1)
+ self.assertFalse(obs.accesses[0]["served"])
+ self.assertIsNone(obs.accesses[0]["value"])
+
+ def test_gmmu_wpr_overlap_leaves_underlying_path_identical(self):
+ # gmmu-wpr-lock (profile) overlaps the fwsec WPR2 window; the
+ # observer must record and decline, leaving whatever the underlying
+ # path does byte-for-byte identical to a run without the observer.
+ profile = load_profile(DEFAULT_PROFILE)
+ addr = 0x20000000001FA824
+
+ def probe(p):
+ machine = Machine(memory_mb=16, ram_base=0, unknown_mmio="lenient")
+ reg = registry_from_profile(p, machine=machine)
+ machine.devices = reg
+ value = machine._mmio_load(addr, 4, pc=0x40)
+ observations = collect_observations(reg)
+ return (machine.mmio_unknown, reg.unhandled, value,
+ {d.label: d.counts for d in observations})
+
+ with_observers = probe(profile)
+ stripped = json.loads(json.dumps(profile))
+ stripped["devices"].pop("gmmu", None)
+ stripped["devices"].pop("fbif", None)
+ without_observers = probe(stripped)
+ self.assertEqual(with_observers[:3], without_observers[:3])
+ self.assertEqual(with_observers[3]["gmmu-wpr-lock"]["declined"], 1)
+ self.assertEqual(without_observers[3], {})
+
+
+class ProfileAndBootTests(unittest.TestCase):
+ def test_profile_declares_gmmu_fbif_observe_windows(self):
+ profile = load_profile(DEFAULT_PROFILE)
+ self.assertIn("gmmu", profile["devices"])
+ self.assertIn("fbif", profile["devices"])
+ machine = Machine(memory_mb=16, ram_base=0, unknown_mmio="lenient")
+ reg = registry_from_profile(profile, machine=machine)
+ observers = collect_observations(reg)
+ self.assertEqual(len(observers), 23)
+ by_label = {d.label: d for d in observers}
+ self.assertEqual(by_label["gmmu-pri-mmu"].base, 0x2000000000100C80)
+ self.assertEqual(by_label["gmmu-pri-mmu"].size, 0x280)
+ self.assertEqual(by_label["gmmu-wpr-lock"].base, 0x20000000001FA7D8)
+ self.assertEqual(by_label["gmmu-hsmmu"].base, 0x20000000001FAC00)
+ self.assertEqual(by_label["fbpa"].base, 0x20000000009A0000)
+ self.assertEqual(by_label["fbpa-partition-15"].base,
+ 0x2000000000900000 + 15 * 0x4000)
+ self.assertEqual(by_label["fbpa-mc-2"].base,
+ 0x2000000000980000 + 2 * 0x4000)
+ for dev in observers:
+ self.assertEqual(dev.mode, "observe-only")
+ self.assertEqual(by_label["gmmu-pri-mmu"].registers[0x0]["name"],
+ "NV_PFB_PRI_MMU_CTRL")
+ self.assertEqual(by_label["fbpa"].registers[0x50]["name"],
+ "NV_PFB_FBPA_SPARE")
+
+ def test_b2_numbers_unchanged_by_observer_windows(self):
+ from sim.gsp_boot import run_boot
+
+ r = run_boot(image=DEFAULT_IMAGE, profile_path=DEFAULT_PROFILE,
+ instructions=20_000, record=False, use_shim=True)
+ self.assertEqual(r.rung, "B2")
+ self.assertEqual(r.instructions, 20_000)
+ self.assertEqual(r.stop_reason, "budget")
+ self.assertEqual(r.mmio_unknown, 0)
+ self.assertEqual(r.device_unhandled, 0)
+ self.assertEqual(r.device_accesses, 15)
+ # The run reaches the WPR2 reads inside the gmmu-wpr-lock observer
+ # window (offsets 0x4C/0x50); the observer records them without
+ # serving, and the fwsec device still answers.
+ self.assertIsNotNone(r.trace_summary)
+ self.assertEqual(r.trace_summary["windows"], 23)
+ self.assertEqual(r.trace_summary["accesses"], 2)
+ self.assertEqual(r.trace_summary["served"], 0)
+
+ # Same run with the observer sections removed: identical execution.
+ with open(DEFAULT_PROFILE, "r", encoding="utf-8") as fh:
+ profile = json.load(fh)
+ del profile["devices"]["gmmu"]
+ del profile["devices"]["fbif"]
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "no-observe.json")
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(profile, fh)
+ base = run_boot(image=DEFAULT_IMAGE, profile_path=path,
+ instructions=20_000, record=False, use_shim=True)
+ self.assertEqual(base.instructions, r.instructions)
+ self.assertEqual(base.stop_reason, r.stop_reason)
+ self.assertEqual(base.stop_pc, r.stop_pc)
+ self.assertEqual(base.mmio_unknown, r.mmio_unknown)
+ self.assertEqual(base.device_unhandled, r.device_unhandled)
+ self.assertEqual(base.device_accesses, r.device_accesses)
+ self.assertEqual(base.registers, r.registers)
+
+ def test_boot_trace_schema_and_determinism(self):
+ from sim.gsp_boot import run_boot
+
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "trace-dir")
+ os.makedirs(path)
+ r = run_boot(image=DEFAULT_IMAGE, profile_path=DEFAULT_PROFILE,
+ instructions=20_000, record=False, use_shim=True,
+ trace_out=path)
+ self.assertEqual(r.trace_path,
+ os.path.join(path, "gmmu-fbif-trace.json"))
+ with open(r.trace_path, "r", encoding="utf-8") as fh:
+ doc = json.load(fh)
+ self.assertEqual(doc["schema"], TRACE_SCHEMA)
+ self.assertEqual(doc["image_sha256"], r.image_sha256)
+ self.assertEqual(set(doc["summary"]),
+ {"windows", "windows_touched", "accesses",
+ "retained_accesses", "dropped_accesses",
+ "reads", "writes", "served", "registers_touched"})
+ self.assertEqual(doc["summary"]["accesses"], 2)
+ self.assertEqual(doc["summary"]["retained_accesses"], 2)
+ self.assertEqual(doc["summary"]["dropped_accesses"], 0)
+ labels = [w["label"] for w in doc["windows"]]
+ self.assertIn("gmmu-pri-mmu", labels)
+ self.assertIn("fbpa", labels)
+ for win in doc["windows"]:
+ self.assertEqual(set(win),
+ {"label", "base", "size", "mode", "evidence",
+ "counts", "dropped_accesses",
+ "retained_accesses", "registers",
+ "unclassified"})
+ self.assertEqual(win["mode"], "observe-only")
+ self.assertEqual(win["dropped_accesses"], 0)
+ self.assertTrue(win["base"].startswith("0x"))
+ r2 = run_boot(image=DEFAULT_IMAGE, profile_path=DEFAULT_PROFILE,
+ instructions=20_000, record=False, use_shim=True)
+ self.assertEqual(r.device_state_sha256, r2.device_state_sha256)
+
+ def test_runtime_window_records_without_faking_and_resolves_symbols(self):
+ from sim.gsp_boot import run_boot
+
+ symbols = {"format": "greeneye-symbols/1", "count": 1,
+ "symbols": [{"addr": "0x4009014", "name": "boot_probe",
+ "size": 64}]}
+ with tempfile.TemporaryDirectory() as tmp:
+ sym_path = os.path.join(tmp, "symbols.json")
+ trace_path = os.path.join(tmp, "trace.json")
+ with open(sym_path, "w", encoding="utf-8") as fh:
+ json.dump(symbols, fh)
+ r = run_boot(
+ image=DEFAULT_IMAGE, profile_path=DEFAULT_PROFILE,
+ instructions=20_000, record=False, use_shim=True,
+ observe_windows=["0x%x:0x10:pmc-probe" % PMC_BOOT_42],
+ symbols_path=sym_path, trace_out=trace_path)
+ # Two gmmu-wpr-lock reads (the WPR2 pair) plus the two BOOT_42
+ # reads inside the runtime PMC probe window.
+ self.assertEqual(r.trace_summary["accesses"], 4)
+ self.assertEqual(r.trace_summary["windows"], 24)
+ with open(trace_path, "r", encoding="utf-8") as fh:
+ doc = json.load(fh)
+ self.assertEqual(doc["symbols"], sym_path)
+ win = [w for w in doc["windows"] if w["label"] == "pmc-probe"][0]
+ entry = win["unclassified"]["accesses"][0]
+ self.assertEqual(entry["addr"], "0x%x" % PMC_BOOT_42)
+ self.assertEqual(entry["rw"], "r")
+ self.assertIsNone(entry["value"]) # observe-only: no fake read
+ self.assertFalse(entry["served"])
+ self.assertEqual(entry["pc_symbol"], "boot_probe")
+ self.assertIsInstance(entry["vt"], int)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_gmmu_oracle.py b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_oracle.py
new file mode 100644
index 0000000..9211c81
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_oracle.py
@@ -0,0 +1,397 @@
+"""Tests for the GMMU/FBIF oracle comparison (harness/gmmu_oracle.py, docs/44).
+
+All fixtures are synthetic and written to temporary directories: no oracle
+capture, no hardware, no simulator run, no QEMU, no network. The comparison
+is offline file-in/file-out. Tests that would need real inputs are written
+to assert the clean error path instead.
+
+Run::
+
+ python3 -m unittest tests.test_gmmu_oracle -v
+"""
+from __future__ import annotations
+
+import contextlib
+import io
+import json
+import os
+import sys
+import tempfile
+import unittest
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+TOOL_ROOT = os.path.abspath(os.path.join(HERE, ".."))
+if TOOL_ROOT not in sys.path:
+ sys.path.insert(0, TOOL_ROOT)
+
+from harness.gmmu_debug import GmmuInputError, TRACE_FORMAT # noqa: E402
+from harness.gmmu_oracle import ( # noqa: E402
+ ORACLE_FORMAT,
+ REPORT_FORMAT,
+ compare,
+ load_oracle,
+ load_sim_trace,
+ main,
+ print_capture_hints,
+ run_compare,
+)
+
+
+def _write_json(path: str, payload) -> str:
+ with open(path, "w", encoding="utf-8") as fh:
+ json.dump(payload, fh)
+ return path
+
+
+def _oracle_items() -> list[dict]:
+ return [
+ {
+ "name": "FBIF_CTL",
+ "window": "falcon",
+ "offset": "0x624",
+ "value": "0x1",
+ "source": "rm-readback",
+ "captured_at": "2026-10-03",
+ },
+ {
+ "name": "GMMU_PDB_LO",
+ "window": "gmmu",
+ "offset": "0x0",
+ "value": "0x400000",
+ "source": "rm-readback",
+ "captured_at": "2026-10-03",
+ },
+ ]
+
+
+def _sim_events() -> list[dict]:
+ return [
+ {
+ "seq": 0,
+ "pc": "0x4002040",
+ "kind": "write",
+ "window": "falcon",
+ "offset": "0x624",
+ "name": "FBIF_CTL",
+ "size": 4,
+ "value": "0x1",
+ },
+ {
+ "seq": 1,
+ "pc": "0x4001010",
+ "kind": "write",
+ "window": "gmmu",
+ "offset": "0x0",
+ "name": "GMMU_PDB_LO",
+ "size": 4,
+ "value": "0x400000",
+ },
+ ]
+
+
+def _run_main(argv: list[str]) -> tuple[int, str, str]:
+ out, err = io.StringIO(), io.StringIO()
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ rc = main(argv)
+ return rc, out.getvalue(), err.getvalue()
+
+
+class IngestTests(unittest.TestCase):
+ def test_oracle_object_and_bare_list(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ tagged = _write_json(
+ os.path.join(tmp, "o1.json"),
+ {"format": ORACLE_FORMAT, "captures": _oracle_items()},
+ )
+ bare = _write_json(os.path.join(tmp, "o2.json"), _oracle_items())
+ self.assertEqual(load_oracle(tagged).format, ORACLE_FORMAT)
+ self.assertEqual(load_oracle(bare).format, "unlabeled-list")
+ self.assertEqual(len(load_oracle(tagged).items), 2)
+ self.assertEqual(load_oracle(tagged).items[0].value, 1)
+ self.assertEqual(load_oracle(tagged).items[1].offset, 0)
+
+ def test_oracle_wrong_format_is_clean_error(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = _write_json(
+ os.path.join(tmp, "o.json"), {"format": "something-else", "captures": []}
+ )
+ with self.assertRaises(GmmuInputError):
+ load_oracle(path)
+
+ def test_oracle_missing_file_is_clean_error(self):
+ with self.assertRaises(GmmuInputError):
+ load_oracle("/does/not/exist.json")
+
+ def test_trace_aliases(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = _write_json(
+ os.path.join(tmp, "t.json"),
+ {
+ "format": TRACE_FORMAT,
+ "events": [
+ {
+ "pc": 0x4002040,
+ "rw": "w",
+ "block": "falcon",
+ "reg_offset": 0x624,
+ "register": "FBIF_CTL",
+ "value": 1,
+ }
+ ],
+ },
+ )
+ trace = load_sim_trace(path)
+ obs = trace.observations[0]
+ self.assertEqual(obs.kind, "write")
+ self.assertEqual(obs.window, "falcon")
+ self.assertEqual(obs.offset, 0x624)
+ self.assertEqual(obs.name, "FBIF_CTL")
+ self.assertEqual(obs.value, 1)
+
+ def test_trace_missing_file_is_clean_error(self):
+ with self.assertRaises(GmmuInputError):
+ load_sim_trace("/does/not/exist.json")
+
+
+class ComparisonTests(unittest.TestCase):
+ def _compare(self, items, events):
+ with tempfile.TemporaryDirectory() as tmp:
+ oracle_path = _write_json(
+ os.path.join(tmp, "oracle.json"),
+ {"format": ORACLE_FORMAT, "captures": items},
+ )
+ trace_path = _write_json(
+ os.path.join(tmp, "trace.json"), {"format": TRACE_FORMAT, "events": events}
+ )
+ return compare(load_oracle(oracle_path), load_sim_trace(trace_path))
+
+ def test_full_agreement(self):
+ report = self._compare(_oracle_items(), _sim_events())
+ summary = report["summary"]
+ self.assertEqual(summary["oracle_items"], 2)
+ self.assertEqual(summary["compared"], 2)
+ self.assertEqual(summary["agreed"], 2)
+ self.assertEqual(summary["mismatched"], 0)
+ self.assertEqual(summary["oracle_only"], 0)
+ self.assertEqual(summary["simulator_only"], 0)
+ self.assertEqual(summary["agreement_pct"], 100.0)
+ self.assertEqual(summary["coverage_pct"], 100.0)
+ self.assertTrue(all(row["status"] == "agree" for row in report["rows"]))
+ self.assertTrue(all(row["fidelity"] == "observed vs modelled" for row in report["rows"]))
+
+ def test_mismatch_is_reported(self):
+ events = _sim_events()
+ events[0]["value"] = "0x2"
+ report = self._compare(_oracle_items(), events)
+ summary = report["summary"]
+ self.assertEqual(summary["mismatched"], 1)
+ self.assertEqual(summary["agreed"], 1)
+ self.assertEqual(summary["agreement_pct"], 50.0)
+ mismatch = next(row for row in report["rows"] if row["status"] == "mismatch")
+ self.assertEqual(mismatch["oracle_value"], 1)
+ self.assertEqual(mismatch["sim_value"], 2)
+
+ def test_missing_simulator_observation(self):
+ report = self._compare(_oracle_items(), _sim_events()[:1])
+ summary = report["summary"]
+ self.assertEqual(summary["oracle_only"], 1)
+ self.assertEqual(summary["coverage_pct"], 50.0)
+ self.assertEqual(len(report["unmatched_oracle"]), 1)
+ self.assertEqual(report["unmatched_oracle"][0]["register"], "GMMU_PDB_LO")
+ oracle_only = next(row for row in report["rows"] if row["status"] == "oracle-only")
+ self.assertIn("observed", oracle_only["fidelity"])
+
+ def test_missing_oracle_counterpart(self):
+ extra = {
+ "seq": 9,
+ "pc": "0x4003010",
+ "kind": "write",
+ "window": "gmmu",
+ "offset": "0x200",
+ "name": "PTE_DATA",
+ "value": "0x1",
+ }
+ report = self._compare(_oracle_items(), _sim_events() + [extra])
+ self.assertEqual(report["summary"]["simulator_only"], 1)
+ self.assertEqual(len(report["unmatched_simulator"]), 1)
+ self.assertEqual(report["unmatched_simulator"][0]["register"], "PTE_DATA")
+ self.assertEqual(report["summary"]["simulator_match_pct"], 66.67)
+
+ def test_name_fallback_when_oracle_has_no_offset(self):
+ items = [{"name": "IRQSTAT", "window": "falcon", "value": "0x0", "source": "rm"}]
+ events = [
+ {
+ "seq": 0,
+ "pc": 0x4002050,
+ "kind": "read",
+ "window": "falcon",
+ "offset": "0x8",
+ "name": "IRQSTAT",
+ "value": "0x0",
+ }
+ ]
+ report = self._compare(items, events)
+ self.assertEqual(report["summary"]["agreed"], 1)
+ self.assertEqual(report["rows"][0]["matched_by"], "window-name")
+
+ def test_no_value_is_not_agreement(self):
+ items = [{"name": "FBIF_CTL", "window": "falcon", "offset": "0x624"}]
+ report = self._compare(items, _sim_events()[:1])
+ self.assertEqual(report["summary"]["no_value"], 1)
+ self.assertEqual(report["summary"]["agreement_pct"], None)
+
+ def test_g1_nested_document_compares(self):
+ document = {
+ "schema": TRACE_FORMAT,
+ "image_sha256": "ab" * 32,
+ "profile": "tu104-570.144",
+ "windows": [
+ {
+ "label": "falcon-observe",
+ "registers": [
+ {
+ "offset": "0x624",
+ "name": "FBIF_CTL",
+ "access": "rw",
+ "accesses": [
+ {"addr": "0x300110624", "offset": "0x624",
+ "size": 4, "rw": "w", "value": "0x1",
+ "pc": "0x4002040", "window": "falcon-observe",
+ "register": "FBIF_CTL", "served": False,
+ "pc_symbol": None},
+ ],
+ }
+ ],
+ }
+ ],
+ }
+ with tempfile.TemporaryDirectory() as tmp:
+ oracle_path = _write_json(
+ os.path.join(tmp, "oracle.json"),
+ {
+ "format": ORACLE_FORMAT,
+ "captures": [
+ {"name": "FBIF_CTL", "window": "falcon-observe",
+ "offset": "0x624", "value": "0x1", "source": "rm"}
+ ],
+ },
+ )
+ trace_path = _write_json(os.path.join(tmp, "trace.json"), document)
+ report = compare(load_oracle(oracle_path), load_sim_trace(trace_path))
+ self.assertEqual(report["summary"]["oracle_items"], 1)
+ self.assertEqual(report["summary"]["agreed"], 1)
+ self.assertEqual(report["inputs"]["simulator"]["format"], TRACE_FORMAT)
+
+ def test_empty_inputs(self):
+ report = self._compare([], [])
+ summary = report["summary"]
+ self.assertEqual(summary["oracle_items"], 0)
+ self.assertEqual(summary["compared"], 0)
+ self.assertEqual(summary["agreement_pct"], None)
+ self.assertEqual(summary["coverage_pct"], None)
+ self.assertEqual(report["rows"], [])
+ self.assertEqual(report["unmatched_simulator"], [])
+
+
+class SessionIngestTests(unittest.TestCase):
+ def test_session_directory_with_blobs_trace(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ session = os.path.join(tmp, "session")
+ blobs = os.path.join(session, "blobs")
+ os.makedirs(blobs)
+ _write_json(
+ os.path.join(session, "session.json"),
+ {"format": "greeneye-session/1", "meta": {}},
+ )
+ _write_json(
+ os.path.join(blobs, "gmmu-fbif-trace.json"),
+ {"format": TRACE_FORMAT, "events": _sim_events()},
+ )
+ trace = load_sim_trace(session)
+ self.assertEqual(len(trace.observations), 2)
+ self.assertEqual(trace.format, TRACE_FORMAT)
+
+ def test_session_without_trace_is_clean_error(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ with self.assertRaises(GmmuInputError):
+ load_sim_trace(tmp)
+
+
+class ReportArtifactTests(unittest.TestCase):
+ def test_run_compare_writes_json_and_markdown(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ oracle_path = _write_json(
+ os.path.join(tmp, "oracle.json"),
+ {"format": ORACLE_FORMAT, "captures": _oracle_items()},
+ )
+ trace_path = _write_json(
+ os.path.join(tmp, "trace.json"),
+ {"format": TRACE_FORMAT, "events": _sim_events()},
+ )
+ out = os.path.join(tmp, "out")
+ report = run_compare(oracle_path, trace_path, out)
+ self.assertEqual(report["format"], REPORT_FORMAT)
+ self.assertTrue(os.path.isfile(report["artifacts"]["report_json"]))
+ self.assertTrue(os.path.isfile(report["artifacts"]["report_md"]))
+ with open(report["artifacts"]["report_json"], "r", encoding="utf-8") as fh:
+ loaded = json.load(fh)
+ self.assertEqual(loaded["summary"]["agreed"], 2)
+ with open(report["artifacts"]["report_md"], "r", encoding="utf-8") as fh:
+ md = fh.read()
+ self.assertIn("oracle vs simulator report", md)
+ self.assertIn("No cycle/timing claim", md)
+ self.assertIn("Oracle items with no simulator observation", md)
+
+
+class CliTests(unittest.TestCase):
+ def test_print_capture_hints_executes_nothing(self):
+ out, err = io.StringIO(), io.StringIO()
+ with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
+ print_capture_hints(out)
+ text = out.getvalue()
+ self.assertIn("SUPERVISED, READ-ONLY, HUMAN-RUN ONLY", text)
+ self.assertIn("executes NOTHING", text)
+ self.assertIn("RUNBOOK P1/P7", text)
+ self.assertEqual(err.getvalue(), "")
+
+ def test_print_capture_hints_cli(self):
+ rc, stdout, _stderr = _run_main(["--print-capture-hints"])
+ self.assertEqual(rc, 0)
+ self.assertIn("rmharness/harness.sh", stdout)
+ self.assertIn("gmmu_oracle.py compare", stdout)
+
+ def test_compare_cli_writes_report(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ oracle_path = _write_json(
+ os.path.join(tmp, "oracle.json"),
+ {"format": ORACLE_FORMAT, "captures": _oracle_items()},
+ )
+ trace_path = _write_json(
+ os.path.join(tmp, "trace.json"),
+ {"format": TRACE_FORMAT, "events": _sim_events()},
+ )
+ out = os.path.join(tmp, "out")
+ rc, stdout, _stderr = _run_main(
+ ["compare", "--oracle", oracle_path, "--sim", trace_path, "--out", out]
+ )
+ self.assertEqual(rc, 0)
+ payload = json.loads(stdout[stdout.index("{"):stdout.rindex("}") + 1])
+ self.assertEqual(payload["summary"]["agreed"], 2)
+ self.assertTrue(os.path.isfile(os.path.join(out, "gmmu-oracle-report.json")))
+
+ def test_compare_missing_inputs_exits_2(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ rc, _stdout, stderr = _run_main(
+ ["compare", "--oracle", "/nope.json", "--sim", "/nope.json", "--out", tmp]
+ )
+ self.assertEqual(rc, 2)
+ self.assertIn("oracle not found", stderr)
+
+ def test_no_command_prints_help(self):
+ rc, stdout, _stderr = _run_main([])
+ self.assertEqual(rc, 2)
+ self.assertIn("usage", stdout.lower())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_gmmu_pte.py b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_pte.py
new file mode 100644
index 0000000..2460f1f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_gmmu_pte.py
@@ -0,0 +1,534 @@
+"""Golden-vector tests for the clean-room TU104 GMMU PTE/PDE decoder.
+
+Every vector is constructed field-by-field from the bit positions documented
+in ``sim/gmmu_pte.py`` (see ``docs/43-GMMU-PTE-DECODER.md``), then decoded and asserted field-by-field.
+The tests also pin the explicit failure behaviour: reserved bits are
+reported, unsupported encodings/levels error instead of guessing, empty and
+truncated blobs are handled deterministically, and decode output is
+repeatable.
+"""
+import contextlib
+import io
+import json
+import os
+import sys
+import tempfile
+import unittest
+
+sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
+
+from sim.gmmu_pte import ( # noqa: E402
+ APERTURE_NAMES_PDE,
+ APERTURE_NAMES_PTE,
+ E_BAD_VALUE,
+ E_TRUNCATED,
+ E_UNSUPPORTED,
+ GmmuDecodeError,
+ KIND_VER2,
+ KIND_VER2_DUAL,
+ LEVELS,
+ PTE_KIND_NAMES,
+ SUPPORTED_LEVELS,
+ decode_dual_pde,
+ decode_entry,
+ decode_pde,
+ decode_pte,
+ decode_table,
+ main,
+)
+
+
+def make_pte(*, valid=1, aperture=2, volatile=1, encrypted=0, privilege=0,
+ read_only=0, atomic_disable=1, address=0, comptagline=0,
+ peer_id=0, kind=0x06):
+ value = 0
+ value |= (valid & 1) << 0
+ value |= (aperture & 3) << 1
+ value |= (volatile & 1) << 3
+ value |= (encrypted & 1) << 4
+ value |= (privilege & 1) << 5
+ value |= (read_only & 1) << 6
+ value |= (atomic_disable & 1) << 7
+ if aperture in (2, 3):
+ value |= ((address >> 12) & ((1 << 46) - 1)) << 8
+ else:
+ value |= ((address >> 12) & ((1 << 25) - 1)) << 8
+ value |= (peer_id & 7) << 33
+ value |= (comptagline & ((1 << 20) - 1)) << 36
+ value |= (kind & 0xFF) << 56
+ return value
+
+
+def make_pde(*, aperture=1, volatile=1, no_ats=0, address=0, peer_id=0):
+ value = 0
+ value |= (aperture & 3) << 1
+ value |= (volatile & 1) << 3
+ value |= (no_ats & 1) << 5
+ value |= ((address >> 12) & ((1 << 46) - 1)) << 8
+ value |= (peer_id & 7) << 33
+ return value
+
+
+def make_dual(*, aperture_big=1, volatile_big=1, aperture_small=1,
+ volatile_small=1, address_big=0, address_small=0,
+ peer_big=0, peer_small=0, no_ats=0):
+ value = 0
+ value |= (aperture_big & 3) << 1
+ value |= (volatile_big & 1) << 3
+ value |= ((address_big >> 8) & ((1 << 50) - 1)) << 4
+ value |= (peer_big & 7) << 33
+ value |= (aperture_small & 3) << 65
+ value |= (volatile_small & 1) << 67
+ value |= ((address_small >> 12) & ((1 << 46) - 1)) << 72
+ value |= (peer_small & 7) << 97
+ value |= (no_ats & 1) << 5
+ return value
+
+
+class GoldenVectorTests(unittest.TestCase):
+ """Field-by-field constructed vectors must round-trip exactly."""
+
+ def test_pte_system_coherent_round_trip(self):
+ pa = 0x123456789000
+ value = make_pte(valid=1, aperture=2, volatile=1, encrypted=0,
+ privilege=1, read_only=1, atomic_disable=0,
+ address=pa, kind=0x06)
+ d = decode_pte(value)
+ self.assertEqual(d["role"], "pte")
+ self.assertTrue(d["valid"])
+ self.assertEqual(d["aperture"], 2)
+ self.assertEqual(d["aperture_name"], "system_coherent_memory")
+ self.assertTrue(d["volatile"])
+ self.assertFalse(d["encrypted"])
+ self.assertTrue(d["privilege"])
+ self.assertTrue(d["read_only"])
+ self.assertFalse(d["writable"])
+ self.assertFalse(d["atomic_disable"])
+ self.assertEqual(d["address"], pa)
+ self.assertTrue(d["address_complete"])
+ self.assertEqual(d["kind"], 0x06)
+ self.assertEqual(d["kind_name"], "GENERIC_MEMORY")
+ self.assertTrue(d["kind_known"])
+
+ def test_pte_system_non_coherent_round_trip(self):
+ pa = 0x2AAAAA0000
+ value = make_pte(aperture=3, address=pa, read_only=0,
+ atomic_disable=1, kind=0x00)
+ d = decode_pte(value)
+ self.assertEqual(d["aperture_name"], "system_non_coherent_memory")
+ self.assertEqual(d["address"], pa)
+ self.assertTrue(d["writable"])
+ self.assertTrue(d["atomic_disable"])
+ self.assertEqual(d["kind_name"], "PITCH")
+
+ def test_pte_video_aperture_round_trip(self):
+ pa_low = 0x0ABCDEF000
+ value = make_pte(aperture=0, address=pa_low, comptagline=0x12345,
+ kind=0x06)
+ d = decode_pte(value)
+ self.assertEqual(d["aperture_name"], "video_memory")
+ self.assertEqual(d["address"], pa_low)
+ self.assertFalse(d["address_complete"])
+ self.assertEqual(d["comptagline"], 0x12345)
+ self.assertEqual(d["peer_id"], 0)
+
+ def test_pte_peer_aperture_round_trip(self):
+ pa_low = 0x0100000000
+ value = make_pte(aperture=1, address=pa_low, peer_id=5, kind=0x06)
+ d = decode_pte(value)
+ self.assertEqual(d["aperture_name"], "peer_memory")
+ self.assertEqual(d["address"], pa_low)
+ self.assertFalse(d["address_complete"])
+ self.assertEqual(d["peer_id"], 5)
+
+ def test_pte_aperture_names_are_the_proven_four(self):
+ self.assertEqual(
+ APERTURE_NAMES_PTE,
+ {0: "video_memory", 1: "peer_memory",
+ 2: "system_coherent_memory", 3: "system_non_coherent_memory"})
+ for aperture in range(4):
+ d = decode_pte(make_pte(aperture=aperture))
+ self.assertEqual(d["aperture_name"], APERTURE_NAMES_PTE[aperture])
+
+ def test_pde_video_pointer_round_trip(self):
+ pa = 0x00000123456000
+ value = make_pde(aperture=1, volatile=1, no_ats=0, address=pa)
+ d = decode_pde(value)
+ self.assertEqual(d["role"], "pde")
+ self.assertTrue(d["is_pde_pointer"])
+ self.assertFalse(d["is_leaf"])
+ self.assertEqual(d["aperture_name"], "video_memory")
+ self.assertTrue(d["volatile"])
+ self.assertFalse(d["no_ats"])
+ self.assertEqual(d["address"], pa)
+ self.assertFalse(d["address_complete"])
+
+ def test_pde_system_pointer_round_trip(self):
+ pa = 0x1FFFF0000000
+ value = make_pde(aperture=2, volatile=0, no_ats=1, address=pa)
+ d = decode_pde(value)
+ self.assertEqual(d["aperture_name"], "system_coherent_memory")
+ self.assertFalse(d["volatile"])
+ self.assertTrue(d["no_ats"])
+ self.assertEqual(d["address"], pa)
+ self.assertTrue(d["address_complete"])
+
+ def test_pde_invalid_aperture_has_no_address(self):
+ d = decode_pde(make_pde(aperture=0, volatile=0))
+ self.assertEqual(d["aperture_name"], "invalid")
+ self.assertIsNone(d["address"])
+ self.assertFalse(d["address_complete"])
+
+ def test_pde_leaf_bit_is_rejected(self):
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_pde(1)
+ self.assertEqual(ctx.exception.code, E_UNSUPPORTED)
+
+ def test_dual_pde_halves_round_trip(self):
+ addr_big = 0x0000001234560000
+ addr_small = 0x0000000FEDCBA000
+ value = make_dual(aperture_big=1, volatile_big=1, aperture_small=1,
+ volatile_small=1, address_big=addr_big,
+ address_small=addr_small)
+ d = decode_dual_pde(value)
+ self.assertEqual(d["role"], "dual_pde")
+ self.assertTrue(d["is_pde_pointer"])
+ self.assertFalse(d["is_leaf"])
+ self.assertFalse(d["no_ats"])
+ self.assertEqual(d["big"]["aperture_name"], "video_memory")
+ self.assertTrue(d["big"]["volatile"])
+ self.assertEqual(d["big"]["address"], addr_big)
+ self.assertFalse(d["big"]["address_complete"])
+ self.assertEqual(d["small"]["aperture_name"], "video_memory")
+ self.assertTrue(d["small"]["volatile"])
+ self.assertEqual(d["small"]["address"], addr_small)
+ self.assertFalse(d["small"]["address_complete"])
+
+ def test_dual_pde_no_ats_bit_does_not_perturb_big_address(self):
+ # The format names bit 5 NO_ATS inside the BIG address field; valid
+ # depth-3 allocations are 4 KB aligned, so address bit 9 is zero and
+ # the flag must decode without changing the reconstructed address.
+ addr_big = 0x0000001234560000
+ d = decode_dual_pde(make_dual(address_big=addr_big, no_ats=1))
+ self.assertTrue(d["no_ats"])
+ self.assertEqual(d["big"]["address"], addr_big)
+ self.assertEqual(d["big"]["address_vid"] & (1 << 1), 1 << 1)
+
+ def test_dual_pde_system_halves_are_complete(self):
+ value = make_dual(aperture_big=2, aperture_small=3,
+ address_big=0x1000000000,
+ address_small=0x2000000000)
+ d = decode_dual_pde(value)
+ self.assertEqual(d["big"]["aperture_name"], "system_coherent_memory")
+ self.assertTrue(d["big"]["address_complete"])
+ self.assertEqual(d["big"]["address"], 0x1000000000)
+ self.assertEqual(d["small"]["aperture_name"],
+ "system_non_coherent_memory")
+ self.assertTrue(d["small"]["address_complete"])
+ self.assertEqual(d["small"]["address"], 0x2000000000)
+
+ def test_dual_pde_peer_ids_round_trip(self):
+ value = make_dual(aperture_big=1, aperture_small=1, peer_big=3,
+ peer_small=6)
+ d = decode_dual_pde(value)
+ self.assertEqual(d["big"]["peer_id"], 3)
+ self.assertEqual(d["small"]["peer_id"], 6)
+
+ def test_pde_aperture_names_are_the_proven_four(self):
+ self.assertEqual(
+ APERTURE_NAMES_PDE,
+ {0: "invalid", 1: "video_memory",
+ 2: "system_coherent_memory", 3: "system_non_coherent_memory"})
+ for aperture in range(4):
+ d = decode_pde(make_pde(aperture=aperture))
+ self.assertEqual(d["aperture_name"], APERTURE_NAMES_PDE[aperture])
+
+ def test_known_kind_names_spot_values(self):
+ self.assertEqual(PTE_KIND_NAMES[0x06], "GENERIC_MEMORY")
+ self.assertEqual(PTE_KIND_NAMES[0x00], "PITCH")
+ self.assertEqual(PTE_KIND_NAMES[0x07], "INVALID")
+ self.assertEqual(PTE_KIND_NAMES[0x0F], "SMSKED_MESSAGE")
+ self.assertEqual(PTE_KIND_NAMES[0xFE], "GENERIC_16BX2")
+ self.assertEqual(len(PTE_KIND_NAMES), 243)
+
+ def test_unknown_kind_field_is_reported_not_guessed(self):
+ value = make_pte(kind=0xFF)
+ d = decode_pte(value)
+ self.assertEqual(d["kind"], 0xFF)
+ self.assertIsNone(d["kind_name"])
+ self.assertFalse(d["kind_known"])
+
+ def test_level_metadata(self):
+ self.assertEqual([lv["depth"] for lv in LEVELS],
+ list(SUPPORTED_LEVELS))
+ self.assertEqual(decode_pte(make_pte(), level=3)["page_size"], "2m")
+ self.assertEqual(decode_pte(make_pte(), level=4)["page_size"],
+ "4k_or_64k")
+ self.assertEqual(decode_pte(make_pte())["level_candidates"], [3, 4])
+
+
+class ReservedBitTests(unittest.TestCase):
+ """Set bits with no named field must be reported, never dropped."""
+
+ def test_pde_all_bits_set_reports_exact_reserved_set(self):
+ d = decode_pde(((1 << 64) - 1) & ~1) # bit 0 clear = directory entry
+ self.assertEqual(d["reserved_bits"], [4, 6, 7] + list(range(54, 64)))
+ self.assertEqual(d["reserved_mask"],
+ sum(1 << b for b in d["reserved_bits"]))
+
+ def test_pde_named_bits_are_not_reported(self):
+ d = decode_pde(make_pde(aperture=2, volatile=1, no_ats=1,
+ address=0x12345000))
+ self.assertEqual(d["reserved_bits"], [])
+
+ def test_dual_pde_all_bits_set_reports_exact_reserved_set(self):
+ d = decode_dual_pde((1 << 128) - 1)
+ expected = list(range(54, 65)) + list(range(68, 72)) + \
+ list(range(118, 128))
+ self.assertEqual(d["reserved_bits"], expected)
+ self.assertEqual(d["reserved_mask"],
+ sum(1 << b for b in expected))
+
+ def test_dual_pde_unnamed_small_half_bit_64_is_reported(self):
+ d = decode_dual_pde(1 << 64)
+ self.assertEqual(d["reserved_bits"], [64])
+
+ def test_pte_has_no_reserved_bits(self):
+ d = decode_pte((1 << 64) - 1)
+ self.assertEqual(d["reserved_bits"], [])
+ self.assertEqual(d["reserved_mask"], 0)
+
+
+class ErrorBehaviourTests(unittest.TestCase):
+ """Unsupported requests error; they never guess."""
+
+ def test_unknown_encoding_kind_errors(self):
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_pte(0, pte_kind="legacy")
+ self.assertEqual(ctx.exception.code, E_UNSUPPORTED)
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_pde(0, pde_kind="ver1")
+ self.assertEqual(ctx.exception.code, E_UNSUPPORTED)
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_table(b"", base_pa=0, kind="nope")
+ self.assertEqual(ctx.exception.code, E_UNSUPPORTED)
+
+ def test_wrong_role_for_encoding_errors(self):
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_pte(0, pte_kind=KIND_VER2_DUAL)
+ self.assertEqual(ctx.exception.code, E_UNSUPPORTED)
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_dual_pde(0, pde_kind=KIND_VER2)
+ self.assertEqual(ctx.exception.code, E_UNSUPPORTED)
+
+ def test_unsupported_levels_error(self):
+ value = make_pde().to_bytes(8, "little")
+ for bad in (5, 99, -1, (0, 7)):
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_table(value, base_pa=0, levels=bad)
+ self.assertEqual(ctx.exception.code, E_UNSUPPORTED)
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_pte(0, level=0)
+ self.assertEqual(ctx.exception.code, E_UNSUPPORTED)
+
+ def test_entry_size_must_match_kind(self):
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_table(b"\x00" * 8, base_pa=0, kind=KIND_VER2_DUAL)
+ self.assertEqual(ctx.exception.code, E_UNSUPPORTED)
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_table(b"\x00" * 16, base_pa=0, entry_size=16)
+ self.assertEqual(ctx.exception.code, E_UNSUPPORTED)
+
+ def test_bad_values_error(self):
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_pte(1 << 64)
+ self.assertEqual(ctx.exception.code, E_BAD_VALUE)
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_dual_pde(-1)
+ self.assertEqual(ctx.exception.code, E_BAD_VALUE)
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_table(b"", base_pa=-1)
+ self.assertEqual(ctx.exception.code, E_BAD_VALUE)
+
+
+class TableDecodeTests(unittest.TestCase):
+ def test_empty_blob_is_empty_list(self):
+ self.assertEqual(decode_table(b"", base_pa=0x1000), [])
+ self.assertEqual(decode_table(b"", base_pa=0x1000, kind=KIND_VER2_DUAL,
+ entry_size=16), [])
+
+ def test_short_blob_errors(self):
+ for blob in (b"\x01", b"\x01\x02\x03", b"\x00" * 4, b"\x00" * 12):
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_table(blob, base_pa=0)
+ self.assertEqual(ctx.exception.code, E_TRUNCATED)
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_table(b"\x00" * 24, base_pa=0, kind=KIND_VER2_DUAL,
+ entry_size=16)
+ self.assertEqual(ctx.exception.code, E_TRUNCATED)
+
+ def test_two_entry_table_round_trip_and_addresses(self):
+ pte = make_pte(aperture=2, address=0x40000000)
+ pde = make_pde(aperture=1, address=0x50000000)
+ blob = pte.to_bytes(8, "little") + pde.to_bytes(8, "little")
+ entries = decode_table(blob, base_pa=0x1000)
+ self.assertEqual(len(entries), 2)
+ self.assertEqual(entries[0]["index"], 0)
+ self.assertEqual(entries[0]["entry_pa"], 0x1000)
+ self.assertEqual(entries[0]["role"], "pte")
+ self.assertEqual(entries[0]["address"], 0x40000000)
+ self.assertEqual(entries[1]["index"], 1)
+ self.assertEqual(entries[1]["entry_pa"], 0x1008)
+ self.assertEqual(entries[1]["role"], "pde")
+ self.assertEqual(entries[1]["address"], 0x50000000)
+
+ def test_auto_role_uses_bit_zero_and_reports_inference(self):
+ entries = decode_table(make_pte().to_bytes(8, "little"),
+ base_pa=0)
+ self.assertEqual(entries[0]["role"], "pte")
+ self.assertTrue(entries[0]["role_inferred"])
+ self.assertIsNone(entries[0]["level"])
+ self.assertEqual(entries[0]["level_candidates"], [3, 4])
+ entries = decode_table(make_pde().to_bytes(8, "little"), base_pa=0)
+ self.assertEqual(entries[0]["role"], "pde")
+ self.assertTrue(entries[0]["role_inferred"])
+ self.assertEqual(entries[0]["level_candidates"], [0, 1, 2, 3])
+
+ def test_pinned_level_selects_role(self):
+ pte = make_pte()
+ pde = make_pde()
+ leaf = decode_table(pte.to_bytes(8, "little"), base_pa=0, levels=3)
+ self.assertEqual((leaf[0]["role"], leaf[0]["level"]), ("pte", 3))
+ self.assertEqual(leaf[0]["page_size"], "2m")
+ ptr = decode_table(pde.to_bytes(8, "little"), base_pa=0, levels=3)
+ self.assertEqual((ptr[0]["role"], ptr[0]["level"]), ("pde", 3))
+ upper = decode_table(pde.to_bytes(8, "little"), base_pa=0, levels=1)
+ self.assertEqual((upper[0]["role"], upper[0]["level"]), ("pde", 1))
+ page = decode_table(pte.to_bytes(8, "little"), base_pa=0, levels=4)
+ self.assertEqual((page[0]["role"], page[0]["level"]), ("pte", 4))
+
+ def test_level_sequence_narrows_candidates(self):
+ entries = decode_table(make_pte().to_bytes(8, "little"), base_pa=0,
+ levels=(3, 4))
+ self.assertEqual(entries[0]["level_candidates"], [3, 4])
+ entries = decode_table(make_pde().to_bytes(8, "little"), base_pa=0,
+ levels=(3, 4))
+ self.assertEqual(entries[0]["level_candidates"], [3])
+
+ def test_dual_table_entry(self):
+ value = make_dual(address_big=0x1000, address_small=0x2000)
+ entries = decode_table(value.to_bytes(16, "little"), base_pa=0x2000,
+ kind=KIND_VER2_DUAL, entry_size=16)
+ self.assertEqual(len(entries), 1)
+ self.assertEqual(entries[0]["role"], "dual_pde")
+ self.assertEqual(entries[0]["entry_pa"], 0x2000)
+ self.assertEqual(entries[0]["encoding"], KIND_VER2_DUAL)
+ self.assertFalse(entries[0]["role_inferred"])
+ self.assertEqual(entries[0]["big"]["address"], 0x1000)
+ self.assertEqual(entries[0]["small"]["address"], 0x2000)
+
+ def test_decode_entry_explicit_roles(self):
+ pte = decode_entry(make_pte(), role="pte")
+ self.assertEqual(pte["role"], "pte")
+ pde = decode_entry(make_pde(), role="pde")
+ self.assertEqual(pde["role"], "pde")
+ dual = decode_entry(make_dual(), kind=KIND_VER2_DUAL, role="dual_pde")
+ self.assertEqual(dual["role"], "dual_pde")
+ with self.assertRaises(GmmuDecodeError) as ctx:
+ decode_entry(0, role="bogus")
+ self.assertEqual(ctx.exception.code, E_UNSUPPORTED)
+
+ def test_output_is_deterministic(self):
+ blob = (make_pte(aperture=2, address=0x1000).to_bytes(8, "little")
+ + make_pde(aperture=1, address=0x2000).to_bytes(8, "little")
+ + make_dual(address_big=0x3000,
+ address_small=0x4000).to_bytes(16, "little"))
+ first = decode_table(blob, base_pa=0x1000)
+ second = decode_table(blob, base_pa=0x1000)
+ self.assertEqual(first, second)
+ self.assertEqual(json.dumps(first, sort_keys=True),
+ json.dumps(second, sort_keys=True))
+
+ def test_blob_accepts_bytearray_and_memoryview(self):
+ pte = make_pte(aperture=2, address=0x1000).to_bytes(8, "little")
+ for blob in (bytearray(pte), memoryview(pte)):
+ entries = decode_table(blob, base_pa=0)
+ self.assertEqual(entries[0]["address"], 0x1000)
+
+
+class CliTests(unittest.TestCase):
+ def test_value_json(self):
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ rc = main(["--value", hex(make_pte(aperture=2, address=0x4000)),
+ "--role", "pte", "--json"])
+ self.assertEqual(rc, 0)
+ data = json.loads(buf.getvalue())
+ self.assertEqual(len(data), 1)
+ self.assertEqual(data[0]["role"], "pte")
+ self.assertEqual(data[0]["address"], 0x4000)
+
+ def test_value_level_is_honoured(self):
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ rc = main(["--value", hex(make_pte()), "--role", "pte",
+ "--levels", "3", "--json"])
+ self.assertEqual(rc, 0)
+ data = json.loads(buf.getvalue())
+ self.assertEqual(data[0]["level"], 3)
+ self.assertEqual(data[0]["page_size"], "2m")
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err):
+ rc = main(["--value", hex(make_pte()), "--role", "pte",
+ "--levels", "3,4"])
+ self.assertEqual(rc, 2)
+ self.assertIn(E_BAD_VALUE, err.getvalue())
+
+ def test_blob_json(self):
+ blob = (make_pte(aperture=2, address=0x1000).to_bytes(8, "little")
+ + make_pde(aperture=1, address=0x2000).to_bytes(8, "little"))
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "table.bin")
+ with open(path, "wb") as handle:
+ handle.write(blob)
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ rc = main(["--blob", path, "--base-pa", "0x1000", "--json"])
+ self.assertEqual(rc, 0)
+ data = json.loads(buf.getvalue())
+ self.assertEqual([e["role"] for e in data], ["pte", "pde"])
+ self.assertEqual(data[1]["entry_pa"], 0x1008)
+
+ def test_text_output_is_deterministic(self):
+ value = hex(make_pte(aperture=2, address=0x4000))
+ outputs = []
+ for _ in range(2):
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ rc = main(["--value", value, "--role", "pte"])
+ self.assertEqual(rc, 0)
+ outputs.append(buf.getvalue())
+ self.assertEqual(outputs[0], outputs[1])
+ self.assertIn("role=pte", outputs[0])
+
+ def test_truncated_blob_exits_two(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ path = os.path.join(tmp, "short.bin")
+ with open(path, "wb") as handle:
+ handle.write(b"\x01\x02\x03")
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err):
+ rc = main(["--blob", path, "--base-pa", "0x1000"])
+ self.assertEqual(rc, 2)
+ self.assertIn(E_TRUNCATED, err.getvalue())
+
+ def test_unsupported_level_exits_two(self):
+ err = io.StringIO()
+ with contextlib.redirect_stderr(err):
+ rc = main(["--value", "0x1", "--role", "pde", "--levels", "9"])
+ self.assertEqual(rc, 2)
+ self.assertIn(E_UNSUPPORTED, err.getvalue())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_mpu.py b/debug/risc-v-gsp-rm-vm/tests/test_mpu.py
new file mode 100644
index 0000000..73ac81f
--- /dev/null
+++ b/debug/risc-v-gsp-rm-vm/tests/test_mpu.py
@@ -0,0 +1,177 @@
+"""MMU (NVMPU) model tests: CSR semantics, translation, machine integration.
+
+The model is exercised directly and through the machine's guest paths; the
+real-firmware relocation-copy success is covered by
+``tests/test_sim_devices.py`` (boot ladder). All fixtures are synthetic; no
+hardware, module, guest or network is touched.
+"""
+from __future__ import annotations
+
+import unittest
+
+from sim.machine import Machine, R_EBREAK
+from sim.mpu import (
+ MSTATUS_VM_NVMPU,
+ MMPUATTR,
+ MMPUIDX,
+ MMPUPA,
+ MMPURNG,
+ MMPUVA,
+ RiscvMpu,
+)
+
+KB = 1024
+
+
+def enable(mpu: RiscvMpu) -> None:
+ mpu.on_csr_write(0x300, MSTATUS_VM_NVMPU << 24)
+
+
+def entry(mpu: RiscvMpu, index: int, va: int, pa: int, rng: int, attr: int = 0x600) -> None:
+ mpu.on_csr_write(MMPUIDX, index)
+ mpu.on_csr_write(MMPUPA, pa)
+ mpu.on_csr_write(MMPURNG, rng)
+ mpu.on_csr_write(MMPUATTR, attr)
+ mpu.on_csr_write(MMPUVA, va | 1) # VLD
+
+
+class RiscvMpuTests(unittest.TestCase):
+ def test_disabled_does_not_translate(self):
+ mpu = RiscvMpu()
+ entry(mpu, 0, 0x80000000, 0x1000, 0x1000)
+ self.assertFalse(mpu.enabled)
+ self.assertIsNone(mpu.translate(0x80000000))
+
+ def test_enable_is_the_nvmpu_vm_mode(self):
+ mpu = RiscvMpu()
+ entry(mpu, 0, 0x80000000, 0x1000, 0x1000)
+ mpu.on_csr_write(0x300, 0x1E000000)
+ self.assertTrue(mpu.enabled)
+ # Bare mode disables translation again.
+ mpu.on_csr_write(0x300, 0)
+ self.assertFalse(mpu.enabled)
+ self.assertIsNone(mpu.translate(0x80000000))
+
+ def test_translate_range_and_offset(self):
+ mpu = RiscvMpu()
+ entry(mpu, 2, 0x400E000, 0x1000, 0x3000)
+ enable(mpu)
+ self.assertEqual(mpu.translate(0x400E000), 0x1000)
+ self.assertEqual(mpu.translate(0x400E800), 0x1800)
+ self.assertEqual(mpu.translate(0x4010FFF), 0x3FFF)
+ self.assertIsNone(mpu.translate(0x400DFFF))
+ self.assertIsNone(mpu.translate(0x4011000))
+
+ def test_lowest_index_precedence(self):
+ mpu = RiscvMpu()
+ entry(mpu, 3, 0x1000, 0x9000, 0x1000)
+ entry(mpu, 0, 0x1000, 0x5000, 0x1000)
+ entry(mpu, 1, 0x1000, 0x7000, 0x1000)
+ enable(mpu)
+ self.assertEqual(mpu.translate(0x1000), 0x5000)
+
+ def test_vld_clear_disables_entry(self):
+ mpu = RiscvMpu()
+ entry(mpu, 0, 0x80000000, 0x1000, 0x1000)
+ enable(mpu)
+ self.assertIsNotNone(mpu.translate(0x80000000))
+ mpu.on_csr_write(MMPUIDX, 0)
+ mpu.on_csr_write(MMPUVA, 0) # VLD clear
+ self.assertIsNone(mpu.translate(0x80000000))
+
+ def test_index_field_is_six_bits(self):
+ mpu = RiscvMpu()
+ mpu.on_csr_write(MMPUIDX, 0x140) # 0x40 | 0x00 -> index 0
+ entry(mpu, 0, 0x1000, 0x2000, 0x1000)
+ enable(mpu)
+ self.assertEqual(mpu.translate(0x1000), 0x2000)
+
+ def test_state_and_reset(self):
+ mpu = RiscvMpu()
+ entry(mpu, 0, 0x80000000, 0x1000, 0x1000)
+ enable(mpu)
+ state = mpu.state()
+ self.assertTrue(state["enabled"])
+ self.assertEqual(state["entries"][0]["index"], 0)
+ self.assertEqual(state["entries"][0]["pa"], 0x1000)
+ mpu.reset()
+ self.assertFalse(mpu.enabled)
+ self.assertIsNone(mpu.translate(0x80000000))
+
+
+class MachineMpuTests(unittest.TestCase):
+ def test_guest_load_store_through_mapping(self):
+ m = Machine(memory_mb=1, ram_base=0, unknown_mmio="fault")
+ m.write_mem(0x1000, b"\x11\x22\x33\x44\x55\x66\x77\x88")
+ entry(m.mpu, 0, 0x80000000, 0x1000, 0x1000)
+ enable(m.mpu)
+ self.assertEqual(m._guest_load(0x80000000, 8),
+ int.from_bytes(b"\x11\x22\x33\x44\x55\x66\x77\x88", "little"))
+ m._guest_store(0x80000004, 8, 0xAABBCCDD)
+ self.assertEqual(m.read_mem(0x1004, 4), b"\xdd\xcc\xbb\xaa")
+ # The debugger surface stays raw/strict: the mapped VA has no backing.
+ with self.assertRaises(Exception):
+ m.read_mem(0x80000000, 4)
+
+ def test_instruction_fetch_is_translated(self):
+ m = Machine(memory_mb=1, ram_base=0, unknown_mmio="fault")
+ program = bytes.fromhex("13057000" "73001000") # addi a0,zero,7; ebreak
+ m.write_mem(0x2000, program)
+ entry(m.mpu, 0, 0x80002000, 0x2000, 0x1000)
+ enable(m.mpu)
+ m.pc = 0x80002000 # virtual entry; the backing lives at PA 0x2000
+ reason, pc, _, _ = m.run(10)
+ self.assertEqual(reason, R_EBREAK)
+ self.assertEqual(m.x[10], 7)
+ self.assertEqual(pc, 0x80002004)
+
+ def test_unmapped_fetch_still_faults(self):
+ m = Machine(memory_mb=1, ram_base=0, unknown_mmio="fault")
+ entry(m.mpu, 0, 0x80002000, 0x2000, 0x1000)
+ enable(m.mpu)
+ m.pc = 0x90000000 # no entry covers it, no backing RAM
+ reason, _, _, _ = m.run(1)
+ self.assertNotEqual(reason, R_EBREAK)
+
+
+class Wpr2RelativeOffsetTests(unittest.TestCase):
+ def test_window_relative_offsets_read_the_same_pair(self):
+ from sim.devices.fwsec import Wpr2Block
+
+ dev = Wpr2Block()
+ dev.set_range(0x2000000, 0x6000000)
+ self.assertEqual(dev.read(0x824, 4), dev.read(0x1FA824, 4))
+ self.assertEqual(dev.read(0x828, 4), dev.read(0x1FA828, 4))
+ self.assertIsNone(dev.read(0x820, 4))
+
+
+class BootShimArgsTests(unittest.TestCase):
+ def test_bootstrap_args_written_and_image_mirror_absent(self):
+ from sim.gsp_boot import _apply_boot_shim, DEFAULT_IMAGE, DEFAULT_PROFILE
+ from greeneye.elf import load_elf
+ from sim.devices import load_profile
+
+ profile = load_profile(DEFAULT_PROFILE)
+ m = Machine(memory_mb=16, ram_base=0, unknown_mmio="lenient")
+ shim = dict(profile.get("boot_shim", {}))
+ shim["_image_path"] = DEFAULT_IMAGE
+ meta = {"enabled": True, "assumptions": []}
+ _apply_boot_shim(m, shim, load_elf(DEFAULT_IMAGE), meta)
+ args = profile["boot_shim"]["bootstrap_args"]
+ pa = int(args["pa"], 0)
+ import struct
+
+ fields = struct.unpack("<6Q", m.read_mem(pa, 48))
+ self.assertEqual(fields[0], int(args["imem_base_pa"], 0))
+ self.assertEqual(fields[1], int(args["imem_size"], 0))
+ self.assertEqual(fields[2], int(args["imem_bs_size"], 0))
+ self.assertEqual(fields[3], int(args["dmem_base_pa"], 0))
+ self.assertEqual(fields[4], int(args["dmem_size"], 0))
+ self.assertEqual(fields[5], int(args["dmem_bs_size"], 0))
+ # The DMEM image mirror hack is gone from this profile.
+ self.assertNotIn("image_at", shim)
+ self.assertNotIn("image_at", meta)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/debug/risc-v-gsp-rm-vm/tests/test_sim_devices.py b/debug/risc-v-gsp-rm-vm/tests/test_sim_devices.py
index 8063ee9..13912b5 100644
--- a/debug/risc-v-gsp-rm-vm/tests/test_sim_devices.py
+++ b/debug/risc-v-gsp-rm-vm/tests/test_sim_devices.py
@@ -428,18 +428,34 @@ class BootLadderTests(unittest.TestCase):
self.assertEqual(r.mmio_unknown, 0)
self.assertEqual(r.device_unhandled, 0)
- def test_shim_reaches_relocation_b2(self):
+ def test_shim_reaches_message_loop_b3(self):
+ from sim.gsp_boot import run_boot
+ r = run_boot(image=DEFAULT_IMAGE, profile_path=DEFAULT_PROFILE,
+ instructions=2_000_000, record=False, use_shim=True)
+ self.assertTrue(r.ran)
+ self.assertEqual(r.rung, "B3")
+ self.assertIn("relocation_copy", r.milestones)
+ self.assertEqual(r.mmio_unknown, 0)
+ self.assertEqual(r.device_unhandled, 0)
+ # The MMU model resolves the resident-section copy, the libos service
+ # path runs (DMA/pagetable registration) and the message-loop task
+ # blocks in the port wait -- deterministic, not faked.
+ self.assertEqual(r.stop_reason, "ecall")
+ self.assertEqual(r.stop_pc, 0x4018038)
+ stopped = r.libos.get("stopped") or {}
+ self.assertEqual(stopped.get("num"), 1) # task_port_send_recv_wait
+
+ def test_shim_reaches_relocation_plus_mmu(self):
+ """The relocation copy reads its real backing through the MPU."""
from sim.gsp_boot import run_boot
r = run_boot(image=DEFAULT_IMAGE, profile_path=DEFAULT_PROFILE,
instructions=20_000, record=False, use_shim=True)
self.assertTrue(r.ran)
- self.assertEqual(r.rung, "B2")
self.assertIn("relocation_copy", r.milestones)
self.assertEqual(r.mmio_unknown, 0)
- # Known unknown: the DMEM runspace content the app relocates from is
- # not reconstructed; the run stops there rather than faking it.
- self.assertEqual(r.stop_reason, "fault")
- self.assertEqual(r.stop_pc, 0x400EA0C)
+ # The run no longer stops at the old B2 fault; with a 20k budget it
+ # is mid-init and deterministic.
+ self.assertNotEqual(r.stop_pc, 0x400EA0C)
def test_two_runs_identical(self):
from sim.gsp_boot import run_boot