diff --git a/docs/reports/mt7981_wa-20221208-vs-20260515.md b/docs/reports/mt7981_wa-20221208-vs-20260515.md new file mode 100644 index 0000000..9e94732 Binary files /dev/null and b/docs/reports/mt7981_wa-20221208-vs-20260515.md differ diff --git a/docs/reports/mt7981_wm-20221208-vs-20240823.md b/docs/reports/mt7981_wm-20221208-vs-20240823.md new file mode 100644 index 0000000..fb72b6b Binary files /dev/null and b/docs/reports/mt7981_wm-20221208-vs-20240823.md differ diff --git a/docs/reports/mt7981_wm-20240823-vs-20260515.md b/docs/reports/mt7981_wm-20240823-vs-20260515.md new file mode 100644 index 0000000..9086532 Binary files /dev/null and b/docs/reports/mt7981_wm-20240823-vs-20260515.md differ diff --git a/tools/fw_diff.py b/tools/fw_diff.py new file mode 100755 index 0000000..4f53b29 --- /dev/null +++ b/tools/fw_diff.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Diff two Connac2 firmware container versions (same file, different builds). + +Compares: metadata (hidden build strings, sizes), region tables aligned by +address, per-region byte-equality ratio, and printable-string set changes +(added/removed — the human-readable changelog signal). + +Usage: fw_diff.py A.bin B.bin [--md out.md] +""" +import argparse +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from mtk_fw_extract import REGION, TRAILER # noqa: E402 + + +def parse(d: bytes): + t = TRAILER.unpack_from(d, len(d) - TRAILER.size) + n = t[2] + table = len(d) - TRAILER.size - n * REGION.size + off, regions = 0, {} + for i in range(n): + r = REGION.unpack_from(d, table + i * REGION.size) + addr, ln, feat = r[4], r[5], r[6] + regions[addr] = {'len': ln, 'feat': feat, 'data': d[off:off + ln]} + off += ln + hidden = None + gap = table - off + if gap > 0: + runs = [x.strip(b'#') for x in re.findall(rb'[\x20-\x7e]{8,}', d[off:table])] + runs = [x for x in runs if len(x) >= 8] + hidden = runs[0].decode() if runs else None + return {'trailer_date': t[7].decode(errors='replace'), + 'n_region': n, 'hidden': hidden, 'regions': regions, + 'size': len(d)} + + +def equal_ratio(a: bytes, b: bytes) -> float: + if not a and not b: + return 1.0 + n = min(len(a), len(b)) + eq = sum(x == y for x, y in zip(a[:n], b[:n])) + return eq / max(len(a), len(b)) + + +JUNK = re.compile(r'^[A-Za-z0-9 _%:.\-(){}\[\],;+*/=<>!\'"?&|$@#~`^\\]*$') + + +def strings(data: bytes, minlen=6): + """Human-plausible strings only: leading identifier character, a real + alphabetic run, and letter-density floor. Filters random binary that + happens to decode printable.""" + out = set() + for m in re.findall(rb'[\x20-\x7e]{%d,}' % minlen, data): + s = m.strip(b'#').decode(errors='replace') + if len(s) < minlen: + continue + if not re.match(r'^[A-Za-z%_]', s): + continue + if not re.search(r'[A-Za-z]{3,}', s): + continue + letters = sum(c.isalpha() or c == '_' for c in s) + if letters / len(s) < 0.5: + continue + out.add(s) + return out + + +def diff(a_path: Path, b_path: Path) -> dict: + A, B = parse(a_path.read_bytes()), parse(b_path.read_bytes()) + addrs = sorted(set(A['regions']) | set(B['regions'])) + regions = [] + for a in addrs: + ra, rb = A['regions'].get(a), B['regions'].get(a) + if ra and rb: + regions.append({'addr': f'0x{a:08x}', 'in': 'both', + 'len_a': ra['len'], 'len_b': rb['len'], + 'feat_a': f"0x{ra['feat']:02x}", + 'feat_b': f"0x{rb['feat']:02x}", + 'equal': round(equal_ratio(ra['data'], rb['data']), 4)}) + else: + r = ra or rb + regions.append({'addr': f'0x{a:08x}', + 'in': 'a' if ra else 'b', + 'len': r['len'], 'feat': f"0x{r['feat']:02x}"}) + sa = set().union(*[strings(r['data']) for r in A['regions'].values()]) + sb = set().union(*[strings(r['data']) for r in B['regions'].values()]) + return { + 'a': {'file': str(a_path), 'build': A['hidden'], + 'trailer_date': A['trailer_date'], 'size': A['size']}, + 'b': {'file': str(b_path), 'build': B['hidden'], + 'trailer_date': B['trailer_date'], 'size': B['size']}, + 'regions': regions, + 'strings_added': sorted(sb - sa), + 'strings_removed': sorted(sa - sb), + } + + +def to_markdown(r: dict, cap=60) -> str: + L = [f"# {Path(r['a']['file']).name} vs {Path(r['b']['file']).name}", + '', + f"- A: `{r['a']['build']}` (trailer {r['a']['trailer_date']}, {r['a']['size']}B)", + f"- B: `{r['b']['build']}` (trailer {r['b']['trailer_date']}, {r['b']['size']}B)", + '', '## Regions', '', + '| addr | in | len A | len B | feat A→B | equal bytes |', + '|---|---|---|---|---|---|'] + for g in r['regions']: + if g['in'] == 'both': + L.append(f"| {g['addr']} | both | {g['len_a']} | {g['len_b']} " + f"| {g['feat_a']}→{g['feat_b']} | {g['equal']:.1%} |") + else: + L.append(f"| {g['addr']} | {g['in']} only | {g.get('len','')} | " + f" | {g.get('feat','')} | — |") + L += ['', f"## Strings added in B ({len(r['strings_added'])}, capped {cap})", ''] + L += [f'- `{s}`' for s in r['strings_added'][:cap]] + L += ['', f"## Strings removed in B ({len(r['strings_removed'])}, capped {cap})", ''] + L += [f'- `{s}`' for s in r['strings_removed'][:cap]] + return '\n'.join(L) + '\n' + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('a') + ap.add_argument('b') + ap.add_argument('--md') + ap.add_argument('--json') + args = ap.parse_args() + r = diff(Path(args.a), Path(args.b)) + md = to_markdown(r) + if args.md: + Path(args.md).parent.mkdir(parents=True, exist_ok=True) + Path(args.md).write_text(md) + if args.json: + Path(args.json).write_text(__import__('json').dumps(r, indent=2)) + print(md[:2500]) + print(f"... [{len(r['strings_added'])} added / " + f"{len(r['strings_removed'])} removed strings]") + + +if __name__ == '__main__': + main()