aboutsummaryrefslogtreecommitdiffstats
path: root/psp_out/f32_probe.py
blob: 6f0cdeba30b6092bec0889d5e63c083985418a24 (plain) (blame)
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
#!/usr/bin/env python3
"""Characterise the AMD "F32" microcode (SDMA, RLC) that has no public disassembler.

This exists so the "F32 is a blocker" row in FIRMWARE-COVERAGE-MATRIX.md rests on a
measurement rather than an inherited claim. It reports, for each image:

  * container handling, including the nested $PS1 PSP header (code starts at +0x200)
  * the most common 32-bit words -- the NOP encoding shows up here
  * per-bit entropy across the word, to look for a low-entropy opcode field
  * how many distinct values the top 4..8 bits take, i.e. whether an opcode field
    of that width is plausible
  * capstone coverage across every architecture it supports, as a negative control

Interpretation notes (2026-09 run on Navi 31 blobs):
  SDMA `sdma_6_0_0.bin`  NOP 0x90000000 (x132), top-4 bits take all 16 values
  RLC  `gc_11_0_0_rlc.bin` NOP 0x90000000 (x191), top-4 bits take all 16 values
  No capstone architecture fits: the best score is Thumb at 98%, which is
  meaningless because Thumb decodes almost any byte string. So: fixed-width 32-bit,
  a NOP of 0x90000000, and no known ISA -- consistent with the tree's long-standing
  "AMD proprietary F32, no public disassembler" call, now verified rather than
  assumed. Deriving the encoding would need paired execution traces, which is a
  project in itself.

usage: f32_probe.py <image> [<image> ...]
"""
import collections
import math
import struct
import sys

try:
    import capstone as cs
except ImportError:
    cs = None

ARCHES = [('riscv64', 'CS_ARCH_RISCV', 'CS_MODE_RISCV64'), ('arm', 'CS_ARCH_ARM', 'CS_MODE_ARM'),
          ('thumb', 'CS_ARCH_ARM', 'CS_MODE_THUMB'), ('xtensa', 'CS_ARCH_XTENSA', 'CS_MODE_XTENSA_ESP32'),
          ('mips32el', 'CS_ARCH_MIPS', 'CS_MODE_MIPS32'), ('ppc32', 'CS_ARCH_PPC', 'CS_MODE_32'),
          ('sparc', 'CS_ARCH_SPARC', None), ('tricore', 'CS_ARCH_TRICORE', None),
          ('sh', 'CS_ARCH_SH', None), ('m68k', 'CS_ARCH_M68K', None),
          ('hppa', 'CS_ARCH_HPPA', None), ('alpha', 'CS_ARCH_ALPHA', None)]


def payload(path):
    d = open(path, 'rb').read()
    ucode_size, ucode_off = struct.unpack_from('<II', d, 0x14)
    nested = d[ucode_off + 0x10:ucode_off + 0x14] == b'$PS1'
    return d[ucode_off + (0x100 if nested else 0):ucode_off + ucode_size], nested


def bit_entropy(words):
    n = len(words) or 1
    out = []
    for bit in range(32):
        ones = sum(1 for w in words if w >> bit & 1)
        p = ones / n
        out.append(0.0 if p in (0.0, 1.0) else -(p * math.log2(p) + (1 - p) * math.log2(1 - p)))
    return out


def main():
    for path in sys.argv[1:]:
        b, nested = payload(path)
        words = [struct.unpack_from('<I', b, i)[0] for i in range(0, len(b) - 3, 4)]
        cnt = collections.Counter(words)
        print('=== %s  payload 0x%x bytes, nested $PS1=%s, %d words' % (
            path, len(b), nested, len(words)))
        print('    most common words: %s' % ' '.join(
            '0x%08x(x%d)' % kv for kv in cnt.most_common(5)))
        ent = bit_entropy(words)
        print('    per-bit entropy 31..0: %s' % ''.join(
            '%d' % min(9, int(e * 9.99)) for e in reversed(ent)))
        for width in (4, 5, 6, 7, 8):
            top = collections.Counter(w >> (32 - width) for w in words)
            print('      top-%d bits: %d/%d distinct values, most common %s' % (
                width, len(top), 1 << width,
                ' '.join('0x%x(x%d)' % kv for kv in top.most_common(3))))
        if cs is None:
            print('    (capstone unavailable; skipping the negative control)')
            continue
        nz = next((i for i in range(0, max(1, len(b) - 63), 4) if b[i:i + 64] != bytes(64)), 0)
        rows = []
        for name, arch_n, mode_n in ARCHES:
            arch = getattr(cs, arch_n, None)
            if arch is None:
                continue
            mode = getattr(cs, mode_n, 0) if mode_n else 0
            if name == 'mips32el':
                mode |= cs.CS_MODE_LITTLE_ENDIAN
            try:
                md = cs.Cs(arch, mode)
            except Exception:                            # noqa: BLE001
                continue
            md.skipdata = True
            cov = tot = 0
            for x in md.disasm(b[nz:nz + 0x2000], 0):
                tot += x.size
                if x.mnemonic not in ('.byte', '.db', 'unimp', 'c.unimp', 'ill',
                                      'illegal', 'undefined'):
                    cov += x.size
            rows.append((cov / max(1, tot), name))
        print('    capstone coverage: %s' % ', '.join(
            '%s=%.0f%%' % (n, 100 * c) for c, n in sorted(rows, reverse=True)[:6]))
        print('    NOTE: a high Thumb score is not evidence -- Thumb decodes almost '
              'anything. Only a coherent, structured decode counts.')


if __name__ == '__main__':
    main()