#!/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 per-function summary ta_diff.py --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())