aboutsummaryrefslogtreecommitdiffstats
path: root/fw/ta_re.py
blob: 74dcaf7b2c75975be7483998c7e50ab5b9111243 (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
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
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
#!/usr/bin/env python3
"""Symbolicate and decompile every build of a TA type, in a form that diffs cleanly.

Why this exists: the project had one symbolicated build per TA type, so cross-version
comparison was impossible -- there was nothing to compare against. `mkelf_ta.py` produced an
ELF per corpus build; this ports the reference symbols onto each one and emits whole-program
pseudocode.

## Porting tiers (lifted from psp_out/phase2_port_symbols.py, which proved the approach)

    EXACT  identical function bytes
    MNEM   identical mnemonic sequence, operands differ (the usual cross-version case:
           a relinked literal pool or branch displacement)
    FUZZY  positional mnemonic similarity >= THRESHOLD
    (none) below threshold -> LEFT AS sub_*, never guessed

A reference signature shared by two or more *differently named* functions is **ambiguous** and
is not applied at all, rather than picking one arbitrarily.

## The self-validation gate

Porting the reference onto its own build must yield ~100% EXACT. Anything else means the
matcher, the base address or the body extraction is broken, and the run for that type is
**void** -- reported as such, not silently emitted. This is the control that catches a
rebasing error, which would otherwise mislabel every function in every build.

## Why the output is shaped the way it is

Addresses shift between builds, so including them would make every diff hunk noise. Hence:
  * `decompile(include_addresses=False)`;
  * functions ordered by **name**, not address, so the same function lines up across versions;
  * unnamed `sub_*` functions collected at the end, ordered by size then body hash, since
    their numbering is meaningless across builds;
  * each function carries its porting tier, so a FUZZY name is never mistaken for a certainty.

usage:
  ./ta_re.py --type gcp [--limit N] [--outdir fw/decomp] [--validate-only]
  ./ta_re.py --list
"""
from __future__ import annotations

import argparse
import glob
import hashlib
import json
import os
import re
import struct
import sys
from pathlib import Path

HERE = Path(__file__).resolve().parent
REPO = HERE.parent
sys.path.insert(0, str(Path.home() / "dev" / "ida-tui-maybe"))
sys.path.insert(0, str(Path(__file__).resolve().parent))
import ta_metrics

ELF_DIR = REPO / "psp_out" / "versions_win" / "elf_all"
THRESHOLD = 0.90

# Reference build per type: the one that already carries hand-made symbols.
# 12 Windows types -> fw/libdumps/<t>.json (ea/size/name/hash, richest source).
LIBDUMP_REF = {
    "auc": "16.0.0.9", "csu": "42.0.0.6", "fp": "19.0.0.8", "gcp": "22.0.4.50",
    "merit": "24.0.4.42", "oc": "50.0.0.1", "opm": "25.0.4.48",
    "powerplay": "34.0.0.20", "rap": "7.0.2.19", "tmz": "29.0.0.4",
    "wspe": "30.0.4.27", "xgmi": "32.0.0.20",
}


def elf_body(path: str) -> tuple[bytes, int]:
    raw = Path(path).read_bytes()
    ph = struct.unpack("<I", raw[28:32])[0]
    off, va, _pa, fsz = struct.unpack("<IIII", raw[ph + 4:ph + 20])
    return raw[off:off + fsz], va


def mnems(body: bytes, va: int, start: int, size: int) -> tuple[str, ...]:
    from capstone import Cs, CS_ARCH_ARM, CS_MODE_THUMB
    md = Cs(CS_ARCH_ARM, CS_MODE_THUMB)
    off = start - va
    if off < 0 or off + size > len(body):
        return ()
    return tuple(i.mnemonic for i in md.disasm(body[off:off + size], start))


def sim(a: tuple, b: tuple) -> float:
    if not a or not b:
        return 0.0
    n, m = min(len(a), len(b)), max(len(a), len(b))
    return sum(1 for i in range(n) if a[i] == b[i]) / m


def vtuple(ver: str) -> tuple[int, ...]:
    try:
        return tuple(int(x) for x in ver.split("."))
    except ValueError:
        return (0, 0, 0, 0)   # non-numeric (e.g. the handover "reference" module)


def vdist(a: str, b: str) -> int:
    """Version distance, major-weighted, so 22.0.4.50 is near 22.0.4.49 and far from 22.0.0.53."""
    ta_, tb = vtuple(a), vtuple(b)
    n = max(len(ta_), len(tb))
    ta_ = ta_ + (0,) * (n - len(ta_))
    tb = tb + (0,) * (n - len(tb))
    w = [10 ** (3 * (n - 1 - i)) for i in range(n)]
    return sum(wi * abs(x - y) for wi, x, y in zip(w, ta_, tb))


POOL_TIERS = ("EXACT", "MNEM")


def funcs_as_reference(funcs, assigned, body, va):
    """Turn a just-processed build into reference material for its neighbours.

    Only EXACT and MNEM names are contributed. A FUZZY name is a similarity judgement, and
    feeding one back as a seed would let a single bad guess propagate down the whole chain
    and look more confident at every hop.
    """
    out = []
    for fn in funcs:
        nm, tier = assigned.get(fn["addr"], (None, None))
        if not nm or tier not in POOL_TIERS:
            continue
        ea, size = int(fn["addr"], 16), fn["size"]
        off = ea - va
        if off < 0 or size <= 0 or off + size > len(body):
            continue
        b = body[off:off + size]
        out.append({"name": nm, "ea": ea, "size": size, "bytes": b,
                    "hash": hashlib.sha256(b).hexdigest(),
                    "mnems": mnems(body, va, ea, size)})
    return out


# Types whose symbols live in psp_out/handover rather than fw/libdumps. These carry
# prototypes and comments as well as names, which the libdump types do not.
HANDOVER_REF = {"asd", "hdcp", "ras", "dtm"}


def load_handover_reference(ta: str):
    """-> (ref_elf_path, funcs, protos, comments); reference is the recorded module itself.

    The reference ELF is psp_out/handover/elf/<ta>2.elf, the image those symbols were
    recorded against -- not a corpus build. Every one of its symbol addresses falls inside
    its LOAD segment (1562/1562 for ASD), so the pairing is sound; the self-port gate still
    has to confirm it before anything is emitted.
    """
    refelf = str(REPO / "psp_out" / "handover" / "elf" / f"{ta}2.elf")
    sympath = REPO / "psp_out" / "handover" / "symbols" / f"{ta}.json"
    if not os.path.exists(refelf) or not sympath.exists():
        raise SystemExit(f"handover reference missing for {ta!r}")
    body, va = elf_body(refelf)
    sym = json.loads(sympath.read_text())
    out, protos, comments = [], {}, {}
    for k, v in sym.get("functions", {}).items():
        ea = int(k, 16)
        end = int(v.get("end", 0))
        size = end - ea
        name = v.get("name")
        if not name or size <= 0:
            continue
        off = ea - va
        if off < 0 or off + size > len(body):
            continue
        b = body[off:off + size]
        out.append({"name": name, "ea": ea, "size": size, "bytes": b,
                    "hash": hashlib.sha256(b).hexdigest(),
                    "mnems": mnems(body, va, ea, size)})
        if v.get("proto"):
            protos[name] = v["proto"]
        if v.get("cmt"):
            comments[name] = v["cmt"]
    return refelf, out, protos, comments


PSEUDO_SIG = re.compile(
    r"^((?:[A-Za-z_][\w \*]*?)\s+__fastcall\s+([A-Za-z_]\w*)\s*\([^;{]*\))\s*$", re.M)


def pseudo_prototypes(ta: str) -> dict[str, str]:
    """name -> full C prototype, harvested from the hand-made fw/pseudo/*.c surface files.

    fw/libdumps carries only {ea,hash,name,size}, so the twelve Windows TA types have no
    recorded type information at all and every signature came out as int/_DWORD. The pseudo
    files are the one place those types exist -- they are already written as
    'int __fastcall gcp_cmd01_AllocateDRMSession(struct gcp_ctx *, struct ta_params *)'.
    """
    out: dict[str, str] = {}
    for p in glob.glob(str(HERE / "pseudo" / f"{ta}*.c")):
        try:
            txt = Path(p).read_text(errors="replace")
        except Exception:
            continue
        for sig, name in PSEUDO_SIG.findall(txt):
            out.setdefault(name, sig.strip())
    return out


CMD_IN_NAME = re.compile(r"_cmd([0-9A-Fa-f]{2,4})(?:_|$)")


def cmd_prototypes(ta: str):
    """command id -> (semantic_name, prototype), from the hand-written pseudo files.

    Matching prototypes to functions BY NAME only reached 8 of GCP's 24, because the tree
    carries two naming generations that disagree about which command a handler serves. The
    hardware findings settle it: FINDING-GCP-ACQUIRE-GATE-BYPASS records 0xFE0C as
    CreateCryptoSession, which is what fw/pseudo says, while fw/libdumps calls that same
    function gcp_cmdFE0C_CreateAuthenticatedSession. Keying on the COMMAND ID and resolving
    the address through the dispatcher sidesteps the disagreement entirely -- the dispatcher
    is the only authority on which function serves which command.
    """
    out: dict[int, tuple[str, str]] = {}
    for name, sig in pseudo_prototypes(ta).items():
        m = CMD_IN_NAME.search(name)
        if not m:
            continue
        # a handler fragment (__hN) is not the command entry; skip it
        if re.search(r"__h\d+$", name):
            continue
        try:
            cmd = int(m.group(1), 16)
        except ValueError:
            continue
        out.setdefault(cmd, (name, sig))
    return out


def load_type_decls(ta: str) -> list[str]:
    """C declarations to parse into the database BEFORE any prototype is applied.

    This ordering is the whole point: a prototype naming a struct is rejected outright if the
    struct is not declared yet, which is why 202 of 1434 ASD prototypes failed on the first
    pass. Declared as ONE parse unit, because separate parses cannot see each other's types.
    """
    decls: list[str] = []
    sym = REPO / "psp_out" / "handover" / "symbols" / f"{ta}.json"
    if sym.exists():
        lt = json.loads(sym.read_text()).get("local_types") or []
        decls.extend(x for x in lt if isinstance(x, str))
    # hand-written declarations that existed but were never applied by this pipeline
    try:
        src = (HERE / "ta_ida.py").read_text()
        # both the request struct and any INTERNAL structs for this component
        for pat in (r"^%s_DECLS\s*=\s*r?\"\"\"(.*?)\"\"\"",
                    r"^%s_INTERNAL_DECLS\s*=\s*r?\"\"\"(.*?)\"\"\""):
            m = re.search(pat % ta.upper(), src, re.S | re.M)
            if m:
                decls.append(m.group(1))
        # the TEE runtime state is shared by every module, so declare it unconditionally
        mr = re.search(r"^TEE_RUNTIME_DECLS\s*=\s*r?\"\"\"(.*?)\"\"\"", src, re.S | re.M)
        if mr:
            decls.append(mr.group(1))
    except Exception:
        pass
    return decls


def load_reference(ta: str):
    """-> (ref_elf_path, [ {name,ea,size,bytes,hash,mnems} ])"""
    ver = LIBDUMP_REF.get(ta)
    if ver is None:
        raise SystemExit(f"no reference build registered for type {ta!r}")
    # Pin the reference by HASH, not just version. MERIT ships two distinct 24.0.4.42
    # builds; globbing on the version alone picked bc1706f8 while the libdump was recorded
    # from 444eceee, so 24 of 31 symbols landed on nothing and every MERIT build was capped
    # near 30% named. fw/syms/<ta>.meta.json records the exact image the symbols came from.
    want_hash = None
    meta = HERE / "syms" / f"{ta}.meta.json"
    if meta.exists():
        mp = json.loads(meta.read_text()).get("path") or ""
        m = re.search(r"_([0-9a-f]{8})\.bin$", mp)
        if m:
            want_hash = m.group(1)
    cands = glob.glob(str(ELF_DIR / f"{ta}_{ver}_*.elf"))
    if want_hash:
        pinned = [c for c in cands if c.endswith(f"_{want_hash}.elf")]
        if pinned:
            cands = pinned
        else:
            print("  WARNING: symbols were recorded from %s but no matching ELF exists; "
                  "falling back to %s" % (want_hash, os.path.basename(cands[0]) if cands
                                          else "nothing"), flush=True)
    if not cands:
        raise SystemExit(f"reference ELF missing: {ta}_{ver}_*.elf (run mkelf_ta.py --all)")
    refelf = sorted(cands)[0]
    body, va = elf_body(refelf)
    lib = json.loads((HERE / "libdumps" / f"{ta}.json").read_text())
    # Apply LIB_RENAME to the SEED names as well. Renaming only the live databases made the
    # pool inconsistent: the seed still carried gcp_lib_cede31a8_14 while the processed
    # reference build contributed gcp_isdigit for the SAME body hash, so build_index saw one
    # hash mapping to two names, marked it ambiguous and refused to name it anywhere. That is
    # the whole of the 138-name regression -- ambiguity in build 22.0.0.53 went from 8 to 36 --
    # and it was neither a rename defect nor a collision, which were my two earlier guesses.
    try:
        import importlib.util as _iu
        _sp = _iu.spec_from_file_location("_tid", HERE / "ta_ida.py")
        _tid = _iu.module_from_spec(_sp); _sp.loader.exec_module(_tid)
        _lr = getattr(_tid, "LIB_RENAME", {})
    except Exception:
        _lr = {}
    out = []
    for f in lib:
        name, size, ea = f.get("name"), f.get("size", 0), f.get("ea")
        if name and _lr:
            for _ph, _sem in _lr.items():
                if name == "%s_%s" % (ta, _ph):
                    name = "%s_%s" % (ta, _sem); break
                if name.startswith("%s_%s__h" % (ta, _ph)):
                    name = name.replace("%s_%s" % (ta, _ph), "%s_%s" % (ta, _sem), 1); break
        if not name or size <= 0 or ea is None or name.startswith("sub_"):
            continue
        off = ea - va
        if off < 0 or off + size > len(body):
            continue
        b = body[off:off + size]
        out.append({
            "name": name, "ea": ea, "size": size, "bytes": b,
            "hash": hashlib.sha256(b).hexdigest(),
            "mnems": mnems(body, va, ea, size),
        })
    return refelf, out


def build_index(ref):
    """Signature -> name, with signatures shared by different names marked ambiguous."""
    by_hash, by_mnem = {}, {}
    for r in ref:
        by_hash.setdefault(r["hash"], set()).add(r["name"])
        if r["mnems"]:
            by_mnem.setdefault(r["mnems"], set()).add(r["name"])
    amb = {"hash": {k for k, v in by_hash.items() if len(v) > 1},
           "mnem": {k for k, v in by_mnem.items() if len(v) > 1}}
    return by_hash, by_mnem, amb


def match_build(ref, by_hash, by_mnem, amb, tgt_funcs, body, va):
    """-> {addr_hex: (name, tier)}, stats"""
    assigned: dict[str, tuple[str, str]] = {}
    used: set[str] = set()
    stats = {"EXACT": 0, "MNEM": 0, "FUZZY": 0, "none": 0, "ambiguous": 0}
    pending = []
    for fn in tgt_funcs:
        ea, size = int(fn["addr"], 16), fn["size"]
        off = ea - va
        if off < 0 or size <= 0 or off + size > len(body):
            stats["none"] += 1
            continue
        b = body[off:off + size]
        h = hashlib.sha256(b).hexdigest()
        if h in amb["hash"]:
            stats["ambiguous"] += 1
            continue
        names = by_hash.get(h)
        if names:
            n = next(iter(names))
            if n not in used:
                assigned[fn["addr"]] = (n, "EXACT"); used.add(n); stats["EXACT"] += 1
                continue
        pending.append((fn, b, ea, size))

    for fn, b, ea, size in pending:
        mm = mnems(body, va, ea, size)
        if mm and mm in amb["mnem"]:
            stats["ambiguous"] += 1
            continue
        names = by_mnem.get(mm) if mm else None
        if names:
            n = next(iter(names))
            if n not in used:
                assigned[fn["addr"]] = (n, "MNEM"); used.add(n); stats["MNEM"] += 1
                continue
        best, score = None, 0.0
        for r in ref:
            if r["name"] in used or not r["mnems"] or not mm:
                continue
            s = sim(mm, r["mnems"])
            if s > score:
                best, score = r["name"], s
        if best and score >= THRESHOLD:
            assigned[fn["addr"]] = (best, "FUZZY"); used.add(best); stats["FUZZY"] += 1
        else:
            stats["none"] += 1
    return assigned, stats


def ta_entry_points(body: bytes, va: int):
    """(marker, trampoline, create, invoke) from the image's own header.

    Layout per psp_emu/pspemu/ta_entries.py, which validated it 3/3 against independently
    known entries: header at the load base, +0x00 marker 0x200, +0x10 trampoline VA, then
    trampoline+0xA0 = TA_Create, +0xB0 = TA_InvokeCommand (Thumb bit set).
    """
    marker = struct.unpack_from("<I", body, 0)[0]
    if marker != 0x200:
        raise ValueError("header marker %#x != 0x200" % marker)
    tramp = struct.unpack_from("<I", body, 0x10)[0]
    off = tramp - va
    if off < 0 or off + 0xB4 > len(body):
        raise ValueError("trampoline %#x outside image" % tramp)
    create = struct.unpack_from("<I", body, off + 0xA0)[0] & ~1
    invoke = struct.unpack_from("<I", body, off + 0xB0)[0] & ~1
    return marker, tramp, create, invoke


# The five GP entry-point slots, trampoline+0xA0 through +0xB0. Only +0xA0 and +0xB0 were being
# read; the three between were left on the floor, which is why TMZ sat at 50% named with its
# TA_Destroy, TA_OpenSession and TA_CloseSession stubs anonymous.
#
# The ORDER is not assumed from the GP spec. It is read off GCP, where all five are already
# named independently, and corroborated by WSPE and HDCP:
#
#   GCP table @0x405d28: [0] TA_CreateEntryPoint   [1] nullsub_2 (destroy)
#                        [2] TA_OpenSessionEntryPoint
#                        [3] TA_CloseSessionEntryPoint
#                        [4] TA_InvokeCommandEntryPoint
#
# The types corroborate it too: slots 0, 2 and 4 return a status, slots 1 and 3 are void.
ENTRY_SLOTS = (
    (0xA0, "TA_CreateEntryPoint"),
    (0xA4, "TA_DestroyEntryPoint"),
    (0xA8, "TA_OpenSessionEntryPoint"),
    (0xAC, "TA_CloseSessionEntryPoint"),
    (0xB0, "TA_InvokeCommandEntryPoint"),
)


def ta_entry_names(body: bytes, va: int, ta: str) -> dict:
    """address -> entry-point name, for all five slots."""
    marker = struct.unpack_from("<I", body, 0)[0]
    if marker != 0x200:
        return {}
    tramp = struct.unpack_from("<I", body, 0x10)[0]
    off = tramp - va
    if off < 0 or off + 0xB4 > len(body):
        return {}
    out = {}
    for slot, nm in ENTRY_SLOTS:
        ea = struct.unpack_from("<I", body, off + slot)[0] & ~1
        if va <= ea < va + len(body):
            out[hex(ea)] = "%s_%s" % (ta, nm)
    return out


def _thumb_expand(imm12: int) -> int:
    if imm12 >> 10 == 0:
        k, b = (imm12 >> 8) & 3, imm12 & 0xFF
        return [b, b << 16 | b, b << 24 | b << 8, b << 24 | b << 16 | b << 8 | b][k]
    un, rot = 0x80 | (imm12 & 0x7F), (imm12 >> 7) & 0x1F
    return ((un >> rot) | (un << (32 - rot))) & 0xFFFFFFFF


def svc_stub_opcode(code: bytes):
    """The RPC opcode a per-module SVC stub loads for itself, or None.

    These stubs decompile to four uninformative lines -- "SVC 0xF2; return 2" -- and I recorded
    early on that they "carry no semantic information whatsoever". That is true of the SHARED
    thunks, where the caller sets the operation, and false of the per-module stubs, which load
    their own opcode and store it as the first word of the request frame.

    The rule is structural rather than statistical: track MOV/MOVW/MOVS immediates per register,
    then return the value of whichever register is stored to [SP, #0]. That word is the opcode,
    because R1 is pointed at SP and passed to the supervisor call.

    Validated against the 940 stubs the libdump already names *_rpc_N: **940 match, 0 disagree**,
    and it declines to answer on none of them. The first version of this function instead took the
    sole in-range immediate, which scored 296/299 and gave no answer on 102 stubs; the structural
    rule supersedes it because it is both stronger and complete. Picking the "first" immediate
    would have scored 4/4 on the multi-immediate cases, but all four were the same function in
    four builds, which is n=1 dressed up as n=4 and not evidence.
    """
    if b"\xf2\xdf" not in code:
        return None
    reg, i, n = {}, 0, len(code)
    while i < n - 1:
        hw1 = struct.unpack_from("<H", code, i)[0]
        if (hw1 & 0xF800) == 0xF000 and i + 3 < n:
            hw2 = struct.unpack_from("<H", code, i + 2)[0]
            if (hw1 & 0xFBEF) == 0xF04F and not hw2 & 0x8000:          # MOV.W Rd,#imm
                reg[(hw2 >> 8) & 0xF] = _thumb_expand(
                    ((hw1 >> 10) & 1) << 11 | ((hw2 >> 12) & 7) << 8 | (hw2 & 0xFF))
            elif (hw1 & 0xFBF0) == 0xF240 and not hw2 & 0x8000:        # MOVW Rd,#imm16
                reg[(hw2 >> 8) & 0xF] = ((hw1 & 0xF) << 12 | ((hw1 >> 10) & 1) << 11
                                         | ((hw2 >> 12) & 7) << 8 | (hw2 & 0xFF))
            i += 4
            continue
        if (hw1 & 0xF800) == 0xE800 and i + 3 < n:
            hw2 = struct.unpack_from("<H", code, i + 2)[0]
            # STRD Rt,Rt2,[SP,#0] -- Rt lands on the first frame word
            if (hw1 & 0xFFF0) == 0xE9C0 and (hw1 & 0xF) == 13 and (hw2 & 0xFF) == 0:
                rt = (hw2 >> 12) & 0xF
                if rt in reg:
                    return reg[rt]
            i += 4
            continue
        if (hw1 & 0xF800) == 0x2000:                                   # MOVS Rd,#imm8
            reg[(hw1 >> 8) & 7] = hw1 & 0xFF
            i += 2
            continue
        if (hw1 & 0xF800) == 0x9000 and (hw1 & 0xFF) == 0:             # STR Rt,[SP,#0]
            rt = (hw1 >> 8) & 7
            if rt in reg:
                return reg[rt]
        i += 2

    # Fall back to the count rule where no store to [SP,#0] is visible. Both rules are validated
    # independently -- structural 940/940, count 296/299 -- and the structural one is preferred
    # where it answers. Without this fallback the switch to the stronger rule LOST names: OPM went
    # from 75% to 72% and GCP from 91% to 90%, because those stubs build the frame in a way this
    # decoder does not follow. Caught by comparing per-module coverage before and after, not by the
    # corpus total, which went up and hid it.
    cand = [v for v in reg.values() if 0x80 <= v <= 0xFFFF]
    return cand[0] if len(cand) == 1 else None


CASE_RE = re.compile(r"case\s+(0x[0-9A-Fa-f]+|\d+)\s*:")
CALL_RE = re.compile(r"\b([A-Za-z_]\w*)\s*\(")


def dispatch_map(client, remote_ops, body, va, funcs, max_depth=1):
    """command id -> handler address, recovered from the dispatcher's switch.

    Byte/mnemonic porting cannot name a handler whose code diverged, which is how
    gcp_cmdFE0C -- a function with a known exploitable bug -- ended up unnamed on six GCP
    builds. The dispatch table does not care about code similarity: whatever sits in the
    0xFE0C arm *is* the 0xFE0C handler, by construction.

    Hex-Rays recovers the switch; parsing its output beats hand-rolling Thumb jump-table
    decoding. Callee tokens are resolved through the live function list rather than by
    assuming they look like sub_<addr>, so this works whether or not names were applied
    first.
    """
    name2addr = {f["name"]: f["addr"] for f in funcs}
    try:
        _m, _t, _c, invoke = ta_entry_points(body, va)
    except ValueError as exc:
        return {}, "header: %s" % exc, None
    # Only the invoke entry and its DIRECT callees are candidate dispatchers. Walking deeper
    # swept up nested sub-command switches inside individual handlers and invented commands
    # that do not exist (a bogus cmd 0x0F for GCP, which has no such command).
    candidates = []
    seen: set[str] = set()
    frontier = [(hex(invoke), 0)]
    while frontier:
        addr, depth = frontier.pop(0)
        if addr in seen or depth > max_depth:
            continue
        seen.add(addr)
        try:
            r = client.call(remote_ops.decompile, addr=addr, include_addresses=False)
            code = (r or {}).get("code") or ""
        except Exception:
            continue
        if not code:
            continue
        parts = CASE_RE.split(code)
        if len(parts) > 1:
            m: dict[str, str] = {}
            for i in range(1, len(parts) - 1, 2):
                cmd, blk = parts[i], parts[i + 1]
                for tok in CALL_RE.findall(blk):
                    if tok in name2addr:
                        m.setdefault("0x%x" % int(cmd, 0), name2addr[tok])
                        break
            if m:
                candidates.append((depth, len(m), addr, m))
        if depth < max_depth:
            for tok in CALL_RE.findall(code):
                if tok in name2addr and name2addr[tok] not in seen:
                    frontier.append((name2addr[tok], depth + 1))
    if not candidates:
        return {}, "no switch found within depth %d of invoke" % max_depth, None
    # Take ONLY the richest switch at the shallowest depth. Merging every sibling switch at
    # that depth was wrong: a command handler reached from the entry has its own sub-command
    # switch, and its arms were absorbed as if they were top-level commands. That is how GCP
    # acquired phantom commands 0x8, 0x9, 0xA and 0xF -- which are sub-commands of 0xFE0A --
    # and the name-versus-dispatcher audit is what exposed it. The handler names were right;
    # this map was wrong.
    best_depth = min(c[0] for c in candidates)
    at_depth = [c for c in candidates if c[0] == best_depth]
    at_depth.sort(key=lambda c: -c[1])
    _d, _n, chosen, out = at_depth[0]
    return dict(out), None, chosen


def named_declaration(sig: str, nm: str) -> str | None:
    """Turn a recorded prototype into a declaration IDA will accept.

    Handover prototypes are stored WITHOUT a function name, in two shapes: the obvious
    'int __fastcall(args)' and the bare 'int()'. Only special-casing __fastcall left 205 of
    239 ASD rejections -- every 'int()' became the unparsable 'int();'. The name goes before
    the first parenthesis in all cases; pseudo-file prototypes already carry it and are used
    as they are.
    """
    sig = sig.strip().rstrip(";")
    if not sig:
        return None
    if re.search(r"\b%s\s*\(" % re.escape(nm), sig):
        return sig + ";"
    i = sig.find("(")
    if i < 0:
        return None
    head = sig[:i].rstrip()
    if not head:
        return None
    return "%s %s%s;" % (head, nm, sig[i:])


# Components whose dispatcher stages the host request in a local buffer, and the struct that
# describes it. Declaring a struct only makes it available; nothing uses it until a variable
# is typed with it, and for these the variable is a dispatcher local rather than an argument.
# How each component's dispatcher exposes the host request, and the struct describing it.
#   ("frame", size)     the request is COPIED into a stack buffer of that size (XGMI)
#   ("deref", argname)  the dispatcher works on the host buffer directly via `v = *arg`
# Declaring a struct only makes it available; nothing uses it until a variable is typed.
STAGING = {
    "xgmi": ("struct xgmi_req", ("frame", 0xC20)),
    "oc":   ("struct oc_req *", ("deref", "a4")),
    "wspe": ("struct wspe_req *", ("deref", "a4")),
    "hdcp": ("struct hdcp_req *", ("deref", "params")),
    "powerplay": ("struct powerplay_req *", ("deref", "a4")),
    "auc": ("struct auc_req *", ("deref", "a4")),
    "fp": ("struct fp_req *", ("deref", "a4")),
    "rap": ("struct rap_req *", ("deref", "a4")),
    "opm": ("struct opm_req *", ("deref", "a4")),
    "csu": ("struct csu_req *", ("deref", "a4")),
    "merit": ("struct merit_req *", ("deref", "a4")),
    "dtm": ("struct dtm_req *", ("deref", "a4")),
}


def type_staging_lvar(client, remote_ops, ta, dispatcher_addr):
    """Type the dispatcher's staging buffer. -> (ok, detail)

    The local is found by its FRAME SIZE, not its name: Hex-Rays numbers locals per build, so
    'v12' in one version is something else in the next, while the staged request keeps the
    size the copy and the size check both use.
    """
    spec = STAGING.get(ta)
    if not spec or not dispatcher_addr:
        return False, None
    decl, (mode, key) = spec
    try:
        code = (client.call(remote_ops.decompile, addr=dispatcher_addr,
                            include_addresses=False) or {}).get("code") or ""
    except Exception as exc:
        return False, str(exc)
    if mode == "frame":
        # '_DWORD v12[3]; // [sp+8h] [bp-C20h] BYREF'
        m = re.search(r"\b(\w+)\s*\[\s*\d+\s*\]\s*;\s*//\s*\[sp\+[0-9A-Fa-f]+h\]\s*"
                      r"\[bp-%Xh\]" % key, code)
        if not m:
            return False, "no local with frame size %#x" % key
    else:
        # 'v5 = *a4;'  -- the local that holds the host request pointer. The argument's NAME
        # varies by build: where a prototype has been applied it is 'params', otherwise
        # Hex-Rays calls it 'a4'. Keying on one name typed only 2 of HDCP's 7 builds, so the
        # declared name is tried first and then every parameter of the function.
        body_code = "\n".join(l for l in code.splitlines() if not l.lstrip().startswith("//"))
        head = body_code[:body_code.find("{")] if "{" in body_code else body_code
        params_list = re.findall(r"(\w+)\s*(?:,|\))", head[head.find("(") + 1:]) \
            if "(" in head else []
        # ONLY the request argument is a candidate: the declared name, else the LAST
        # parameter, which is the params pointer in every TA entry seen here. Trying every
        # parameter matched an unrelated assignment and typed the wrong local.
        cands = [key] + ([params_list[-1]] if params_list else [])
        m = None
        for cand in cands:
            if not re.match(r"^[A-Za-z_]\w*$", cand or ""):
                continue
            # an applied prototype can introduce a cast: 'v5 = (int *)*params;'
            # the cast can sit on either side of the dereference:
            #   'v5 = (int *)*a4;'   and   'v6 = *(unsigned int **)a4;'
            m = re.search(r"\b(\w+)\s*=\s*(?:\([^)]*\)\s*)?\*\s*(?:\([^)]*\)\s*)?%s\s*;"
                          % re.escape(cand), code)
            if m:
                break
        if not m:
            return False, "no local assigned *<arg> (tried %s)" % ",".join(
                c for c in cands if c)
    var = m.group(1)
    try:
        r = client.call(remote_ops.set_lvar_type, addr=dispatcher_addr,
                        variable=var, type=decl)
        return bool(r.get("ok")), r.get("error") or var
    except Exception as exc:
        return False, str(exc)


ASD_STUB = (0xE1A00002, 0xE59FC010, 0xE12FFF3C, 0xEF000075)
ASD_DISPATCH_OFF = 0x1C


def asd_dispatcher(body: bytes, va: int):
    """ASD's RPC dispatcher, via the trampoline's +0x1C slot.

    ASD has no TA entry trampoline, so ta_entry_points does not apply. The stub shape is
    verified rather than the offset assumed, exactly as psp_emu/pspemu/ta_entries.py does --
    if the layout ever changes, this returns nothing instead of a wrong address.
    """
    def rd(addr, n=1):
        return struct.unpack_from("<%dI" % n, body, addr - va)
    try:
        if rd(va)[0] != 0x200:
            return None, "header marker is not 0x200"
        tramp = rd(va + 0x10)[0]
        if not (va <= tramp < va + len(body) - 0x20):
            return None, "trampoline %#x outside image" % tramp
        if rd(tramp, 4) != ASD_STUB:
            return None, "trampoline stub shape differs"
        return rd(tramp + ASD_DISPATCH_OFF)[0] & ~1, None
    except Exception as exc:
        return None, str(exc)


def retype_arg(client, remote_ops, addr, fname, stype, idx=1):
    """Rewrite only the Nth parameter of a function's LIVE prototype (1-based).

    Replacing the whole declaration would discard the rest of the signature, which for the
    handover components came from recorded prototypes and is better than anything inferred here.
    """
    try:
        r = client.call(remote_ops.decompile, addr=addr, include_addresses=False) or {}
        code_f = r.get("code") or ""
        if "(" not in code_f:
            return False
        body_c = "\n".join(l for l in code_f.splitlines()
                           if not l.lstrip().startswith("//"))
        head = body_c[:body_c.find("{")]
        mm = re.match(r"\s*(.+?)\s+(\w+)\s*\((.*)\)\s*$", head.strip(), re.S)
        if not mm:
            return False
        ret, args = mm.group(1), mm.group(3)
        parts = [x.strip() for x in args.split(",")]
        if not parts or not parts[0] or parts[0] == "void":
            return False
        if idx < 1 or idx > len(parts):
            return False
        pn = re.findall(r"(\w+)\s*$", parts[idx - 1])
        parts[idx - 1] = "%s%s" % (stype, pn[0] if pn else "a%d" % idx)
        decl = "%s %s(%s);" % (ret, fname, ", ".join(parts))
        rr = client.call(remote_ops.set_type, edits=[{"addr": addr, "signature": decl}])
        return bool((rr.get("result") or [{}])[0].get("ok"))
    except Exception:
        return False


def retype_arg1(client, remote_ops, addr, fname, stype):
    """Backwards-compatible alias for the first parameter."""
    return retype_arg(client, remote_ops, addr, fname, stype, 1)


def cmd_label(ta: str, cmd: int) -> str:
    """Match the existing convention: gcp_cmd01, gcp_cmdFE0C."""
    return "%s_cmd%s" % (ta, ("%02X" if cmd <= 0xFF else "%04X") % cmd)


def thumb_bw_target(hw1: int, hw2: int):
    """Decode a 32-bit Thumb-2 B.W and return its pc-relative displacement.

    ASD's 4-byte thunks are Thumb B.W, not ARM B. An earlier attempt to decode them as
    ARM read 41 of them as top byte 0xB8, 37 as 0xBE and so on -- those are the SECOND
    halfword of a Thumb branch, which is how the wrong assumption announced itself.
    """
    if (hw1 & 0xF800) != 0xF000 or (hw2 & 0xD000) != 0x9000:
        return None
    s_, imm10 = (hw1 >> 10) & 1, hw1 & 0x3FF
    j1, j2, imm11 = (hw2 >> 13) & 1, (hw2 >> 11) & 1, hw2 & 0x7FF
    v = (s_ << 24) | ((1 - (j1 ^ s_)) << 23) | ((1 - (j2 ^ s_)) << 22) \
        | (imm10 << 12) | (imm11 << 1)
    return v - (1 << 25) if s_ else v


def decompile_all(client, remote_ops, funcs, assigned, body=None, va=None):
    rows = []
    for fn in funcs:
        name, tier = assigned.get(fn["addr"], (None, "none"))
        try:
            r = client.call(remote_ops.decompile, addr=fn["addr"], include_addresses=False)
            code = r.get("code") if isinstance(r, dict) else None
            if code is None:
                code = "/* decompile failed: %s */" % (
                    (r or {}).get("error", "no code returned"))
        except Exception as exc:
            code = "/* decompile failed: %s */" % exc
        # If porting gave no name, take the one the DECOMPILER states in the signature.
        # Hex-Rays resolves thunks and prints 'j_<target>' even where the stored symbol is
        # still sub_*, so thousands of 4-byte ASD thunks carry a perfectly good derived name
        # that this pipeline was discarding and reporting as ANON. This is IDA's own thunk
        # resolution rather than an inference, so it is recorded as tier=THUNK to keep it
        # distinguishable from a ported name.
        if not name:
            msig = re.match(r"\s*(?:[\w \*]+?)\s+([A-Za-z_]\w*)\s*\(",
                            "\n".join(l for l in code.splitlines()
                                       if not l.lstrip().startswith("//")))
            cand = msig.group(1) if msig else None
            # A j_sub_* candidate is kept HERE on purpose, even though it is address-derived:
            # the re-resolution pass below needs the target address to recover a real name from
            # it. Anything still address-derived after that pass is dropped at the end, so the
            # artifacts never claim a name like sub_D07364_12 is one. A plain sub_* candidate
            # carries no target and is rejected immediately.
            if cand and (cand.startswith("j_") or not ta_metrics.is_addr_name(cand)):
                name, tier = cand, ("THUNK" if cand.startswith("j_") else "IDA")
        rows.append({"addr": fn["addr"], "size": fn["size"], "name": name,
                     "tier": tier, "code": code,
                     "fallback": fn["name"]})

    # Late thunk re-resolution. The j_<target> name above is whatever Hex-Rays printed at the
    # moment that function was decompiled, so a thunk decompiled before its target was renamed
    # keeps a stale j_sub_D06CE4 even though the target now has a real name. That left 87 ASD
    # thunks carrying an address-derived name -- which also quietly inflated the "named" count,
    # since the name field was set.
    #
    # Rewriting from `assigned` rather than re-decompiling keeps this a pure lookup: the target
    # name is one this pipeline already decided on, so no new inference is introduced. Thunks
    # whose target is still unnamed are left alone, because j_sub_* is the honest answer there.
    # The address-name test lives in ta_metrics so it cannot drift between the pipeline, the
    # repair pass and the audit; it previously drifted in all three at once.
    real = {a.lower(): nm for a, (nm, _t) in assigned.items()
            if ta_metrics.is_meaningful(nm)}
    used_names = {r["name"] for r in rows if r["name"]}
    fixed = 0
    for row in rows:
        m = re.match(r"^j_sub_([0-9A-Fa-f]+)(?:_\d+)?$", row["name"] or "")
        if not m:
            continue
        tgt = real.get("0x" + m.group(1).lower())
        if tgt:
            # Uniquify: many thunks share a target, and a colliding rename is refused, which
            # would leave the banner and the body disagreeing. On the ASD reference only 55 of
            # 786 reconciled before this was added.
            base, nm, k = "j_" + tgt, "j_" + tgt, 2
            while nm in used_names:
                nm, k = "%s_%d" % (base, k), k + 1
            used_names.add(nm)
            row["name"] = nm
            fixed += 1
    if fixed:
        print("    thunk names re-resolved after target naming: %d" % fixed)

    # Final honesty pass: whatever is still an IDA address default is not a name. These rows
    # become anonymous, which puts them in the .anon.c file and out of the named count, instead
    # of inflating coverage with 1,295 entries that tell a reader nothing.
    dropped = 0
    for row in rows:
        if ta_metrics.is_addr_name(row["name"]):
            row["name"], row["tier"], dropped = None, "none", dropped + 1
    if dropped:
        print("    address-derived names dropped as uninformative: %d" % dropped)

    # Thunks that Hex-Rays did not label at all. Runs AFTER the drop above, deliberately: before
    # it, these rows still carry the stale j_sub_<addr> label and so look named, which is why the
    # first version of this pass resolved nothing. The re-resolution only repairs a name
    # that already reads j_sub_<addr>; 909 ASD thunks had NO name, so nothing matched them even
    # though their target is properly named. Decoding the branch from the ELF settles where each
    # one goes without asking the decompiler, which makes this a resolution and not an inference,
    # the same justification the existing THUNK tier rests on.
    by_ea = {}
    if body is not None and va is not None:
        by_ea = {int(r["addr"], 16): r for r in rows}
        taken = {r["name"] for r in rows if r["name"]}
        taken |= used_names
        resolved = 0
        for row in rows:
            if row["name"] or row["size"] != 4:
                continue
            off = int(row["addr"], 16) - va
            if off < 0 or off + 4 > len(body):
                continue
            hw1, hw2 = struct.unpack_from("<HH", body, off)
            disp = thumb_bw_target(hw1, hw2)
            if disp is None:
                continue
            tgt = (int(row["addr"], 16) & ~1) + 4 + disp
            t = by_ea.get(tgt) or by_ea.get(tgt | 1) or by_ea.get(tgt & ~1)
            if t and ta_metrics.is_meaningful(t["name"]):
                # Several distinct thunks can branch to the same target, so j_<target> is not
                # unique and a plain rename is refused on collision -- which left the banner and
                # the body disagreeing. Suffix the duplicates instead. The suffix here is honest:
                # these really are separate functions with the same destination, unlike the
                # address-derived _N suffixes that were dropped earlier.
                base = "j_" + t["name"]
                nm, k = base, 2
                while nm in taken:
                    nm, k = "%s_%d" % (base, k), k + 1
                taken.add(nm)
                row["name"], row["tier"] = nm, "THUNK"
                resolved += 1
        if resolved:
            print("    4-byte thunks resolved by decoding Thumb B.W: %d" % resolved)

    # Reconcile the artifact with itself. Both thunk passes above set row["name"] in this
    # script's metadata only, so the banner could read j_asd_clear_link_outputs while the
    # signature inside the body still read j_sub_D07E10_16 -- 1,018 functions disagreed with
    # their own banner. A reader seeing those two differ cannot tell which to trust, which is
    # worse than leaving the function anonymous.
    #
    # So push every derived name into the database and re-decompile the ones that still
    # disagree. Names are uniquified first because several distinct thunks can branch to the
    # same target, and a colliding rename is silently refused.
    SIGNAME = re.compile(r"^\s*[\w \*]+?\s+([A-Za-z_]\w*)\s*\(", re.M)

    def _body_name(code):
        txt = "\n".join(l for l in (code or "").splitlines()
                         if not l.lstrip().startswith(("//", "/*")))
        m = SIGNAME.search(txt)
        return m.group(1) if m else None

    stale = [r for r in rows if r["name"] and _body_name(r["code"]) not in (None, r["name"])]
    if stale:
        try:
            client.call(remote_ops.rename,
                        batch={"func": [{"addr": r["addr"], "name": r["name"]} for r in stale],
                               "allow_overwrite": False})
            agreed = 0
            for r in stale:
                rr = client.call(remote_ops.decompile, addr=r["addr"],
                                 include_addresses=False)
                code = rr.get("code") if isinstance(rr, dict) else None
                if code:
                    r["code"] = code
                    if _body_name(code) == r["name"]:
                        agreed += 1
            print("    banner/body reconciled: %d of %d" % (agreed, len(stale)))
        except Exception as exc:
            print("    banner/body reconcile failed: %s" % exc)

    return rows


REF_TOKEN = re.compile(r"_(?:[0-9A-Fa-f]{6,8})(?:__h\d+)?$|_[0-9A-Fa-f]{8}_")


def _banner(fh, meta, nrows, nnamed, nanon, kind):
    fh.write("/* %s %s  (%s) -- %s\n" % (meta["type"], meta["version"], meta["hash8"], kind))
    fh.write(" * generated by fw/ta_re.py -- do not edit\n")
    fh.write(" * reference: %s\n" % meta["reference"])
    fh.write(" * functions: %d total in build, %d named, %d anonymous\n"
             % (nrows, nnamed, nanon))
    fh.write(" * porting tiers: %s\n" % json.dumps(meta["stats"], sort_keys=True))
    fh.write(" *\n"
             " * Addresses are omitted and functions are ordered by NAME so that the same\n"
             " * function lines up across versions. See the .index.json sidecar for\n"
             " * name -> address.\n"
             " *\n"
             " * CAUTION on ported names: a name is only an identity claim at tier=EXACT.\n"
             " * At tier=MNEM/FUZZY the bytes differ from the reference, so any ADDRESS or\n"
             " * CONTENT HASH embedded in the name (e.g. *_4025F8, gcp_lib_<hash8>_*) still\n"
             " * refers to the REFERENCE build and does not describe this one.\n */\n\n")


def emit_c(path: Path, meta: dict, rows: list):
    """Named functions and anonymous ones go to separate files.

    Mixing them defeats the purpose: a function named in build A but anonymous in build B
    reorders everything after it, so a two-build comparison came out as ~2900 diff lines when
    only ~48 functions had actually changed. The named file is the one worth diffing.
    """
    named = [r for r in rows if r["name"]]
    anon = [r for r in rows if not r["name"]]
    named.sort(key=lambda r: r["name"])
    # sub_* numbering is build-specific and meaningless across versions; order by shape
    anon.sort(key=lambda r: (r["size"], hashlib.sha256(r["code"].encode()).hexdigest()))
    with path.open("w") as fh:
        _banner(fh, meta, len(rows), len(named), len(anon), "named functions")
        for r in named:
            flag = "" if r["tier"] == "EXACT" or not REF_TOKEN.search(r["name"]) \
                   else "  REF-DERIVED-NAME"
            fh.write("/* ==== %s  [tier=%s size=%d]%s ==== */\n"
                     % (r["name"], r["tier"], r["size"], flag))
            fh.write(r["code"].rstrip() + "\n\n")
    apath = path.with_suffix(".anon.c")
    with apath.open("w") as fh:
        _banner(fh, meta, len(rows), len(named), len(anon), "UNNAMED functions")
        fh.write("/* These did not match the reference above the %.2f similarity floor and are\n"
                 " * deliberately left unnamed rather than guessed. Ordered by size then body\n"
                 " * hash, because sub_* numbering is not stable across builds.\n */\n\n"
                 % THRESHOLD)
        for r in anon:
            fh.write("/* ==== ANON size=%d ==== */\n" % r["size"])
            fh.write(r["code"].rstrip() + "\n\n")
    idx = {"type": meta["type"], "version": meta["version"], "hash8": meta["hash8"],
           "stats": meta["stats"],
           "functions": [{"name": r["name"], "addr": r["addr"], "size": r["size"],
                          "tier": r["tier"], "ida_name": r["fallback"]} for r in rows]}
    path.with_suffix(".index.json").write_text(json.dumps(idx, indent=1, sort_keys=True))


def run_type(ta: str, outdir: Path, limit: int | None, validate_only: bool) -> int:
    from idatui import remote_ops
    from idatui.nexus_client import NexusClient

    protos: dict[str, str] = {}
    fn_comments: dict[str, str] = {}
    standalone_comments: dict[str, str] = {}
    ref_is_corpus = True
    if ta in HANDOVER_REF:
        refelf, seed_ref, protos, fn_comments = load_handover_reference(ta)
        ref_is_corpus = False       # the reference module is not one of the corpus builds
        try:
            standalone_comments = json.loads(
                (REPO / "psp_out" / "handover" / "symbols" / f"{ta}.json").read_text()
            ).get("comments") or {}
        except Exception:
            standalone_comments = {}
    else:
        refelf, seed_ref = load_reference(ta)
        protos = pseudo_prototypes(ta)
    type_decls = load_type_decls(ta)
    print("  types: %d declarations, %d prototypes, %d function comments"
          % (len(type_decls), len(protos), len(fn_comments)), flush=True)
    _bh, _bm, _amb = build_index(seed_ref)
    print("type %s: reference %s, %d named reference functions "
          "(%d ambiguous byte sigs, %d ambiguous mnem sigs)"
          % (ta, os.path.basename(refelf), len(seed_ref),
             len(_amb["hash"]), len(_amb["mnem"])), flush=True)

    builds = sorted(glob.glob(str(ELF_DIR / f"{ta}_*.elf")))
    vers = {}
    for b in builds:
        m = re.match(r"^(.+?)_([0-9.]+)_([0-9a-f]{8})\.elf$", os.path.basename(b))
        if m:
            vers[b] = m.group(2)

    # CHAINED ORDER: grow outward from the reference, always taking the unprocessed build
    # closest to something already named. Porting every build from the single distant
    # reference left the 22.0.0.62-77 branch at 52% named; adjacent builds share far more
    # exact bytes, so each one is seeded from its nearest already-processed neighbour.
    order = [refelf]
    remaining = [b for b in builds if b != refelf and b in vers]
    if not ref_is_corpus:
        vers[refelf] = "0.0.0.0"    # unknown; leave chain growth to version order below
    while remaining:
        nxt = min(remaining, key=lambda b: min(vdist(vers[b], vers[d]) for d in order))
        order.append(nxt)
        remaining.remove(nxt)
    if limit:
        order = order[:limit + 1]

    outdir.mkdir(parents=True, exist_ok=True)
    gate_ok = None
    summary = []
    # reference pool: the seed libdump plus every build processed so far
    pool = [{"ver": vers.get(refelf, LIBDUMP_REF.get(ta, "0.0.0.0")), "funcs": seed_ref,
             "src": os.path.basename(refelf)}]
    for path in order:
        name = os.path.basename(path)
        m = re.match(r"^(.+?)_([0-9.]+)_([0-9a-f]{8})\.elf$", name)
        if not m and path != refelf:
            print("  SKIP unparsable name %s" % name); continue
        _t, ver, h8 = m.groups() if m else (ta, "reference", f"{ta}2")
        # Union of every source processed so far, nearest version first. The union is what
        # lifts coverage: a function unchanged since an old build matches EXACT against that
        # build even when the reference has diverged.
        srcs = sorted(pool, key=lambda s: vdist(ver, s["ver"]))
        ref = [r for s in srcs for r in s["funcs"]]
        by_hash, by_mnem, amb = build_index(ref)
        nearest = srcs[0]["src"] if srcs else "-"
        body, va = elf_body(path)
        client = NexusClient(path)
        try:
            client.connect(progress=None)
            funcs, off = [], 0
            while True:
                r = client.call(remote_ops.list_funcs,
                                queries=[{"count": 500, "offset": off}])
                blk = r["result"][0]
                funcs.extend(blk["data"])
                off = blk["next_offset"]
                if off >= blk["total"]:
                    break
            # On the reference build, create functions at any recorded symbol address that
            # auto-analysis did not define. Without this the seed pool silently shrinks to
            # whatever IDA happened to find: MERIT contributed only 7 of its 31 recorded
            # symbols, capping every MERIT build at ~30% named.
            if path == refelf:
                starts0 = {int(f["addr"], 16) for f in funcs}
                missing = sorted({r["ea"] for r in ref} - starts0)
                if missing:
                    try:
                        client.call(remote_ops.define_func,
                                    items=[{"addr": hex(e)} for e in missing])
                    except Exception as exc:
                        print("    define_func failed: %s" % exc, flush=True)
                    funcs, off = [], 0
                    while True:
                        r = client.call(remote_ops.list_funcs,
                                        queries=[{"count": 500, "offset": off}])
                        blk = r["result"][0]
                        funcs.extend(blk["data"])
                        off = blk["next_offset"]
                        if off >= blk["total"]:
                            break
                    recovered = len({int(f["addr"], 16) for f in funcs} & set(missing))
                    print("    defined %d/%d missing reference functions"
                          % (recovered, len(missing)), flush=True)

            assigned, stats = match_build(ref, by_hash, by_mnem, amb, funcs, body, va)

            # Dispatcher-anchored naming. Independent of code similarity, so it both fills
            # the gaps porting cannot reach and cross-checks the names porting did apply.
            dmap, derr, disp_addr = dispatch_map(client, remote_ops, body, va, funcs)
            if not dmap:
                # Some TAs put the switch one level deeper (a thin invoke -> guard ->
                # dispatcher chain). Only fall back when depth 1 found nothing at all, so
                # the usual case cannot pick up nested sub-command switches.
                dmap, derr, disp_addr = dispatch_map(client, remote_ops, body, va, funcs,
                                                     max_depth=2)
                if dmap:
                    derr = None
            stats["DISPATCH"] = 0
            stats["dispatch_conflict"] = 0
            conflicts = []
            # One handler commonly serves several commands (GCP routes 0xFE03-0xFE07 to one
            # function), so group by address before naming or judging agreement.
            by_addr: dict[str, list[int]] = {}
            for cmd, haddr in dmap.items():
                by_addr.setdefault(haddr, []).append(int(cmd, 0))
            for haddr, cmds in sorted(by_addr.items()):
                cmds.sort()
                want = cmd_label(ta, cmds[0])
                have = assigned.get(haddr, (None, None))[0]
                if have is None:
                    assigned[haddr] = (want, "DISPATCH")
                    stats["DISPATCH"] += 1
                    stats["none"] = max(0, stats["none"] - 1)
                    continue
                # agreement = the ported name mentions ANY command this handler serves
                if any(cmd_label(ta, c).lower() in have.lower() for c in cmds):
                    continue
                # Only a name that itself claims a command can CONTRADICT the dispatcher.
                # HDCP's symbols are not named <ta>_cmdNN at all, and treating every such
                # name as a conflict reported 16 of them on a build with 16 commands.
                claim = re.search(r"_cmd([0-9A-Fa-f]+)", have)
                if not claim:
                    continue
                stats["dispatch_conflict"] += 1
                conflicts.append(("/".join("%#x" % c for c in cmds), haddr, have, want))
            if derr:
                print("    dispatch unavailable (%s)" % derr, flush=True)
            for cmd, haddr, have, want in conflicts:
                print("    CONFLICT cmd %s @%s ported=%s dispatch=%s"
                      % (cmd, haddr, have, want), flush=True)
            is_ref = (path == refelf)
            if is_ref:
                # The gate must test the MATCHER, not IDA's function discovery. Dividing by
                # every reference symbol failed types where auto-analysis simply does not
                # define a function at a recorded address (TMZ scored 17% that way, with a
                # perfectly sound matcher). Denominator is therefore the reference functions
                # that actually exist as function starts here and are not ambiguous.
                starts = {int(f["addr"], 16) for f in funcs}
                eligible = [r for r in ref
                            if r["ea"] in starts and r["hash"] not in amb["hash"]]
                denom = max(1, len(eligible))
                exact_rate = stats["EXACT"] / denom
                gate_ok = exact_rate >= 0.95
                orphan = sum(1 for r in ref if r["ea"] not in starts)
                print("  GATE self-port %s: EXACT %d/%d eligible (%.0f%%) "
                      "[%d ref syms, %d not a function start here] -> %s"
                      % (ver, stats["EXACT"], denom, 100 * exact_rate, len(ref), orphan,
                         "PASS" if gate_ok else "FAIL -- type is VOID"), flush=True)
                # The eligible-only denominator makes the gate robust to IDA discovery gaps,
                # but that also makes it blind to the reference ELF being the WRONG BUILD --
                # which shows up as most symbols landing on nothing, not as a bad match rate.
                if len(ref) and orphan / len(ref) > 0.10:
                    print("  WARNING: %d/%d (%.0f%%) reference symbols are not function "
                          "starts in the reference build -- suspect a build/symbol mismatch"
                          % (orphan, len(ref), 100 * orphan / len(ref)), flush=True)
                if not gate_ok:
                    return 2
                if validate_only:
                    return 0
            if assigned:
                items = [{"addr": a, "name": n} for a, (n, _t2) in assigned.items()]
                rr = client.call(remote_ops.rename,
                                 batch={"func": items, "allow_overwrite": False})
                # rename returns {"func":[...], "summary":{"ok":N,"failed":M}}; the old
                # reading looked for a "result" list that never exists, so this counter
                # reported zero errors unconditionally and hid every failed rename.
                errs = int((rr.get("summary") or {}).get("failed", 0))
                if errs:
                    print("    %d rename errors" % errs, flush=True)
            # 1. TYPES FIRST. A prototype that names a struct is rejected if the struct is
            # not in the database yet, so declaration order decides how many prototypes
            # actually land. One parse unit: separate parses cannot see each other's types.
            ntypes = 0
            if type_decls:
                try:
                    rr = client.call(remote_ops.declare_type,
                                     decls=["\n".join(type_decls)])
                    ntypes = sum(1 for r in (rr.get("result") or []) if r.get("ok"))
                    if not ntypes:   # one bad declaration voids the batch; fall back
                        rr = client.call(remote_ops.declare_type, decls=type_decls)
                        ntypes = sum(1 for r in (rr.get("result") or []) if r.get("ok"))
                except Exception as exc:
                    print("    declare_type failed: %s" % exc, flush=True)

            # 2a. Command-anchored prototypes and names. Resolved through the dispatcher by
            # COMMAND ID rather than by name, so the two disagreeing naming generations in
            # this tree cannot cause a miss (or, worse, a confident mislabel).
            ncmdproto = nrelabel = 0
            cprotos = cmd_prototypes(ta) if ta not in HANDOVER_REF else {}
            if cprotos and dmap:
                cedits, renames = [], []
                for cmd_s, haddr in dmap.items():
                    ent = cprotos.get(int(cmd_s, 0))
                    if not ent:
                        continue
                    sem, sig = ent
                    decl = named_declaration(sig, sem)
                    if decl:
                        cedits.append({"addr": haddr, "signature": decl})
                    have = assigned.get(haddr, (None, None))[0]
                    claim = CMD_IN_NAME.search(have or "")
                    if have and claim and int(claim.group(1), 16) != int(cmd_s, 0):
                        # the existing name claims a DIFFERENT command than the dispatcher
                        renames.append({"addr": haddr, "name": sem})
                        assigned[haddr] = (sem, "DISPATCH")
                if cedits:
                    try:
                        rr = client.call(remote_ops.set_type, edits=cedits)
                        ncmdproto = sum(1 for r in (rr.get("result") or []) if r.get("ok"))
                    except Exception:
                        pass
                if renames:
                    try:
                        rr = client.call(remote_ops.rename,
                                         batch={"func": renames, "allow_overwrite": True})
                        nrelabel = len(renames)
                        for r_ in renames:
                            print("    RELABEL %s -> %s (dispatcher)"
                                  % (r_["addr"], r_["name"]), flush=True)
                    except Exception:
                        pass
            stats["cmd_protos"] = ncmdproto
            stats["relabelled"] = nrelabel

            # 2b. Prototypes matched by name.
            nproto = nproto_try = 0
            if protos:
                edits = []
                for addr, (nm, _t3) in assigned.items():
                    sig = protos.get(nm)
                    if not sig:
                        continue
                    decl = named_declaration(sig, nm)
                    if decl:
                        edits.append({"addr": addr, "signature": decl})
                if edits:
                    nproto_try = len(edits)
                    try:
                        rr = client.call(remote_ops.set_type, edits=edits)
                        nproto = sum(1 for r in (rr.get("result") or []) if r.get("ok"))
                    except Exception as exc:
                        print("    set_type failed: %s" % exc, flush=True)

            # 3. Comments -- the actual RE notes, the most valuable annotation in the tree.
            # Function comments are keyed by NAME so they follow a function across builds;
            # the standalone address-keyed set is only meaningful on the reference module.
            ncmt = 0
            citems = []
            for addr, (nm, _t4) in assigned.items():
                c = fn_comments.get(nm)
                if c:
                    citems.append({"addr": addr, "comment": c})
            if path == refelf and standalone_comments:
                for k, v in standalone_comments.items():
                    if isinstance(v, str) and v.strip():
                        citems.append({"addr": "0x" + k.lstrip("0x"), "comment": v})
            if citems:
                try:
                    rr = client.call(remote_ops.set_comments, items=citems)
                    rows_ = rr.get("result") or []
                    ncmt = sum(1 for r in rows_ if not r.get("error"))
                except Exception as exc:
                    print("    set_comments failed: %s" % exc, flush=True)
            if ntypes or nproto_try or ncmt or ncmdproto:
                print("    types=%d  prototypes=%d/%d  cmd-anchored=%d  relabelled=%d  "
                      "comments=%d" % (ntypes, nproto, nproto_try, ncmdproto, nrelabel,
                                       ncmt), flush=True)
            stats["types"] = ntypes
            stats["protos"] = nproto
            stats["comments"] = ncmt

            # Retype the first argument of the functions whose a1 is a known internal
            # structure. ASD's dereferences are concentrated: 86% of its a1 accesses are in
            # ~20 functions forming two families, so typing those converts far more lines than
            # the dispatcher did. The rest of each signature is preserved by rewriting only the
            # first parameter of the LIVE prototype rather than inventing a whole declaration.
            # Re-apply the durable call-graph name map before anything else reads names.
            # fw/ta_cgname.py writes it to fw/cgnames/<ta>.json precisely so that rebuilding a
            # database does not silently discard those names, which has already happened.
            ncg = 0
            cgf = HERE / "cgnames" / f"{ta}.json"
            if cgf.exists():
                try:
                    cgmap = json.loads(cgf.read_text()).get(name.replace(".elf", ""), {})
                except Exception:
                    cgmap = {}
                if cgmap:
                    have = {f["addr"] for f in funcs}
                    items = [{"addr": ad, "name": nm2}
                             for ad, nm2 in cgmap.items() if ad in have]
                    if items:
                        try:
                            rr = client.call(remote_ops.rename,
                                             batch={"func": items,
                                                    "allow_overwrite": False})
                            ncg = int((rr.get("summary") or {}).get("ok", 0))
                            print("    call-graph names re-applied: %d/%d"
                                  % (ncg, len(items)), flush=True)
                        except Exception as exc:
                            print("    cgname re-apply failed: %s" % exc, flush=True)

            # The five GP entry points, from the image header. This is the strongest naming
            # evidence available for these addresses -- the loader itself uses these slots -- so
            # it is applied for every module, but only where porting left the address unnamed, so
            # a genuine ported name is never overwritten. Any disagreement is printed rather than
            # silently resolved.
            try:
                ents = ta_entry_names(body, va, ta)
                if ents:
                    cur = {f["addr"].lower(): (assigned.get(f["addr"], (None,))[0])
                           for f in funcs}
                    # Do not introduce a SECOND holder of a role. RAS's libdump already names
                    # TA_CreateEntryPoint and TA_DestroyEntryPoint, at addresses the header
                    # assigns to OpenSession and CloseSession -- a systematic two-slot
                    # disagreement. Applying the header names there would leave the artifact
                    # asserting two different Create entry points, which is worse than either
                    # answer alone. Where a role is already claimed, the conflict is reported
                    # and nothing is written; 18 RAS instances take this path.
                    roles_taken = {}
                    for f in funcs:
                        nmx = assigned.get(f["addr"], (None,))[0] or ""
                        m_ = re.search(r"TA_\w+EntryPoint", nmx)
                        if m_:
                            roles_taken[m_.group(0)] = nmx
                    todo, conflict = [], []
                    for ad, nm3 in ents.items():
                        if ad not in cur:
                            continue
                        role = nm3.split("%s_" % ta, 1)[-1]
                        have_nm = cur[ad]
                        if not have_nm and role in roles_taken:
                            conflict.append((ad, "role held by " + roles_taken[role], nm3))
                        elif not have_nm:
                            todo.append({"addr": ad, "name": nm3})
                            roles_taken[role] = nm3
                        elif nm3.split("_TA_")[-1] not in have_nm:
                            conflict.append((ad, have_nm, nm3))
                    if todo:
                        rr = client.call(remote_ops.rename,
                                         batch={"func": todo, "allow_overwrite": False})
                        nok = int((rr.get("summary") or {}).get("ok", 0))
                        for it in todo:
                            assigned[it["addr"]] = (it["name"], "ENTRY")
                        print("    GP entry points named from header: %d/%d"
                              % (nok, len(todo)), flush=True)
                    for ad, a_, b_ in conflict:
                        print("    ENTRY conflict %s ported=%s header=%s" % (ad, a_, b_),
                              flush=True)
            except Exception as exc:
                print("    entry-point naming failed: %s" % exc, flush=True)

            # Per-module SVC stubs, named from the opcode they load for themselves.
            try:
                taken_rpc = {(assigned.get(f["addr"], (None,))[0] or "") for f in funcs}
                todo_rpc = []
                for f in funcs:
                    if assigned.get(f["addr"], (None,))[0]:
                        continue
                    sz = f.get("size") or 0
                    if sz < 8 or sz > 200:
                        continue
                    o = int(f["addr"], 16) - va
                    if o < 0 or o + sz > len(body):
                        continue
                    op = svc_stub_opcode(body[o:o + sz])
                    if op is None:
                        continue
                    # Prefix matches the libdump's own convention, sysdrv_rpc_<N>, not a new
                    # one. ta_diff.py showed why this matters: with "<ta>_rpc_3" here and
                    # "<ta>_sysdrv_rpc_3" in the libdump, a cross-version diff reported 8
                    # functions added and 8 removed between 22.0.4.49 and 22.0.4.50 when nothing
                    # had changed except which naming pass reached them first. The decoded
                    # numbers already agree with the libdump's digits exactly.
                    base = "%s_sysdrv_rpc_%X" % (ta, op)
                    nm4, k = base, 2
                    while nm4 in taken_rpc:
                        nm4, k = "%s_%d" % (base, k), k + 1
                    taken_rpc.add(nm4)
                    todo_rpc.append({"addr": f["addr"], "name": nm4})
                if todo_rpc:
                    rr = client.call(remote_ops.rename,
                                     batch={"func": todo_rpc, "allow_overwrite": False})
                    nok = int((rr.get("summary") or {}).get("ok", 0))
                    for it in todo_rpc:
                        assigned[it["addr"]] = (it["name"], "SVCOP")
                    print("    SVC stubs named from their own opcode: %d/%d"
                          % (nok, len(todo_rpc)), flush=True)
            except Exception as exc:
                print("    SVC stub naming failed: %s" % exc, flush=True)

            # Shared-runtime arg1 typing, for every module. Keyed on the content-hash name
            # fragment so it follows the byte-identical cluster across modules and versions.
            try:
                import importlib.util as _ilu0
                _sp0 = _ilu0.spec_from_file_location("_ti0", HERE / "ta_ida.py")
                _ti0 = _ilu0.module_from_spec(_sp0); _sp0.loader.exec_module(_ti0)
                shared = getattr(_ti0, "SHARED_ARG1", [])
            except Exception:
                shared = []
            nshared = 0
            # Key on BODY HASH, not on the name. Matching name fragments tied this typing to
            # the names LIB_RENAME upgrades, so enabling the rename silently un-typed these
            # functions. Content identity is what established the cluster, and it survives any
            # rename.
            try:
                fmt_hashes = set(getattr(_ti0, "TEE_FMT_HASHES", ()))
            except Exception:
                fmt_hashes = set()
            if fmt_hashes:
                for fn0 in funcs:
                    ad = fn0["addr"]
                    off0 = int(ad, 16) - va
                    if off0 < 0 or fn0["size"] <= 0 or off0 + fn0["size"] > len(body):
                        continue
                    h0 = hashlib.sha256(body[off0:off0 + fn0["size"]]).hexdigest()
                    if h0 not in fmt_hashes:
                        continue
                    nm0 = assigned.get(ad, (None, None))[0] or fn0["name"]
                    if retype_arg1(client, remote_ops, ad, nm0,
                                   "struct tee_fmt_state *"):
                        nshared += 1
            elif shared:
                for ad, (nm0, _t0) in list(assigned.items()):
                    stype = next((st for frag, st in shared if frag in nm0), None)
                    if not stype:
                        continue
                    if retype_arg1(client, remote_ops, ad, nm0, stype):
                        nshared += 1
                if nshared:
                    print("    shared runtime arg1 typed on %d functions" % nshared,
                          flush=True)

            # Any module that declares a name-keyed arg1 map, not just ASD.
            if ta in getattr(_ti0, "ARG1_BY_TA", {}):
                try:
                    _ti = _ti0
                    _mapname = getattr(_ti, "ARG1_BY_TA", {}).get(ta)
                    want = getattr(_ti, _mapname, {}) if _mapname else {}
                except Exception:
                    want = {}
                a2n = {nm: ad for ad, (nm, _tt) in assigned.items()}
                nret = 0
                for fname, stype in want.items():
                    ad = a2n.get(fname)
                    if not ad:
                        continue
                    # Use the shared helper. This block used to carry its own inline copy of
                    # the rewrite that did NOT strip comment lines before parsing the head, so
                    # on any function carrying a recorded comment the parse ran across the
                    # comment and produced a declaration IDA rejected. That silently cost the
                    # single largest typing target in the corpus: asd_program_link_by_signal
                    # kept 504 raw dereferences while reporting as "applied".
                    if retype_arg1(client, remote_ops, ad, fname, stype):
                        nret += 1
                if want:
                    print("    %s arg1 structs applied to %d/%d functions" % (ta,
                          nret, len(want)), flush=True)

            # Semantic name corrections, applied by name so they hold for every build.
            try:
                fixes = dict(getattr(_ti0, "RENAME_FIX", {}).get(ta, {}))
            except Exception:
                fixes = {}
            # Content-hash placeholders upgraded to the real name, for every module. The
            # mapping is module-independent, so it is expanded with this module's prefix.
            # LIB_RENAME is now ON. It was gated off last pass because applying it cost ~1175
            # typed field accesses: the shared-runtime typing keyed on NAME fragments, so the
            # rename moved those functions out from under the rule that typed their argument.
            # That typing is now keyed on body hash instead, and the trade is gone -- every
            # affected module gains on BOTH counts (gcp 82.5%->83.6% share with placeholders
            # 389->143, wspe 91.3%->92.6% / 252->133, powerplay 65.3%->70.7% / 186->60,
            # xgmi 69.1%->71.0% / 100->55).
            if True:
              try:
                for _ph, _sem in getattr(_ti0, "LIB_RENAME", {}).items():
                    fixes["%s_%s" % (ta, _ph)] = "%s_%s" % (ta, _sem)
                    for _h in range(1, 6):
                        fixes["%s_%s__h%d" % (ta, _ph, _h)] = \
                            "%s_%s__h%d" % (ta, _sem, _h)
              except Exception:
                pass
            if fixes:
                nfix = 0
                # Names can arrive from porting OR from the durable call-graph map, and the
                # fix must see both: looking only at `assigned` left four builds carrying the
                # old FE0C name because their copy was named by the map instead.
                byname2 = {nm5: ad5 for ad5, (nm5, _t5) in assigned.items()}
                try:
                    for _ad, _nm in (cgmap or {}).items():
                        byname2.setdefault(_nm, _ad)
                except Exception:
                    pass
                # Two classes, and they need different collision policy. The explicit
                # RENAME_FIX corrections target a name nothing else holds, so forcing is safe.
                # The LIB_RENAME upgrades map several placeholder clusters onto shared library
                # names, and forcing those STRIPS the name from whichever function already held
                # it -- that is what cost 138 names across gcp, powerplay, wspe and xgmi, in
                # proportion to the number of renames applied. Upgrades are therefore refused
                # on collision rather than allowed to steal.
                explicit = set(getattr(_ti0, "RENAME_FIX", {}).get(ta, {}))
                items5 = []
                items5_soft = []
                for old, new in fixes.items():
                    ad5 = byname2.get(old)
                    if ad5:
                        (items5 if old in explicit else items5_soft).append(
                            {"addr": ad5, "name": new})
                        # the address may have come from the call-graph map rather than from
                        # porting, in which case it has no `assigned` entry yet
                        assigned[ad5] = (new, assigned.get(ad5, (None, "IDA"))[1])
                for batch_, force in ((items5, True), (items5_soft, False)):
                    if not batch_:
                        continue
                    try:
                        rr5 = client.call(remote_ops.rename,
                                          batch={"func": batch_,
                                                 "allow_overwrite": force})
                        nfix += int((rr5.get("summary") or {}).get("ok", 0))
                    except Exception:
                        pass
                if items5 or items5_soft:
                    print("    semantic name fixes applied: %d/%d (%d forced, %d soft)"
                          % (nfix, len(items5) + len(items5_soft),
                             len(items5), len(items5_soft)), flush=True)

            # Structures that arrive as an argument other than the first.            # Structures that arrive as an argument other than the first.
            try:
                argn = getattr(_ti0, "ARGN_BY_TA", {}).get(ta, {})
            except Exception:
                argn = {}
            if argn:
                a2n2 = {nm: ad for ad, (nm, _t9) in assigned.items()}
                nargn = 0
                for fname, (idx, stype3) in argn.items():
                    ad5 = a2n2.get(fname)
                    if not ad5:
                        continue
                    if retype_arg(client, remote_ops, ad5, fname, stype3, idx):
                        nargn += 1
                if argn:
                    print("    %s argN structs applied to %d/%d functions"
                          % (ta, nargn, len(argn)), flush=True)

            # Generic signature-based a1 typing, driven by SIG_BY_TA. A candidate must have
            # EVERY a1 offset inside the structure's declared FIELDS (padding excluded, because
            # an access into padding means the layout does not describe that function), touch at
            # least min_distinct of them, and include at least one distinctive offset.
            #
            # HDCP's rule was measured back when hdcp2_session was derived -- 3 unnamed
            # functions, 135 dereferences, zero false positives among the named -- and then
            # never wired in. Those dereferences stayed raw in the three builds where
            # hdcp2_rx_RepeaterAuth_ReceiverID_List is unnamed, which no name-keyed rule can
            # reach.
            try:
                sig_spec = getattr(_ti0, "SIG_BY_TA", {}).get(ta)
            except Exception:
                sig_spec = None
            if sig_spec:
                sname, stype2, mind, distinct = sig_spec
                mstruct = re.search(r"struct %s \{(.*?)\};" % re.escape(sname),
                                    (HERE / "ta_ida.py").read_text(), re.S)
                cov = set()
                if mstruct:
                    cur2 = 0
                    for mm in re.finditer(
                            r"(unsigned int|unsigned short|unsigned char)\s+(\w+)"
                            r"(?:\[(\d+)\])?;", mstruct.group(1)):
                        wd = {"unsigned int": 4, "unsigned short": 2,
                              "unsigned char": 1}[mm.group(1)]
                        cnt = int(mm.group(3)) if mm.group(3) else 1
                        if not mm.group(2).startswith("pad"):
                            cov |= set(range(cur2, cur2 + wd * cnt))
                        cur2 += wd * cnt
                already = set()
                try:
                    _mn = getattr(_ti0, "ARG1_BY_TA", {}).get(ta)
                    already = set(getattr(_ti0, _mn, {})) if _mn else set()
                except Exception:
                    already = set()
                nsig2 = 0
                if cov:
                    for fn4 in funcs:
                        ad4 = fn4["addr"]
                        nm4 = assigned.get(ad4, (None, None))[0] or fn4["name"]
                        if nm4 in already:
                            continue
                        try:
                            rr4 = client.call(remote_ops.decompile, addr=ad4,
                                              include_addresses=False) or {}
                            cf4 = rr4.get("code") or ""
                        except Exception:
                            continue
                        offs4 = {int(x, 0) for x in re.findall(
                            r"\*\(\s*_?\w+\s*\*\s*\)\s*\(\s*a1\s*\+\s*"
                            r"(\d+|0x[0-9A-Fa-f]+)\s*\)", cf4)}
                        if len(offs4) < mind or not offs4 <= cov:
                            continue
                        if not (offs4 & set(distinct)):
                            continue
                        if retype_arg1(client, remote_ops, ad4, nm4, stype2):
                            nsig2 += 1
                    if nsig2:
                        print("    %s signature-typed a1 on %d functions" % (ta, nsig2),
                              flush=True)

            # Signature-based a1 typing for functions no name-keyed rule reaches.
            # Signature+size typing for the shared formatting state, every module.
            try:
                fmt_off = set(_ti0.FMT_SIG_OFFSETS); fmt_sz = set(_ti0.FMT_SIG_SIZES)
                fmt_ty = _ti0.FMT_SIG_TYPE; fmt_min = _ti0.FMT_SIG_MIN_DEREFS
            except Exception:
                fmt_off, fmt_sz, fmt_ty = set(), set(), None
            nfmt = 0
            if fmt_off and fmt_ty:
                for fn3 in funcs:
                    if fn3["size"] not in fmt_sz:
                        continue
                    ad3 = fn3["addr"]
                    nm3 = assigned.get(ad3, (None, None))[0] or fn3["name"]
                    if any(fr in nm3 for fr, _st in shared):
                        continue            # already typed by name
                    try:
                        rr3 = client.call(remote_ops.decompile, addr=ad3,
                                          include_addresses=False) or {}
                        cf3 = rr3.get("code") or ""
                    except Exception:
                        continue
                    hits3 = re.findall(r"\*\(\s*_?\w+\s*\*\s*\)\s*\(\s*a1\s*\+\s*"
                                       r"(\d+|0x[0-9A-Fa-f]+)\s*\)", cf3)
                    if len(hits3) < fmt_min:
                        continue
                    offs3 = {int(x, 0) for x in hits3}
                    if not offs3 or not offs3 <= fmt_off:
                        continue
                    if retype_arg1(client, remote_ops, ad3, nm3, fmt_ty):
                        nfmt += 1
                if nfmt:
                    print("    fmt-state signature-typed a1 on %d functions" % nfmt,
                          flush=True)

            if ta == "asd":
                core = set(getattr(_ti, "ASD_SIG_CORE", ()) if "_ti" in dir() else ())
                try:
                    core = set(_ti.ASD_SIG_CORE); sigtype = _ti.ASD_SIG_TYPE
                    minsz = _ti.ASD_SIG_MIN_SIZE
                except Exception:
                    core, sigtype, minsz = set(), None, 1 << 30
                nsig = 0
                if core and sigtype:
                    for fn2 in funcs:
                        if fn2["size"] < minsz:
                            continue
                        ad2 = fn2["addr"]
                        if assigned.get(ad2, (None, None))[0] in \
                                getattr(_ti, "ASD_ARG1_STRUCT", {}):
                            continue        # already typed by name
                        try:
                            rr2 = client.call(remote_ops.decompile, addr=ad2,
                                              include_addresses=False) or {}
                            cf = rr2.get("code") or ""
                        except Exception:
                            continue
                        offs2 = {int(x, 0) for x in re.findall(
                            r"\*\(\s*_?\w+\s*\*\s*\)\s*\(\s*a1\s*\+\s*"
                            r"(\d+|0x[0-9A-Fa-f]+)\s*\)", cf)}
                        if not offs2 or not core <= offs2:
                            continue
                        nm2 = assigned.get(ad2, (None, None))[0] or fn2["name"]
                        if retype_arg1(client, remote_ops, ad2, nm2, sigtype):
                            nsig += 1
                    if nsig:
                        print("    asd signature-typed a1 on %d functions" % nsig,
                              flush=True)

            if ta == "asd":
                dsp, derr2 = asd_dispatcher(body, va)
                if dsp is None:
                    print("    asd dispatcher: NOT resolved (%s)" % derr2, flush=True)
                else:
                    try:
                        rr = client.call(
                            remote_ops.set_type,
                            edits=[{"addr": hex(dsp),
                                    "signature": "unsigned int __fastcall asd_dispatch"
                                                "(struct asd_req *req, int a2, int a3, "
                                                "int a4);"}])
                        okd = bool((rr.get("result") or [{}])[0].get("ok"))
                        if okd and hex(dsp) not in assigned:
                            client.call(remote_ops.rename,
                                        batch={"func": [{"addr": hex(dsp),
                                                         "name": "asd_dispatch"}],
                                               "allow_overwrite": True})
                            assigned[hex(dsp)] = ("asd_dispatch", "DISPATCH")
                        print("    asd dispatcher @%s: struct asd_req %s"
                              % (hex(dsp), "applied" if okd else "REJECTED"), flush=True)
                    except Exception as exc:
                        print("    asd dispatcher typing failed: %s" % exc, flush=True)

            if ta in STAGING:
                # OC and WSPE dispatch with if/else chains rather than a switch, so no case
                # labels exist and dispatch_map returns nothing -- but their invoke entry IS
                # the dispatcher, so fall back to it instead of skipping the struct.
                try:
                    _m2, _t2, _c2, inv2 = ta_entry_points(body, va)
                    inv_hex = hex(inv2)
                except Exception:
                    inv_hex = None
                # 'deref' describes the ENTRY's handling of the params pointer, so it must be
                # read there. Using the switch-bearing function instead failed every HDCP
                # build, because HDCP's switch is not in the entry that does 'v5 = *a4'.
                mode = STAGING[ta][1][0]
                sd = (inv_hex or disp_addr) if mode == "deref" else (disp_addr or inv_hex)
                ok, detail = type_staging_lvar(client, remote_ops, ta, sd)
                print("    staging struct: %s (%s)"
                      % ("applied" if ok else "NOT applied", detail), flush=True)

            # Local-variable names for the finding-bearing handlers. Applied after every
            # rename and type so the function is in its final form, and before the cache
            # invalidation below so the emitted text picks them up.
            try:
                lnames = getattr(_ti0, "LOCAL_NAMES", {}).get(ta, {})
            except Exception:
                lnames = {}
            if lnames:
                byname6 = {nm6: ad6 for ad6, (nm6, _t6) in assigned.items()}
                edits6 = []
                for fname6, vmap in lnames.items():
                    ad6 = byname6.get(fname6)
                    if not ad6:
                        continue
                    for old6, new6 in vmap.items():
                        edits6.append({"func_addr": ad6, "old": old6, "new": new6})
                if edits6:
                    try:
                        rr6 = client.call(remote_ops.rename, batch={"local": edits6})
                        ok6 = int((rr6.get("summary") or {}).get("ok", 0))
                        print("    local variable names applied: %d/%d"
                              % (ok6, len(edits6)), flush=True)
                    except Exception as exc:
                        print("    local rename failed: %s" % exc, flush=True)

            # Invalidate every cached pseudocode before emitting.            # Invalidate every cached pseudocode before emitting. Hex-Rays caches ctext per
            # function and remote_ops.rename refreshes only the function it renamed, so a
            # CALLER keeps printing the old callee name. That is how the dispatcher kept
            # showing gcp_cmdFE0C_CreateAuthenticatedSession in four builds after the rename
            # had in fact succeeded -- a stale cache reading as a failed rename.
            try:
                client.call(remote_ops.force_recompile,
                            items=[{"addr": fn5["addr"]} for fn5 in funcs])
            except Exception as exc:
                print("    force_recompile failed: %s" % exc, flush=True)

            rows = decompile_all(client, remote_ops, funcs, assigned, body, va)
            meta = {"type": ta, "version": ver, "hash8": h8, "stats": stats,
                    "reference": os.path.basename(refelf)}
            emit_c(outdir / f"{ta}_{ver}_{h8}.c", meta, rows)
            try:
                client.save_database()
            except Exception:
                pass
            nf = sum(1 for r in rows if r["code"].startswith("/* decompile failed"))
            # measurable proof that types actually reached the output, rather than a
            # prototype count that says only that IDA accepted something
            styped = sum(1 for r in rows
                         if re.search(r"\b(struct|union|enum)\s+\w+",
                                      r["code"].split("\n", 1)[0]))
            stats["struct_typed_sigs"] = styped
            print("  %-30s funcs=%-4d named=%-4d E=%-3d M=%-3d F=%-3d D=%-3d none=%-3d "
                  "amb=%-3d cmds=%-3d conf=%-2d fail=%d"
                  % (f"{ver}_{h8}", len(funcs), len(assigned),
                     stats["EXACT"], stats["MNEM"], stats["FUZZY"], stats["DISPATCH"],
                     stats["none"], stats["ambiguous"], len(dmap),
                     stats["dispatch_conflict"], nf), flush=True)
            print("      -> struct-typed signatures in output: %d" % styped, flush=True)
            pool.append({"ver": ver, "src": name,
                         "funcs": funcs_as_reference(funcs, assigned, body, va)})
            summary.append({"version": ver, "hash8": h8, "funcs": len(funcs),
                            "nearest_source": nearest, "commands": dmap,
                            "named": len(assigned), "stats": stats, "decomp_fail": nf})
        finally:
            try:
                client.close()
            except Exception:
                pass
    (outdir / f"{ta}.summary.json").write_text(json.dumps(summary, indent=1))
    return 0


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--type", help="TA type, e.g. gcp")
    ap.add_argument("--list", action="store_true")
    ap.add_argument("--limit", type=int, default=None,
                    help="process only the first N non-reference builds")
    ap.add_argument("--outdir", default=str(HERE / "decomp"))
    ap.add_argument("--validate-only", action="store_true",
                    help="run the self-port gate and stop")
    a = ap.parse_args()
    if a.list:
        seen: dict[str, int] = {}
        for p in sorted(glob.glob(str(ELF_DIR / "*.elf"))):
            t = os.path.basename(p).split("_")[0]
            seen[t] = seen.get(t, 0) + 1
        for k in sorted(seen):
            print("  %-10s %2d builds%s" % (k, seen[k],
                  "" if k in LIBDUMP_REF else "   (no libdump reference)"))
        return 0
    if not a.type:
        ap.error("--type or --list required")
    return run_type(a.type, Path(a.outdir), a.limit, a.validate_only)


if __name__ == "__main__":
    sys.exit(main())