#!/usr/bin/env python3
"""Trace the generic VM path that can execute map palette commands.

This review is deliberately narrower than ``build_map_animation_tile_review``.
That report identifies layer1 0x40 cells and broad palette command candidates.
This one answers a different question: how the EXE reaches palette opcode
handlers 0x38/0x39, and which parent/nested script producers can feed those
handlers.

The result should not be read as a per-map binding.  It promotes the palette
handler/dispatch mechanics and leaves exact map execution roots unpromoted.
"""
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 probe_exe_scene_tables import read_sections, va_to_offset, offset_to_va  # noqa: E402


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

GENERAL_VM_TABLE = 0x00440538
VM_RUNNER = 0x00402321
VM_NESTED_RUNNER = 0x00402360
VM_CONTEXT_CREATE = 0x004022F0
VM_STOP_FLAG = 0x0055A1B8

PALETTE_STEP_HANDLER = 0x004060DB
PALETTE_SET_HANDLER = 0x00406206
PALETTE_SET_HANDLER_END = 0x004062DB
DEFERRED_PALETTE_APPLY = 0x00401000
DIRECTDRAW_PALETTE_SETENTRIES = 0x00416677
PALETTE_LIST_SETTER = 0x00401036
PALETTE_LIST_STEPPER = 0x004010D2
PALETTE_RANGE_STEPPER = 0x00401518
PALETTE_PAIR_STEPPER = 0x0040146A
PALETTE_CURRENT_RANGE_SETTER = 0x004015B9
PALETTE_BACKUP_RANGE_SETTER = 0x00401632
PALETTE_BACKUP_LIST_SETTER = 0x00401699
PALETTE_BACKUP_COPY_FROM_CURRENT = 0x004012C0
PALETTE_CURRENT_RESTORE_FROM_BACKUP = 0x00401344
PALETTE_CHANNEL_SHIFT = 0x004011D5
PALETTE_CURRENT_RANGE_COPY = 0x004013D2
PALETTE_DIRTY_FLAG = 0x00559D98
PALETTE_BUFFER = 0x004676E8
PALETTE_BACKUP_BUFFER = 0x00559DA1

PROMOTION_STATUS = "palette-handler-producer-family-grounded-map-binding-unproven"

OPCODE_ROLE_NOTES = {
    0x20: "stores a resumable frame/nested script pointer at context+0x64; later VM runtime can resume it",
    0x38: "palette fade/step command handler",
    0x39: "palette direct update command handler",
    0x5A: "nested stream producer; calls 0x00402360 with object/list script pointers",
    0x5B: "nested stream producer variant; calls 0x00402360",
    0x5C: "nested stream producer variant; calls 0x00402360",
    0x5E: "active/resource object nested execution handler; calls 0x00402360",
    0x97: "display/object nested execution family; calls 0x00402360",
    0x9A: "display/object nested execution family; calls 0x00402360 several times",
    0xA0: "display/object nested execution family; calls 0x00402360",
    0xA2: "display/object nested execution family; calls 0x00402360",
}

OBJECT_RUNTIME_NOTES = {
    0x004320BE: "object/display runtime nested script callsite outside the handler table",
    0x004321FE: "object/display runtime nested script callsite outside the handler table",
    0x0043240E: "object/display runtime nested script callsite outside the handler table",
    0x004325A7: "object/display runtime nested script callsite outside the handler table",
    0x00432750: "object/display runtime nested script callsite outside the handler table",
    0x00433183: "object/display runtime nested script callsite outside the handler table",
    0x004331AF: "object/display runtime nested script callsite outside the handler table",
}


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


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


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


def read_u32(blob: bytes, sections: list[dict[str, Any]], va: int) -> int:
    offset = va_to_offset(sections, va)
    if offset is None:
        raise ValueError(f"VA cannot be mapped: {hx(va)}")
    return struct.unpack_from("<I", blob, offset)[0]


def section_for_offset(sections: list[dict[str, Any]], offset: int) -> str | None:
    for section in sections:
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        if start <= offset < end:
            return str(section["name"])
    return None


def text_section(sections: list[dict[str, Any]]) -> dict[str, Any]:
    return next(section for section in sections if section["name"] == ".text")


def scan_rel32_calls(blob: bytes, sections: list[dict[str, Any]], target: int) -> list[int]:
    text = text_section(sections)
    start = int(text["raw"])
    size = int(text["raw_size"])
    text_blob = blob[start : start + size]
    rows: list[int] = []
    for offset in range(0, len(text_blob) - 4):
        if text_blob[offset] != 0xE8:
            continue
        rel = struct.unpack_from("<i", text_blob, offset + 1)[0]
        source = int(text["va"]) + offset
        if source + 5 + rel == target:
            rows.append(source)
    return rows


def scan_calls_in_range(
    blob: bytes,
    sections: list[dict[str, Any]],
    start_va: int,
    end_va: int,
) -> list[dict[str, Any]]:
    start_off = va_to_offset(sections, start_va)
    end_off = va_to_offset(sections, end_va)
    if start_off is None or end_off is None:
        return []
    out: list[dict[str, Any]] = []
    for offset in range(start_off, max(start_off, end_off - 4)):
        if blob[offset] != 0xE8:
            continue
        rel = struct.unpack_from("<i", blob, offset + 1)[0]
        source = offset_to_va(sections, offset)
        if source is None:
            continue
        target = source + 5 + rel
        out.append({"sourceVa": source, "sourceVaHex": hx(source), "targetVa": target, "targetVaHex": hx(target)})
    return out


def scan_dword_refs(blob: bytes, sections: list[dict[str, Any]], target: int) -> list[dict[str, Any]]:
    needle = struct.pack("<I", target)
    refs: list[dict[str, Any]] = []
    offset = 0
    while True:
        hit = blob.find(needle, offset)
        if hit < 0:
            break
        va = offset_to_va(sections, hit)
        if va is not None:
            refs.append(
                {
                    "va": va,
                    "vaHex": hx(va),
                    "section": section_for_offset(sections, hit) or "?",
                }
            )
        offset = hit + 1
    return refs


def handler_table(blob: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for opcode in range(256):
        handler = read_u32(blob, sections, GENERAL_VM_TABLE + opcode * 4)
        rows.append(
            {
                "opcode": opcode,
                "opcodeHex": f"0x{opcode:02x}",
                "entryVa": GENERAL_VM_TABLE + opcode * 4,
                "entryVaHex": hx(GENERAL_VM_TABLE + opcode * 4),
                "handlerVa": handler,
                "handlerVaHex": hx(handler),
                "role": OPCODE_ROLE_NOTES.get(opcode, ""),
            }
        )
    return rows


def handler_ranges(rows: list[dict[str, Any]], sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    text = text_section(sections)
    text_start = int(text["va"])
    text_end = text_start + int(text["raw_size"])
    unique = sorted(
        {
            int(row["handlerVa"])
            for row in rows
            if text_start <= int(row["handlerVa"]) < text_end
        }
    )
    ranges: list[dict[str, Any]] = []
    for index, start in enumerate(unique):
        end = unique[index + 1] if index + 1 < len(unique) else text_end
        opcodes = [row["opcode"] for row in rows if row["handlerVa"] == start]
        ranges.append(
            {
                "startVa": start,
                "startVaHex": hx(start),
                "endVa": end,
                "endVaHex": hx(end),
                "opcodes": opcodes,
                "opcodeHexes": [f"0x{op:02x}" for op in opcodes],
                "role": OPCODE_ROLE_NOTES.get(opcodes[0], "") if opcodes else "",
            }
        )
    return ranges


def containing_handler_range(ranges: list[dict[str, Any]], va: int) -> dict[str, Any] | None:
    max_plausible_handler_span = 0x4000
    for row in ranges:
        if int(row["startVa"]) <= va < int(row["endVa"]):
            if va - int(row["startVa"]) > max_plausible_handler_span:
                return None
            return row
    return None


def nested_runner_call_rows(
    blob: bytes,
    sections: list[dict[str, Any]],
    table_rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    ranges = handler_ranges(table_rows, sections)
    callsites = scan_rel32_calls(blob, sections, VM_NESTED_RUNNER)
    rows: list[dict[str, Any]] = []
    for source in callsites:
        containing = containing_handler_range(ranges, source)
        if containing:
            opcodes = containing["opcodes"]
            role = containing.get("role") or ", ".join(
                OPCODE_ROLE_NOTES.get(op, "") for op in opcodes if op in OPCODE_ROLE_NOTES
            )
            classification = "generic-vm-handler"
        else:
            opcodes = []
            role = OBJECT_RUNTIME_NOTES.get(source, "outside known generic handler range")
            classification = "object-display-runtime" if source in OBJECT_RUNTIME_NOTES else "unclassified"
        rows.append(
            {
                "callsiteVa": source,
                "callsiteVaHex": hx(source),
                "classification": classification,
                "handlerVaHex": containing["startVaHex"] if containing else "",
                "handlerEndVaHex": containing["endVaHex"] if containing else "",
                "opcodes": opcodes,
                "opcodeHexes": [f"0x{op:02x}" for op in opcodes],
                "role": role,
            }
        )
    return rows


def focused_handler_rows(table_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    focused = [0x20, 0x38, 0x39, 0x5A, 0x5B, 0x5C, 0x5E, 0x97, 0x9A, 0xA0, 0xA2]
    return [table_rows[opcode] for opcode in focused]


def palette_handler_semantics(blob: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return [
        {
            "opcode": "0x38",
            "handlerVaHex": hx(PALETTE_STEP_HANDLER),
            "lengthBytes": 8,
            "directCallsToHandler": len(scan_rel32_calls(blob, sections, PALETTE_STEP_HANDLER)),
            "dwordRefs": scan_dword_refs(blob, sections, PALETTE_STEP_HANDLER),
            "callsInsideHandler": scan_calls_in_range(blob, sections, PALETTE_STEP_HANDLER, PALETTE_SET_HANDLER),
            "modes": [
                "mode 0: repeat byte[+2] times; call 0x004010d2 with dword pointer at +4; step current palette list toward target RGB entries",
                "mode 1: repeat byte[+7] times; call 0x00401518 with range bytes +2/+3 and RGB target at +4; step current palette range toward stream RGB",
                "mode 2: repeat byte[+4] times; call 0x0040146a with bytes +2/+3; step current palette range toward backup palette bytes",
            ],
            "cursorAdvance": 8,
        },
        {
            "opcode": "0x39",
            "handlerVaHex": hx(PALETTE_SET_HANDLER),
            "lengthBytes": 8,
            "directCallsToHandler": len(scan_rel32_calls(blob, sections, PALETTE_SET_HANDLER)),
            "dwordRefs": scan_dword_refs(blob, sections, PALETTE_SET_HANDLER),
            "callsInsideHandler": scan_calls_in_range(blob, sections, PALETTE_SET_HANDLER, PALETTE_SET_HANDLER_END),
            "modes": [
                "mode 0: call 0x00401036 with dword pointer at +4; set current palette list immediately",
                "mode 1: call 0x004015b9 with bytes +2/+3/+4; fill current palette range",
                "mode 2: call 0x00401632 with bytes +2/+3/+4; fill backup palette range",
                "mode 3: call 0x00401699 with dword pointer at +4; set backup palette list",
            ],
            "cursorAdvance": 8,
        },
    ]


def palette_candidate_snapshot() -> dict[str, Any]:
    review = load_json(MAP_ANIMATION_REVIEW, {})
    scan = review.get("paletteCommandScan") or {}
    commands = scan.get("commands") or []
    rows: list[dict[str, Any]] = []
    for command in commands:
        alignment = command.get("scriptAlignment") or {}
        binding = command.get("resourceBinding") or {}
        if alignment.get("status") not in {"vm-aligned-local-high", "vm-aligned-local-medium"}:
            continue
        nearest_animated = [
            item
            for item in binding.get("nearestSceneRecords", [])
            if item.get("animatedMap")
        ][:3]
        rows.append(
            {
                "commandVaHex": command.get("va"),
                "opcode": command.get("opcode"),
                "mode": command.get("mode"),
                "kind": command.get("kind"),
                "range": command.get("range"),
                "alignmentStatus": alignment.get("status"),
                "bestRootVaHex": alignment.get("bestRootVa"),
                "rootReferenceCount": alignment.get("directReferenceCount"),
                "resourceBindingStatus": binding.get("status"),
                "nearestAnimatedSceneRecords": [
                    {
                        "map": item.get("map"),
                        "recordVaHex": item.get("recordVaHex"),
                        "distanceBytes": item.get("distanceBytes"),
                        "tilesets": item.get("tilesets"),
                    }
                    for item in nearest_animated
                ],
            }
        )
    return {
        "source": str(MAP_ANIMATION_REVIEW.relative_to(ROOT)),
        "summary": review.get("summary", {}),
        "alignmentCounts": scan.get("alignmentCounts", {}),
        "resourceBindingCounts": scan.get("resourceBindingCounts", {}),
        "promotedMapBindingCount": scan.get("resourceBindingCounts", {}).get("resource-group-contains-animated-map", 0),
        "highOrMediumRows": rows,
    }


def palette_helper_rows(blob: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    helpers = [
        (
            PALETTE_LIST_SETTER,
            "current-list-setter",
            "0x39 mode 0",
            "reads 0xff-terminated entries and writes target RGB nibbles into current palette buffer 0x004676e8",
        ),
        (
            PALETTE_LIST_STEPPER,
            "current-list-stepper",
            "0x38 mode 0",
            "steps current palette list entries one byte toward target RGB entries through 0x0040118e",
        ),
        (
            PALETTE_RANGE_STEPPER,
            "current-range-step-to-rgb",
            "0x38 mode 1",
            "steps current palette range toward immediate RGB bytes from the command stream",
        ),
        (
            PALETTE_PAIR_STEPPER,
            "current-range-step-to-backup",
            "0x38 mode 2",
            "steps current palette range toward backup palette bytes at 0x00559da1..",
        ),
        (
            PALETTE_CURRENT_RANGE_SETTER,
            "current-range-setter",
            "0x39 mode 1",
            "fills a current palette range with one stream byte value",
        ),
        (
            PALETTE_BACKUP_RANGE_SETTER,
            "backup-range-setter",
            "0x39 mode 2",
            "fills a backup palette range with one stream byte value",
        ),
        (
            PALETTE_BACKUP_LIST_SETTER,
            "backup-list-setter",
            "0x39 mode 3",
            "reads 0xff-terminated entries and writes target RGB nibbles into the backup palette buffer",
        ),
        (
            PALETTE_BACKUP_COPY_FROM_CURRENT,
            "backup-copy-from-current",
            "direct helper",
            "copies a current palette range into backup bytes",
        ),
        (
            PALETTE_CURRENT_RESTORE_FROM_BACKUP,
            "current-restore-from-backup",
            "direct helper",
            "restores a current palette range from backup bytes and marks the palette dirty",
        ),
        (
            PALETTE_CHANNEL_SHIFT,
            "current-channel-shift",
            "direct helper",
            "shifts current palette channel bytes left/right for a range and marks the palette dirty",
        ),
        (
            PALETTE_CURRENT_RANGE_COPY,
            "current-range-copy",
            "direct helper",
            "copies one current palette range to another current palette range and marks the palette dirty",
        ),
    ]
    return [
        {
            "helperVa": va,
            "helperVaHex": hx(va),
            "role": role,
            "usedBy": used_by,
            "directCallers": [hx(call) for call in scan_rel32_calls(blob, sections, va)],
            "meaning": meaning,
        }
        for va, role, used_by, meaning in helpers
    ]


def build() -> dict[str, Any]:
    blob = EXE.read_bytes()
    sections = read_sections(blob)
    table_rows = handler_table(blob, sections)
    nested_calls = nested_runner_call_rows(blob, sections, table_rows)
    nested_opcode_counts = Counter(
        opcode_hex
        for row in nested_calls
        for opcode_hex in row.get("opcodeHexes", [])
    )
    deferred_apply_callers = scan_rel32_calls(blob, sections, DEFERRED_PALETTE_APPLY)
    context_creator_callers = scan_rel32_calls(blob, sections, VM_CONTEXT_CREATE)
    vm_runner_callers = scan_rel32_calls(blob, sections, VM_RUNNER)

    handler_semantics = palette_handler_semantics(blob, sections)
    palette_handler_direct_call_count = sum(item["directCallsToHandler"] for item in handler_semantics)

    return {
        "version": 1,
        "kind": "hwanse-map-palette-handler-trace-review",
        "promotionStatus": PROMOTION_STATUS,
        "summary": {
            "generalVmTableVaHex": hx(GENERAL_VM_TABLE),
            "paletteOpcode38HandlerVaHex": hx(table_rows[0x38]["handlerVa"]),
            "paletteOpcode39HandlerVaHex": hx(table_rows[0x39]["handlerVa"]),
            "paletteHandlersTableOnly": palette_handler_direct_call_count == 0,
            "paletteHandlerDirectCallCount": palette_handler_direct_call_count,
            "nestedRunnerCallCount": len(nested_calls),
            "nestedRunnerGenericHandlerCallCount": sum(1 for row in nested_calls if row["classification"] == "generic-vm-handler"),
            "nestedRunnerObjectRuntimeCallCount": sum(1 for row in nested_calls if row["classification"] == "object-display-runtime"),
            "deferredPaletteApplyCallers": [hx(va) for va in deferred_apply_callers],
            "deferredPaletteApplyFrameCallerVaHex": hx(deferred_apply_callers[0]) if deferred_apply_callers else "",
            "contextCreatorCallers": [hx(va) for va in context_creator_callers],
            "vmRunnerDirectCallers": [hx(va) for va in vm_runner_callers],
            "exactPerMapPaletteRootProven": False,
            "mapAnimationPaletteBindingStatus": "candidate-roots-exist-but-map-execution-binding-unproven",
        },
        "confirmed": [
            "Generic script execution is anchored at 0x00402321: it reads the opcode from context+0x40 and dispatches through table 0x00440538.",
            "Palette opcode 0x38 resolves to 0x004060db and opcode 0x39 resolves to 0x00406206 in the generic VM table.",
            "There are no direct calls to 0x004060db or 0x00406206; these palette handlers are table-dispatched.",
            "Both palette handlers are 8-byte VM commands and advance context+0x40 by 8.",
            "Nested script execution is anchored at 0x00402360; multiple generic VM handlers and object/display runtime callsites feed it alternate stream pointers.",
            "Palette writes are dirty-buffered: helpers update 0x004676e8 and set 0x00559d98, while 0x00401000 applies the palette through DirectDraw SetEntries later in the frame/update loop.",
            "Palette opcode semantics split current palette updates from backup palette seeding/restoration. 0x38 steps current values, while 0x39 can seed either current or backup values depending on mode.",
        ],
        "unresolved": [
            "The exact executed palette stream root for each animated map is not proven yet.",
            "The high/medium palette command candidates remain local VM-shape evidence, not per-map execution proof.",
            "The next useful target is the descriptor/stream producer feeding nested runner callsites, not more raw 0x38/0x39 byte scans.",
        ],
        "vmDispatch": {
            "runnerVaHex": hx(VM_RUNNER),
            "nestedRunnerVaHex": hx(VM_NESTED_RUNNER),
            "contextCreatorVaHex": hx(VM_CONTEXT_CREATE),
            "handlerTableVaHex": hx(GENERAL_VM_TABLE),
            "stopFlagVaHex": hx(VM_STOP_FLAG),
            "focusedHandlers": focused_handler_rows(table_rows),
        },
        "paletteHandlers": handler_semantics,
        "paletteHelpers": palette_helper_rows(blob, sections),
        "nestedRunnerCallsites": nested_calls,
        "nestedRunnerOpcodeCounts": dict(sorted(nested_opcode_counts.items())),
        "paletteApply": {
            "deferredApplyVaHex": hx(DEFERRED_PALETTE_APPLY),
            "directDrawSetEntriesVaHex": hx(DIRECTDRAW_PALETTE_SETENTRIES),
            "dirtyFlagVaHex": hx(PALETTE_DIRTY_FLAG),
            "paletteBufferVaHex": hx(PALETTE_BUFFER),
            "paletteBackupBufferVaHex": hx(PALETTE_BACKUP_BUFFER),
            "callers": [{"va": va, "vaHex": hx(va)} for va in deferred_apply_callers],
            "directDrawSetEntriesCallers": [
                {"va": va, "vaHex": hx(va)}
                for va in scan_rel32_calls(blob, sections, DIRECTDRAW_PALETTE_SETENTRIES)
            ],
        },
        "paletteCandidateSnapshot": palette_candidate_snapshot(),
    }


def td(value: Any) -> str:
    return f"<td>{esc(value)}</td>"


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


def render_html(data: dict[str, Any]) -> str:
    focused_rows = [
        [
            row["opcodeHex"],
            row["entryVaHex"],
            row["handlerVaHex"],
            row.get("role", ""),
        ]
        for row in data["vmDispatch"]["focusedHandlers"]
    ]
    nested_rows = [
        [
            row["callsiteVaHex"],
            row["classification"],
            ", ".join(row.get("opcodeHexes") or []),
            row.get("handlerVaHex", ""),
            row.get("role", ""),
        ]
        for row in data["nestedRunnerCallsites"]
    ]
    palette_rows = []
    for row in data["paletteHandlers"]:
        calls = ", ".join(call["targetVaHex"] for call in row["callsInsideHandler"])
        refs = ", ".join(f'{ref["vaHex"]} {ref["section"]}' for ref in row["dwordRefs"])
        palette_rows.append(
            [
                row["opcode"],
                row["handlerVaHex"],
                row["lengthBytes"],
                row["directCallsToHandler"],
                refs,
                calls,
                " / ".join(row["modes"]),
            ]
        )
    helper_rows = [
        [
            row["helperVaHex"],
            row["role"],
            row["usedBy"],
            ", ".join(row["directCallers"]),
            row["meaning"],
        ]
        for row in data["paletteHelpers"]
    ]
    candidate = data["paletteCandidateSnapshot"]
    candidate_rows = [
        [
            row.get("commandVaHex"),
            row.get("opcode"),
            row.get("mode"),
            row.get("alignmentStatus"),
            row.get("bestRootVaHex"),
            row.get("resourceBindingStatus"),
            "; ".join(
                f'{item.get("map")} {item.get("recordVaHex")} +{item.get("distanceBytes")}B'
                for item in row.get("nearestAnimatedSceneRecords", [])
            ),
        ]
        for row in candidate.get("highOrMediumRows", [])
    ]

    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <title>Map Palette Handler Trace Review</title>
  <style>
    body {{ font-family: system-ui, sans-serif; margin: 24px; line-height: 1.5; color: #1f2933; }}
    table {{ border-collapse: collapse; width: 100%; margin: 12px 0 28px; font-size: 13px; }}
    th, td {{ border: 1px solid #d7dde5; padding: 6px 8px; vertical-align: top; }}
    th {{ background: #eef2f7; text-align: left; }}
    code {{ background: #eef2f7; padding: 1px 4px; border-radius: 3px; }}
    .status {{ display: inline-block; padding: 3px 8px; border-radius: 999px; background: #fff3bf; }}
    .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 12px; }}
    .panel {{ border: 1px solid #d7dde5; padding: 12px; border-radius: 6px; background: #fbfcfe; }}
    ul {{ margin-top: 6px; }}
  </style>
</head>
<body>
  <h1>Map Palette Handler Trace Review</h1>
  <p><span class="status">{esc(data["promotionStatus"])}</span></p>
  <div class="grid">
    <div class="panel">
      <h2>Summary</h2>
      <p>handler table <code>{esc(data["summary"]["generalVmTableVaHex"])}</code></p>
      <p>0x38 -> <code>{esc(data["summary"]["paletteOpcode38HandlerVaHex"])}</code>, 0x39 -> <code>{esc(data["summary"]["paletteOpcode39HandlerVaHex"])}</code></p>
      <p>table-only handlers: <strong>{esc(data["summary"]["paletteHandlersTableOnly"])}</strong></p>
      <p>nested runner callsites: <strong>{esc(data["summary"]["nestedRunnerCallCount"])}</strong></p>
      <p>deferred palette apply caller: <code>{esc(data["summary"]["deferredPaletteApplyFrameCallerVaHex"])}</code></p>
    </div>
    <div class="panel">
      <h2>Binding State</h2>
      <p>exact per-map palette root proven: <strong>{esc(data["summary"]["exactPerMapPaletteRootProven"])}</strong></p>
      <p>{esc(data["summary"]["mapAnimationPaletteBindingStatus"])}</p>
      <p>source: <code>{esc(candidate.get("source"))}</code></p>
    </div>
  </div>

  <h2>Confirmed</h2>
  <ul>{"".join(f"<li>{esc(item)}</li>" for item in data["confirmed"])}</ul>

  <h2>Unresolved</h2>
  <ul>{"".join(f"<li>{esc(item)}</li>" for item in data["unresolved"])}</ul>

  <h2>Focused Generic VM Handlers</h2>
  {render_table(["opcode", "table entry", "handler", "role"], focused_rows)}

  <h2>Palette Handler Semantics</h2>
  {render_table(["opcode", "handler", "len", "direct handler calls", "dword refs", "calls inside handler", "modes"], palette_rows)}

  <h2>Palette Helpers</h2>
  {render_table(["helper", "role", "used by", "direct callers", "meaning"], helper_rows)}

  <h2>Nested Runner Producers</h2>
  {render_table(["callsite", "classification", "opcode(s)", "handler", "role"], nested_rows)}

  <h2>Palette Candidate Root Snapshot</h2>
  <p>Alignment counts: <code>{esc(candidate.get("alignmentCounts"))}</code></p>
  <p>Resource binding counts: <code>{esc(candidate.get("resourceBindingCounts"))}</code></p>
  {render_table(["command", "opcode", "mode", "alignment", "best root", "resource binding", "nearest animated scene records"], candidate_rows)}
</body>
</html>
"""


def main() -> None:
    data = build()
    OUT.mkdir(exist_ok=True)
    (OUT / "map_palette_handler_trace_review.json").write_text(
        json.dumps(data, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    (OUT / "map_palette_handler_trace_review.html").write_text(render_html(data), encoding="utf-8")
    print(
        "wrote out/map_palette_handler_trace_review.{json,html} "
        f"nestedCalls={data['summary']['nestedRunnerCallCount']} "
        f"tableOnly={data['summary']['paletteHandlersTableOnly']}"
    )


if __name__ == "__main__":
    main()
