#!/usr/bin/env python3 """Generate every meaningful within-branch version diff into fw/diffs/. Two correctness rules, both learned by getting them wrong first: 1. The third version field is a BRANCH, not a sequence. ASD ships 33.0.0.x, 33.0.4.x, 33.0.8.x, 33.0.12.x, 33.0.16.x, 33.0.24.x and 33.0.28.x in parallel. Comparing across them produces nonsense -- asd_dispatch oscillating 842 <-> 1306 <-> 1352 bytes is branch identity, not change over time. Only same-branch pairs are emitted, ordered by the final build number. 2. A raw diff is 6-17% of lines changed and almost none of it is firmware. Four noise sources are normalised away: tier annotations, address-derived tokens (off_40480C -> off_40481C), long hex literals, and local renumbering. See fw/ta_diff.py for the measurements. Each pair gets a .diff with per-function hunks, and the size table from the index sidecars, which is the one signal no decompiler instability can fake: same function, different byte size means the code changed. """ import difflib import json import re import sys from collections import defaultdict from pathlib import Path HERE = Path(__file__).resolve().parent DECOMP = HERE / "decomp" OUT = HERE / "diffs" sys.path.insert(0, str(HERE)) import ta_metrics as M # noqa: E402 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]*? ==== \*/") DECL = re.compile(r"^\s*(int|unsigned|char|_BYTE|_DWORD|_WORD|__int64|bool|signed|void|struct" r"|float|double|__int16|_QWORD)\b[^;]*;\s*(//.*)?$") def ver(name): m = re.search(r"_(\d+(?:\.\d+)+)_", name) return tuple(int(x) for x in m.group(1).split(".")) if m else None def norm(line): line = TIER.sub("[..]", line) line = ADDR_TOKEN.sub(lambda m: m.group(0).split("_")[0] + "_X", line) line = HEX_LITERAL.sub("0xX", line) return LOCAL.sub("vX", line) def functions(path): out, cur = {}, None for line in path.read_text().splitlines(): m = BANNER.match(line.strip()) if m: cur = m.group(1) out[cur] = [] elif cur: out[cur].append(line) return out def sizes(build): p = DECOMP / f"{build}.index.json" return {x["name"]: x.get("size") for x in json.loads(p.read_text())["functions"] if M.is_meaningful(x.get("name"))} def main(): builds = defaultdict(lambda: defaultdict(list)) for p in DECOMP.glob("*.index.json"): b = p.name.replace(".index.json", "") if "reference" in b or not ver(b): continue builds[b.split("_")[0]][ver(b)[:3]].append(b) if OUT.exists(): for f in OUT.rglob("*"): if f.is_file(): f.unlink() OUT.mkdir(exist_ok=True) index = [] for mod in sorted(builds): for branch in sorted(builds[mod]): group = sorted(builds[mod][branch], key=ver) if len(group) < 2: continue for a, b in zip(group, group[1:]): va, vb = ".".join(map(str, ver(a))), ".".join(map(str, ver(b))) if va == vb: continue fa, fb = functions(DECOMP / f"{a}.c"), functions(DECOMP / f"{b}.c") sa, sb = sizes(a), sizes(b) resized = sorted(((n, sa[n], sb[n]) for n in sa.keys() & sb.keys() if sa[n] != sb[n]), key=lambda t: -abs(t[2] - t[1])) new, gone = sorted(sb.keys() - sa.keys()), sorted(sa.keys() - sb.keys()) hunks = [] for name in sorted(fa.keys() & fb.keys()): na = [norm(x) for x in fa[name]] nb = [norm(x) for x in fb[name]] if na == nb: continue d = [l for l in difflib.unified_diff(na, nb, lineterm="", n=1) if l[:1] in "+- " and not l.startswith(("+++", "---"))] d = [l for l in d if not DECL.match(l[1:])] if any(l[:1] in "+-" for l in d): hunks.append((name, d)) if not (resized or new or gone or hunks): continue mdir = OUT / mod mdir.mkdir(exist_ok=True) f = mdir / f"{va}__to__{vb}.diff" with f.open("w") as fh: fh.write("# %s %s -> %s\n" % (mod, va, vb)) fh.write("# branch %s, consecutive builds\n#\n" % ".".join(map(str, branch))) fh.write("# %d functions resized, %d new, %d removed, %d with body changes\n" % (len(resized), len(new), len(gone), len(hunks))) fh.write("# total named bytes %+d\n#\n" % (sum(sb.values()) - sum(sa.values()))) fh.write("# Normalised: tier annotations, off_/loc_/sub_ address tokens,\n" "# long hex literals and vN renumbering. Local declarations are\n" "# dropped. Raw diffs are 6-17% of lines and almost all noise.\n\n") if resized: fh.write("## resized functions (byte size from the index sidecar)\n") for n, x, y in resized: fh.write(" %-56s %6d -> %-6d %+d\n" % (n, x, y, y - x)) fh.write("\n") if new: fh.write("## functions present only in %s\n" % vb) for n in new: fh.write(" + %-54s %s bytes\n" % (n, sb[n])) fh.write("\n") if gone: fh.write("## functions present only in %s\n" % va) for n in gone: fh.write(" - %-54s %s bytes\n" % (n, sa[n])) fh.write("\n") fh.write("## body changes, per function\n\n") for name, d in hunks: fh.write("### %s\n" % name) for l in d: fh.write(" %s\n" % l.rstrip()) fh.write("\n") index.append((mod, ".".join(map(str, branch)), va, vb, len(resized), len(new), len(gone), len(hunks), sum(sb.values()) - sum(sa.values()), f.relative_to(OUT).as_posix())) with (OUT / "INDEX.md").open("w") as fh: fh.write("# TA version diffs\n\n") fh.write("Generated by `fw/ta_diff_all.py`. Only **same-branch consecutive builds**: the\n" "third version field is a branch, not a sequence, so cross-branch comparison is\n" "meaningless (ASD's `asd_dispatch` merely oscillates 842/1306/1352 bytes between\n" "branches). Noise normalised per `fw/ta_diff.py`.\n\n") fh.write("""## Findings worth starting from **HDCP 23.0.0.52 -> 23.0.0.57 is a timing side-channel fix.** `hdcp2_rx_LC_Send_L_prime`, the HDCP 2.x locality check, stopped verifying L' with `memcmp_be` (early exit on the first differing word) and switched to `ct_memcmp`, which ORs all 16 bytes with no early exit. That comparison is exactly where an early return leaks a byte-at-a-time oracle. `receiver_id_lookup` still uses `memcmp_be`, which is fine -- receiver IDs are not secret. **GCP extends an ASIC allowlist at nearly every release.** `gcp_cmdFE08_KeyExchangeAuthenticatedSession__h4` grows +10 bytes almost every version. It calls `gcp_sysdrv_rpc_200()` and switches on the returned word, yielding 0 to proceed or 611 to reject: 14 allowed IDs at 22.0.0.53, then 16, 19, 20, 22, 24, 26, 28 by 22.0.0.77. `0xe` arrives late, at .68, out of order. `0xd` is still absent. **GCP 22.0.0.60 -> 22.0.0.62 adds teardown to the session lifecycle.** +1598 bytes concentrated in `gcp_cmd01` AllocateDRMSession (+90), `gcp_cmd05` CloseDRMSession (+96) and `gcp_cmdFE0D` DestroyCryptoSession (+68), each gaining the same new call sequence on its error and exit paths. The callee is still unnamed, so what it releases is not established. **Do not read ASD's dispatch case lists as change.** `asd_dispatch` appears to lose 48 `case` labels at 163 -> 173 and regain them at 195 -> 239. That is Hex-Rays alternating between an explicit `switch` and the range check `if (req->rpc_id - 384 <= 0x40)` -- 384 is 0x180 and 0x180+0x40 is 0x1C0, exactly the range that seems to vanish. The function does genuinely grow 778 -> 1352 bytes inside the 33.0.0 branch, but the case delta is not evidence of new RPCs. """) fh.write("`resized` is the trustworthy column -- same function, different byte size, taken\n" "from the index sidecar rather than the decompiler. `new`/`gone` can also reflect a\n" "function being named in one build and not another, so read them as leads.\n\n") fh.write("| module | branch | from | to | resized | new | gone | bodies | bytes | file |\n") fh.write("|---|---|---|---|--:|--:|--:|--:|--:|---|\n") for r in index: fh.write("| %s | %s | %s | %s | %d | %d | %d | %d | %+d | [%s](%s) |\n" % (r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[9], r[9])) print("%d diffs written to %s" % (len(index), OUT)) return 0 if __name__ == "__main__": raise SystemExit(main())