#!/usr/bin/env python3
"""Build a review page for layer1 0x40 animation-redraw map regions."""
from __future__ import annotations

import html
import json
import struct
import sys
from collections import Counter, defaultdict, deque
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))

from decode_cns import decompress_cns, parse_image  # noqa: E402


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXTRACT_FLD = ROOT / "extract_fld"
EXE = ROOT / "Hwanse2.exe"
ANIMATED_TILE_FLAG = 0x40
PE_SECTIONS = [
    (".text", 0x00401000, 0x3978C, 0x00000400),
    (".rdata", 0x0043B000, 0x359, 0x00039C00),
    (".data", 0x0043C000, 0x11DE00, 0x0003A000),
]

# Generic object/display VM command lengths grounded from the 0x00440538
# handler table. These are intentionally conservative and only used to score
# local bytecode alignment around palette commands; they do not promote a
# candidate to per-map execution proof by themselves.
VM_OPCODE_LENGTHS = {
    0x00: 4,
    0x01: 4,
    0x02: 4,
    0x03: 8,
    0x04: 8,
    0x05: 4,
    0x06: 8,
    0x07: 8,
    0x08: 4,
    0x09: 4,
    0x0A: 4,
    0x0B: 4,
    0x0C: 4,
    0x0D: 4,
    0x0E: 4,
    0x0F: 4,
    0x10: 4,
    0x11: 8,
    0x12: 8,
    0x13: 8,
    0x14: 8,
    0x15: 8,
    0x16: 8,
    0x17: 8,
    0x18: 8,
    0x19: 4,
    0x1A: 4,
    0x1B: 4,
    0x1C: 4,
    0x1D: 12,
    0x1E: 4,
    0x1F: 4,
    0x20: 8,
    0x21: 8,
    0x22: 4,
    0x23: 4,
    0x24: 4,
    0x25: 4,
    0x26: 4,
    0x27: 4,
    0x28: 4,
    0x29: 4,
    0x2A: 8,
    0x2B: 4,
    0x2C: 4,
    0x2D: 4,
    0x2E: 12,
    0x2F: 8,
    0x30: 8,
    0x31: 4,
    0x32: 8,
    0x33: 4,
    0x34: 4,
    0x35: 4,
    0x36: 8,
    0x37: 4,
    0x38: 8,
    0x39: 8,
    0x3A: 4,
    0x3B: 4,
    0x3C: 4,
    0x3D: 4,
    0x3E: 4,
    0x3F: 8,
    0x40: 4,
    0x41: 4,
    0x42: 4,
    0x43: 4,
    0x44: 4,
    0x45: 4,
    0x46: 12,
    0x47: 4,
    0x48: 4,
    0x49: 4,
    0x4A: 8,
    0x4B: 4,
    0x4C: 12,
    0x4D: 4,
    0x4E: 4,
    0x4F: 4,
    0x50: 4,
    0x51: 4,
    0x52: 8,
    0x53: 12,
    0x54: 4,
    0x55: 8,
    0x56: 8,
    0x57: 4,
    0x58: 8,
    0x59: 4,
    0x5A: 8,
    0x5B: 4,
    0x5C: 4,
    0x5D: 8,
    0x5E: 4,
    0x5F: 4,
    0x60: 4,
    0x61: 4,
    0x62: 4,
    0x63: 4,
    0x64: 8,
    0x65: 12,
    0x66: 8,
    0x67: 8,
    0x68: 8,
    0x69: 4,
    0x6A: 4,
    0x6B: 12,
    0x6C: 16,
    0x6D: 4,
    0x6E: 8,
    0x6F: 4,
    0x70: 8,
    0x71: 12,
    0x72: 8,
    0x73: 8,
    0x74: 8,
    0x75: 4,
    0x76: 12,
    0x77: 16,
    0x78: 4,
    0x79: 4,
    0x7A: 4,
    0x7B: 4,
    0x7C: 8,
    0x7D: 4,
    0x7E: 4,
    0x7F: 4,
    0xAD: 8,
    0xBC: 4,
    0xBD: 4,
    0xBF: 4,
    0xC0: 4,
    0xC1: 8,
    0xC2: 4,
}


def load_maps() -> dict:
    text = (OUT / "maps.js").read_text(encoding="utf-8")
    return json.loads(text.split("=", 1)[1].strip().rstrip(";"))


def animated_cells(map_info: dict) -> list[dict]:
    width = map_info["width"]
    layer0 = map_info["layers"][0]
    layer1 = map_info["layers"][1]
    cells = []
    for index, flag in enumerate(layer1):
        if not (flag & ANIMATED_TILE_FLAG):
            continue
        cells.append(
            {
                "x": index % width,
                "y": index // width,
                "layer0": layer0[index],
                "layer1": flag,
                "layer1Hex": f"0x{flag:02x}",
            }
        )
    return cells


def connected_components(cells: list[dict]) -> list[dict]:
    by_xy = {(cell["x"], cell["y"]): cell for cell in cells}
    pending = set(by_xy)
    components = []
    while pending:
        start = pending.pop()
        queue = deque([start])
        coords = [start]
        while queue:
            x, y = queue.popleft()
            for neighbor in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
                if neighbor not in pending:
                    continue
                pending.remove(neighbor)
                queue.append(neighbor)
                coords.append(neighbor)
        component_cells = [by_xy[coord] for coord in coords]
        xs = [cell["x"] for cell in component_cells]
        ys = [cell["y"] for cell in component_cells]
        components.append(
            {
                "x0": min(xs),
                "y0": min(ys),
                "x1": max(xs),
                "y1": max(ys),
                "width": max(xs) - min(xs) + 1,
                "height": max(ys) - min(ys) + 1,
                "cellCount": len(component_cells),
                "layer0Tiles": sorted({cell["layer0"] for cell in component_cells}),
            }
        )
    return sorted(components, key=lambda row: (row["y0"], row["x0"], row["y1"], row["x1"]))


def placement_components(cells: list[dict]) -> list[dict]:
    by_xy = {(cell["x"], cell["y"]): cell for cell in cells}
    components = connected_components(cells)
    placements = []
    for component in components:
        rows = []
        for y in range(component["y0"], component["y1"] + 1):
            tiles = []
            for x in range(component["x0"], component["x1"] + 1):
                cell = by_xy.get((x, y))
                tiles.append(cell["layer0"] if cell else None)
            rows.append({"y": y, "tiles": tiles})
        placements.append({**component, "rows": rows})
    return placements


def tileset_size(stem: str) -> dict:
    decoded = decompress_cns((EXTRACT_FLD / f"{stem}.cns").read_bytes())
    width, height, _palette, _pixels, _bpp = parse_image(decoded)
    return {"width": width, "height": height, "columns": width // 16, "rows": height // 16}


def load_tileset_indices(stem: str) -> tuple[int, int, list[tuple[int, int, int]], list[list[int]]]:
    decoded = decompress_cns((EXTRACT_FLD / f"{stem}.cns").read_bytes())
    width, height, palette, pixels, bpp = parse_image(decoded)
    stride = ((width * bpp + 31) // 32) * 4
    rows: list[list[int]] = []
    for y in range(height):
        row = pixels[(height - 1 - y) * stride : (height - y) * stride]
        if bpp == 8:
            rows.append(list(row[:width]))
        elif bpp == 4:
            values = []
            for byte in row[: (width + 1) // 2]:
                values.append(byte >> 4)
                if len(values) < width:
                    values.append(byte & 0x0F)
            rows.append(values)
        else:
            raise ValueError(f"unsupported tileset bpp: {bpp}")
    return width, height, palette, rows


def palette_ranges(indices: list[int]) -> list[dict]:
    if not indices:
        return []
    ranges = []
    start = previous = indices[0]
    for index in indices[1:]:
        if index == previous + 1:
            previous = index
            continue
        ranges.append({"start": start, "end": previous, "startHex": f"0x{start:02x}", "endHex": f"0x{previous:02x}"})
        start = previous = index
    ranges.append({"start": start, "end": previous, "startHex": f"0x{start:02x}", "endHex": f"0x{previous:02x}"})
    return ranges


def parse_hex_va(value: str | None) -> int | None:
    if not value:
        return None
    try:
        return int(value, 16)
    except ValueError:
        return None


def normalize_resource_name(name: str) -> str:
    return name[:-4] if name.lower().endswith(".cns") else name


def load_scene_resource_groups() -> list[dict]:
    path = OUT / "scene_seq_resource_record_link_review.json"
    if not path.exists():
        return []
    payload = json.loads(path.read_text(encoding="utf-8"))
    groups = []
    for group in payload.get("groups", []):
        start_va = parse_hex_va(group.get("rootVaHex"))
        end_va = parse_hex_va(group.get("rootEndVaHex"))
        resources = [normalize_resource_name(name) for name in group.get("resources", [])]
        maps = list(group.get("fieldMaps") or [])
        if group.get("map") and group.get("map") != "unbound":
            maps.append(group["map"])
        groups.append(
            {
                "id": group.get("id"),
                "contextKind": group.get("contextKind"),
                "contextLabel": group.get("contextLabel"),
                "selector": group.get("selector"),
                "rootVaHex": group.get("rootVaHex"),
                "rootEndVaHex": group.get("rootEndVaHex"),
                "startVa": start_va,
                "endVa": end_va,
                "maps": sorted(set(maps)),
                "resources": sorted(set(resources)),
                "linkClass": group.get("linkClass"),
                "evidenceStatus": group.get("evidenceStatus"),
            }
        )
    return groups


def load_scene_records() -> list[dict]:
    records = []
    path = OUT / "scene_manifest.json"
    if path.exists():
        for row in json.loads(path.read_text(encoding="utf-8")):
            record_va = row.get("recordVa")
            if not isinstance(record_va, int):
                continue
            records.append(
                {
                    "map": row.get("map"),
                    "recordVa": record_va,
                    "recordVaHex": row.get("recordVaHex") or f"0x{record_va:08x}",
                    "sceneIdHex": row.get("sceneIdHex"),
                    "tilesets": [normalize_resource_name(name) for name in row.get("tilesets", [])],
                    "resources": [normalize_resource_name(item.get("name", "")) for item in row.get("resources", []) if item.get("name")],
                }
            )
    deduped = {}
    for row in records:
        key = (row["map"], row["recordVa"])
        deduped[key] = row
    return sorted(deduped.values(), key=lambda row: row["recordVa"])


def group_summary(group: dict, animated_maps: set[str], animated_tilesets: set[str]) -> dict:
    maps = group.get("maps") or []
    resources = group.get("resources") or []
    return {
        "id": group.get("id"),
        "selector": group.get("selector") or "",
        "rootVaHex": group.get("rootVaHex") or "",
        "rootEndVaHex": group.get("rootEndVaHex") or "",
        "maps": maps[:8],
        "resources": resources[:8],
        "animatedMaps": sorted(set(maps) & animated_maps),
        "animatedTilesets": sorted(set(resources) & animated_tilesets),
        "linkClass": group.get("linkClass") or "",
        "evidenceStatus": group.get("evidenceStatus") or "",
    }


def scene_record_summary(record: dict, animated_maps: set[str], animated_tilesets: set[str], root_va: int) -> dict:
    return {
        "map": record.get("map"),
        "recordVaHex": record.get("recordVaHex"),
        "sceneIdHex": record.get("sceneIdHex"),
        "distanceBytes": abs(root_va - record["recordVa"]),
        "tilesets": record.get("tilesets", [])[:6],
        "resources": record.get("resources", [])[:8],
        "animatedMap": record.get("map") in animated_maps,
        "animatedTilesets": sorted(set(record.get("tilesets", [])) & animated_tilesets),
    }


def build_palette_binding_context(rows: list[dict]) -> dict:
    return {
        "animatedMaps": {row["map"] for row in rows},
        "animatedTilesets": {row["sourceTileset"] for row in rows},
        "groups": load_scene_resource_groups(),
        "sceneRecords": load_scene_records(),
    }


def palette_root_resource_binding(root_va_hex: str | None, context: dict) -> dict:
    root_va = parse_hex_va(root_va_hex)
    if root_va is None:
        return {"status": "no-vm-root", "note": "No VM root was selected for this palette command candidate."}

    animated_maps = context.get("animatedMaps") or set()
    animated_tilesets = context.get("animatedTilesets") or set()
    groups = context.get("groups") or []
    records = context.get("sceneRecords") or []

    containing = [
        group
        for group in groups
        if group.get("startVa") is not None and group.get("endVa") is not None and group["startVa"] <= root_va < group["endVa"]
    ]
    containing_summaries = [group_summary(group, animated_maps, animated_tilesets) for group in containing]

    ranged_groups = [group for group in groups if group.get("startVa") is not None and group.get("endVa") is not None]
    nearest_groups = []
    for group in ranged_groups:
        if group in containing:
            continue
        distance = min(abs(root_va - group["startVa"]), abs(root_va - group["endVa"]))
        nearest_groups.append((distance, group))
    nearest_group_summaries = []
    for distance, group in sorted(nearest_groups, key=lambda item: item[0])[:2]:
        summary = group_summary(group, animated_maps, animated_tilesets)
        summary["distanceBytes"] = distance
        nearest_group_summaries.append(summary)

    nearest_records = [
        scene_record_summary(record, animated_maps, animated_tilesets, root_va)
        for record in sorted(records, key=lambda row: abs(root_va - row["recordVa"]))[:3]
    ]

    contained_animated = [
        row for row in containing_summaries if row["animatedMaps"] or row["animatedTilesets"]
    ]
    near_record_animated = [
        row for row in nearest_records if row["distanceBytes"] <= 0x1800 and (row["animatedMap"] or row["animatedTilesets"])
    ]
    near_group_animated = [
        row for row in nearest_group_summaries if row["distanceBytes"] <= 0x4000 and (row["animatedMaps"] or row["animatedTilesets"])
    ]

    if contained_animated:
        status = "resource-group-contains-animated-map"
        note = "The VM root falls inside a selector-root group that also references animated map resources."
    elif containing_summaries:
        status = "resource-group-contained-nonanimated"
        note = "The VM root falls inside a selector-root group, but that group does not reference the currently known 0x40 animated maps/tilesets."
    elif near_record_animated:
        status = "near-animated-scene-record-unverified"
        note = "The VM root is close to an animated scene/resource record, but not inside a selector-root range."
    elif near_group_animated:
        status = "near-animated-resource-group-unverified"
        note = "The nearest selector-root group references animated resources, but the VM root is outside that group."
    else:
        status = "map-binding-unproven"
        note = "No resource group or scene record currently binds this VM root to a specific animated map."

    return {
        "status": status,
        "rootVaHex": f"0x{root_va:08x}",
        "containingGroups": containing_summaries,
        "nearestGroups": nearest_group_summaries,
        "nearestSceneRecords": nearest_records,
        "nearestAnimatedSceneRecords": near_record_animated,
        "nearestAnimatedGroups": near_group_animated,
        "note": note,
    }


def tile_palette_counts(index_rows: list[list[int]], tile: int, columns: int, tile_size: int = 16) -> Counter:
    tile_x = tile % columns
    tile_y = tile // columns
    counts: Counter = Counter()
    for y in range(tile_y * tile_size, tile_y * tile_size + tile_size):
        for x in range(tile_x * tile_size, tile_x * tile_size + tile_size):
            counts[index_rows[y][x]] += 1
    return counts


def palette_usage(stem: str, tiles: list[int], columns: int) -> dict:
    _width, _height, palette, index_rows = load_tileset_indices(stem)
    total: Counter = Counter()
    per_tile = []
    for tile in tiles:
        counts = tile_palette_counts(index_rows, tile, columns)
        nonzero_counts = Counter({index: count for index, count in counts.items() if index != 0})
        total.update(nonzero_counts)
        indices = sorted(nonzero_counts)
        per_tile.append(
            {
                "tile": tile,
                "nonzeroPixelCount": sum(nonzero_counts.values()),
                "indexCount": len(indices),
                "indices": indices,
                "ranges": palette_ranges(indices),
            }
        )
    used = sorted(total)
    return {
        "status": "cns-palette-indices-mapped",
        "transparentIndex": 0,
        "paletteSize": len(palette),
        "indexCount": len(used),
        "indices": used,
        "ranges": palette_ranges(used),
        "topIndices": [
            {"index": index, "indexHex": f"0x{index:02x}", "pixels": count}
            for index, count in total.most_common(16)
        ],
        "perTile": per_tile,
        "note": "Counts are from the current layer0 source tiles inside layer1 0x40 cells. Index 0 is treated as transparent/background and excluded from this summary.",
    }


def va_to_offset(va: int) -> int | None:
    for _name, vma, size, offset in PE_SECTIONS:
        if vma <= va < vma + size:
            return offset + (va - vma)
    return None


def offset_to_va(offset: int) -> int | None:
    for _name, vma, size, section_offset in PE_SECTIONS:
        if section_offset <= offset < section_offset + size:
            return vma + (offset - section_offset)
    return None


def palette_list_entries(exe: bytes, ptr: int) -> list[tuple[int, int, int, int]] | None:
    offset = va_to_offset(ptr)
    if offset is None:
        return None
    entries: list[tuple[int, int, int, int]] = []
    cursor = offset
    for _ in range(256):
        if cursor >= len(exe):
            return None
        index = exe[cursor]
        if index == 0xFF:
            return entries
        if cursor + 4 > len(exe):
            return None
        entries.append(tuple(exe[cursor : cursor + 4]))
        cursor += 4
    return None


def palette_effect_scope(row: dict) -> dict:
    palette_range = row.get("range") or {}
    start = palette_range.get("start")
    end = palette_range.get("end")
    width = end - start + 1 if isinstance(start, int) and isinstance(end, int) and end >= start else None
    if width is None:
        return {
            "class": "unknown-palette-scope",
            "paletteEntryCount": None,
            "note": "No bounded palette index range was available for this command.",
        }
    if width >= 128 or (start <= 0x10 and end >= 0xE0):
        return {
            "class": "broad-screen-palette-transition",
            "paletteEntryCount": width,
            "note": "Touches a broad palette range; treat as screen fade/transition evidence, not a localized map animation loop by itself.",
        }
    if width >= 48:
        return {
            "class": "medium-palette-transition",
            "paletteEntryCount": width,
            "note": "Touches a medium palette range; this can be an effect/fade and needs map-root binding before promotion.",
        }
    return {
        "class": "localized-palette-candidate",
        "paletteEntryCount": width,
        "note": "Touches a relatively small palette range; still needs map-root execution binding before promotion.",
    }


def dword_references(exe: bytes, va: int, limit: int = 12) -> list[dict]:
    needle = struct.pack("<I", va)
    refs = []
    start = 0
    while True:
        offset = exe.find(needle, start)
        if offset < 0:
            break
        ref_va = offset_to_va(offset)
        if ref_va is not None:
            section = next((name for name, vma, size, section_offset in PE_SECTIONS if section_offset <= offset < section_offset + size), "?")
            refs.append({"va": f"0x{ref_va:08x}", "section": section})
            if len(refs) >= limit:
                break
        start = offset + 1
    return refs


def command_summary(exe: bytes, offset: int, length: int) -> str:
    opcode = exe[offset]
    if opcode in (0x38, 0x39):
        mode = exe[offset + 1] if offset + 1 < len(exe) else 0
        if offset + 8 > len(exe):
            return f"0x{opcode:02x}/m{mode} truncated"
        if opcode == 0x38 and mode == 0:
            repeat = exe[offset + 2]
            ptr = struct.unpack_from("<I", exe, offset + 4)[0]
            return f"0x38/m0 list-step ptr=0x{ptr:08x} repeat={repeat}"
        if opcode == 0x38 and mode == 1:
            start = exe[offset + 2]
            count = exe[offset + 3]
            rgb = tuple(exe[offset + 4 : offset + 7])
            repeat = exe[offset + 7]
            return f"0x38/m1 range-step-to-rgb 0x{start:02x}+{count} rgb={rgb} repeat={repeat}"
        if opcode == 0x38 and mode == 2:
            start = exe[offset + 2]
            count = exe[offset + 3]
            repeat = exe[offset + 4]
            return f"0x38/m2 range-step-to-backup 0x{start:02x}+{count} repeat={repeat}"
        if opcode == 0x39 and mode == 0:
            ptr = struct.unpack_from("<I", exe, offset + 4)[0]
            return f"0x39/m0 list-set-current ptr=0x{ptr:08x}"
        if opcode == 0x39 and mode == 1:
            start = exe[offset + 2]
            count = exe[offset + 3]
            value = exe[offset + 4]
            return f"0x39/m1 range-set-current-gray 0x{start:02x}+{count} value={value}"
        if opcode == 0x39 and mode == 2:
            start = exe[offset + 2]
            count = exe[offset + 3]
            value = exe[offset + 4]
            return f"0x39/m2 range-set-backup-gray 0x{start:02x}+{count} value={value}"
        if opcode == 0x39 and mode == 3:
            ptr = struct.unpack_from("<I", exe, offset + 4)[0]
            return f"0x39/m3 list-set-backup ptr=0x{ptr:08x}"
    if opcode in (0x03, 0x06, 0x07, 0x13, 0x14, 0x15) and length >= 8 and offset + 8 <= len(exe):
        ptr = struct.unpack_from("<I", exe, offset + 4)[0]
        if va_to_offset(ptr) is not None:
            return f"0x{opcode:02x} -> 0x{ptr:08x}"
    return f"0x{opcode:02x} {exe[offset + 1 : offset + length].hex(' ')}".rstrip()


def parse_vm_stream(exe: bytes, start_offset: int, max_offset: int, max_commands: int = 48) -> list[dict]:
    commands = []
    cursor = start_offset
    while cursor < max_offset and len(commands) < max_commands:
        opcode = exe[cursor]
        length = VM_OPCODE_LENGTHS.get(opcode)
        if length is None or cursor + length > max_offset:
            break
        va = offset_to_va(cursor)
        commands.append(
            {
                "offset": cursor,
                "va": va,
                "vaHex": f"0x{va:08x}" if va is not None else None,
                "opcode": opcode,
                "opcodeHex": f"0x{opcode:02x}",
                "length": length,
                "summary": command_summary(exe, cursor, length),
            }
        )
        if opcode == 0x00:
            break
        cursor += length
    return commands


def palette_command_alignment(exe: bytes, command_offset: int) -> dict:
    data_start = va_to_offset(0x0043C000) or 0
    data_end = min(len(exe), data_start + 0x11DE00)
    lower = max(data_start, command_offset - 96)
    upper = min(command_offset + 160, data_end)
    candidates = []
    for start in range(lower, command_offset + 1):
        commands = parse_vm_stream(exe, start, upper)
        boundary_indexes = [index for index, command in enumerate(commands) if command["offset"] == command_offset]
        if not boundary_indexes:
            continue
        boundary_index = boundary_indexes[0]
        before = boundary_index
        after = max(0, len(commands) - boundary_index - 1)
        root_va = offset_to_va(start)
        refs = dword_references(exe, root_va, limit=8) if root_va is not None else []
        branch_targets = []
        command_offsets = {command["offset"] for command in commands}
        for command in commands:
            if command["opcode"] not in (0x03, 0x06, 0x07, 0x13, 0x14, 0x15) or command["offset"] + 8 > len(exe):
                continue
            ptr = struct.unpack_from("<I", exe, command["offset"] + 4)[0]
            target_offset = va_to_offset(ptr)
            if target_offset is None:
                continue
            branch_targets.append(
                {
                    "fromVa": command["vaHex"],
                    "targetVa": f"0x{ptr:08x}",
                    "insideParsedWindow": target_offset in command_offsets or lower <= target_offset < upper,
                }
            )
        score = len(commands) + before * 2 + after + len(refs) * 4 + sum(1 for row in branch_targets if row["insideParsedWindow"]) * 2
        if start % 4 == 0:
            score += 2
        candidates.append(
            {
                "rootOffset": start,
                "rootVa": root_va,
                "rootVaHex": f"0x{root_va:08x}" if root_va is not None else None,
                "commandIndex": boundary_index,
                "commandCount": len(commands),
                "commandsBefore": before,
                "commandsAfter": after,
                "directReferenceCount": len(refs),
                "directReferences": refs,
                "branchTargets": branch_targets[:10],
                "score": score,
                "preview": [command["summary"] for command in commands[:14]],
            }
        )
    if not candidates:
        return {
            "status": "unbound-static-candidate",
            "note": "No plausible local VM command stream alignment found around this byte pattern.",
        }
    candidates.sort(key=lambda row: (row["score"], row["commandCount"], row["commandsBefore"]), reverse=True)
    best = candidates[0]
    if best["commandCount"] >= 8 and best["commandsBefore"] >= 2 and best["commandsAfter"] >= 2:
        status = "vm-aligned-local-high"
    elif best["commandCount"] >= 4 and best["commandsAfter"] >= 1:
        status = "vm-aligned-local-medium"
    else:
        status = "vm-aligned-local-weak"
    return {
        "status": status,
        "bestRootVa": best["rootVaHex"],
        "commandIndex": best["commandIndex"],
        "commandCount": best["commandCount"],
        "commandsBefore": best["commandsBefore"],
        "commandsAfter": best["commandsAfter"],
        "directReferenceCount": best["directReferenceCount"],
        "directReferences": best["directReferences"],
        "branchTargets": best["branchTargets"],
        "score": best["score"],
        "preview": best["preview"],
        "alternateRootCount": max(0, len(candidates) - 1),
        "note": "Local alignment proof only. This shows the palette bytes fit the generic VM command stream shape; it does not yet bind the stream to a specific map root.",
    }


def palette_command_scan(indexes_of_interest: list[int], binding_context: dict | None = None) -> dict:
    if not EXE.exists():
        return {"status": "exe-missing", "commands": []}
    exe = EXE.read_bytes()
    if not indexes_of_interest:
        return {"status": "no-indexes", "commands": []}
    minimum = min(indexes_of_interest)
    maximum = max(indexes_of_interest)
    commands = []
    data_offset = va_to_offset(0x0043C000) or 0
    data_end = data_offset + 0x11DE00
    for offset in range(data_offset, min(len(exe) - 8, data_end)):
        opcode = exe[offset]
        if opcode not in (0x38, 0x39):
            continue
        mode = exe[offset + 1]
        va = offset_to_va(offset)
        if va is None:
            continue
        row: dict | None = None
        if opcode == 0x38 and mode == 0:
            ptr = struct.unpack_from("<I", exe, offset + 4)[0]
            entries = palette_list_entries(exe, ptr)
            if entries and len(entries) <= 100:
                indices = sorted({entry[0] for entry in entries})
                if any(minimum <= index <= maximum for index in indices):
                    row = {
                        "va": f"0x{va:08x}",
                        "opcode": "0x38",
                        "mode": mode,
                        "kind": "list-step-to-rgb",
                        "listPointer": f"0x{ptr:08x}",
                        "entryCount": len(entries),
                        "repeatCount": exe[offset + 2],
                        "range": {
                            "start": min(indices),
                            "end": max(indices),
                            "startHex": f"0x{min(indices):02x}",
                            "endHex": f"0x{max(indices):02x}",
                        },
                        "status": "list-overlaps-animated-palette-indices",
                    }
        elif opcode == 0x38 and mode in (1, 2):
            start = exe[offset + 2]
            count = exe[offset + 3]
            end = start + count - 1
            if count and end < 0x100 and start <= maximum and end >= minimum:
                row = {
                    "va": f"0x{va:08x}",
                    "opcode": "0x38",
                    "mode": mode,
                    "kind": "range-step-to-rgb" if mode == 1 else "range-step-to-backup",
                    "range": {"start": start, "end": end, "startHex": f"0x{start:02x}", "endHex": f"0x{end:02x}"},
                    "repeatCount": exe[offset + 7] if mode == 1 else exe[offset + 4],
                    "targetRgb": list(exe[offset + 4 : offset + 7]) if mode == 1 else None,
                    "payloadHex": exe[offset + 4 : offset + 8].hex(" "),
                    "status": "range-overlaps-animated-palette-indices",
                }
        elif opcode == 0x39 and mode in (1, 2):
            start = exe[offset + 2]
            count = exe[offset + 3]
            end = start + count - 1
            if count and end < 0x100 and start <= maximum and end >= minimum:
                row = {
                    "va": f"0x{va:08x}",
                    "opcode": "0x39",
                    "mode": mode,
                    "kind": "range-set-current-gray" if mode == 1 else "range-set-backup-gray",
                    "range": {"start": start, "end": end, "startHex": f"0x{start:02x}", "endHex": f"0x{end:02x}"},
                    "value": exe[offset + 4],
                    "payloadHex": exe[offset + 4 : offset + 8].hex(" "),
                    "status": "range-overlaps-animated-palette-indices",
                }
        elif opcode == 0x39 and mode in (0, 3):
            ptr = struct.unpack_from("<I", exe, offset + 4)[0]
            entries = palette_list_entries(exe, ptr)
            if entries and len(entries) <= 100:
                indices = sorted({entry[0] for entry in entries})
                if any(minimum <= index <= maximum for index in indices):
                    row = {
                        "va": f"0x{va:08x}",
                        "opcode": "0x39",
                        "mode": mode,
                        "kind": "list-set-current" if mode == 0 else "list-set-backup",
                        "listPointer": f"0x{ptr:08x}",
                        "entryCount": len(entries),
                        "range": {
                            "start": min(indices),
                            "end": max(indices),
                            "startHex": f"0x{min(indices):02x}",
                            "endHex": f"0x{max(indices):02x}",
                        },
                        "status": "list-overlaps-animated-palette-indices",
                    }
        if row:
            row["effectScope"] = palette_effect_scope(row)
            refs = dword_references(exe, va)
            row["directReferenceCount"] = len(refs)
            row["directReferences"] = refs
            row["scriptAlignment"] = palette_command_alignment(exe, offset)
            if binding_context:
                row["resourceBinding"] = palette_root_resource_binding(
                    (row.get("scriptAlignment") or {}).get("bestRootVa"),
                    binding_context,
                )
            commands.append(row)
    alignment_counts = Counter((command.get("scriptAlignment") or {}).get("status", "none") for command in commands)
    binding_counts = Counter((command.get("resourceBinding") or {}).get("status", "not-scanned") for command in commands)
    scope_counts = Counter((command.get("effectScope") or {}).get("class", "unknown") for command in commands)
    near_animated_broad_transition_count = sum(
        1
        for command in commands
        if (command.get("effectScope") or {}).get("class") == "broad-screen-palette-transition"
        and (command.get("resourceBinding") or {}).get("nearestAnimatedSceneRecords")
    )
    return {
        "status": "candidate-static-scan",
        "indexRange": {
            "start": minimum,
            "end": maximum,
            "startHex": f"0x{minimum:02x}",
            "endHex": f"0x{maximum:02x}",
        },
        "commandCount": len(commands),
        "alignmentCounts": dict(sorted(alignment_counts.items())),
        "resourceBindingCounts": dict(sorted(binding_counts.items())),
        "effectScopeCounts": dict(sorted(scope_counts.items())),
        "nearAnimatedBroadTransitionCount": near_animated_broad_transition_count,
        "commands": commands[:80],
        "note": "This is a broad static scan over .data for palette VM command forms. It proves matching command shapes exist, not that a specific map root executes them.",
    }


def component_source_block(component: dict, columns: int) -> dict | None:
    if not component.get("rows"):
        return None
    first_row = component["rows"][0]["tiles"]
    if not first_row or first_row[0] is None:
        return None
    base_tile = first_row[0]
    source_x = base_tile % columns
    source_y = base_tile // columns
    if source_x + component["width"] > columns:
        return None
    for y_offset, placement_row in enumerate(component["rows"]):
        for x_offset, tile in enumerate(placement_row["tiles"]):
            expected = base_tile + y_offset * columns + x_offset
            if tile != expected:
                return None
    return {
        "status": "rectangular-source-block",
        "sourceX": source_x,
        "sourceY": source_y,
        "width": component["width"],
        "height": component["height"],
        "baseTile": base_tile,
    }


def source_block_candidates(component: dict, columns: int, source_size: dict) -> list[dict]:
    block = component.get("sourceBlock")
    if not block:
        return []
    width = block["width"]
    height = block["height"]
    source_y = block["sourceY"]
    if width <= 0 or height <= 0 or source_y + height > source_size["rows"]:
        return []
    if source_size["columns"] % width == 0:
        source_x_values = range(0, source_size["columns"] - width + 1, width)
    else:
        source_x_values = range(0, source_size["columns"] - width + 1)
    return [
        {
            "sourceX": source_x,
            "sourceY": source_y,
            "width": width,
            "height": height,
            "baseTile": source_y * columns + source_x,
            "isPlacedBaseBlock": source_x == block["sourceX"],
        }
        for source_x in source_x_values
    ]


def summarize_frame_groups(cells: list[dict], columns: int) -> list[dict]:
    by_column: dict[int, Counter] = defaultdict(Counter)
    for cell in cells:
        tile = cell["layer0"]
        by_column[tile % columns][tile] += 1

    groups = []
    for column, counts in sorted(by_column.items()):
        tiles = sorted(counts)
        has_alternates = len(tiles) >= 2 and all((tile - tiles[0]) % columns == 0 for tile in tiles)
        row_offsets = [(tile - tiles[0]) // columns for tile in tiles]
        if has_alternates:
            confidence = "same-column-static-source-cluster"
            note = "same source-column layer0 tiles inside 0x40 cells; this is static tile coverage, not an animation frame list"
        else:
            confidence = "single-layer0-tile"
            note = "0x40 cell source tile; 0x40 marks redraw coverage; final motion source is not proven here"
        groups.append(
            {
                "column": column,
                "tiles": tiles,
                "counts": [counts[tile] for tile in tiles],
                "rowOffsetsFromFirstTile": row_offsets,
                "confidence": confidence,
                "note": note,
            }
        )
    return groups


def build_review() -> dict:
    maps = load_maps()
    rows = []
    for name, map_info in sorted(maps.items()):
        cells = animated_cells(map_info)
        if not cells:
            continue
        columns = map_info.get("tilesetColumns") or 40
        source_tileset = (map_info.get("layerTilesets") or [map_info.get("tileset")])[0]
        source_size = tileset_size(source_tileset)
        placements = placement_components(cells)
        for component in placements:
            component["sourceBlock"] = component_source_block(component, columns)
            component["sourceBlockCandidates"] = source_block_candidates(component, columns, source_size)
        rows.append(
            {
                "map": name,
                "width": map_info["width"],
                "height": map_info["height"],
                "tileSize": map_info.get("tileSize", 16),
                "sourceTileset": source_tileset,
                "sourceTilesetSize": source_size,
                "layerTilesets": map_info.get("layerTilesets") or [],
                "tilesetColumns": columns,
                "animatedCellCount": len(cells),
                "componentCount": len(connected_components(cells)),
                "components": connected_components(cells),
                "placementComponents": placements,
                "animatedCells": cells,
                "uniqueLayer0TileCount": len({cell["layer0"] for cell in cells}),
                "uniqueLayer0Tiles": sorted({cell["layer0"] for cell in cells}),
                "frameGroups": summarize_frame_groups(cells, columns),
                "paletteUsage": palette_usage(source_tileset, sorted({cell["layer0"] for cell in cells}), columns),
                "evidence": {
                    "flagSource": "layer1 bit 0x40",
                    "tileSource": "layer0 tile id at each flagged cell",
                    "sourceGrouping": "same tile column in a 40-column map tileset, separated by +40 rows",
                    "paletteSource": "non-zero palette indices used by the flagged layer0 source tiles",
                    "timing": "not detected here",
                },
            }
        )
    all_palette_indices = sorted({index for row in rows for index in row["paletteUsage"]["indices"]})
    binding_context = build_palette_binding_context(rows)
    palette_scan = palette_command_scan(all_palette_indices, binding_context)
    return {
        "version": 1,
        "kind": "hwanse-map-layer1-0x40-animation-tile-review",
        "animatedFlagHex": "0x40",
        "exeEvidence": {
            "status": "animation-redraw-consumer-confirmed",
            "summary": (
                "EXE consumers for layer1 0x40, dirty redraw, and palette mutation were found. "
                "0x40 marks cells that must be redrawn because their pixels can change without "
                "changing the layer0 tile id at the redraw consumer. Palette-only does not explain "
                "the observed spatial motion, so the per-map visible motion stream remains unproven."
            ),
            "addresses": [
                {
                    "va": "0x00425494",
                    "kind": "layer1-grid-read",
                    "evidence": "reads WORD [0x0058d7d0 + index*2]",
                    "meaning": "viewport scan over layer1 collision/flag grid",
                },
                {
                    "va": "0x0042549c",
                    "kind": "layer1-0x40-consumer",
                    "evidence": "test cl, 0x40",
                    "meaning": "flags 0x40 cells into the redraw/dirty byte grid; does not choose alternate frame tiles here",
                },
                {
                    "va": "0x0040234c",
                    "kind": "vm-dispatch",
                    "evidence": "call DWORD PTR [opcode*4 + 0x00440538]",
                    "meaning": "command stream dispatch table used by the game/event VM",
                },
                {
                    "va": "0x00407686",
                    "kind": "tile-write-handler",
                    "evidence": "handler table entry at 0x00440698; writes WORD to 0x00595af0 or 0x0058d7d0",
                    "meaning": "command layout uses layer selector, tile id, x, y; then invalidates the touched tile",
                },
                {
                    "va": "0x00408d10",
                    "kind": "active-object-relative-tile-write",
                    "evidence": "iterates active object list and writes tile id at object-relative map coordinates",
                    "meaning": "variant for object/NPC/event-relative map-grid changes",
                },
                {
                    "va": "0x00409f23",
                    "kind": "active-object-list-tile-write",
                    "evidence": "iterates linked active object list and writes tile id at object-relative map coordinates",
                    "meaning": "another object-list variant for map-grid changes",
                },
                {
                    "va": "0x004255eb",
                    "kind": "tile-invalidate",
                    "evidence": "called after tile-grid mutation with x/y",
                    "meaning": "marks one touched map tile for redraw",
                },
                {
                    "va": "0x004060db",
                    "kind": "palette-vm-handler",
                    "evidence": "dispatch table 0x00440538 opcode 0x38",
                    "meaning": "8-byte palette step command: list-step to RGB, range-step to RGB, or range-step to backup palette",
                },
                {
                    "va": "0x00406206",
                    "kind": "palette-vm-handler",
                    "evidence": "dispatch table 0x00440538 opcode 0x39",
                    "meaning": "8-byte palette set command: set current palette list/range or seed backup palette list/range",
                },
                {
                    "va": "0x00401036",
                    "kind": "palette-list-setter",
                    "evidence": "reads entries until 0xff terminator and writes RGB nibbles into 0x004676e8 palette buffer",
                    "meaning": "sets palette entries immediately and marks palette dirty at 0x00559d98",
                },
                {
                    "va": "0x004010d2",
                    "kind": "palette-list-stepper",
                    "evidence": "uses 0x0040118e to increment/decrement RGB bytes toward target values",
                    "meaning": "gradual palette transition/fade helper",
                },
                {
                    "va": "0x00401518",
                    "kind": "palette-range-stepper",
                    "evidence": "called by opcode 0x38 mode 1; walks a palette range toward stream RGB bytes",
                    "meaning": "range fade helper for current palette entries",
                },
                {
                    "va": "0x0040146a",
                    "kind": "palette-range-step-to-backup",
                    "evidence": "called by opcode 0x38 mode 2; walks a palette range toward backup bytes at 0x00559da1..",
                    "meaning": "range restore/fade-to-backup helper",
                },
                {
                    "va": "0x004015b9",
                    "kind": "palette-current-range-setter",
                    "evidence": "called by opcode 0x39 mode 1; writes one gray/RGB byte to current palette range",
                    "meaning": "direct current palette range fill helper",
                },
                {
                    "va": "0x00401632",
                    "kind": "palette-backup-range-setter",
                    "evidence": "called by opcode 0x39 mode 2; writes one gray/RGB byte to backup palette range",
                    "meaning": "backup palette range fill helper",
                },
                {
                    "va": "0x00401699",
                    "kind": "palette-backup-list-setter",
                    "evidence": "called by opcode 0x39 mode 3; reads 0xff-terminated palette list",
                    "meaning": "backup palette list seed helper",
                },
                {
                    "va": "0x00401000",
                    "kind": "deferred-palette-apply",
                    "evidence": "if 0x00559d98 is set, calls 0x00416677 with start 0 and count 0x100 then clears the dirty flag",
                    "meaning": "applies changed palette buffer to DirectDraw before presentation/update",
                },
                {
                    "va": "0x00416677",
                    "kind": "directdraw-palette-setentries",
                    "evidence": "calls IDirectDrawPalette::SetEntries through vtable offset 0x18",
                    "meaning": "pushes a palette range from 0x004676e8 into the active DirectDraw palette",
                },
            ],
            "confirmed": [
                "layer1 bit 0x40 is read by EXE during viewport redraw preparation.",
                "The 0x40 consumer marks the same map cells dirty every redraw pass; it does not choose alternate tile ids.",
                "The map draw path uses the current layer0 tile id directly, while 0x40 only affects redraw invalidation.",
                "The EXE has palette VM handlers and a deferred DirectDrawPalette SetEntries path.",
                "Generic VM palette opcodes are now length/mode grounded: 0x38 and 0x39 are 8-byte commands, while 0x3a and 0x3b are 4-byte commands.",
                "Opcode 0x38 is a palette step family: mode 0 steps a list toward target RGB, mode 1 steps a range toward stream RGB, and mode 2 steps a range toward backup palette bytes.",
                "Opcode 0x39 is a palette set family: mode 0 sets current palette list, mode 1 fills current palette range, mode 2 fills backup palette range, and mode 3 sets backup palette list.",
                "The non-transparent palette indices used by the current 0x40 layer0 source tiles have been mapped from CNS pixels.",
                "A stricter local VM alignment scan promotes a subset of raw 0x38/0x39 byte-pattern hits to VM-shaped palette script candidates.",
                "VM-aligned palette script roots are now cross-checked against scene/resource groups and nearby map resource records.",
                "The animated fire/waterfall/water regions sit on ordinary layer0 tile ids; 0x40 dirty redraw is grounded, but the visible motion stream is still unbound.",
            ],
            "unresolved": [
                "The exact executed palette command for each map animation has not been bound to a specific map root yet.",
                "The palette-cycle timing/order is not fully tied to a specific map root yet.",
                "Resource binding is a correlation layer only. A contained or nearby resource group does not by itself prove runtime execution for that map.",
                "Broad static palette-command scans contain false positives unless tied to an executed VM command stream; local alignment reduces but does not eliminate this risk.",
                "Generic VM tile-write handlers still exist, but current 0x40 map animation evidence does not require them.",
            ],
        },
        "summary": {
            "mapCount": len(maps),
            "animatedMapCount": len(rows),
            "animatedCellCount": sum(row["animatedCellCount"] for row in rows),
            "componentCount": sum(row["componentCount"] for row in rows),
            "sameColumnStaticSourceGroupCount": sum(
                1
                for row in rows
                for group in row["frameGroups"]
                if group["confidence"] == "same-column-static-source-cluster"
            ),
            "rectangularSourceBlockComponentCount": sum(
                1
                for row in rows
                for component in row["placementComponents"]
                if component.get("sourceBlock")
            ),
            "animatedPaletteIndexCount": len(all_palette_indices),
            "animatedPaletteIndexRanges": palette_ranges(all_palette_indices),
            "paletteCommandCandidateCount": palette_scan.get("commandCount", 0),
            "paletteCommandAlignmentCounts": palette_scan.get("alignmentCounts", {}),
            "paletteCommandHighAlignmentCount": palette_scan.get("alignmentCounts", {}).get("vm-aligned-local-high", 0),
            "paletteCommandResourceBindingCounts": palette_scan.get("resourceBindingCounts", {}),
            "paletteCommandAnimatedBindingCount": palette_scan.get("resourceBindingCounts", {}).get("resource-group-contains-animated-map", 0),
            "paletteCommandEffectScopeCounts": palette_scan.get("effectScopeCounts", {}),
            "paletteCommandNearAnimatedBroadTransitionCount": palette_scan.get("nearAnimatedBroadTransitionCount", 0),
        },
        "paletteCommandScan": palette_scan,
        "notes": [
            "This review is CNS-grounded: layer1 0x40 identifies animated cells and layer0 identifies the visible source tile used by those cells.",
            "EXE analysis found that layer1 0x40 feeds redraw/dirty marking and that palette changes are deferred into DirectDrawPalette::SetEntries.",
            "Palette index usage is CNS-grounded. Palette command candidates are broad EXE static-scan evidence and must not be promoted to per-map execution proof yet.",
            "Palette command local alignment now checks whether the bytes sit on generic VM command boundaries near 0x00440538 command lengths. High alignment is stronger than a raw byte-pattern hit, but still not a per-map execution binding.",
            "Resource binding now shows whether the selected VM root is inside a selector-root resource group or only near scene records. This is a prioritization aid, not final execution proof.",
            "Same-column tile groups are static source coverage inside flagged cells, not complete animation frames by themselves.",
            "The old alternate-source-rect model is now demoted for these map animations; keep tile-write VM evidence separate for doors/objects/event changes.",
            "This does not yet prove the exact palette cycle range, timing, or per-map activation script.",
        ],
        "maps": rows,
    }


def esc(value) -> str:
    return html.escape(str(value))


def render_group_previews(row: dict) -> str:
    parts = []
    for group in row["frameGroups"]:
        confidence = group["confidence"]
        tiles = group["tiles"]
        canvas_list = []
        for tile, count in zip(tiles, group["counts"], strict=True):
            canvas_list.append(
                "<span class=\"tile-preview-wrap\">"
                f"<canvas class=\"tile-preview\" width=\"48\" height=\"48\" "
                f"data-tileset=\"{esc(row['sourceTileset'])}\" "
                f"data-columns=\"{row['tilesetColumns']}\" "
                f"data-tile=\"{tile}\"></canvas>"
                f"<code>#{tile}</code><span class=\"count\">x{count}</span>"
                "</span>"
            )
        parts.append(
            "<div class=\"frame-group\">"
            f"<div><span class=\"tag {'good' if confidence == 'same-column-static-source-cluster' else ''}\">{esc(confidence)}</span> "
            f"<span class=\"muted\">col {group['column']}</span></div>"
            f"<div class=\"tile-preview-row\">{''.join(canvas_list)}</div>"
            f"<p>{esc(group['note'])}</p>"
            "</div>"
        )
    return "".join(parts)


def render_components(row: dict) -> str:
    return "<br>".join(
        f"<code>({component['x0']},{component['y0']})-({component['x1']},{component['y1']})</code> "
        f"{component['width']}x{component['height']} · {component['cellCount']} cells"
        for component in row["components"]
    )


def render_placement_grid(row: dict) -> str:
    sections = []
    for index, component in enumerate(row["placementComponents"], start=1):
        cells = []
        for placement_row in component["rows"]:
            y = placement_row["y"]
            for offset, tile in enumerate(placement_row["tiles"]):
                x = component["x0"] + offset
                if tile is None:
                    cells.append('<span class="placement-cell missing">.</span>')
                    continue
                hue = ((tile % row["tilesetColumns"]) * 31) % 360
                cells.append(
                    f"<span class=\"placement-cell\" style=\"--tile-hue:{hue}\" "
                    f"title=\"x={x}, y={y}, layer0 tile #{tile}\">{tile}</span>"
                )
        sections.append(
            "<section class=\"placement-component\">"
            f"<h3>region {index}: <code>({component['x0']},{component['y0']})-({component['x1']},{component['y1']})</code></h3>"
            f"<div class=\"placement-grid\" style=\"grid-template-columns: repeat({component['width']}, 34px);\">{''.join(cells)}</div>"
            "</section>"
        )
    return (
        "<details class=\"placement-details\">"
        "<summary>0x40 placement grid: flagged map cells and current layer0 tile ids</summary>"
        f"{''.join(sections)}"
        "</details>"
    )


def render_source_block_candidates(row: dict) -> str:
    sections = []
    for index, component in enumerate(row["placementComponents"], start=1):
        block = component.get("sourceBlock")
        if not block:
            sections.append(
                "<section class=\"placement-component\">"
                f"<h3>region {index}</h3>"
                "<p class=\"muted\">이 영역은 소스 타일셋의 단일 직사각형 블록으로 떨어지지 않는다. 폭포처럼 넓은 영역은 여러 정적 layer0 tile을 0x40 redraw 대상으로 묶는다.</p>"
                "</section>"
            )
            continue
        sections.append(
            "<section class=\"placement-component\">"
            f"<h3>region {index}: static source footprint <code>({block['sourceX']},{block['sourceY']}) {block['width']}x{block['height']}</code></h3>"
            "<span class=\"source-block-wrap\">"
            f"<canvas class=\"source-block-canvas placed\" "
            f"data-tileset=\"{esc(row['sourceTileset'])}\" "
            f"data-block-x=\"{block['sourceX']}\" "
            f"data-block-y=\"{block['sourceY']}\" "
            f"data-block-w=\"{block['width']}\" "
            f"data-block-h=\"{block['height']}\"></canvas>"
            f"<code>#{block['baseTile']}</code><span class=\"count\">current layer0 block</span>"
            "</span>"
            "<p class=\"muted\">현재 맵에 놓인 정적 layer0 블록이다. 대체 프레임 rect 후보가 아니라 animation redraw 대상 footprint로 본다.</p>"
            "</section>"
        )
    return (
        "<details class=\"placement-details\">"
        "<summary>source footprint: current layer0 block or grouped tiles</summary>"
        f"{''.join(sections)}"
        "</details>"
    )


def render_ranges(ranges: list[dict]) -> str:
    return ", ".join(
        row["startHex"] if row["start"] == row["end"] else f"{row['startHex']}-{row['endHex']}"
        for row in ranges
    )


def render_palette_usage(row: dict) -> str:
    usage = row["paletteUsage"]
    top = ", ".join(f"{item['indexHex']}({item['pixels']})" for item in usage["topIndices"][:10])
    return (
        "<details class=\"placement-details\">"
        "<summary>palette indices used by 0x40 source tiles</summary>"
        f"<p><strong>{usage['indexCount']}</strong> non-transparent indices · "
        f"<code>{esc(render_ranges(usage['ranges']) or '-')}</code></p>"
        f"<p class=\"muted\">top pixel counts: {esc(top or '-')}</p>"
        "</details>"
    )


def compact_list(values: list[str], limit: int = 4) -> str:
    if not values:
        return "-"
    shown = values[:limit]
    suffix = f" +{len(values) - limit}" if len(values) > limit else ""
    return ", ".join(shown) + suffix


def render_resource_binding(binding: dict) -> str:
    status = binding.get("status", "-")
    tag = "good" if status == "resource-group-contains-animated-map" else ""
    lines = [
        f"<span class=\"tag {tag}\">{esc(status)}</span>",
        f"<br><code>{esc(binding.get('rootVaHex', '-'))}</code>",
    ]
    containing = binding.get("containingGroups") or []
    if containing:
        group = containing[0]
        animated = compact_list(group.get("animatedMaps") or group.get("animatedTilesets") or [])
        maps = compact_list(group.get("maps") or [])
        lines.append(
            f"<br><span class=\"muted\">contains {esc(group.get('id', '-'))} "
            f"{esc(group.get('selector', ''))} · animated: {esc(animated)} · maps: {esc(maps)}</span>"
        )
    nearest_records = binding.get("nearestSceneRecords") or []
    if nearest_records:
        record_parts = []
        for record in nearest_records[:2]:
            marker = "*" if record.get("animatedMap") or record.get("animatedTilesets") else ""
            record_parts.append(f"{record.get('map')}{marker}@{record.get('recordVaHex')} +{record.get('distanceBytes')}")
        lines.append(f"<br><span class=\"muted\">near scene: {esc('; '.join(record_parts))}</span>")
    nearest_groups = binding.get("nearestGroups") or []
    if nearest_groups:
        group_parts = []
        for group in nearest_groups[:2]:
            marker = "*" if group.get("animatedMaps") or group.get("animatedTilesets") else ""
            group_parts.append(f"{group.get('id')}{marker} +{group.get('distanceBytes')}")
        lines.append(f"<br><span class=\"muted\">near group: {esc('; '.join(group_parts))}</span>")
    lines.append(f"<br><span class=\"muted\">{esc(binding.get('note', ''))}</span>")
    return "".join(lines)


def render_palette_command_scan(review: dict) -> str:
    scan = review.get("paletteCommandScan") or {}
    rows = []
    for command in scan.get("commands", [])[:40]:
        range_row = command.get("range") or {}
        alignment = command.get("scriptAlignment") or {}
        alignment_status = alignment.get("status", "-")
        alignment_tag = "good" if alignment_status == "vm-aligned-local-high" else ""
        preview = " / ".join(alignment.get("preview", [])[:6])
        refs = ", ".join(f"{ref.get('va')}:{ref.get('section')}" for ref in alignment.get("directReferences", [])[:4])
        binding = command.get("resourceBinding") or {}
        scope = command.get("effectScope") or {}
        rows.append(
            "<tr>"
            f"<td><code>{esc(command.get('va', '-'))}</code></td>"
            f"<td>{esc(command.get('opcode', '-'))} / mode {esc(command.get('mode', '-'))}</td>"
            f"<td>{esc(command.get('kind', '-'))}</td>"
            f"<td><code>{esc(range_row.get('startHex', '-'))}-{esc(range_row.get('endHex', '-'))}</code></td>"
            f"<td><span class=\"tag\">{esc(scope.get('class', '-'))}</span><br>"
            f"<span class=\"muted\">entries: {esc(scope.get('paletteEntryCount', '-'))}</span><br>"
            f"<span class=\"muted\">{esc(scope.get('note', ''))}</span></td>"
            f"<td>{esc(command.get('listPointer') or command.get('payloadHex') or '-')}</td>"
            f"<td><span class=\"tag {alignment_tag}\">{esc(alignment_status)}</span><br>"
            f"<code>{esc(alignment.get('bestRootVa', '-'))}</code> "
            f"<span class=\"muted\">idx {esc(alignment.get('commandIndex', '-'))} / cmds {esc(alignment.get('commandCount', '-'))}</span>"
            f"<br><span class=\"muted\">refs: {esc(refs or '-')}</span>"
            f"<br><span class=\"muted\">{esc(preview or alignment.get('note', '-'))}</span></td>"
            f"<td>{render_resource_binding(binding)}</td>"
            "</tr>"
        )
    if not rows:
        rows.append('<tr><td colspan="8" class="muted">No overlapping palette command candidates.</td></tr>')
    index_range = scan.get("indexRange") or {}
    alignment_counts = scan.get("alignmentCounts") or {}
    binding_counts = scan.get("resourceBindingCounts") or {}
    scope_counts = scan.get("effectScopeCounts") or {}
    alignment_summary = ", ".join(f"{key}: {value}" for key, value in alignment_counts.items()) or "-"
    binding_summary = ", ".join(f"{key}: {value}" for key, value in binding_counts.items()) or "-"
    scope_summary = ", ".join(f"{key}: {value}" for key, value in scope_counts.items()) or "-"
    return f"""
  <section class="panel">
    <h2>Palette command static scan</h2>
    <p>animated source tile palette index range: <code>{esc(index_range.get('startHex', '-'))}-{esc(index_range.get('endHex', '-'))}</code> · candidates {esc(scan.get('commandCount', 0))}</p>
    <p>local VM alignment: <code>{esc(alignment_summary)}</code></p>
    <p>resource binding: <code>{esc(binding_summary)}</code></p>
    <p>effect scope: <code>{esc(scope_summary)}</code> · near animated broad transitions: <code>{esc(scan.get('nearAnimatedBroadTransitionCount', 0))}</code></p>
    <p class="muted">{esc(scan.get('note', ''))}</p>
    <details class="placement-details">
      <summary>candidate palette commands touching animated tile indices</summary>
      <div class="table-wrap">
        <table>
          <thead><tr><th>VA</th><th>opcode/mode</th><th>kind</th><th>range</th><th>effect scope</th><th>payload/list</th><th>local VM alignment</th><th>resource binding</th></tr></thead>
          <tbody>{''.join(rows)}</tbody>
        </table>
      </div>
    </details>
  </section>
"""


def render_exe_evidence(review: dict) -> str:
    evidence = review["exeEvidence"]
    address_rows = []
    for row in evidence["addresses"]:
        address_rows.append(
            "<tr>"
            f"<td><code>{esc(row['va'])}</code></td>"
            f"<td>{esc(row['kind'])}</td>"
            f"<td>{esc(row['evidence'])}</td>"
            f"<td>{esc(row['meaning'])}</td>"
            "</tr>"
        )
    confirmed = "".join(f"<li>{esc(item)}</li>" for item in evidence["confirmed"])
    unresolved = "".join(f"<li>{esc(item)}</li>" for item in evidence["unresolved"])
    return f"""
  <section class="panel">
    <h2>EXE consumer evidence</h2>
    <p>{esc(evidence['summary'])}</p>
    <div class="two-cols">
      <div>
        <h3>confirmed</h3>
        <ul>{confirmed}</ul>
      </div>
      <div>
        <h3>unresolved</h3>
        <ul>{unresolved}</ul>
      </div>
    </div>
    <details class="placement-details" open>
      <summary>address evidence</summary>
      <div class="table-wrap">
        <table>
          <thead><tr><th>VA</th><th>kind</th><th>evidence</th><th>meaning</th></tr></thead>
          <tbody>{''.join(address_rows)}</tbody>
        </table>
      </div>
    </details>
  </section>
"""


def write_html(review: dict) -> None:
    summary = review["summary"]
    rows_html = []
    for row in review["maps"]:
        rows_html.append(
            "<tr>"
            f"<td><a href=\"../web/map_review.html?map={esc(row['map'])}&amp;visualFilter=animation\" target=\"_blank\"><code>{esc(row['map'])}</code></a></td>"
            f"<td><code>{esc(row['sourceTileset'])}</code><br><span class=\"muted\">{esc(', '.join(row['layerTilesets']))}</span></td>"
            f"<td>{row['animatedCellCount']} cells<br>{row['componentCount']} components<br>{row['uniqueLayer0TileCount']} layer0 tiles</td>"
            f"<td>{render_components(row)}</td>"
            f"<td>{render_group_previews(row)}{render_palette_usage(row)}{render_placement_grid(row)}{render_source_block_candidates(row)}</td>"
            "</tr>"
        )

    doc = f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" href="../favicon.ico">
  <title>맵 애니메이션 0x40 redraw 리뷰</title>
  <style>
    body {{ margin: 0; padding: 18px; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f6f7f9; color: #17202a; }}
    h1 {{ margin: 0 0 6px; font-size: 24px; }}
    p {{ margin: 6px 0; }}
    a {{ color: #185abc; text-decoration: none; }}
    a:hover {{ text-decoration: underline; }}
    .panel {{ background: #fff; border: 1px solid #d8dee6; border-radius: 8px; padding: 14px; margin: 14px 0; }}
    .metrics {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; }}
    .metric {{ border: 1px solid #d8dee6; border-radius: 6px; background: #f8fafc; padding: 10px; }}
    .metric strong {{ display: block; font-size: 20px; }}
    .muted, .count {{ color: #607080; font-size: 12px; }}
    table {{ width: 100%; border-collapse: collapse; background: #fff; }}
    th, td {{ border: 1px solid #d8dee6; padding: 8px; vertical-align: top; text-align: left; }}
    th {{ background: #eef2f6; }}
    code {{ background: #f1f5f9; border-radius: 4px; padding: 1px 4px; }}
    .tag {{ display: inline-block; border: 1px solid #d8dee6; border-radius: 999px; padding: 1px 7px; font-size: 12px; color: #607080; background: #f8fafc; }}
    .tag.good {{ border-color: #99d3ca; color: #0f766e; background: #ecfdf5; }}
    .frame-group {{ margin-bottom: 10px; }}
    .frame-group p {{ color: #607080; font-size: 12px; margin: 3px 0 0; }}
    .tile-preview-row {{ display: flex; flex-wrap: wrap; gap: 6px; margin-top: 5px; }}
    .tile-preview-wrap {{ display: inline-grid; grid-template-columns: auto; gap: 2px; align-items: center; justify-items: center; }}
    .tile-preview {{ width: 48px; height: 48px; image-rendering: pixelated; background: repeating-conic-gradient(#ddd 0 25%, #f8fafc 0 50%) 0 / 12px 12px; border: 1px solid #cbd5e1; }}
    .placement-details {{ margin-top: 10px; border-top: 1px solid #d8dee6; padding-top: 8px; }}
    .placement-details summary {{ cursor: pointer; font-weight: 700; color: #185abc; }}
    .placement-component h3 {{ margin: 10px 0 6px; font-size: 13px; }}
    .placement-grid {{ display: grid; gap: 1px; width: max-content; max-width: 100%; overflow: auto; padding: 6px; background: #e2e8f0; border: 1px solid #cbd5e1; }}
    .placement-cell {{ display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 20px; font: 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; color: #17202a; background: hsl(var(--tile-hue) 85% 88%); border: 1px solid hsla(var(--tile-hue), 60%, 48%, 0.45); }}
    .placement-cell.missing {{ background: #f8fafc; color: #94a3b8; border-color: #e2e8f0; }}
    .source-block-row {{ display: flex; flex-wrap: wrap; gap: 8px; }}
    .source-block-wrap {{ display: inline-grid; gap: 3px; justify-items: center; }}
    .source-block-canvas {{ image-rendering: pixelated; background: repeating-conic-gradient(#ddd 0 25%, #f8fafc 0 50%) 0 / 12px 12px; border: 1px solid #cbd5e1; }}
    .source-block-canvas.placed {{ border: 3px solid #0f766e; }}
    .table-wrap {{ overflow-x: auto; }}
    h2 {{ margin: 0 0 8px; font-size: 18px; }}
    h3 {{ margin: 10px 0 6px; font-size: 13px; }}
    ul {{ margin: 6px 0 0; padding-left: 20px; }}
    .two-cols {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 14px; }}
  </style>
</head>
<body>
  <h1>맵 애니메이션 0x40 redraw 리뷰</h1>
  <p>layer1 <code>0x40</code> 플래그 셀에서 실제로 표시되는 layer0 타일을 뽑고, EXE의 redraw/palette consumer 근거와 함께 정리했다.</p>
  <p class="muted">현재 결론: layer1 0x40 dirty redraw 대상은 확정이지만, 불꽃/폭포/물결의 실제 움직임 stream은 아직 미확정이다. palette-only는 시각적으로 맞지 않는다.</p>
  <section class="panel metrics">
    <div class="metric"><strong>{summary['animatedMapCount']}</strong><span class="muted">0x40 flag maps</span></div>
    <div class="metric"><strong>{summary['animatedCellCount']}</strong><span class="muted">animated cells</span></div>
    <div class="metric"><strong>{summary['componentCount']}</strong><span class="muted">connected regions</span></div>
    <div class="metric"><strong>{summary['sameColumnStaticSourceGroupCount']}</strong><span class="muted">same-column source groups</span></div>
    <div class="metric"><strong>{summary['rectangularSourceBlockComponentCount']}</strong><span class="muted">rectangular source block regions</span></div>
    <div class="metric"><strong>{summary['animatedPaletteIndexCount']}</strong><span class="muted">animated palette indices</span></div>
    <div class="metric"><strong>{summary['paletteCommandCandidateCount']}</strong><span class="muted">palette command candidates</span></div>
    <div class="metric"><strong>{summary['paletteCommandHighAlignmentCount']}</strong><span class="muted">high VM-aligned candidates</span></div>
    <div class="metric"><strong>{summary['paletteCommandAnimatedBindingCount']}</strong><span class="muted">animated resource bindings</span></div>
  </section>
{render_exe_evidence(review)}
{render_palette_command_scan(review)}
  <section class="panel table-wrap">
    <table>
      <thead><tr><th>map</th><th>source tileset</th><th>coverage</th><th>flagged region</th><th>layer0 source / animation redraw footprint</th></tr></thead>
      <tbody>{''.join(rows_html)}</tbody>
    </table>
  </section>
  <script src="../web/engine/cns/renderer.js"></script>
  <script>
    async function drawTilePreview(canvas) {{
      const tileset = canvas.dataset.tileset;
      const tile = Number(canvas.dataset.tile);
      const columns = Number(canvas.dataset.columns || 40);
      const source = await window.HWANSE_CNS_RENDERER.loadImageCanvas(tileset);
      const scale = 3;
      canvas.width = 16 * scale;
      canvas.height = 16 * scale;
      const ctx = canvas.getContext("2d");
      ctx.imageSmoothingEnabled = false;
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      const sx = (tile % columns) * 16;
      const sy = Math.floor(tile / columns) * 16;
      ctx.drawImage(source, sx, sy, 16, 16, 0, 0, 16 * scale, 16 * scale);
    }}
    async function drawSourceBlockPreview(canvas) {{
      const tileset = canvas.dataset.tileset;
      const source = await window.HWANSE_CNS_RENDERER.loadImageCanvas(tileset);
      const tileSize = 16;
      const scale = 2;
      const blockX = Number(canvas.dataset.blockX);
      const blockY = Number(canvas.dataset.blockY);
      const blockW = Number(canvas.dataset.blockW);
      const blockH = Number(canvas.dataset.blockH);
      canvas.width = blockW * tileSize * scale;
      canvas.height = blockH * tileSize * scale;
      const ctx = canvas.getContext("2d");
      ctx.imageSmoothingEnabled = false;
      ctx.clearRect(0, 0, canvas.width, canvas.height);
      ctx.drawImage(
        source,
        blockX * tileSize,
        blockY * tileSize,
        blockW * tileSize,
        blockH * tileSize,
        0,
        0,
        canvas.width,
        canvas.height,
      );
    }}
    for (const canvas of document.querySelectorAll(".tile-preview")) {{
      drawTilePreview(canvas).catch((error) => {{
        const ctx = canvas.getContext("2d");
        ctx.fillStyle = "#fee2e2";
        ctx.fillRect(0, 0, canvas.width, canvas.height);
        ctx.fillStyle = "#991b1b";
        ctx.font = "10px sans-serif";
        ctx.fillText("load fail", 2, 14);
        console.error(error);
      }});
    }}
    for (const canvas of document.querySelectorAll(".source-block-canvas")) {{
      drawSourceBlockPreview(canvas).catch((error) => {{
        const ctx = canvas.getContext("2d");
        ctx.fillStyle = "#fee2e2";
        ctx.fillRect(0, 0, canvas.width || 96, canvas.height || 72);
        ctx.fillStyle = "#991b1b";
        ctx.font = "10px sans-serif";
        ctx.fillText("load fail", 2, 14);
        console.error(error);
      }});
    }}
  </script>
</body>
</html>
"""
    (OUT / "map_animation_tile_review.html").write_text(doc, encoding="utf-8")


def main() -> None:
    review = build_review()
    (OUT / "map_animation_tile_review.json").write_text(json.dumps(review, ensure_ascii=False, indent=2), encoding="utf-8")
    write_html(review)
    print(
        f"wrote map animation tile review: {review['summary']['animatedMapCount']} maps, "
        f"{review['summary']['animatedCellCount']} cells"
    )


if __name__ == "__main__":
    main()
