#!/usr/bin/env python3
"""Build a focused EXE evidence review for map animation.

The map renderer has two different kinds of evidence that are easy to mix up:

* visual source-tile shift previews used in ``web/map_review.html``; and
* EXE-grounded dirty-redraw paths and generic palette VM candidates.

This report keeps them separated.  It also records a negative scan for simple
tile-id replacement tables so future work does not keep re-testing the same
assumption.
"""
from __future__ import annotations

import html
import json
import struct
import sys
from collections import Counter
from pathlib import Path
from typing import Any

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

from build_map_animation_tile_review import (  # noqa: E402
    build_palette_binding_context,
    palette_command_alignment,
    palette_root_resource_binding,
)
from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset  # noqa: E402


ROOT = Path(__file__).resolve().parents[1]
EXE = ROOT / "Hwanse2.exe"
OUT = ROOT / "out"
WEB = ROOT / "web"
MAP_ANIMATION_REVIEW = OUT / "map_animation_tile_review.json"
PALETTE_TRACE_REVIEW = OUT / "map_palette_handler_trace_review.json"


SUSPECTED_TILE_SEQUENCES: dict[str, list[list[int]]] = {
    "map1_01a fire 4x3 row variants": [
        [396, 436, 476],
        [397, 437, 477],
        [398, 438, 478],
        [399, 439, 479],
    ],
    "map1_02b waterfall/water row variants": [
        [394, 434, 474],
        [395, 435, 475],
        [436, 396, 476],
        [437, 397, 477],
        [438, 398, 478],
        [439, 399, 479],
    ],
    "map1_02b bridge/static neighbor controls": [
        [58, 59, 60],
        [85, 86, 87],
        [293, 294, 295],
    ],
}


def esc(value: Any) -> str:
    return html.escape(str(value if value is not None else ""))


def hx(value: int | None, width: int = 8) -> str:
    if value is None:
        return ""
    return f"0x{value:0{width}x}"


def load_json(path: Path, fallback: Any) -> Any:
    if not path.exists():
        return fallback
    return json.loads(path.read_text(encoding="utf-8"))


def all_occurrences(blob: bytes, needle: bytes) -> list[int]:
    if not needle:
        return []
    out: list[int] = []
    start = 0
    while True:
        hit = blob.find(needle, start)
        if hit < 0:
            return out
        out.append(hit)
        start = hit + 1


def encode_sequence(seq: list[int], width: str) -> bytes | None:
    if width == "u8":
        if any(value < 0 or value > 0xFF for value in seq):
            return None
        return bytes(seq)
    if width == "u16le":
        if any(value < 0 or value > 0xFFFF for value in seq):
            return None
        return b"".join(struct.pack("<H", value) for value in seq)
    if width == "u32le":
        return b"".join(struct.pack("<I", value) for value in seq)
    raise ValueError(width)


def scan_tile_sequences(blob: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    rows: list[dict[str, Any]] = []
    individual_tiles: set[int] = set()

    for family, patterns in SUSPECTED_TILE_SEQUENCES.items():
        for seq in patterns:
            individual_tiles.update(seq)
            encodings: list[dict[str, Any]] = []
            for width in ["u8", "u16le", "u32le"]:
                encoded = encode_sequence(seq, width)
                if encoded is None:
                    continue
                hits = all_occurrences(blob, encoded)
                encodings.append(
                    {
                        "encoding": width,
                        "bytePattern": encoded.hex(" "),
                        "hitCount": len(hits),
                        "hitVas": [hx(offset_to_va(sections, hit)) for hit in hits[:12]],
                    }
                )
            rows.append(
                {
                    "family": family,
                    "sequence": seq,
                    "sequenceText": " -> ".join(str(value) for value in seq),
                    "encodings": encodings,
                    "totalContiguousHitCount": sum(row["hitCount"] for row in encodings),
                }
            )

    individual_rows = []
    for tile in sorted(individual_tiles):
        encoded = struct.pack("<H", tile)
        hits = all_occurrences(blob, encoded)
        section_counts: Counter[str] = Counter()
        hit_vas = []
        for hit in hits:
            va = offset_to_va(sections, hit)
            if va is not None:
                hit_vas.append(hx(va))
            for section in sections:
                start = int(section["raw"])
                end = start + int(section["raw_size"])
                if start <= hit < end:
                    section_counts[str(section["name"])] += 1
                    break
        individual_rows.append(
            {
                "tile": tile,
                "u16le": encoded.hex(" "),
                "hitCount": len(hits),
                "sectionCounts": dict(section_counts),
                "hitVas": hit_vas[:16],
            }
        )

    return {
        "status": "simple-contiguous-tile-replacement-table-not-found",
        "scope": "known fire/waterfall/waterfall-rope candidate tile-id triples and nearby static controls",
        "limitation": "negative result only covers simple contiguous u8/u16/u32 encoded sequences, not compressed code, arithmetic generation, or palette-only animation",
        "sequenceRows": rows,
        "individualTileRows": individual_rows,
        "totalSequenceRows": len(rows),
        "contiguousHitRows": sum(1 for row in rows if row["totalContiguousHitCount"] > 0),
    }


def ascii_token_around(blob: bytes, offset: int, radius: int = 64) -> str:
    segment = blob[max(0, offset - radius) : min(len(blob), offset + radius)]
    runs: list[bytes] = []
    current = bytearray()
    for byte in segment:
        if 0x20 <= byte <= 0x7E:
            current.append(byte)
        else:
            if len(current) >= 4:
                runs.append(bytes(current))
            current.clear()
    if len(current) >= 4:
        runs.append(bytes(current))
    text_runs = []
    for run in runs:
        try:
            text_runs.append(run.decode("ascii"))
        except UnicodeDecodeError:
            continue
    interesting = [
        text
        for text in text_runs
        if any(token in text.lower() for token in (".cns", ".mlk", ".wlk", "map_", "btl_", "cara_", "middata"))
    ]
    return " | ".join(interesting[:4])


def scan_tile_write_commands(
    blob: bytes,
    sections: list[dict[str, Any]],
    animation_review: dict[str, Any],
) -> dict[str, Any]:
    """Scan opcode 0x58 tile-write command shapes against known 0x40 regions.

    Opcode 0x58 is grounded by disassembly as a real tile-grid write command,
    but a raw byte hit is not enough.  This scan keeps three things separate:

    * handler semantics, which are confirmed from code;
    * plausible aligned 0x58 command streams; and
    * whether any such command directly writes a known animated source tile id.
    """
    maps = animation_review.get("maps") or []
    animated_tiles = {
        int(cell["layer0"])
        for row in maps
        for cell in row.get("animatedCells", [])
        if isinstance(cell.get("layer0"), int)
    }
    animated_coord_pairs = {
        (int(cell["x"]), int(cell["y"]))
        for row in maps
        for cell in row.get("animatedCells", [])
        if isinstance(cell.get("x"), int) and isinstance(cell.get("y"), int)
    }
    max_width = max((int(row.get("width") or 0) for row in maps), default=0)
    max_height = max((int(row.get("height") or 0) for row in maps), default=0)
    binding_context = build_palette_binding_context(maps)

    data_section = next(section for section in sections if section["name"] == ".data")
    data_start = int(data_section["raw"])
    data_end = min(len(blob) - 8, data_start + int(data_section["raw_size"]))

    alignment_counts: Counter[str] = Counter()
    plausible_aligned = 0
    direct_animated_writes: list[dict[str, Any]] = []
    coordinate_only_hits: list[dict[str, Any]] = []
    raw_animated_tile_hits: list[dict[str, Any]] = []
    ascii_false_hits: list[dict[str, Any]] = []

    for offset in range(data_start, data_end):
        if blob[offset] != 0x58:
            continue
        mode = blob[offset + 1]
        if mode not in (0, 1):
            continue
        tile = struct.unpack_from("<H", blob, offset + 2)[0]
        x = struct.unpack_from("<H", blob, offset + 4)[0]
        y = struct.unpack_from("<H", blob, offset + 6)[0]
        plausible = tile < 1024 and x < max_width and y < max_height
        tile_hit = tile in animated_tiles
        coord_hit = (x, y) in animated_coord_pairs
        if not (plausible or tile_hit or coord_hit):
            continue

        alignment = palette_command_alignment(blob, offset)
        status = alignment.get("status") or "unknown"
        alignment_counts[status] += 1
        aligned = status in {"vm-aligned-local-high", "vm-aligned-local-medium"}
        if plausible and aligned:
            plausible_aligned += 1

        best_root = alignment.get("bestRootVa")
        binding = palette_root_resource_binding(best_root, binding_context)
        row = {
            "va": hx(offset_to_va(sections, offset)),
            "mode": mode,
            "modeMeaning": "write layer0 tile grid 0x00595af0" if mode == 0 else "write layer1 flag grid 0x0058d7d0",
            "tile": tile,
            "x": x,
            "y": y,
            "raw": blob[offset : offset + 8].hex(" "),
            "alignmentStatus": status,
            "bestRootVa": best_root,
            "directReferenceCount": alignment.get("directReferenceCount"),
            "preview": (alignment.get("preview") or [])[:8],
            "resourceBinding": {
                "status": binding.get("status"),
                "rootVaHex": binding.get("rootVaHex"),
                "containingGroups": [
                    {
                        "id": group.get("id"),
                        "selector": group.get("selector"),
                        "rootVaHex": group.get("rootVaHex"),
                        "maps": group.get("maps", [])[:8],
                        "animatedMaps": group.get("animatedMaps", []),
                        "animatedTilesets": group.get("animatedTilesets", []),
                        "linkClass": group.get("linkClass"),
                    }
                    for group in binding.get("containingGroups", [])[:3]
                ],
                "nearestGroups": [
                    {
                        "id": group.get("id"),
                        "selector": group.get("selector"),
                        "rootVaHex": group.get("rootVaHex"),
                        "distanceBytes": group.get("distanceBytes"),
                        "maps": group.get("maps", [])[:8],
                        "animatedMaps": group.get("animatedMaps", []),
                        "animatedTilesets": group.get("animatedTilesets", []),
                        "linkClass": group.get("linkClass"),
                    }
                    for group in binding.get("nearestGroups", [])[:3]
                ],
                "note": binding.get("note"),
            },
        }
        ascii_context = ascii_token_around(blob, offset)
        if ascii_context:
            if len(ascii_false_hits) < 16:
                ascii_false_hits.append(
                    {
                        **row,
                        "asciiContext": ascii_context,
                        "rejectedReason": "candidate overlaps asset/string bytes rather than an isolated VM command stream",
                    }
                )
            continue
        if tile_hit and not plausible and len(raw_animated_tile_hits) < 16:
            raw_animated_tile_hits.append(
                {
                    **row,
                    "rejectedReason": "known animated tile id appears, but x/y is outside known map dimensions; treated as false-positive byte overlap",
                }
            )
        if tile_hit and plausible and aligned and len(direct_animated_writes) < 24:
            direct_animated_writes.append(row)
        if coord_hit and not tile_hit and plausible and aligned and len(coordinate_only_hits) < 24:
            binding_status = (row.get("resourceBinding") or {}).get("status")
            if ascii_context:
                classification = "rejected-descriptor-or-resource-table-overlap"
            elif binding_status == "resource-group-contains-animated-map":
                classification = "coordinate-correlation-inside-animated-resource-root"
            elif binding_status and binding_status != "no-vm-root":
                classification = "rejected-nonanimated-or-unbound-root"
            else:
                classification = "coordinate-correlation-only"
            coordinate_only_hits.append(
                {
                    **row,
                    "classification": classification,
                    "note": "The command touches a coordinate that is animated in at least one map, but writes a non-animated tile id. This is not visible animation proof unless the root also binds to the animated map.",
                }
            )

    return {
        "status": "tile-write-handler-grounded-animation-binding-unproven",
        "handlers": [
            {
                "opcode": "0x58",
                "handlerVa": "0x00407686",
                "commandBytes": 8,
                "layout": "58 mode u16(tileId) u16(x) u16(y)",
                "semantics": [
                    "mode 0 writes tileId to layer0 grid 0x00595af0 at y * mapWidth + x",
                    "mode 1 writes tileId/flag to layer1 grid 0x0058d7d0 at y * mapWidth + x",
                    "both modes call 0x004255eb(x, y) to invalidate the touched tile",
                ],
                "promotion": "confirmed-handler",
            },
            {
                "opcode": "0x6b",
                "handlerVa": "0x00408d10",
                "commandBytes": 12,
                "layout": "6b mode actorFilter u16(xOffset) u16(yOffset) u16(tileId)",
                "semantics": [
                    "iterates active descriptor slots 0x004576e8/0x0059dd70",
                    "matches actor id or 0xff wildcard",
                    "writes tileId at active-object base coordinate plus signed offsets",
                ],
                "promotion": "confirmed-handler-object-relative",
            },
            {
                "opcode": "0x76",
                "handlerVa": "0x00409f23",
                "commandBytes": 12,
                "layout": "76 mode actorFilter u16(xOffset) u16(yOffset) u16(tileId)",
                "semantics": [
                    "iterates linked active object list rooted at 0x00574100",
                    "matches actor id, 0xfe current context, or 0xff wildcard",
                    "writes tileId at active-object base coordinate plus signed offsets",
                ],
                "promotion": "confirmed-handler-object-relative",
            },
        ],
        "summary": {
            "animatedTileIdCount": len(animated_tiles),
            "animatedCoordinatePairCount": len(animated_coord_pairs),
            "maxAnimatedMapWidth": max_width,
            "maxAnimatedMapHeight": max_height,
            "candidateAlignmentCounts": dict(sorted(alignment_counts.items())),
            "plausibleAlignedOpcode58Count": plausible_aligned,
            "directAnimatedTileWriteCount": len(direct_animated_writes),
            "coordinateOnlyAlignedHitCount": len(coordinate_only_hits),
            "coordinateOnlyInsideAnimatedRootCount": sum(
                1
                for row in coordinate_only_hits
                if row.get("classification") == "coordinate-correlation-inside-animated-resource-root"
            ),
            "coordinateOnlyRejectedCount": sum(
                1
                for row in coordinate_only_hits
                if str(row.get("classification", "")).startswith("rejected-")
            ),
            "rawAnimatedTileFalsePositiveSampleCount": len(raw_animated_tile_hits),
            "asciiFalsePositiveSampleCount": len(ascii_false_hits),
        },
        "directAnimatedTileWrites": direct_animated_writes,
        "coordinateOnlyAlignedHits": coordinate_only_hits,
        "rawAnimatedTileFalsePositiveSamples": raw_animated_tile_hits,
        "asciiFalsePositiveSamples": ascii_false_hits,
        "conclusion": [
            "Tile-grid mutation opcodes are real and their layouts are now separated from palette evidence.",
            "No aligned, plausible opcode 0x58 command was found that directly writes one of the current known 0x40 animated layer0 tile ids.",
            "Some aligned tile writes touch coordinates that are animated in a known map, but the known hits either write ordinary tile ids from nonanimated/unbound roots or overlap resource/descriptor tables.",
            "Object-relative tile write opcodes 0x6b/0x76 may matter for doors/NPC/event map edits, but they do not currently bind the fire/waterfall loop.",
        ],
    }


def read_rect_table(
    blob: bytes,
    sections: list[dict[str, Any]],
    base_va: int,
    count: int,
) -> list[dict[str, Any]]:
    offset = va_to_offset(sections, base_va)
    if offset is None:
        return []
    rows = []
    for index in range(count):
        item_offset = offset + index * 16
        if item_offset + 16 > len(blob):
            break
        left, top, right, bottom = struct.unpack_from("<4i", blob, item_offset)
        rows.append(
            {
                "index": index,
                "va": hx(base_va + index * 16),
                "rect": [left, top, right, bottom],
                "size": [right - left, bottom - top],
            }
        )
    return rows


def scan_rect_copy_command_family(blob: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    """Record the self-blit command path separately from tile-id mutation.

    Runtime traces already prove that map_a updates a live cache area through
    surface self-blits.  Static evidence shows the generic helper that performs
    those self-blits, but map_c's beach rows have not been tied to the same
    executed command family yet.
    """

    def sample_commands(start_va: int, end_va: int, opcodes: set[int], limit: int = 24) -> list[dict[str, Any]]:
        start = va_to_offset(sections, start_va)
        end = va_to_offset(sections, end_va)
        if start is None or end is None:
            return []
        rows = []
        for offset in range(start, min(end, len(blob) - 12)):
            opcode = blob[offset]
            if opcode not in opcodes or blob[offset + 1] != 0x02:
                continue
            word4 = struct.unpack_from("<H", blob, offset + 4)[0]
            word6 = struct.unpack_from("<H", blob, offset + 6)[0]
            ptr8 = struct.unpack_from("<I", blob, offset + 8)[0]
            rows.append(
                {
                    "va": hx(offset_to_va(sections, offset)),
                    "opcode": hx(opcode, 2),
                    "submode": "0x02",
                    "word4": word4,
                    "word6": word6,
                    "ptrOrArg8": hx(ptr8),
                    "raw": blob[offset : offset + 12].hex(" "),
                }
            )
            if len(rows) >= limit:
                break
        return rows

    map_a_samples = sample_commands(0x00442AE0, 0x00442C80, {0x5D})
    map_c_samples = sample_commands(0x0045D7A0, 0x00468B18, {0x65})
    return {
        "status": "self-blit-helper-grounded-map-c-beach-runtime-confirmed",
        "confirmedHelperPath": [
            {
                "va": "0x004256D1",
                "role": "surface self-blit helper",
                "evidence": "calls BltFast wrapper twice, surface 0x0a -> 0x0a and surface 0x0b -> 0x0b, using rectBase + wordIndex * 16",
            },
            {
                "va": "0x0040791F",
                "role": "stream consumer into self-blit helper",
                "evidence": "reads current stream pointer from context+0x40, rect table base from context+0xa8, then forwards two stream words into 0x004256D1",
            },
            {
                "va": "0x00417750",
                "role": "DirectDraw Blt/BltFast wrapper",
                "evidence": "final DirectDraw surface copy wrapper used by the self-blit helper",
            },
        ],
        "mapARectTables": [
            {
                "baseVa": "0x00442761",
                "role": "map_a 64x48 cache block + source frames",
                "rects": read_rect_table(blob, sections, 0x00442761, 4),
            },
            {
                "baseVa": "0x004427A1",
                "role": "map_b/map_a strip-style cache source candidate",
                "rects": read_rect_table(blob, sections, 0x004427A1, 4),
            },
            {
                "baseVa": "0x004427B1",
                "role": "strip cache rect continuation",
                "rects": read_rect_table(blob, sections, 0x004427B1, 4),
            },
        ],
        "mapACommandSamples": map_a_samples,
        "mapCRootCommandCandidates": map_c_samples,
        "mapCRuntimeTraceEvidence": {
            "trace": "captures/runtime/hwanse-runtime-trace-2026-07-11T15-18-27-318Z-ani-wave.jsonl",
            "selfBlitCaller": "0x004177DA",
            "screenConsumerCaller": "0x00419BC7",
            "surfaces": ["map_c1.cns", "map_c2.cns"],
            "cacheDestRect": [592, 160, 640, 192],
            "sourceFrameRects": [
                [0, 160, 48, 192],
                [48, 160, 96, 192],
                [96, 160, 144, 192],
                [144, 160, 192, 192],
                [192, 160, 240, 192],
                [240, 160, 288, 192],
                [288, 160, 336, 192],
                [336, 160, 384, 192],
                [384, 160, 432, 192],
                [432, 160, 480, 192],
            ],
            "observedCadence": "every 4 runtime frames, roughly 200ms in ani-wave",
        },
        "summary": {
            "mapACommandSampleCount": len(map_a_samples),
            "mapCRootCandidateCount": len(map_c_samples),
        },
        "conclusion": [
            "The self-blit/cache mechanism is real and is strong evidence for map_a/map_b style animation when paired with runtime surface traces.",
            "ani-wave runtime binds map_c beach motion to the same cache-consumer model: map_c1/map_c2 self-blit 48x32 source frames into cache 592,160..640,192, then 0x00419BC7 draws those cache cells.",
            "The older map_c 0x65/0x02 local-root rows remain unpromoted and should not be used as the animation model now that the runtime cache path is known.",
        ],
    }


def scan_map_c3_whirlpool(blob: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    """Record the map_c3 whirlpool source-rect and frame-loop evidence.

    This is intentionally separate from layer1 0x40 tile animation.  map_c3 is
    loaded as an extra tileset/object resource and has its own 4-frame source
    rect table.  The map-space placement still needs a direct draw consumer
    trace, so the review labels the position as a candidate rather than a final
    promotion.
    """

    frame_loop = b"".join(struct.pack("<HHHH", 0x21, 1, index, 0x0C) for index in range(4))
    hits = []
    for offset in all_occurrences(blob, frame_loop):
        va = offset_to_va(sections, offset)
        if va is None:
            continue
        context_start = max(0, offset - 96)
        context = blob[context_start : offset + len(frame_loop) + 64]
        context_words = list(struct.unpack("<" + "H" * (len(context) // 2), context[: (len(context) // 2) * 2]))
        placement_candidates = []
        for index in range(0, max(0, len(context_words) - 3)):
            # Encoded as little-endian word pairs in the surrounding active-object
            # command stream.  Earlier map-object work treats these as e8/ea tile
            # position setters, but this scan keeps the claim as a candidate until
            # a direct object draw consumer is captured.
            if context_words[index] == 0xE802 and context_words[index + 2] == 0xEA02:
                x = context_words[index + 1]
                y = context_words[index + 3]
                if x < 128 and y < 128:
                    placement_candidates.append(
                        {
                            "x": x,
                            "y": y,
                            "evidence": f"word-pairs e802 {x:04x}, ea02 {y:04x}",
                        }
                    )
        hits.append(
            {
                "va": hx(va),
                "raw": blob[offset : offset + len(frame_loop)].hex(" "),
                "frames": [0, 1, 2, 3],
                "slotHex": "0x000c",
                "nearbyPlacementCandidates": placement_candidates,
                "contextVa": hx(offset_to_va(sections, context_start)),
                "contextWords": [f"0x{word:04x}" for word in context_words[:48]],
            }
        )

    source_rects = read_rect_table(blob, sections, 0x0052DF6C, 4)
    resource_refs = [
        {
            "map": "map2_13c",
            "recordVa": "0x00531fc4",
            "resourceRefVa": "0x00531fec",
            "slotHex": "0x000c",
            "string": "map_c3.cns",
            "placementStatus": "resource-ref-only; candidate x=53 exceeds map width 37",
        },
        {
            "map": "map6_26c",
            "recordVa": "0x0053207c",
            "resourceRefVa": "0x005320a4",
            "slotHex": "0x000c",
            "string": "map_c3.cns",
            "placementStatus": "source-rect-and-frame-loop-confirmed; placement candidate x=53,y=4",
        },
    ]
    candidate_positions = [
        candidate
        for hit in hits
        for candidate in hit.get("nearbyPlacementCandidates", [])
        if candidate.get("x") == 53 and candidate.get("y") == 4
    ]
    return {
        "status": "source-rect-table-and-slot-frame-loop-confirmed-placement-candidate",
        "asset": "map_c3.cns",
        "map": "map6_26c",
        "sourceRectTableVa": "0x0052df6c",
        "sourceRects": source_rects,
        "frameLoopHits": hits,
        "resourceRefs": resource_refs,
        "candidatePlacement": {
            "x": 53,
            "y": 4,
            "w": 10,
            "h": 8,
            "hitCount": len(candidate_positions),
            "evidenceStatus": "nearby-active-object-position-word-pairs; direct draw consumer still unproven",
        },
        "conclusion": [
            "map_c3.cns is a four-frame 160x128 whirlpool sheet, grounded by EXE source rect table 0x0052df6c.",
            "The slot 0x0c frame loop 0,1,2,3 appears exactly twice in the EXE at 0x005314f0 and 0x00531bc8.",
            "Both loop contexts carry nearby e802/ea02 word pairs with x=53,y=4, which is plausible for map6_26c and out of bounds for map2_13c; map_review therefore renders map6_26c at tile 53,4 as a placement candidate.",
            "The final unresolved item is a direct map-space draw consumer/runtime trace tying those word pairs to the visible object position.",
        ],
    }


def pick_map1_palette_candidates(animation_review: dict[str, Any]) -> list[dict[str, Any]]:
    commands = ((animation_review.get("paletteCommandScan") or {}).get("commands") or [])
    picked = []
    for row in commands:
        binding = row.get("resourceBinding") or {}
        nearest = binding.get("nearestGroups") or []
        nearest_records = binding.get("nearestAnimatedSceneRecords") or [
            record
            for record in binding.get("nearestSceneRecords", [])
            if record.get("animatedMap") or record.get("animatedTilesets")
        ]
        text = json.dumps([binding, nearest, nearest_records], ensure_ascii=False)
        if "map1_01a" not in text and "map1_02b" not in text:
            continue
        picked.append(
            {
                "va": row.get("va"),
                "opcode": row.get("opcode"),
                "mode": row.get("mode"),
                "kind": row.get("kind"),
                "range": row.get("range"),
                "effectScope": row.get("effectScope"),
                "payloadHex": row.get("payloadHex"),
                "scriptAlignment": {
                    "status": (row.get("scriptAlignment") or {}).get("status"),
                    "bestRootVa": (row.get("scriptAlignment") or {}).get("bestRootVa"),
                    "directReferenceCount": (row.get("scriptAlignment") or {}).get("directReferenceCount"),
                    "preview": (row.get("scriptAlignment") or {}).get("preview", [])[:8],
                    "note": (row.get("scriptAlignment") or {}).get("note"),
                },
                "resourceBinding": {
                    "status": binding.get("status"),
                    "rootVaHex": binding.get("rootVaHex"),
                    "nearestAnimatedSceneRecords": nearest_records[:4],
                    "nearestGroups": [
                        {
                            "id": group.get("id"),
                            "selector": group.get("selector"),
                            "rootVaHex": group.get("rootVaHex"),
                            "distanceBytes": group.get("distanceBytes"),
                            "maps": group.get("maps", [])[:8],
                            "animatedMaps": group.get("animatedMaps", []),
                        }
                        for group in nearest[:4]
                    ],
                },
            }
        )
    return picked


def build_data() -> dict[str, Any]:
    blob = EXE.read_bytes()
    sections = read_sections(blob)
    animation_review = load_json(MAP_ANIMATION_REVIEW, {})
    palette_trace = load_json(PALETTE_TRACE_REVIEW, {})
    tile_scan = scan_tile_sequences(blob, sections)
    tile_write_scan = scan_tile_write_commands(blob, sections, animation_review)
    rect_copy_scan = scan_rect_copy_command_family(blob, sections)
    map_c3_whirlpool = scan_map_c3_whirlpool(blob, sections)

    exe_evidence = animation_review.get("exeEvidence") or {}
    summary = animation_review.get("summary") or {}
    trace_summary = palette_trace.get("summary") or {}
    map1_candidates = pick_map1_palette_candidates(animation_review)

    return {
        "version": 1,
        "kind": "hwanse-map-animation-exe-pattern-review",
        "promotionStatus": "animation-redraw-and-cache-visible-motion-grounded",
        "conclusion": {
            "short": "Current EXE/runtime evidence grounds layer1 0x40 dirty redraw and cache self-blit visible motion for the known fire/waterfall/beach animated regions.",
            "tileReplacement": "No simple contiguous tile-id replacement table was found for the known fire/waterfall candidate sequences.",
            "dirtyRedraw": "The layer1 0x40 consumer marks viewport cells dirty and does not choose alternate layer0 tile ids at that point.",
            "paletteVm": "Palette opcodes 0x38/0x39 are table-dispatched VM commands and eventually apply through DirectDrawPalette::SetEntries.",
            "paletteCandidateScope": "Most palette candidates that are near animated scene records touch broad palette ranges, so they are better treated as screen fade/transition evidence than localized fire/waterfall loop proof.",
            "tileWriteVm": "Opcode 0x58/0x6b/0x76 tile-grid mutation handlers are real, but they do not yet bind to the 0x40 fire/waterfall visible motion loop.",
            "remainingGap": "The known visible map animation previews now use cache/self-blit evidence where available. Palette VM and tile-write handlers remain useful secondary paths, but they are not the primary visible-motion model for these regions.",
        },
        "summary": {
            "animatedMapCount": summary.get("animatedMapCount"),
            "animatedCellCount": summary.get("animatedCellCount"),
            "animatedPaletteIndexRanges": summary.get("animatedPaletteIndexRanges"),
            "tileSequenceRowsScanned": tile_scan["totalSequenceRows"],
            "tileSequenceRowsWithContiguousHits": tile_scan["contiguousHitRows"],
            "map1PaletteCandidateCount": len(map1_candidates),
            "paletteCommandEffectScopeCounts": summary.get("paletteCommandEffectScopeCounts"),
            "paletteCommandNearAnimatedBroadTransitionCount": summary.get("paletteCommandNearAnimatedBroadTransitionCount"),
            "paletteHandlersTableOnly": trace_summary.get("paletteHandlersTableOnly"),
            "exactPerMapPaletteRootProven": trace_summary.get("exactPerMapPaletteRootProven"),
            "mapAnimationPaletteBindingStatus": trace_summary.get("mapAnimationPaletteBindingStatus"),
            "tileWritePlausibleAlignedOpcode58Count": tile_write_scan["summary"]["plausibleAlignedOpcode58Count"],
            "tileWriteDirectAnimatedTileWriteCount": tile_write_scan["summary"]["directAnimatedTileWriteCount"],
            "tileWriteCoordinateOnlyAlignedHitCount": tile_write_scan["summary"]["coordinateOnlyAlignedHitCount"],
            "tileWriteCoordinateOnlyInsideAnimatedRootCount": tile_write_scan["summary"]["coordinateOnlyInsideAnimatedRootCount"],
            "tileWriteCoordinateOnlyRejectedCount": tile_write_scan["summary"]["coordinateOnlyRejectedCount"],
            "selfBlitMapACommandSampleCount": rect_copy_scan["summary"]["mapACommandSampleCount"],
            "selfBlitMapCRootCandidateCount": rect_copy_scan["summary"]["mapCRootCandidateCount"],
            "mapC3WhirlpoolFrameLoopHitCount": len(map_c3_whirlpool["frameLoopHits"]),
        },
        "exeEvidenceCarryOver": {
            "source": str(MAP_ANIMATION_REVIEW.relative_to(ROOT)),
            "status": exe_evidence.get("status"),
            "summary": exe_evidence.get("summary"),
            "confirmed": exe_evidence.get("confirmed", []),
            "unresolved": exe_evidence.get("unresolved", []),
            "keyAddresses": exe_evidence.get("keyAddresses", {}),
        },
        "paletteHandlerCarryOver": {
            "source": str(PALETTE_TRACE_REVIEW.relative_to(ROOT)),
            "summary": trace_summary,
            "confirmed": palette_trace.get("confirmed", []),
            "unresolved": palette_trace.get("unresolved", []),
        },
        "tileReplacementScan": tile_scan,
        "tileWriteCommandScan": tile_write_scan,
        "surfaceSelfBlitCommandScan": rect_copy_scan,
        "mapC3WhirlpoolObjectAnimation": map_c3_whirlpool,
        "map1PaletteCandidates": map1_candidates,
        "mapReviewPreviewMeaning": {
            "source": "web/map_review.html MAP_ANIMATION_PREVIEWS",
            "meaning": "visual approximation / audit helper",
            "notProofOf": "runtime tile-id replacement",
            "whyStillUseful": "It lets a human compare promoted visible fire/waterfall/beach motion while keeping palette/tile-write candidates separate. map_c beach preview is now runtime-confirmed from ani-wave self-blit/cache evidence rather than the older unpromoted row-pair or pixel-scroll review candidates.",
        },
    }


def render_table(headers: list[str], rows: list[list[Any]]) -> str:
    body = []
    for row in rows:
        body.append("<tr>" + "".join(f"<td>{cell}</td>" for cell in row) + "</tr>")
    return (
        "<table><thead><tr>"
        + "".join(f"<th>{esc(header)}</th>" for header in headers)
        + "</tr></thead><tbody>"
        + "\n".join(body)
        + "</tbody></table>"
    )


def render_html(data: dict[str, Any]) -> str:
    tile_write = data["tileWriteCommandScan"]
    rect_copy = data["surfaceSelfBlitCommandScan"]
    map_c3 = data["mapC3WhirlpoolObjectAnimation"]
    tile_write_handler_rows = [
        [
            f"<code>{esc(row['opcode'])}</code>",
            f"<code>{esc(row['handlerVa'])}</code>",
            esc(row["commandBytes"]),
            f"<code>{esc(row['layout'])}</code>",
            "<br>".join(esc(item) for item in row["semantics"]),
            esc(row["promotion"]),
        ]
        for row in tile_write["handlers"]
    ]
    tile_write_coord_rows = [
        [
            f"<code>{esc(row['va'])}</code>",
            esc(row["mode"]),
            esc(row["tile"]),
            f"{esc(row['x'])},{esc(row['y'])}",
            esc(row["alignmentStatus"]),
            f"<code>{esc(row.get('bestRootVa'))}</code>",
            f"<span>{esc(row.get('classification'))}</span><br>"
            f"<span class='muted'>{esc((row.get('resourceBinding') or {}).get('status'))}</span>",
            "<br>".join(
                f"{esc(group.get('id'))} {esc(group.get('selector'))} "
                f"{' '.join(esc(map_name) for map_name in group.get('maps', [])[:4])}"
                for group in ((row.get("resourceBinding") or {}).get("containingGroups") or [])
            )
            or "<span class='muted'>-</span>",
            "<br>".join(esc(item) for item in row.get("preview", [])[:5]),
        ]
        for row in tile_write.get("coordinateOnlyAlignedHits", [])
    ]
    tile_write_false_rows = [
        [
            f"<code>{esc(row['va'])}</code>",
            esc(row["tile"]),
            f"{esc(row['x'])},{esc(row['y'])}",
            esc(row["alignmentStatus"]),
            esc(row.get("rejectedReason")),
        ]
        for row in tile_write.get("rawAnimatedTileFalsePositiveSamples", [])
    ]
    tile_write_ascii_false_rows = [
        [
            f"<code>{esc(row['va'])}</code>",
            f"{esc(row['tile'])} @ {esc(row['x'])},{esc(row['y'])}",
            esc(row["alignmentStatus"]),
            esc(row.get("asciiContext")),
            esc(row.get("rejectedReason")),
        ]
        for row in tile_write.get("asciiFalsePositiveSamples", [])
    ]
    rect_copy_helper_rows = [
        [
            f"<code>{esc(row.get('va'))}</code>",
            esc(row.get("role")),
            esc(row.get("evidence")),
        ]
        for row in rect_copy.get("confirmedHelperPath", [])
    ]
    rect_copy_rect_rows = []
    for table in rect_copy.get("mapARectTables", []):
        for rect in table.get("rects", []):
            rect_copy_rect_rows.append(
                [
                    f"<code>{esc(table.get('baseVa'))}</code>",
                    esc(table.get("role")),
                    esc(rect.get("index")),
                    f"<code>{esc(rect.get('va'))}</code>",
                    esc(rect.get("rect")),
                    esc(rect.get("size")),
                ]
            )
    rect_copy_map_a_rows = [
        [
            f"<code>{esc(row.get('va'))}</code>",
            f"<code>{esc(row.get('opcode'))}</code>",
            esc(row.get("word4")),
            esc(row.get("word6")),
            f"<code>{esc(row.get('ptrOrArg8'))}</code>",
            f"<code>{esc(row.get('raw'))}</code>",
        ]
        for row in rect_copy.get("mapACommandSamples", [])
    ]
    rect_copy_map_c_rows = [
        [
            f"<code>{esc(row.get('va'))}</code>",
            f"<code>{esc(row.get('opcode'))}</code>",
            esc(row.get("word4")),
            esc(row.get("word6")),
            f"<code>{esc(row.get('ptrOrArg8'))}</code>",
            f"<code>{esc(row.get('raw'))}</code>",
        ]
        for row in rect_copy.get("mapCRootCommandCandidates", [])
    ]
    map_c3_rect_rows = [
        [
            esc(rect.get("index")),
            f"<code>{esc(rect.get('va'))}</code>",
            esc(rect.get("rect")),
            esc(rect.get("size")),
        ]
        for rect in map_c3.get("sourceRects", [])
    ]
    map_c3_loop_rows = [
        [
            f"<code>{esc(row.get('va'))}</code>",
            esc(row.get("slotHex")),
            esc(row.get("frames")),
            "<br>".join(
                f"{esc(item.get('x'))},{esc(item.get('y'))} <span class='muted'>{esc(item.get('evidence'))}</span>"
                for item in row.get("nearbyPlacementCandidates", [])
            )
            or "<span class='muted'>-</span>",
            f"<code>{esc(row.get('raw'))}</code>",
        ]
        for row in map_c3.get("frameLoopHits", [])
    ]
    map_c3_resource_rows = [
        [
            esc(row.get("map")),
            f"<code>{esc(row.get('recordVa'))}</code>",
            f"<code>{esc(row.get('resourceRefVa'))}</code>",
            esc(row.get("slotHex")),
            esc(row.get("placementStatus")),
        ]
        for row in map_c3.get("resourceRefs", [])
    ]

    sequence_rows = []
    for row in data["tileReplacementScan"]["sequenceRows"]:
        enc = "<br>".join(
            f"{esc(item['encoding'])}: {esc(item['hitCount'])}"
            for item in row["encodings"]
        )
        sequence_rows.append(
            [
                esc(row["family"]),
                f"<code>{esc(row['sequenceText'])}</code>",
                enc,
            ]
        )

    individual_rows = []
    for row in data["tileReplacementScan"]["individualTileRows"]:
        individual_rows.append(
            [
                f"<code>{esc(row['tile'])}</code>",
                esc(row["hitCount"]),
                esc(row["sectionCounts"]),
                " ".join(f"<code>{esc(va)}</code>" for va in row["hitVas"][:8]),
            ]
        )

    candidate_rows = []
    for row in data["map1PaletteCandidates"]:
        script = row["scriptAlignment"]
        binding = row["resourceBinding"]
        nearest = binding.get("nearestAnimatedSceneRecords") or []
        nearest_text = "<br>".join(
            f"{esc(item.get('map'))} <code>{esc(item.get('recordVaHex'))}</code> dist={esc(item.get('distanceBytes'))}"
            for item in nearest
        )
        candidate_rows.append(
            [
                f"<code>{esc(row.get('va'))}</code>",
                f"{esc(row.get('opcode'))}/m{esc(row.get('mode'))}",
                esc(row.get("kind")),
                f"{esc((row.get('range') or {}).get('startHex'))}..{esc((row.get('range') or {}).get('endHex'))}",
                f"<code>{esc(script.get('bestRootVa'))}</code><br>{esc(script.get('status'))}",
                esc(binding.get("status")),
                nearest_text,
            ]
        )

    conclusion = data["conclusion"]
    summary = data["summary"]
    doc = f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Map Animation EXE Pattern Review</title>
  <style>
    :root {{ color-scheme: light dark; --line:#d8dee8; --muted:#64748b; --panel:#f8fafc; }}
    body {{ margin:0; padding:24px; font-family: system-ui, -apple-system, Segoe UI, sans-serif; line-height:1.45; }}
    h1 {{ margin:0 0 12px; font-size:24px; }}
    h2 {{ margin:28px 0 10px; font-size:18px; }}
    code {{ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }}
    .summary {{ display:grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap:10px; margin:16px 0; }}
    .card {{ border:1px solid var(--line); border-radius:8px; padding:12px; background:var(--panel); }}
    .card strong {{ display:block; font-size:18px; }}
    .callout {{ border-left:4px solid #2563eb; padding:10px 12px; background:color-mix(in oklab, #2563eb 8%, transparent); }}
    .warn {{ border-left-color:#d97706; background:color-mix(in oklab, #d97706 10%, transparent); }}
    table {{ width:100%; border-collapse:collapse; font-size:13px; margin:10px 0 20px; }}
    th, td {{ border:1px solid var(--line); padding:7px 8px; vertical-align:top; }}
    th {{ text-align:left; background:rgba(148,163,184,.16); }}
    .muted {{ color:var(--muted); }}
    @media (prefers-color-scheme: dark) {{
      :root {{ --line:#334155; --muted:#94a3b8; --panel:#111827; }}
      body {{ background:#020617; color:#e5e7eb; }}
    }}
  </style>
</head>
<body>
  <h1>Map Animation EXE Pattern Review</h1>
  <p class="muted">타일 교체/offset 가설과 EXE animation dirty redraw 근거를 분리한 요약이다.</p>
  <div class="callout">
    <strong>결론:</strong> {esc(conclusion["short"])}
    <br>{esc(conclusion["remainingGap"])}
  </div>
  <div class="summary">
    <div class="card"><strong>{esc(summary.get('animatedMapCount'))}</strong><span>0x40 animated maps</span></div>
    <div class="card"><strong>{esc(summary.get('animatedCellCount'))}</strong><span>0x40 cells</span></div>
    <div class="card"><strong>{esc(summary.get('tileSequenceRowsWithContiguousHits'))}/{esc(summary.get('tileSequenceRowsScanned'))}</strong><span>tile replacement sequence hits</span></div>
    <div class="card"><strong>{esc(summary.get('map1PaletteCandidateCount'))}</strong><span>map1-near palette candidates</span></div>
    <div class="card"><strong>{esc(summary.get('tileWriteDirectAnimatedTileWriteCount'))}</strong><span>direct animated tile writes</span></div>
    <div class="card"><strong>{esc(summary.get('tileWriteCoordinateOnlyAlignedHitCount'))}</strong><span>coordinate-only tile-write hits</span></div>
    <div class="card"><strong>{esc(summary.get('tileWriteCoordinateOnlyInsideAnimatedRootCount'))}</strong><span>coordinate hits in animated roots</span></div>
    <div class="card"><strong>{esc(summary.get('tileWriteCoordinateOnlyRejectedCount'))}</strong><span>coordinate hits rejected</span></div>
    <div class="card"><strong>{esc(summary.get('selfBlitMapACommandSampleCount'))}</strong><span>map_a self-blit command samples</span></div>
    <div class="card"><strong>{esc(summary.get('selfBlitMapCRootCandidateCount'))}</strong><span>map_c unpromoted candidates</span></div>
    <div class="card"><strong>{esc(summary.get('mapC3WhirlpoolFrameLoopHitCount'))}</strong><span>map_c3 whirlpool frame loops</span></div>
  </div>

  <h2>승격/미승격</h2>
  <div class="callout">
    <p><strong>확정:</strong> {esc(conclusion["dirtyRedraw"])} {esc(conclusion["paletteVm"])} {esc(conclusion["tileWriteVm"])}</p>
    <p><strong>부정 증거:</strong> {esc(conclusion["tileReplacement"])}</p>
  </div>
  <div class="callout warn">
    <p><strong>미확정:</strong> {esc(conclusion["remainingGap"])}</p>
    <p><strong>map_review 미리보기 의미:</strong> {esc(data["mapReviewPreviewMeaning"]["meaning"])}. {esc(data["mapReviewPreviewMeaning"]["notProofOf"])}의 증거로 쓰지 않는다.</p>
  </div>

  <h2>Known Tile Sequence Scan</h2>
  {render_table(["family", "sequence", "contiguous hits by encoding"], sequence_rows)}

  <h2>Individual Tile ID Occurrences</h2>
  <p class="muted">개별 u16 값은 EXE 안에 흩어져 있지만, 순차 치환 테이블 형태로는 잡히지 않는다.</p>
  {render_table(["tile", "u16 hit count", "sections", "sample VAs"], individual_rows)}

  <h2>Tile-Write VM Command Scan</h2>
  <p class="muted">tile-grid write handler 자체는 확정이지만, 현재 known 0x40 source tile id를 직접 써서 모닥불/폭포를 재생한다는 증거는 없다.</p>
  <div class="callout">
    <p><strong>summary:</strong> {esc(tile_write["summary"])}</p>
    <ul>{"".join(f"<li>{esc(item)}</li>" for item in tile_write["conclusion"])}</ul>
  </div>
  {render_table(["opcode", "handler", "bytes", "layout", "semantics", "promotion"], tile_write_handler_rows)}
  <h3>Coordinate-only aligned hits</h3>
  <p class="muted">좌표만 0x40 영역과 겹치는 경우다. tile id가 animated source tile이 아니므로 animation proof로 승격하지 않는다.</p>
  {render_table(["VA", "mode", "tile", "x,y", "alignment", "best root", "classification", "containing root group", "preview"], tile_write_coord_rows or [["", "", "", "", "", "", "", "", "none"]])}
  <h3>Rejected raw animated tile hits</h3>
  <p class="muted">known animated tile id처럼 보이나 좌표가 맵 범위를 벗어난 raw byte overlap 샘플이다.</p>
  {render_table(["VA", "tile", "x,y", "alignment", "reason"], tile_write_false_rows or [["", "", "", "", "none"]])}
  <h3>Rejected ASCII/resource overlaps</h3>
  <p class="muted">리소스 문자열 주변에 걸린 0x58 byte는 command로 승격하지 않는다.</p>
  {render_table(["VA", "tile @ x,y", "alignment", "ascii context", "reason"], tile_write_ascii_false_rows or [["", "", "", "", "none"]])}

  <h2>Surface Self-Blit / Cache Command Scan</h2>
  <p class="muted">0x40 dirty redraw가 아니라, 실제 CNS surface 내부 cache를 갱신하는 경로다. map_a/map_b에는 강한 근거가 있으나 map_c 해변은 아직 이 경로에 묶이지 않았다.</p>
  <div class="callout">
    <p><strong>status:</strong> {esc(rect_copy.get("status"))}</p>
    <ul>{"".join(f"<li>{esc(item)}</li>" for item in rect_copy.get("conclusion", []))}</ul>
  </div>
  {render_table(["VA", "role", "evidence"], rect_copy_helper_rows)}
  <h3>map_a / map_b rect tables</h3>
  {render_table(["base", "role", "index", "rect VA", "rect", "size"], rect_copy_rect_rows or [["", "", "", "", "", "none"]])}
  <h3>map_a promoted-like command samples</h3>
  {render_table(["VA", "opcode", "word4", "word6", "ptr/arg8", "raw"], rect_copy_map_a_rows or [["", "", "", "", "", "none"]])}
  <h3>map_c root candidates, not promoted</h3>
  <p class="muted">map_c root의 0x65/0x02 행들은 trailing 값이 local pointer처럼 보이며, 아직 0x004256D1 self-blit helper 소비자로 연결되지 않았다.</p>
  {render_table(["VA", "opcode", "word4", "word6", "ptr/arg8", "raw"], rect_copy_map_c_rows or [["", "", "", "", "", "none"]])}

  <h2>map6_26c / map_c3 Whirlpool Object Animation</h2>
  <p class="muted">이 섹션은 layer1 0x40 타일 strip이 아니라, map_c3.cns extra-object 프레임 루프 근거다.</p>
  <div class="callout">
    <p><strong>status:</strong> {esc(map_c3.get("status"))}</p>
    <p><strong>candidate placement:</strong> {esc(map_c3.get("candidatePlacement"))}</p>
    <ul>{"".join(f"<li>{esc(item)}</li>" for item in map_c3.get("conclusion", []))}</ul>
  </div>
  <h3>source rect table 0x0052df6c</h3>
  {render_table(["index", "rect VA", "rect", "size"], map_c3_rect_rows or [["", "", "", "none"]])}
  <h3>slot 0x0c frame loop hits</h3>
  {render_table(["VA", "slot", "frames", "nearby x,y candidates", "raw"], map_c3_loop_rows or [["", "", "", "", "none"]])}
  <h3>resource refs</h3>
  {render_table(["map", "record", "resource ref", "slot", "placement status"], map_c3_resource_rows or [["", "", "", "", "none"]])}

  <h2>map1 Palette Candidates</h2>
  <p class="muted">후보 command stream shape는 맞지만 map root 실행 연결은 아직 증명되지 않았다.</p>
  {render_table(["VA", "opcode", "kind", "range", "best root", "binding", "nearest animated scene records"], candidate_rows)}

  <h2>Carried EXE Evidence</h2>
  <pre>{esc(json.dumps(data["exeEvidenceCarryOver"], ensure_ascii=False, indent=2))}</pre>
</body>
</html>
"""
    return doc


def main() -> None:
    data = build_data()
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    (OUT / "map_animation_exe_pattern_review.json").write_text(
        json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    html_text = render_html(data)
    (OUT / "map_animation_exe_pattern_review.html").write_text(html_text, encoding="utf-8")
    print(
        "wrote out/map_animation_exe_pattern_review.{json,html} "
        f"tileSeqHits={data['summary']['tileSequenceRowsWithContiguousHits']}/"
        f"{data['summary']['tileSequenceRowsScanned']}"
    )


if __name__ == "__main__":
    main()
