#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Phase C — vendor palette extraction from libcxsdk.so. Scans the official native library for static 256-entry ARGB palette tables and compares any candidates against the iron-bow table the project already owns (OfficialTables.PALETTE256_ARGB), which is the extraction anchor. RESULT ON THE SHIPPED LIBRARY (recorded in analysis/sdk_re/android_app/palette_extraction_findings.md): the scan finds NO static tables — libcxsdk.so builds all 12 palettes at runtime with arithmetic in CFunctions::SetColorPalette. This script is therefore kept as the reproducible proof of that negative (and would catch a table if a future vendor build embeds one). The actual recovery was done by porting that routine; see analysis/tools/PalIdentify.java + analysis/tools/PalExport2.java. Pure standard library (struct + zlib + json). Writes UTF-8 explicitly. Usage: python3 extract_palettes.py """ import json import os import re import struct import sys import zlib ALPHA_RUN = 240 # minimum entries with alpha==0xFF and non-zero RGB ENTRIES = 256 RUN_BYTES = ENTRIES * 4 def read_anchor(kt_path): """Extract PALETTE256_ARGB (256 ARGB ints) from OfficialTables.kt.""" with open(kt_path, "r", encoding="utf-8") as fh: src = fh.read() m = re.search(r"val PALETTE256_ARGB = intArrayOf\((.*?)\)", src, re.S) if not m: raise SystemExit("PALETTE256_ARGB not found in " + kt_path) vals = [int(x) for x in re.findall(r"-?\d+", m.group(1))] if len(vals) != ENTRIES: raise SystemExit("anchor has %d entries, expected %d" % (len(vals), ENTRIES)) return vals def u32le(buf, off): return struct.unpack_from("> 24) == 0xFF and (v & 0xFFFFFF) != 0: ok += 1 if ok == ENTRIES // 16: # cheap pass, verify fully full = 0 for i in range(ENTRIES): v = u32le(data, off + i * 4) if (v >> 24) == 0xFF and (v & 0xFFFFFF) != 0: full += 1 if full >= ALPHA_RUN: hits.append(off) return hits def merge(hits): """Merge overlapping runs (a 1024 B table can start at off, off+4, ...).""" merged = [] for off in hits: if merged and off <= merged[-1][1]: merged[-1][1] = max(merged[-1][1], off + RUN_BYTES) else: merged.append([off, off + RUN_BYTES]) return merged def write_png(path, rows, row_h, scale): """Minimal PNG writer: one horizontal strip per row of 256 colours.""" width = ENTRIES * scale height = len(rows) * row_h raw = bytearray() for row in rows: line = bytearray() for x in range(width): argb = row[x // scale] line += bytes(((argb >> 16) & 0xFF, (argb >> 8) & 0xFF, argb & 0xFF)) for _ in range(row_h): raw.append(0) # filter type 0 raw += line def chunk(tag, payload): return (struct.pack(">I", len(payload)) + tag + payload + struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF)) png = b"\x89PNG\r\n\x1a\n" png += chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) png += chunk(b"IDAT", zlib.compress(bytes(raw), 9)) png += chunk(b"IEND", b"") with open(path, "wb") as fh: fh.write(png) def main(): if len(sys.argv) != 4: raise SystemExit(__doc__) so_path, kt_path, out_dir = sys.argv[1:4] os.makedirs(out_dir, exist_ok=True) with open(so_path, "rb") as fh: data = fh.read() anchor = read_anchor(kt_path) print("scanned %s: %d bytes; anchor %d entries" % (so_path, len(data), len(anchor))) hits = scan(data) print("static 256-entry alpha=0xFF runs: %d" % len(hits)) runs = merge(hits) candidates = [] anchor_at = None for start, end in runs: for off in range(start, end - RUN_BYTES + 1, 4): if all(u32le(data, off + i * 4) == anchor[i] for i in range(ENTRIES)): anchor_at = off break candidates.append({ "offset": start, "offsetHex": "0x%X" % start, "hexPreview": ["0x%08X" % u32le(data, start + i * 4) for i in range(16)], "length": ENTRIES, }) if anchor_at is not None: print("ironbow anchor found at 0x%X" % anchor_at) else: print("ironbow anchor NOT found — palettes are not stored as static tables") report = { "binary": os.path.basename(so_path), "binarySize": len(data), "anchorFound": anchor_at is not None, "anchorOffset": anchor_at, "staticCandidates": candidates, "conclusion": ("no static palette tables: CFunctions::SetColorPalette builds " "them at runtime" if anchor_at is None else "static tables present"), } json_path = os.path.join(out_dir, "palette_candidates.json") with open(json_path, "w", encoding="utf-8") as fh: json.dump(report, fh, ensure_ascii=False, indent=2) print("wrote %s" % json_path) # strip sheet: one row per candidate (empty sheet when none are found) rows = [] for start, end in runs: rows.append([u32le(data, start + i * 4) for i in range(ENTRIES)]) if not rows: rows.append([0xFF000000] * ENTRIES) # placeholder so the file is valid png_path = os.path.join(out_dir, "palette_candidates.png") write_png(png_path, rows, row_h=32, scale=8) print("wrote %s (%d rows)" % (png_path, len(rows))) if __name__ == "__main__": main()