summaryrefslogtreecommitdiffstats
path: root/debug/risc-v-gsp-rm-vm/harness/valprobe.py
blob: 55952b990cb73dabb17739ab725b58e6a7708849 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
#!/usr/bin/env python3
"""Validation-predicate probe: run a firmware predicate branch with crafted state.

Why this exists
---------------
The live GR campaign keeps burning supervised hardware cycles to ask the same
question class: *which control/entry shapes does the 570.144 GSP-RM firmware
accept or refuse, and why?*  The answer lives in firmware branches that are
buried behind host traffic (B4/B5) and RM object state, so a plain rehost of
the full handler early-outs before the predicate.  This harness instead runs
one **predicate branch** of the real image with the minimum crafted state it
needs: registers, memory, and (where a branch calls out to a lock/table walk
that is not the predicate) one recorded inline stub.  Each run stops on a
labelled outcome address, so the verdict is the branch the firmware took, not
an inference.

It is deliberately not a boot and not a model: the report carries the exact
entry, seeded state, stubs and outcome map, and every limitation in `limits`.
Fidelity is L0/L1 (docs/06); no timing claim.

Bundled target: ``sweep --target fecs-promote`` reproduces the live V6 FECS
matrix (`PM/PATCH/PRIV/UNR accepted, FECS init refused 0x1f, FECS VA-only
accepted`) and evaluates the untested shapes offline, including the
sysmem-aperture FECS-init variant the handoff proposed paying a hardware cycle
for.

Offline only: never touches a GPU, kernel module, mdev, guest or network.
"""
from __future__ import annotations

import argparse
import json
import os
import struct
import sys
import time
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.elf import load_elf  # noqa: E402
from greeneye.errors import GreenEyeError  # noqa: E402
from greeneye.sim_backend import launch_native  # noqa: E402
from sim.devices import load_profile, registry_from_profile  # noqa: E402
from sim.gsp_boot import _apply_boot_shim, _run_with_milestones, _rung_from_run  # noqa: E402
from sim.libos import LibosSyscalls  # noqa: E402
from sim.machine import R_BP, R_BUDGET, R_ECALL, R_FAULT  # noqa: E402

from harness.rehost import (  # noqa: E402
    DEFAULT_IMAGE,
    DEFAULT_PROFILE,
    RUN_CHUNK,
    _parse_u64,
    _sha256_file,
    parse_hex_bytes,
)

#: Schema tag of a single probe run.
PROBE_FORMAT = "greeneye-valprobe/1"
#: Schema tag of a probe spec.
SPEC_FORMAT = "greeneye-valprobe-spec/1"
#: Schema tag of a matrix run (a spec with variants).
MATRIX_FORMAT = "greeneye-valprobe-matrix/1"
#: Schema tag of the bundled demo.
DEMO_FORMAT = "greeneye-valprobe-demo/1"

DEFAULT_BUDGET = 50_000
DEFAULT_TIMEOUT = 20.0
BOOT_INSTRUCTIONS = 2_000_000
EBREAK = 0x00100073

REGISTERS = {
    "zero": 0, "ra": 1, "sp": 2, "gp": 3, "tp": 4,
    "t0": 5, "t1": 6, "t2": 7, "s0": 8, "fp": 8, "s1": 9,
    "a0": 10, "a1": 11, "a2": 12, "a3": 13, "a4": 14, "a5": 15,
    "a6": 16, "a7": 17, "s2": 18, "s3": 19, "s4": 20, "s5": 21,
    "s6": 22, "s7": 23, "s8": 24, "s9": 25, "s10": 26, "s11": 27,
    "t3": 28, "t4": 29, "t5": 30, "t6": 31,
}
for _i in range(32):
    REGISTERS.setdefault(f"x{_i}", _i)

RUNG_RANK = {"B0": 0, "B1": 1, "B2": 2, "B3": 3}


class ValprobeError(GreenEyeError):
    """A probe spec, image or scenario is invalid."""


def reg_index(name: str) -> int:
    key = str(name).strip().lower()
    if key not in REGISTERS:
        raise ValprobeError(f"unknown register {name!r}")
    return REGISTERS[key]


def encode_return_stub(value: int) -> bytes:
    """Encode ``lui a0,hi; addi a0,a0,lo; jalr zero,0(ra)`` (RV64IMAC)."""
    value &= 0xFFFF_FFFF_FFFF_FFFF
    hi = (value + 0x800) >> 12
    lo = value - (hi << 12)
    lui = ((hi & 0xF_FFFF) << 12) | (10 << 7) | 0x37
    addi = ((lo & 0xFFF) << 20) | (10 << 15) | (10 << 7) | 0x13
    ret = 0x0000_8067
    return struct.pack("<III", lui, addi, ret)


def decode_memory_spec(item: dict) -> tuple[int, bytes]:
    """Parse one memory item: ``{addr, hex|u64|u32|u8}``."""
    if "addr" not in item:
        raise ValprobeError(f"memory item needs addr: {item!r}")
    addr = _parse_u64(item["addr"])
    for key, fmt, size in (("hex", None, None), ("u64", "<Q", 8), ("u32", "<I", 4),
                           ("u16", "<H", 2), ("u8", "<B", 1)):
        if key in item:
            if key == "hex":
                return addr, parse_hex_bytes(str(item["hex"]))
            return addr, struct.pack(fmt, _parse_u64(item[key]) & ((1 << (8 * size)) - 1))
    raise ValprobeError(f"memory item needs one of hex/u64/u32/u16/u8: {item!r}")


def _load_spec(path: str) -> dict:
    if not os.path.isfile(path):
        raise ValprobeError(f"spec not found: {path}")
    with open(path, "r", encoding="utf-8") as fh:
        spec = json.load(fh)
    fmt = spec.get("format", SPEC_FORMAT)
    if fmt != SPEC_FORMAT:
        raise ValprobeError(f"unsupported spec format {fmt!r} (want {SPEC_FORMAT})")
    if not spec.get("variants"):
        raise ValprobeError("spec has no variants")
    return spec


def _apply_state(machine, *, registers: dict, memory, stubs, default_sp: int) -> list[dict]:
    """Write stubs, memory and registers; return the applied stub notes."""
    stub_notes = []
    for stub in stubs or ():
        addr = _parse_u64(stub["addr"])
        if "return" not in stub:
            raise ValprobeError(f"stub needs return: {stub!r}")
        code = encode_return_stub(_parse_u64(stub["return"]))
        machine.write_mem(addr, code)
        stub_notes.append({"addr": f"{addr:#x}", "return": f"{_parse_u64(stub['return']):#x}",
                           "code_hex": code.hex()})
    for item in memory or ():
        addr, data = decode_memory_spec(item)
        machine.write_mem(addr, data)
    for name, value in (registers or {}).items():
        machine.x[reg_index(name)] = _parse_u64(value)
    if machine.x[2] == 0:
        machine.x[2] = default_sp
    return stub_notes


def _read_outcomes(machine, reads) -> list[dict]:
    out = []
    for item in reads or ():
        addr = _parse_u64(item["addr"])
        size = int(item.get("size", 8))
        data = bytes(machine.read_mem(addr, size))
        record = {"addr": f"{addr:#x}", "size": size, "hex": data.hex()}
        if item.get("label"):
            record["label"] = item["label"]
        if size == 8:
            record["u64"] = int.from_bytes(data, "little")
        elif size == 4:
            record["u32"] = int.from_bytes(data, "little")
        out.append(record)
    return out


def run_probe(
    *,
    image: str = DEFAULT_IMAGE,
    profile_path: str | None = DEFAULT_PROFILE,
    spec: dict,
    variant: dict,
    out: str | None = None,
    record: bool | None = None,
    name: str = "valprobe",
) -> dict:
    """Run one variant of a probe spec in a fresh machine; return its report."""
    if not os.path.isfile(image):
        raise ValprobeError(f"image not found: {image}")
    image_obj = load_elf(image)
    if not image_obj.is_riscv:
        raise ValprobeError(f"{image}: not a RISC-V ELF image")

    profile = load_profile(profile_path) if profile_path else None
    load = (profile or {}).get("load", {})
    ram_base = _parse_u64(load.get("ram_base", 0))
    memory_mb = int(load.get("memory_mb", 256))
    ram_top = ram_base + memory_mb * 1024 * 1024

    entry = _parse_u64(variant.get("entry", spec.get("target")))
    outcomes = {_parse_u64(a): label for a, label in
                (variant.get("outcomes") or spec.get("outcomes") or {}).items()}
    if not outcomes:
        raise ValprobeError("probe needs at least one outcome address")

    budget = int(variant.get("budget", spec.get("budget", DEFAULT_BUDGET)))
    timeout = float(variant.get("timeout", spec.get("timeout", DEFAULT_TIMEOUT)))
    label = variant.get("label") or f"variant-{entry:#x}"
    boot_to = spec.get("boot_to") or variant.get("boot_to")

    if record is None:
        record = bool(out)
    session = launch_native(
        kernel=image,
        memory_mb=memory_mb,
        ram_base=ram_base,
        record=record,
        name=f"{name}-{label}"[:80],
        device_map=None,
        unknown_mmio="lenient",
        bias=0,
    )
    machine = session.target.machine
    shim_meta: dict[str, Any] = {"enabled": False, "assumptions": []}
    libos = LibosSyscalls()
    boot: dict | None = None
    try:
        if profile is not None:
            machine.devices = registry_from_profile(profile, machine=machine)
            machine.translator = getattr(machine.devices, "dma", None)
            machine.syscall_handler = libos.handle
            shim = dict(profile.get("boot_shim", {}))
            shim["_image_path"] = image
            _apply_boot_shim(machine, shim, image_obj, shim_meta)
            shim_meta["enabled"] = True

        if boot_to:
            milestones: list[str] = []
            reason, pc, _a, _b = _run_with_milestones(machine, BOOT_INSTRUCTIONS, milestones)
            rung = _rung_from_run(machine, shim_meta, milestones, libos)
            boot = {
                "requested": boot_to,
                "rung": rung,
                "instructions": machine.insns,
                "stop_pc": f"{pc:#x}",
                "milestones": milestones,
                "reached": RUNG_RANK.get(rung, -1) >= RUNG_RANK.get(str(boot_to), 99),
            }

        sentinel = (ram_top - 0x100) & ~0xF
        default_sp = (ram_top - 0x8000) & ~0xF
        machine.write_mem(sentinel, EBREAK.to_bytes(4, "little"))
        breakpoints = set(outcomes)
        breakpoints.add(sentinel)

        registers = dict(spec.get("registers") or {})
        registers.update(variant.get("registers") or {})
        registers.setdefault("ra", sentinel)
        memory = list(spec.get("memory") or ()) + list(variant.get("memory") or ())
        stubs = list(spec.get("stubs") or ()) + list(variant.get("stubs") or ())
        stubs_applied = _apply_state(
            machine, registers=registers, memory=memory, stubs=stubs, default_sp=default_sp
        )
        machine.pc = entry

        entered = {f"x{i}": machine.x[i] for i in range(32) if i and machine.x[i]}
        entered["pc"] = entry
        started = time.monotonic()
        status = "budget"
        stop_pc = entry
        fault: dict | None = None
        outcome: str | None = None
        remaining = budget
        while remaining > 0:
            if time.monotonic() - started > timeout:
                status = "timeout"
                break
            chunk = min(RUN_CHUNK, remaining)
            reason, stop_pc, a, b = machine.run(chunk, breakpoints=breakpoints)
            remaining -= chunk
            if reason == R_BP:
                if stop_pc == sentinel:
                    status = "return"
                    break
                outcome = outcomes.get(stop_pc, "unlabelled")
                status = "outcome"
                break
            if reason == R_FAULT:
                status = "fault"
                fault = {"cause": a, "tval": f"{b:#x}"}
                break
            if reason == R_ECALL:
                handled, result = (machine.syscall_handler(machine, stop_pc)
                                   if machine.syscall_handler else (False, None))
                if not handled:
                    status = "ecall"
                    break
                if result is not None:
                    machine.x[10] = result & 0xFFFF_FFFF_FFFF_FFFF
                continue
            if reason == R_BUDGET:
                status = "budget"

        report: dict[str, Any] = {
            "format": PROBE_FORMAT,
            "probe": spec.get("name"),
            "label": label,
            "image": os.path.abspath(image),
            "image_sha256": _sha256_file(image),
            "profile": os.path.basename(profile_path) if profile_path else None,
            "entry": f"{entry:#x}",
            "status": status,
            "outcome": outcome,
            "expect": variant.get("expect"),
            "pass": (None if variant.get("expect") is None else outcome == variant.get("expect")),
            "stop_pc": f"{stop_pc:#x}",
            "fault": fault,
            "retval": machine.x[10],
            "instructions": machine.insns,
            "wall_seconds": round(time.monotonic() - started, 4),
            "boot": boot,
            "stubs": stubs_applied,
            "registers_entered": {k: f"{v:#x}" if isinstance(v, int) else v
                                  for k, v in entered.items()},
            "reads": _read_outcomes(machine, variant.get("reads") or spec.get("reads")),
            "limits": [
                "Predicate-branch probe, not a boot: only the seeded state exists.",
                "Stubs (if any) replace an out-of-predicate dependency; they are listed.",
                "No timing/cycle claim (L0/L1 fidelity, docs/06).",
                "A fault is the first missing state, not a product defect.",
            ],
        }
        if getattr(session.recorder, "dir", None) is not None:
            session.note("valprobe", label=label, outcome=outcome, status=status)
            report["session"] = session.recorder.dir
        return report
    finally:
        session.close()


def run_spec(
    *,
    spec: dict,
    image: str | None = None,
    profile_path: str | None = None,
    out: str | None = None,
    record: bool | None = None,
    name: str | None = None,
) -> dict:
    """Run every variant of a spec; return a matrix report."""
    image = image or spec.get("image") or DEFAULT_IMAGE
    profile_path = profile_path or spec.get("profile") or DEFAULT_PROFILE
    started = time.monotonic()
    rows = []
    for variant in spec["variants"]:
        rows.append(run_probe(
            image=image, profile_path=profile_path, spec=spec, variant=variant,
            out=out, record=record, name=name or (spec.get("name") or "valprobe"),
        ))
    matrix: dict[str, Any] = {
        "format": MATRIX_FORMAT,
        "spec": spec.get("name"),
        "spec_format": spec.get("format"),
        "image": os.path.abspath(image),
        "image_sha256": _sha256_file(image),
        "target": spec.get("target"),
        "boot_to": spec.get("boot_to"),
        "variants": rows,
        "passed": sum(1 for r in rows if r["pass"] is True),
        "failed": sum(1 for r in rows if r["pass"] is False),
        "evaluated": sum(1 for r in rows if r["pass"] is not None),
        "wall_seconds": round(time.monotonic() - started, 4),
    }
    if out:
        os.makedirs(out, exist_ok=True)
        path = os.path.join(out, "valprobe-matrix.json")
        with open(path, "w", encoding="utf-8") as fh:
            json.dump(matrix, fh, indent=2, sort_keys=True)
            fh.write("\n")
        matrix["report_json"] = os.path.abspath(path)
    return matrix


# ---------------------------------------------------------------------------
# Bundled target: the GR PROMOTE_CTX FECS predicate (570.144 firmware).
#
# Firmware facts used (all verified by disassembly of the shipped image):
#   * per-buffer validation branch point: 0x5290dd4 (in FUN_ram_05290a48)
#   * bInitialize=1 and bufferId==9 -> FECS branch at 0x5290e3c
#   * FECS branch: instance = pGpu[(idx+0x37c)], idx = *(u32*)(getter+0xD28);
#     table = *(u64*)(instance+0x190); slot = table[mapped(bufferId)*8];
#     refuse (0x1f) iff slot != 0 -- regardless of physAttr/size/VA
#   * bufferId -> slot index via the firmware's own 0x531b944 jump table
#     (FECS => 6)
#   * bInitialize=0: bNonmapped!=0 => 0x1f, else accept
#
# The instance-index getter (0x524a5b8, a lock/table walk outside the
# predicate) is stubbed with a recorded constant return so the branch can run
# with crafted state; the predicate itself, the mapping table call and the
# slot read are the firmware's own code.
# ---------------------------------------------------------------------------

PROMOTE_VALIDATION = 0x5290DD4
FECS_INIT_BRANCH = 0x5290E3C
INSTANCE_GETTER = 0x524A5B8
OUTCOMES = {
    "0x5290cd0": "accept-commit-path",
    "0x5290ce4": "accept-skip-path",
    "0x5290df8": "refuse-invalid-argument-0x1f",
    "0x5290ebc": "refuse-fecs-slot-exists-0x1f",
}

#: Buffer ids (NV2080_CTRL_GPU_PROMOTE_CTX_BUFFER_ID_*), 570.144 interface order.
BUFFER_ID = {
    "MAIN": 0, "PM": 1, "PATCH": 2, "BUNDLE_CB": 3, "PAGEPOOL": 4,
    "ATTRIBUTE_CB": 5, "RTV_CB_GLOBAL": 6, "GFXP_POOL": 7, "GFXP_CTRL_BLK": 8,
    "FECS_EVENT": 9, "PRIV_ACCESS_MAP": 10, "UNRESTRICTED_PRIV_ACCESS_MAP": 11,
    "GLOBAL_PRIV_ACCESS_MAP": 12,
}


#: Scratch base for crafted predicate state.  Must be encodable by the
#: ``lui+addi`` return stub (below 2^31), inside RAM, outside the image
#: (max vaddr ~0x5bb9000), outside the 0x0..0x8000000 DMA window and below
#: the 0x100000000 DMEM aperture the firmware uses after relocation.
FECS_SCRATCH_BASE = 0x10000000


def _fecs_layout() -> dict:
    """Deterministic scratch layout (see module doc)."""
    scratch = FECS_SCRATCH_BASE
    return {
        "scratch": scratch,
        "sp": scratch + 0x4000,
        "s0": scratch + 0x5000,
        "pgpu": scratch + 0x1000,
        "getter_out": scratch + 0x1800,
        "instance": scratch + 0x3800,
        "entry": scratch + 0x6000,
    }


def _fecs_variant(layout: dict, *, label: str, buffer_id: int, b_initialize: int,
                  b_nonmapped: int, phys_attr: int = 4, size: int = 0x10000,
                  phys: int = 0x1F4150000, virt: int = 0x204608000,
                  slot: int | None = None, expect: str | None = None) -> dict:
    lay = layout
    entry = lay["entry"]
    # table = *(u64*)(instance+0x190) = instance+0x300; FECS maps to slot 6.
    slot_addr = lay["instance"] + 0x300 + 6 * 8
    memory = [
        {"addr": f"{entry + 0x30:#x}", "u64": b_initialize and phys or 0},
        {"addr": f"{entry + 0x38:#x}", "u64": virt},
        {"addr": f"{entry + 0x40:#x}", "u64": size},
        {"addr": f"{entry + 0x48:#x}", "u32": phys_attr},
        {"addr": f"{entry + 0x4c:#x}", "u16": buffer_id},
        {"addr": f"{entry + 0x4e:#x}", "u8": b_initialize},
        {"addr": f"{entry + 0x4f:#x}", "u8": b_nonmapped},
        # pGpu -> instance pointer; getter result -> idx 0; instance -> table.
        {"addr": f"{lay['pgpu'] + 0x1be0:#x}", "u64": lay["instance"]},
        {"addr": f"{lay['instance'] + 0x190:#x}", "u64": lay["instance"] + 0x300},
    ]
    if slot is not None:
        memory.append({"addr": f"{slot_addr:#x}", "u64": slot})
    return {
        "label": label,
        "registers": {
            "s0": f"{lay['s0']:#x}",
            "s2": f"{lay['pgpu']:#x}",
            "s5": f"{entry:#x}",
            "sp": f"{lay['sp']:#x}",
            "a3": buffer_id,
            "t1": 0xC,  # loop bound set by the function before our entry point
        },
        "stubs": [{"addr": f"{INSTANCE_GETTER:#x}", "return": f"{lay['getter_out']:#x}"}],
        "memory": memory,
        "reads": [{"addr": f"{lay['s0'] - 0x1c8:#x}", "size": 4,
                   "label": "firmware-mapped-slot-index"},
                  {"addr": f"{slot_addr:#x}", "size": 8, "label": "fecs-slot-value"}],
        "expect": expect,
    }


def fecs_spec(*, image: str = DEFAULT_IMAGE, profile_path: str = DEFAULT_PROFILE) -> dict:
    """Build the FECS promote-validation matrix spec (bundled target)."""
    layout = _fecs_layout()
    variants = [
        # Live m3j: FECS init (PA) refused -- slot already populated.
        _fecs_variant(layout, label="FECS init vidmem, slot non-NULL",
                      buffer_id=9, b_initialize=1, b_nonmapped=0, slot=1,
                      expect="refuse-fecs-slot-exists-0x1f"),
        # Predicate inverse: the same shape would be accepted on an empty slot.
        _fecs_variant(layout, label="FECS init vidmem, slot NULL",
                      buffer_id=9, b_initialize=1, b_nonmapped=0, slot=0,
                      expect="accept-commit-path"),
        # The handoff's untested variant (a): does a sysmem aperture change it?
        _fecs_variant(layout, label="FECS init sysmem attr 0x6, slot non-NULL",
                      buffer_id=9, b_initialize=1, b_nonmapped=0, phys_attr=6, slot=1,
                      expect="refuse-fecs-slot-exists-0x1f"),
        _fecs_variant(layout, label="FECS init sysmem attr 0x6, slot NULL",
                      buffer_id=9, b_initialize=1, b_nonmapped=0, phys_attr=6, slot=0,
                      expect="accept-commit-path"),
        # Size/PA content must not matter to the predicate.
        _fecs_variant(layout, label="FECS init size 0x1000, slot non-NULL",
                      buffer_id=9, b_initialize=1, b_nonmapped=0, size=0x1000, slot=1,
                      expect="refuse-fecs-slot-exists-0x1f"),
        # Live m7: FECS VA-only accepted (bInitialize=0 path).
        _fecs_variant(layout, label="FECS no-init mapped, slot non-NULL",
                      buffer_id=9, b_initialize=0, b_nonmapped=0, slot=1,
                      expect="accept-skip-path"),
        _fecs_variant(layout, label="FECS no-init nonmapped (bookkeeping refusal)",
                      buffer_id=9, b_initialize=0, b_nonmapped=1, slot=1,
                      expect="refuse-invalid-argument-0x1f"),
        # Live m3j: PM and PATCH init accepted.
        _fecs_variant(layout, label="PM init",
                      buffer_id=1, b_initialize=1, b_nonmapped=0,
                      expect="accept-commit-path"),
        _fecs_variant(layout, label="PATCH init",
                      buffer_id=2, b_initialize=1, b_nonmapped=0,
                      expect="accept-commit-path"),
        _fecs_variant(layout, label="GFXP_CTRL_BLK init (id 8)",
                      buffer_id=8, b_initialize=1, b_nonmapped=0,
                      expect="accept-skip-path"),
        _fecs_variant(layout, label="BUNDLE_CB init (id 3)",
                      buffer_id=3, b_initialize=1, b_nonmapped=0,
                      expect="refuse-invalid-argument-0x1f"),
        _fecs_variant(layout, label="PRIV_ACCESS_MAP init (id 10)",
                      buffer_id=10, b_initialize=1, b_nonmapped=0,
                      expect="accept-skip-path"),
        _fecs_variant(layout, label="UNRESTRICTED_PRIV init (id 11)",
                      buffer_id=11, b_initialize=1, b_nonmapped=0,
                      expect="accept-skip-path"),
        _fecs_variant(layout, label="GLOBAL_PRIV_ACCESS_MAP init (id 12)",
                      buffer_id=12, b_initialize=1, b_nonmapped=0,
                      expect="refuse-invalid-argument-0x1f"),
    ]
    # id > 0xc is refused one block earlier (0x5290cc4, still in the per-entry
    # loop); enter there with the loop's a4/t1 state.
    unknown_id = _fecs_variant(layout, label="unknown buffer id 13",
                               buffer_id=13, b_initialize=1, b_nonmapped=0,
                               expect="refuse-invalid-argument-0x1f")
    unknown_id["entry"] = "0x5290cc0"
    unknown_id["registers"]["a4"] = 13
    variants.append(unknown_id)
    return {
        "format": SPEC_FORMAT,
        "name": "fecs-promote-validation",
        "target": f"{PROMOTE_VALIDATION:#x}",
        "outcomes": OUTCOMES,
        "budget": 20_000,
        "timeout": 20.0,
        "image": image,
        "profile": profile_path,
        "note": (
            "570.144 per-buffer PROMOTE_CTX validation; instance-index getter "
            f"({INSTANCE_GETTER:#x}, lock/table walk outside the predicate) stubbed "
            "to a recorded constant; the predicate, the id->slot mapping call and "
            "the memDesc slot read are the firmware's own code."
        ),
        "variants": variants,
    }


def run_demo(out: str | None = None) -> dict:
    """Run the bundled FECS matrix on the real image, twice for determinism."""
    spec = fecs_spec()
    first = run_spec(spec=spec, out=out, record=False, name="valprobe-fecs")
    second = run_spec(spec=spec, record=False, name="valprobe-fecs")
    verdicts_a = [(r["label"], r["outcome"]) for r in first["variants"]]
    verdicts_b = [(r["label"], r["outcome"]) for r in second["variants"]]
    result = {
        "format": DEMO_FORMAT,
        "ok": first["failed"] == 0 and verdicts_a == verdicts_b,
        "evaluated": first["evaluated"],
        "failed": first["failed"],
        "deterministic": verdicts_a == verdicts_b,
        "verdicts": [{"label": label, "outcome": outcome} for label, outcome in verdicts_a],
    }
    if out:
        os.makedirs(out, exist_ok=True)
        path = os.path.join(out, "valprobe-demo.json")
        with open(path, "w", encoding="utf-8") as fh:
            json.dump(result, fh, indent=2, sort_keys=True)
            fh.write("\n")
        result["artifacts"] = {"demo_json": os.path.abspath(path)}
    return result


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        prog="valprobe.py",
        description=(
            "Run one firmware validation-predicate branch with crafted state "
            "(offline; never touches hardware)."
        ),
    )
    sub = parser.add_subparsers(dest="command")
    sweep = sub.add_parser("sweep", help="run a bundled target matrix")
    sweep.add_argument("--target", default="fecs-promote", choices=["fecs-promote"])
    sweep.add_argument("--image", default=None)
    sweep.add_argument("--profile", default=None)
    sweep.add_argument("--boot-to", default=None, help="boot the image to a rung first (e.g. B3)")
    sweep.add_argument("--out", help="output directory (matrix JSON + sessions)")
    sweep.add_argument("--no-record", action="store_true", help="skip session recording")
    run = sub.add_parser("run", help="run a probe spec file")
    run.add_argument("--spec", required=True)
    run.add_argument("--image", default=None)
    run.add_argument("--profile", default=None)
    run.add_argument("--out", help="output directory (matrix JSON + sessions)")
    run.add_argument("--no-record", action="store_true", help="skip session recording")
    demo = sub.add_parser("demo", help="bundled FECS matrix, run twice (determinism)")
    demo.add_argument("--out", help="output directory")
    return parser


def main(argv: list[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)
    try:
        if args.command == "sweep":
            spec = fecs_spec(image=args.image or DEFAULT_IMAGE,
                             profile_path=args.profile or DEFAULT_PROFILE)
            if args.boot_to:
                spec["boot_to"] = args.boot_to
            matrix = run_spec(spec=spec, image=args.image, profile_path=args.profile,
                              out=args.out, record=False if args.no_record else None)
        elif args.command == "run":
            spec = _load_spec(args.spec)
            matrix = run_spec(spec=spec, image=args.image, profile_path=args.profile,
                              out=args.out, record=False if args.no_record else None)
        elif args.command == "demo":
            matrix = run_demo(args.out)
        else:
            parser.print_help()
            return 2
    except ValprobeError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2
    if matrix.get("format") == MATRIX_FORMAT:
        for row in matrix["variants"]:
            mark = {True: "PASS", False: "FAIL", None: "----"}[row["pass"]]
            print(f"{mark}  {row['label']:<48} {row['outcome'] or row['status']}")
        print(json.dumps({k: matrix[k] for k in
                          ("format", "spec", "evaluated", "passed", "failed",
                           "wall_seconds", "report_json") if k in matrix},
                         indent=2, sort_keys=True))
        return 3 if matrix["failed"] else 0
    print(json.dumps(matrix, indent=2, sort_keys=True))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())