#!/usr/bin/env python3
"""Extract EXE-side source rectangles for map_*3 scene-extra tilesets.

For map_*3 image CNS records the stable pattern observed so far is:

    <map_*3.cns string pointer>, <u32 xyxy source-rect table pointer>, ...

This extracts those ref+4 tables. It intentionally does not infer destination
placement on field maps.
"""
from __future__ import annotations

import argparse
import html
import json
import re
import struct
import zlib
from collections import Counter
from pathlib import Path
from typing import Any

from probe_exe_scene_tables import find_cns_strings, offset_to_va, read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
TILE_SIZE = 16
TILE_COLUMNS = 40
MAX_RECTS_PER_TABLE = 256
MAP3_CONFIRMED_TILE_RANGE_OVERRIDES = {
    "map_j3": [(0, 122), (3, 165), (6, 169)],
    "map_n3": [(0, 162), (3, 45), (83, 125), (6, 128), (10, 10)],
    "map_o3": [(0, 162), (3, 167), (8, 8), (9, 9)],
    "map_p3": [(0, 162), (3, 167), (8, 8), (9, 9)],
}


def read_png_rgba(path: Path) -> tuple[int, int, list[list[tuple[int, int, int, int]]]]:
    data = path.read_bytes()
    if data[:8] != b"\x89PNG\r\n\x1a\n":
        raise ValueError(f"not a PNG: {path}")
    pos = 8
    width = height = bit_depth = color_type = None
    palette: list[tuple[int, int, int]] = []
    transparency = b""
    idat: list[bytes] = []
    while pos + 8 <= len(data):
        length = struct.unpack_from(">I", data, pos)[0]
        kind = data[pos + 4 : pos + 8]
        chunk = data[pos + 8 : pos + 8 + length]
        pos += length + 12
        if kind == b"IHDR":
            width, height, bit_depth, color_type, _compression, _filter, _interlace = struct.unpack(">IIBBBBB", chunk)
        elif kind == b"PLTE":
            palette = [tuple(chunk[index : index + 3]) for index in range(0, len(chunk), 3)]
        elif kind == b"tRNS":
            transparency = chunk
        elif kind == b"IDAT":
            idat.append(chunk)
        elif kind == b"IEND":
            break
    if width is None or height is None or bit_depth != 8 or color_type not in {0, 2, 3, 6}:
        raise ValueError(f"unsupported PNG format: {path}")

    channels = {0: 1, 2: 3, 3: 1, 6: 4}[color_type]
    bpp = max(1, channels)
    stride = width * channels
    raw = zlib.decompress(b"".join(idat))
    rows: list[list[tuple[int, int, int, int]]] = []
    previous = [0] * stride
    offset = 0
    for _y in range(height):
        filter_type = raw[offset]
        offset += 1
        current = list(raw[offset : offset + stride])
        offset += stride
        for index, value in enumerate(current):
            left = current[index - bpp] if index >= bpp else 0
            above = previous[index]
            upper_left = previous[index - bpp] if index >= bpp else 0
            if filter_type == 1:
                current[index] = (value + left) & 0xFF
            elif filter_type == 2:
                current[index] = (value + above) & 0xFF
            elif filter_type == 3:
                current[index] = (value + ((left + above) // 2)) & 0xFF
            elif filter_type == 4:
                predictor = left + above - upper_left
                left_delta = abs(predictor - left)
                above_delta = abs(predictor - above)
                upper_left_delta = abs(predictor - upper_left)
                paeth = left if left_delta <= above_delta and left_delta <= upper_left_delta else above if above_delta <= upper_left_delta else upper_left
                current[index] = (value + paeth) & 0xFF
            elif filter_type != 0:
                raise ValueError(f"unsupported PNG filter {filter_type}: {path}")
        previous = current
        if color_type == 6:
            rows.append([tuple(current[index : index + 4]) for index in range(0, len(current), 4)])
        elif color_type == 2:
            rows.append([tuple(current[index : index + 3]) + (255,) for index in range(0, len(current), 3)])
        elif color_type == 3:
            rows.append([
                palette[value] + ((transparency[value] if value < len(transparency) else 255),)
                for value in current
            ])
        else:
            rows.append([(value, value, value, 255) for value in current])
    return width, height, rows


def png_background_key(rows: list[list[tuple[int, int, int, int]]], width: int, height: int) -> tuple[int, int, int, int]:
    edge: list[tuple[int, int, int, int]] = []
    for x in range(width):
        edge.append(rows[0][x])
        edge.append(rows[height - 1][x])
    for y in range(height):
        edge.append(rows[y][0])
        edge.append(rows[y][width - 1])
    return Counter(edge).most_common(1)[0][0]


def is_foreground_pixel(pixel: tuple[int, int, int, int], background: tuple[int, int, int, int]) -> bool:
    if background[3] == 0:
        return pixel[3] != 0
    return pixel != background


def load_json(path: Path, fallback: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return fallback


def hex32(value: int | None) -> str | None:
    if value is None:
        return None
    return f"0x{value:08x}"


def dword_at(data: bytes, offset: int) -> int | None:
    if offset < 0 or offset + 4 > len(data):
        return None
    return struct.unpack_from("<I", data, offset)[0]


def find_all(data: bytes, needle: bytes) -> list[int]:
    hits = []
    search = 0
    while True:
        hit = data.find(needle, search)
        if hit < 0:
            return hits
        hits.append(hit)
        search = hit + 1


def find_string_refs(data: bytes, sections: list[dict], string_va: int) -> list[dict[str, Any]]:
    refs = []
    for hit in find_all(data, struct.pack("<I", string_va)):
        ref_va = offset_to_va(sections, hit)
        if ref_va is None:
            continue
        refs.append({"fileOffset": hit, "fileOffsetHex": f"0x{hit:06x}", "refVa": ref_va, "refVaHex": hex32(ref_va)})
    return refs


def valid_xyxy(values: tuple[int, int, int, int], width: int, height: int) -> bool:
    x1, y1, x2, y2 = values
    return 0 <= x1 < x2 <= width and 0 <= y1 < y2 <= height


def decode_xyxy_table(data: bytes, sections: list[dict], table_va: int, width: int, height: int) -> list[dict[str, Any]]:
    start = va_to_offset(sections, table_va)
    if start is None:
        return []
    rects = []
    for index in range(MAX_RECTS_PER_TABLE):
        offset = start + index * 16
        if offset + 16 > len(data):
            break
        values = struct.unpack_from("<IIII", data, offset)
        if not valid_xyxy(values, width, height):
            break
        x1, y1, x2, y2 = values
        x = int(x1)
        y = int(y1)
        w = int(x2 - x1)
        h = int(y2 - y1)
        tile_x1 = x // TILE_SIZE
        tile_y1 = y // TILE_SIZE
        tile_x2 = (x2 - 1) // TILE_SIZE
        tile_y2 = (y2 - 1) // TILE_SIZE
        rects.append(
            {
                "index": index,
                "x": x,
                "y": y,
                "w": w,
                "h": h,
                "x2": int(x2),
                "y2": int(y2),
                "tileRange": [tile_y1 * TILE_COLUMNS + tile_x1, tile_y2 * TILE_COLUMNS + tile_x2],
                "tileBox": [tile_x1, tile_y1, tile_x2, tile_y2],
            }
        )
    return rects


def map3_payload_rows(payloads: list[dict]) -> list[dict]:
    rows = []
    for row in payloads:
        name = str(row.get("name") or "")
        if row.get("kind") != "image":
            continue
        if not re.fullmatch(r"map_[a-z]3\.cns", name):
            continue
        width = int(row.get("width") or 0)
        height = int(row.get("height") or 0)
        if width <= 0 or height <= 0:
            continue
        rows.append({"asset": name[:-4], "cns": name, "width": width, "height": height})
    return sorted(rows, key=lambda item: item["asset"])


def unique_rect_rows(tables: list[dict[str, Any]]) -> list[dict[str, Any]]:
    grouped: dict[tuple[int, int, int, int], dict[str, Any]] = {}
    for table in tables:
        for rect in table["rects"]:
            key = (rect["x"], rect["y"], rect["w"], rect["h"])
            row = grouped.setdefault(
                key,
                {
                    "x": rect["x"],
                    "y": rect["y"],
                    "w": rect["w"],
                    "h": rect["h"],
                    "x2": rect["x2"],
                    "y2": rect["y2"],
                    "tileRange": rect["tileRange"],
                    "tileBox": rect["tileBox"],
                    "tableRefs": [],
                    "occurrenceCount": 0,
                },
            )
            row["occurrenceCount"] += 1
            row["tableRefs"].append({"tableStartVaHex": table["tableStartVaHex"], "rectIndex": rect["index"]})
    unique = []
    for index, row in enumerate(sorted(grouped.values(), key=lambda item: (item["y"], item["x"], item["h"], item["w"]))):
        row["id"] = index
        row["label"] = f"R{index + 1:02d}"
        unique.append(row)
    return unique


def content_pixels(
    rect: dict[str, Any],
    rows: list[list[tuple[int, int, int, int]]],
    image_width: int,
    background: tuple[int, int, int, int],
) -> tuple[set[int], list[int] | None]:
    pixels: set[int] = set()
    x1 = y1 = 10**9
    x2 = y2 = -1
    for y in range(rect["y"], rect["y2"]):
        for x in range(rect["x"], rect["x2"]):
            if not is_foreground_pixel(rows[y][x], background):
                continue
            pixels.add(y * image_width + x)
            x1 = min(x1, x)
            y1 = min(y1, y)
            x2 = max(x2, x + 1)
            y2 = max(y2, y + 1)
    if not pixels:
        return pixels, None
    return pixels, [x1, y1, x2, y2]


def rect_contains_bbox(rect: dict[str, Any], bbox: list[int] | None) -> bool:
    if bbox is None:
        return False
    return rect["x"] <= bbox[0] and rect["y"] <= bbox[1] and rect["x2"] >= bbox[2] and rect["y2"] >= bbox[3]


def rect_from_tile_range(start: int, end: int) -> dict[str, Any]:
    tile_x1 = start % TILE_COLUMNS
    tile_y1 = start // TILE_COLUMNS
    tile_x2 = end % TILE_COLUMNS
    tile_y2 = end // TILE_COLUMNS
    x = tile_x1 * TILE_SIZE
    y = tile_y1 * TILE_SIZE
    x2 = (tile_x2 + 1) * TILE_SIZE
    y2 = (tile_y2 + 1) * TILE_SIZE
    return {
        "x": x,
        "y": y,
        "w": x2 - x,
        "h": y2 - y,
        "x2": x2,
        "y2": y2,
        "tileRange": [start, end],
        "tileBox": [tile_x1, tile_y1, tile_x2, tile_y2],
    }


def apply_confirmed_tile_range_overrides(
    asset: str,
    auto_rects: list[dict[str, Any]],
    raw_rects: list[dict[str, Any]],
    filter_summary: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
    overrides = MAP3_CONFIRMED_TILE_RANGE_OVERRIDES.get(asset)
    if not overrides:
        filter_summary["confirmedOverrideApplied"] = False
        return auto_rects, filter_summary

    png_path = OUT / f"{asset}.png"
    rows: list[list[tuple[int, int, int, int]]] | None = None
    background: tuple[int, int, int, int] | None = None
    image_width = 0
    if png_path.exists():
        image_width, image_height, rows = read_png_rgba(png_path)
        background = png_background_key(rows, image_width, image_height)

    raw_by_tile_range = {tuple(rect.get("tileRange") or []): rect for rect in raw_rects}
    confirmed = []
    for index, (start, end) in enumerate(overrides):
        rect = rect_from_tile_range(start, end)
        raw_match = raw_by_tile_range.get((start, end))
        if raw_match:
            rect["tableRefs"] = raw_match.get("tableRefs", [])
            rect["occurrenceCount"] = raw_match.get("occurrenceCount", 0)
            rect["rawCandidateMatch"] = True
        else:
            rect["tableRefs"] = []
            rect["occurrenceCount"] = 0
            rect["rawCandidateMatch"] = False
        if rows is not None and background is not None:
            pixels, bbox = content_pixels(rect, rows, image_width, background)
            rect["contentPixelCount"] = len(pixels)
            rect["contentRatio"] = round(len(pixels) / max(1, rect["w"] * rect["h"]), 6)
            rect["contentBBox"] = bbox
        rect["id"] = index
        rect["label"] = f"R{index + 1:02d}"
        rect["source"] = "user-confirmed-grid-label-for-pattern-mining"
        confirmed.append(rect)

    filter_summary = dict(filter_summary)
    filter_summary["confirmedOverrideApplied"] = True
    filter_summary["confirmedOverrideSource"] = "user review labels; keep for pattern mining, not destination placement"
    filter_summary["confirmedOverrideCount"] = len(confirmed)
    filter_summary["autoVisibleRectCountBeforeOverride"] = len(auto_rects)
    filter_summary["confirmedOverrideTileRanges"] = [list(item) for item in overrides]
    filter_summary["confirmedOverrideRawMatchCount"] = sum(1 for rect in confirmed if rect.get("rawCandidateMatch"))
    return confirmed, filter_summary


def filter_visible_source_rects(asset: str, rects: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], dict[str, Any]]:
    png_path = OUT / f"{asset}.png"
    if not png_path.exists() or not rects:
        return rects, {
            "filterStatus": "no-png-or-no-raw-rects",
            "rawRectCount": len(rects),
            "backgroundKey": None,
            "hiddenEmptyRectCount": 0,
            "hiddenNestedRectCount": 0,
            "hiddenCompositeRectCount": 0,
        }
    width, height, rows = read_png_rgba(png_path)
    background = png_background_key(rows, width, height)
    candidates = []
    hidden_empty = 0
    for rect in rects:
        pixels, bbox = content_pixels(rect, rows, width, background)
        if not pixels:
            hidden_empty += 1
            continue
        row = dict(rect)
        row["_pixels"] = pixels
        row["_contentArea"] = len(pixels)
        row["_rectArea"] = rect["w"] * rect["h"]
        row["contentPixelCount"] = len(pixels)
        row["contentRatio"] = round(len(pixels) / max(1, row["_rectArea"]), 6)
        row["contentBBox"] = bbox
        candidates.append(row)

    hidden: set[int] = set()
    hidden_nested = 0
    for index, rect in enumerate(candidates):
        for other_index, other in enumerate(candidates):
            if index == other_index or other["_rectArea"] <= rect["_rectArea"]:
                continue
            if rect["_pixels"] <= other["_pixels"] and rect_contains_bbox(other, rect.get("contentBBox")) and other["_contentArea"] > rect["_contentArea"]:
                hidden.add(index)
                hidden_nested += 1
                break

    hidden_composite = 0
    for index, rect in enumerate(candidates):
        if index in hidden:
            continue
        covered: set[int] = set()
        for other_index, other in enumerate(candidates):
            if index == other_index or other_index in hidden:
                continue
            if other["_rectArea"] < rect["_rectArea"]:
                covered |= other["_pixels"] & rect["_pixels"]
        if rect["_pixels"] and len(covered) / len(rect["_pixels"]) >= 0.95:
            hidden.add(index)
            hidden_composite += 1

    visible = []
    for index, rect in enumerate(candidates):
        if index in hidden:
            continue
        row = {key: value for key, value in rect.items() if not key.startswith("_")}
        row["id"] = len(visible)
        row["label"] = f"R{len(visible) + 1:02d}"
        visible.append(row)

    return visible, {
        "filterStatus": "exe-rect-image-content-filtered",
        "rawRectCount": len(rects),
        "backgroundKey": list(background),
        "hiddenEmptyRectCount": hidden_empty,
        "hiddenNestedRectCount": hidden_nested,
        "hiddenCompositeRectCount": hidden_composite,
    }


def scan_asset(data: bytes, sections: list[dict], strings: dict[int, str], row: dict[str, Any]) -> dict[str, Any]:
    cns = row["cns"]
    width = row["width"]
    height = row["height"]
    string_vas = sorted(va for va, name in strings.items() if name == cns)
    table_by_va: dict[int, dict[str, Any]] = {}
    string_refs = []
    for string_va in string_vas:
        for ref in find_string_refs(data, sections, string_va):
            ptr = dword_at(data, int(ref["fileOffset"]) + 4)
            rects = decode_xyxy_table(data, sections, ptr, width, height) if ptr else []
            ref_row = {
                "stringVaHex": hex32(string_va),
                "refVaHex": ref["refVaHex"],
                "refFileOffsetHex": ref["fileOffsetHex"],
                "rectTableFieldVaHex": hex32(int(ref["refVa"]) + 4),
                "rectTableVaHex": hex32(ptr),
                "decodedRectCount": len(rects),
            }
            string_refs.append(ref_row)
            if not ptr or not rects:
                continue
            table = table_by_va.setdefault(
                ptr,
                {
                    "tableStartVa": ptr,
                    "tableStartVaHex": hex32(ptr),
                    "source": "map_*3.cns ref+4 u32 xyxy pointer",
                    "refVas": [],
                    "rects": rects,
                },
            )
            table["refVas"].append(ref["refVaHex"])
    tables = sorted(table_by_va.values(), key=lambda item: item["tableStartVa"])
    for table in tables:
        table["refVas"] = sorted(set(table["refVas"]))
        table["rectCount"] = len(table["rects"])
    unique = unique_rect_rows(tables)
    visible, filter_summary = filter_visible_source_rects(row["asset"], unique)
    visible, filter_summary = apply_confirmed_tile_range_overrides(row["asset"], visible, unique, filter_summary)
    for rect in visible:
        indices = sorted({
            int(ref["rectIndex"])
            for ref in rect.get("tableRefs") or []
            if isinstance(ref, dict) and isinstance(ref.get("rectIndex"), int)
        })
        rect["exeRectTableIndices"] = indices
        rect["field0x28Hexes"] = [f"0x000c{index:04x}" for index in indices]
        rect["sourceRectEvidenceStatus"] = (
            "exe-ref-plus4-table-indexed"
            if indices
            else "manual-review-label-no-exe-table-index"
            if filter_summary.get("confirmedOverrideApplied")
            else "visible-filtered-no-table-index"
        )
    return {
        "asset": row["asset"],
        "cns": cns,
        "width": width,
        "height": height,
        "stringRefCount": len(string_refs),
        "stringRefs": string_refs,
        "tableCount": len(tables),
        "tables": tables,
        "rawUniqueRectCount": len(unique),
        "rawRects": unique,
        "uniqueRectCount": len(visible),
        "rects": visible,
        "filter": filter_summary,
        "classification": (
            "user-confirmed-grid-label-for-pattern-mining"
            if filter_summary.get("confirmedOverrideApplied")
            else "exe-ref-plus4-source-rect-table-visible-filtered"
            if visible
            else "no-visible-ref-plus4-rect-table"
        ),
    }


def run_scan(exe_path: Path, payload_path: Path = OUT / "cns_payloads.json") -> dict[str, Any]:
    data = exe_path.read_bytes()
    sections = read_sections(data)
    strings = find_cns_strings(data, sections)
    payloads = load_json(payload_path, [])
    assets = [scan_asset(data, sections, strings, row) for row in map3_payload_rows(payloads)]
    assets_with_tables = [row for row in assets if row["tableCount"]]
    assets_with_visible_rects = [row for row in assets if row["uniqueRectCount"]]
    return {
        "scope": "map_*3 scene-extra source rect EXE ref+4 table scan",
        "status": "source-rect-pointer-table-found-visible-filtered-placement-unproven",
        "source": [str(exe_path), str(payload_path)],
        "schema": "u32 x1,y1,x2,y2 exclusive source rectangles",
        "fieldPattern": "map_*3.cns string ref + 4",
        "tileSize": TILE_SIZE,
        "tileColumns": TILE_COLUMNS,
        "assetCount": len(assets),
        "assetsWithRectTables": len(assets_with_tables),
        "assetsWithVisibleRects": len(assets_with_visible_rects),
        "totalTableCount": sum(row["tableCount"] for row in assets),
        "totalUniqueRectCount": sum(row["uniqueRectCount"] for row in assets),
        "totalRawUniqueRectCount": sum(row["rawUniqueRectCount"] for row in assets),
        "assets": assets,
        "byAsset": {row["asset"]: row for row in assets},
        "conclusion": (
            "EXE ref+4 source-rect tables can identify candidate regions of map_*3 images. "
            "The browser overlay uses only candidates that contain foreground pixels and survive nested/composite filtering. "
            "Destination placement remains unproven unless a separate draw/object coordinate source is found or manually confirmed."
        ),
    }


def public_overlay_payload(summary: dict[str, Any]) -> dict[str, Any]:
    return {
        "status": summary["status"],
        "schema": summary["schema"],
        "fieldPattern": summary["fieldPattern"],
        "tileSize": summary["tileSize"],
        "tileColumns": summary["tileColumns"],
        "assetCount": summary["assetCount"],
        "assetsWithRectTables": summary["assetsWithRectTables"],
        "assetsWithVisibleRects": summary["assetsWithVisibleRects"],
        "totalUniqueRectCount": summary["totalUniqueRectCount"],
        "totalRawUniqueRectCount": summary["totalRawUniqueRectCount"],
        "byAsset": {
            asset: {
                "asset": row["asset"],
                "cns": row["cns"],
                "width": row["width"],
                "height": row["height"],
                "classification": row["classification"],
                "tableCount": row["tableCount"],
                "uniqueRectCount": row["uniqueRectCount"],
                "rawUniqueRectCount": row["rawUniqueRectCount"],
                "filter": row["filter"],
                "rects": row["rects"],
                "tables": [
                    {
                        "tableStartVaHex": table["tableStartVaHex"],
                        "rectCount": table["rectCount"],
                        "refVas": table["refVas"],
                    }
                    for table in row["tables"]
                ],
            }
            for asset, row in summary["byAsset"].items()
        },
    }


def write_outputs(summary: dict[str, Any], out_dir: Path) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    overlay = public_overlay_payload(summary)
    (out_dir / "map_extra_rect_pattern_scan.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "map_extra_rects.json").write_text(
        json.dumps(overlay, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )


def html_page(summary: dict[str, Any]) -> str:
    rows = []
    for row in summary["assets"]:
        samples = "<br>".join(
            html.escape(f"{rect['label']} {rect['x']},{rect['y']},{rect['w']},{rect['h']} tile {rect['tileRange'][0]}->{rect['tileRange'][1]}")
            for rect in row["rects"][:12]
        )
        rows.append(
            "<tr>"
            f"<td><code>{html.escape(row['asset'])}</code></td>"
            f"<td>{row['width']}x{row['height']}</td>"
            f"<td>{row['tableCount']}</td>"
            f"<td>{row['uniqueRectCount']}</td>"
            f"<td>{row['rawUniqueRectCount']}</td>"
            f"<td>{row.get('filter', {}).get('confirmedOverrideRawMatchCount', '-') if row.get('filter', {}).get('confirmedOverrideApplied') else '-'}</td>"
            f"<td><code>{html.escape(row['classification'])}</code></td>"
            f"<td>{samples or '-'}</td>"
            "</tr>"
        )
    return (
        "<!doctype html><meta charset=\"utf-8\"><title>Map Extra Rect Pattern Scan</title>"
        "<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee}table{border-collapse:collapse}"
        "td,th{border:1px solid #333;padding:6px 8px;vertical-align:top}code{color:#9bd}</style>"
        "<h1>Map Extra Rect Pattern Scan</h1>"
        f"<p>status: <code>{html.escape(summary['status'])}</code>; pattern: <code>{html.escape(summary['fieldPattern'])}</code>; "
        f"assets with tables: {summary['assetsWithRectTables']}/{summary['assetCount']}; visible rects: {summary['totalUniqueRectCount']}; raw candidates: {summary['totalRawUniqueRectCount']}</p>"
        "<table><thead><tr><th>asset</th><th>size</th><th>tables</th><th>visible rects</th><th>raw candidates</th><th>raw match</th><th>classification</th><th>sample rects</th></tr></thead><tbody>"
        + "".join(rows)
        + "</tbody></table>"
    )


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--payloads", type=Path, default=OUT / "cns_payloads.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = run_scan(args.exe, args.payloads)
    write_outputs(summary, args.out_dir)
    print(f"wrote {args.out_dir / 'map_extra_rects.json'}")


if __name__ == "__main__":
    main()
