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
|
#!/usr/bin/env python3
"""Per-function diff between two TA builds.
The emitted artifacts are already aligned for this -- addresses stripped from banners and
functions sorted by name -- but a raw `diff` of two builds is still 6-17% of lines changed,
and almost none of it is a firmware change. Measured on three adjacent GCP pairs, the noise
is:
* `[tier=MNEM size=22]` vs `[tier=EXACT size=22]` -- how the name was derived, not what the
code does. 42 of 302 changed lines on one pair.
* `off_40480C` -> `off_40481C`, `loc_4057E8` -> `loc_4057F8` -- unnamed globals and branch
targets whose addresses shifted when code was inserted upstream. Normalising these alone
cuts one pair from 405 changed lines to 175.
* raw hex in `JUMPOUT(0x4055E4)` and similar.
* IDA resolving the same call site to a name in one build and `loc_*` in another, plus
local-variable renumbering (`v65`..`v76`) when the decompiler's register allocation shifts.
What survives normalisation is worth reading. On 22.0.0.73 -> 22.0.0.77 it is two new switch
arms, `case 0x26` and `case 0x27` -- new command IDs in a dispatcher, which is exactly the kind
of finding this corpus exists to surface.
Residual false positives, measured after the naming convention was aligned:
* A function can carry a richer name in one build than another -- `gcp_cmdFE0C` against
`gcp_cmdFE0C_CreateCryptoSession` -- because the semantic half comes from a pass that only
reaches some builds. That reports as one added and one removed function. It took the
22.0.4.49 -> 22.0.4.50 pair from +8/-8 to +4/-4; the rest is this.
* `start` and `*_ta_main_loop` show as changed on IDA instability alone.
So treat added/removed as "look at this", not as ground truth, and changed-function counts as
sound. Adjacent patch versions across the corpus average 0.5 (ras) to 26 (xgmi) changed
functions, and the high numbers track modules whose builds are genuinely far apart.
Usage:
ta_diff.py <buildA> <buildB> per-function summary
ta_diff.py <buildA> <buildB> --body unified diff per changed function
ta_diff.py --module gcp every adjacent pair in a module
"""
import argparse
import difflib
import re
import sys
from pathlib import Path
DECOMP = Path(__file__).resolve().parent / "decomp"
# Tokens IDA derives from an address. These shift wholesale when code is inserted upstream,
# so they say nothing about whether the function changed.
ADDR_TOKEN = re.compile(r"\b(off|dword|word|byte|loc|unk|sub|flt|dbl|jpt|a)_[0-9A-Fa-f]{4,8}\b")
HEX_LITERAL = re.compile(r"\b0x[0-9A-Fa-f]{5,8}\b")
TIER = re.compile(r"\[tier=\w+ size=\d+\]")
LOCAL = re.compile(r"\bv\d+\b")
BANNER = re.compile(r"/\* ==== (\S+)[^\n]*? ==== \*/")
def normalise(line: str, locals_too: bool = False) -> str:
line = TIER.sub("[..]", line)
line = ADDR_TOKEN.sub(lambda m: m.group(0).split("_")[0] + "_X", line)
line = HEX_LITERAL.sub("0xX", line)
if locals_too:
line = LOCAL.sub("vX", line)
return line
def split_functions(path: Path) -> dict:
"""banner name -> normalised body lines."""
out, cur = {}, None
for line in path.read_text().splitlines():
m = BANNER.match(line.strip())
if m:
cur = m.group(1)
out[cur] = []
continue
if cur:
out[cur].append(line)
return out
def compare(a: Path, b: Path, locals_too: bool):
fa, fb = split_functions(a), split_functions(b)
added = sorted(set(fb) - set(fa))
removed = sorted(set(fa) - set(fb))
changed = []
for name in sorted(set(fa) & set(fb)):
na = [normalise(x, locals_too) for x in fa[name]]
nb = [normalise(x, locals_too) for x in fb[name]]
if na != nb:
changed.append((name, na, nb))
return added, removed, changed
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("builds", nargs="*")
ap.add_argument("--module")
ap.add_argument("--body", action="store_true", help="unified diff per changed function")
ap.add_argument("--keep-locals", action="store_true",
help="do not normalise vN names (noisier, but shows real new locals)")
args = ap.parse_args()
pairs = []
if args.module:
cs = sorted(p for p in DECOMP.glob(f"{args.module}_*.c")
if not p.name.endswith(".anon.c") and "reference" not in p.name)
pairs = list(zip(cs, cs[1:]))
elif len(args.builds) == 2:
pairs = [(DECOMP / (args.builds[0] + ".c"), DECOMP / (args.builds[1] + ".c"))]
else:
ap.error("give two build names or --module")
for a, b in pairs:
if not a.exists() or not b.exists():
print("missing: %s or %s" % (a.name, b.name))
continue
added, removed, changed = compare(a, b, not args.keep_locals)
print("=" * 78)
print("%s -> %s" % (a.stem, b.stem))
print(" functions: +%d -%d changed %d (of %d)"
% (len(added), len(removed), len(changed), len(split_functions(a))))
for n in added:
print(" + %s" % n)
for n in removed:
print(" - %s" % n)
for name, na, nb in changed:
d = [l for l in difflib.unified_diff(na, nb, lineterm="", n=0)
if l[:1] in "+-" and not l.startswith(("+++", "---"))]
print(" ~ %-52s %d lines" % (name[:52], len(d)))
if args.body:
for l in d:
print(" %s" % l[:140])
return 0
if __name__ == "__main__":
raise SystemExit(main())
|