#!/usr/bin/env python3
"""
Asset extractor for bit Generations: Orbital (GBA).

Reads the original ROM in the repo root and carves it into
semantic regions + full-coverage raw chunks under assets/:

  assets/header.bin        ROM header            0x000000-0x0000C0
  assets/boot.bin          ARM boot              0x0000C0-0x0001CC (268B incl. entry)
  assets/code.bin          Thumb code            0x0000C0-0x01F30C (127564B)
  assets/rodata.bin        strings/asserts/tables 0x01F30C-0x02A700 (~44KB)
  assets/tables.json       08xxxxxx pointer index (code -> ROM targets)
  assets/chunks/chunk_XXXXXX.bin  64KB raw tail coverage 0x02A700-0x1000000
  assets/manifest.json     {name, rom_off, size, sha1, kind} for everything

Notes (verified 2026-09-13):
- No valid GBA LZ77/Huff/RLE streams found by decompression probe;
  0x10/0x20/0x30 magic hits are Thumb-code coincidences. Gfx is raw.
- 09xxxxxx "pointers" are data values, not ROM addresses (ROM is
  08000000-08FFFFFF); only 08-range targets are indexed.
- GBA header complement byte (0xBD) does NOT match the computed value
  (0xBC on disk vs 0xD5 computed) -- the header is preserved byte-exact,
  never recomputed.

Usage:
  python3 tools/extract_assets.py [--rom PATH] [--out assets]
"""
import argparse
import hashlib
import json
import re
import struct
import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent
DEFAULT_ROM = ROOT / "bit Generations - Orbital (Japan) (En).gba"

ROM_BASE = 0x08000000
HDR_END = 0xC0
CODE_START = 0xC0
CODE_END = 0x1F30C
RODATA_END = 0x2A700  # past last assert string (~0x2A684) + tables
CHUNK = 0x10000  # 64KB tail chunks


def sha1(b: bytes) -> str:
    return hashlib.sha1(b).hexdigest()


def main() -> int:
    ap = argparse.ArgumentParser(description="Extract Orbital ROM assets")
    ap.add_argument("--rom", default=str(DEFAULT_ROM))
    ap.add_argument("--out", default=str(ROOT / "assets"))
    args = ap.parse_args()
    rom_path = Path(args.rom)
    out = Path(args.out)
    if not rom_path.exists():
        sys.exit(f"ROM not found: {rom_path} (place it in repo root)")
    rom = rom_path.read_bytes()
    n = len(rom)
    print(f"ROM: {rom_path.name} {n} bytes sha1={sha1(rom)}")

    (out / "chunks").mkdir(parents=True, exist_ok=True)
    manifest = []

    def emit(name, off, data, kind, overlap=False):
        p = out / name
        p.parent.mkdir(parents=True, exist_ok=True)
        p.write_bytes(data)
        entry = {"name": name, "rom_off": f"0x{off:07X}",
                 "size": len(data), "sha1": sha1(data), "kind": kind}
        if overlap:
            entry["overlap"] = True  # contained in another region; skip for assembly
        manifest.append(entry)

    # Fixed semantic regions
    emit("header.bin", 0x0, rom[0x0:HDR_END], "header")
    emit("boot.bin", CODE_START, rom[CODE_START:0x1CC], "arm-boot",
         overlap=True)  # subset of code.bin, kept as a named view
    emit("code.bin", CODE_START, rom[CODE_START:CODE_END], "thumb-code")
    emit("rodata.bin", CODE_END, rom[CODE_END:RODATA_END], "rodata")

    # Pointer index: aligned 08-range words inside the code section
    tables = []
    seen = set()
    for off in range(CODE_START, CODE_END - 3, 4):
        v = struct.unpack_from("<I", rom, off)[0]
        if 0x08000000 <= v < ROM_BASE + n and v % 4 == 0 and v >= 0x0801F30C:
            if v not in seen:
                seen.add(v)
                tables.append({"src": f"0x{off + ROM_BASE:08X}",
                               "target": f"0x{v:08X}",
                               "target_off": f"0x{v - ROM_BASE:07X}"})
    tables.sort(key=lambda r: int(r["target"], 16))
    (out / "tables.json").write_text(json.dumps(
        {"count": len(tables), "entries": tables}, indent=1) + "\n")
    print(f"pointer targets indexed: {len(tables)}")

    # Assert/string inventory inside rodata (for docs, not build-critical)
    strs = [{"off": f"0x{m.start() + CODE_END:07X}",
             "text": m.group().decode("ascii", "replace")[:96]}
            for m in re.finditer(rb"[ -~]{6,}", rom[CODE_END:RODATA_END])]
    (out / "strings.json").write_text(json.dumps(
        {"count": len(strs), "entries": strs[:400]}, indent=1) + "\n")

    # Full-coverage raw tail (guarantees lossless round-trip)
    off = RODATA_END
    nchunks = 0
    while off < n:
        emit(f"chunks/chunk_{off:07X}.bin", off, rom[off:off + CHUNK], "raw")
        off += CHUNK
        nchunks += 1
    print(f"tail chunks: {nchunks} x 64KB from 0x{RODATA_END:07X}")

    # Coverage check: fixed regions + chunks must tile [0, n) exactly
    covered = (HDR_END - 0) + (RODATA_END - CODE_START) + (n - RODATA_END)
    assert covered == n, f"coverage gap: {covered} != {n}"
    (out / "manifest.json").write_text(json.dumps(
        {"rom": rom_path.name, "rom_size": n, "rom_sha1": sha1(rom),
         "regions": manifest}, indent=1) + "\n")
    print(f"extracted {len(manifest)} regions -> {out}/manifest.json (lossless)")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
