#!/usr/bin/env python3
"""Review static EXE evidence for monster battle action selection.

This report is intentionally static-only.  Runtime capture is not used here
because reaching arbitrary monster turns in the original game requires too much
manual setup.
"""
from __future__ import annotations

import html
import json
import re
import struct
import subprocess
import sys
from pathlib import Path
from typing import Any

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

from probe_exe_scene_tables import c_string, read_sections, va_to_offset


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

FIELDS = {
    0x58: "action class / command class",
    0x59: "action id / shared action payload id",
    0x5A: "enemy visible action slot / display selector",
}

ENEMY_DESCRIPTOR_TABLE_VA = 0x00442DA1
DESCRIPTOR_SCRIPT_NAMES = ["script0", "script1", "script2", "script3", "script4"]
VM_HANDLER_TABLE_VA = 0x00440538
VM_OPCODE10_VA = 0x00402D2E
ENEMY_STAT_TABLE_JSON = OUT / "enemy_stat_table.json"
SHARED_ACTION_REVIEW_JSON = OUT / "battle_monster_shared_action_effect_review.json"

REGS = ["eax", "ecx", "edx", "ebx", "esp", "ebp", "esi", "edi"]
BYTE_REGS = ["al", "cl", "dl", "bl", "ah", "ch", "dh", "bh"]

FUNCTION_SPECS = [
    {
        "label": "generic script VM write",
        "range": (0x00402590, 0x00402681),
        "status": "script-context-not-battle-actor",
        "meaning": (
            "stream의 type/offset/value를 읽어 [ctx + offset]에 byte/word/dword를 쓴다. "
            "하지만 이 ctx는 +0x40을 script PC로 쓰는 script VM context이며, "
            "battle actor의 +0x40은 drop/reward 계열 필드라 같은 구조체가 아니다."
        ),
        "needles": [
            "[eax+0x40]",
            "cmp    ecx,0xff",
            "mov    BYTE PTR [edx+ecx*1],al",
            "mov    WORD PTR [edx+ecx*1],ax",
            "mov    DWORD PTR [edx+ecx*1],eax",
        ],
    },
    {
        "label": "generic VM opcode 0x10 field/arithmetic writer",
        "range": (0x00402D2E, 0x00402FD6),
        "status": "vm-bytecode-field-writer",
        "meaning": (
            "opcode 0x10은 4-byte VM 명령이다. mode byte의 low nibble은 assign/add/sub 같은 "
            "연산이고, 0x30 source bits는 immediate/global/ctx/active-object source를, "
            "0xc0 destination bits는 global/ctx/active-object destination을 고른다. "
            "따라서 bytecode `10 c0 59 xx`는 active object([ctx+0xa8]) +0x59에 immediate xx를 "
            "쓰는 명령이며, 몬스터 vtable script에서 shared action id producer로 쓰인다."
        ),
        "needles": [
            "BYTE PTR [eax+0x40]",
            "and    eax,0x30",
            "and    eax,0xc0",
            "0x59db60",
            "0x402f6f",
            "[eax+0xa8]",
        ],
    },
    {
        "label": "generic VM opcode 0x2b random range writer",
        "range": (0x004056DE, 0x0040570C),
        "status": "confirmed-rng-value-producer",
        "meaning": (
            "opcode 0x2b는 script operand word를 range로 읽고 0x00427730 RNG helper를 호출한 뒤 "
            "결과를 ctx +0x58에 저장한다. 몬스터 action script의 `2b 00 NN 00`는 "
            "0..NN-1 범위 난수 생산자다."
        ),
        "needles": [
            "[eax+0x40]",
            "WORD PTR [eax+0x2]",
            "0x427730",
            "[ecx+0x58]",
            "0x4",
        ],
    },
    {
        "label": "generic VM opcode 0x0a indexed jump",
        "range": (0x0040288A, 0x004028FC),
        "status": "confirmed-indexed-jump-consumer",
        "meaning": (
            "opcode 0x0a는 script operand byte가 가리키는 ctx field 값을 읽고, count를 초과하면 "
            "count-1로 clamp한 뒤 `script + 4 + index*4`의 dword 주소로 PC를 바꾼다. "
            "`0a 58 NN 00`은 ctx +0x58 값을 사용하는 NN-entry 점프 테이블이다."
        ),
        "needles": [
            "BYTE PTR [eax+0x1]",
            "BYTE PTR [ecx+eax*1]",
            "BYTE PTR [eax+0x2]",
            "[ecx+eax*4+0x4]",
            "[ecx+0x40]",
        ],
    },
    {
        "label": "generic VM opcode 0x13 conditional jump",
        "range": (0x0040353E, 0x00403890),
        "status": "confirmed-conditional-jump",
        "meaning": (
            "opcode 0x13은 두 값을 읽어 low-nibble comparator로 비교하고, 참이면 다음 dword 주소로 PC를 바꾼다. "
            "`13 c1 05 48 <addr>`은 active object +0x05와 immediate 0x48을 비교하며, "
            "low nibble 1은 equality다."
        ),
        "needles": [
            "and    eax,0xc0",
            "and    eax,0x30",
            "[eax+0xa8]",
            "0x40370a",
            "[eax+0x40]",
        ],
    },
    {
        "label": "LCG random helper",
        "range": (0x00427730, 0x00427784),
        "status": "confirmed-rng-helper",
        "meaning": (
            "전역 seed 0x4aaafc를 `seed = seed * 0x41c64e6d + 0x3039`로 갱신하고, "
            "range가 0이 아니면 `(seed >> 16) % range`를 반환한다."
        ),
        "needles": [
            "0x4aaafc",
            "0x41c64e6d",
            "0x3039",
            "shr    eax,0x10",
            "idiv   ecx",
        ],
    },
    {
        "label": "script selection buffer helper",
        "range": (0x0040B84A, 0x0040BA3F),
        "status": "script-buffer-not-action-producer",
        "meaning": (
            "ctx +0xa8 local/selection buffer를 0x457744..0x457749 상태값으로 채운다. "
            "battle actor +0x59/+0x5a를 쓰지 않으므로 monster action producer가 아니다."
        ),
        "needles": [
            "[eax+0xa8]",
            "0x457744",
            "0x457745",
            "0x457746",
            "0x457747",
            "0x457748",
            "0x457749",
            "mov    BYTE PTR [ecx+eax*1],0x1",
        ],
    },
    {
        "label": "enemy actor init from table",
        "range": (0x0040BE38, 0x0040C064),
        "status": "ruled-out-producer",
        "meaning": (
            "enemy stat row를 actor로 복사하고 display object를 만든다. "
            "actor +0x58/+0x59/+0x5a를 직접 세팅하지 않는다."
        ),
        "needles": [
            "0x457c60",
            "0x436e60",
            "[eax+0x8]",
            "[eax+0x14]",
            "0x433112",
            "[eax+0x88]",
        ],
    },
    {
        "label": "enemy actor init from script stream",
        "range": (0x0040C084, 0x0040C2DF),
        "status": "ruled-out-producer",
        "meaning": (
            "script stream에서 enemy id를 읽어 같은 stat row를 복사한다. "
            "display object +0xf2/+0xf3 쪽은 만지지만 actor action id 생산자는 아니다."
        ),
        "needles": [
            "[eax+0x40]",
            "0x457c60",
            "0x436e60",
            "0x433234",
            "[eax+0xf3]",
            "[eax+0xf2]",
        ],
    },
    {
        "label": "player/menu action selector",
        "range": (0x0043329F, 0x004333C4),
        "status": "player-only-producer",
        "meaning": (
            "0x59e340 category와 0x59e34a cursor를 읽어 player actor +0x58/+0x59를 세팅한다. "
            "몬스터 AI가 아니라 플레이어 커맨드 선택자다."
        ),
        "needles": [
            "0x59e340",
            "0x59e34a",
            "[eax+0x58]",
            "[eax+0x59]",
            "0x4577a6",
            "0x4576ec",
        ],
    },
    {
        "label": "per-turn target preparation",
        "range": (0x0040CB98, 0x0040CC30),
        "status": "consumer-not-producer",
        "meaning": (
            "enemy turn이면 0x433402를 호출해 target을 고른다. "
            "0x433402는 이미 들어 있는 actor +0x59로 target scope를 계산하므로 action producer가 아니다."
        ),
        "needles": [
            "0x59e300",
            "0x59e2b0",
            "[eax+0x4]",
            "0x433402",
            "[eax+0x61]",
        ],
    },
    {
        "label": "display phase preparation",
        "range": (0x0040CF0A, 0x0040D04C),
        "status": "confirms-display-slot-split",
        "meaning": (
            "player는 actor +0x59 + 0x0a를 표시 slot으로 쓰지만, "
            "enemy는 actor +0x5a + 0x0a를 표시 slot으로 쓴다. "
            "+0x59 payload id와 +0x5a visible slot이 분리된다는 근거다."
        ),
        "needles": [
            "[eax+0x58]",
            "[eax+0x59]",
            "[eax+0x5a]",
            "[eax+0x60]",
            "0x4326d1",
        ],
    },
    {
        "label": "manual/player action execution",
        "range": (0x0040F57C, 0x0040F6E8),
        "status": "player-only-producer",
        "meaning": (
            "현재 actor clone에 player skill id를 쓰고 target latch를 actor +0x61로 복사한다. "
            "shared action 실행 경로를 확인하는 데는 유용하지만 monster AI 생산자는 아니다."
        ),
        "needles": [
            "0x4577a6",
            "[ecx+0x59]",
            "0x433649",
            "0x59e347",
            "0x435538",
            "0x435711",
        ],
    },
    {
        "label": "script object temporary result handlers",
        "range": (0x0040F1F4, 0x0040F760),
        "status": "script-temp-not-actor",
        "meaning": (
            "여러 helper 결과를 object +0x58에 저장한다. field offset이 같아 보이지만 "
            "전투 actor +0x58/+0x59가 아니라 script object temporary/result slot이다."
        ),
        "needles": [
            "0x421ef7",
            "[eax+0x58]",
            "0x422093",
            "0x42234e",
            "0x4226e9",
            "0x422899",
        ],
    },
]


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


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


def disassemble(start_va: int, stop_va: int) -> str:
    try:
        result = subprocess.run(
            [
                "objdump",
                "-Mintel",
                "-D",
                "-b",
                "pei-i386",
                f"--start-address=0x{start_va:08x}",
                f"--stop-address=0x{stop_va:08x}",
                str(EXE),
            ],
            check=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
        )
    except (OSError, subprocess.CalledProcessError) as exc:
        return f"; disassembly unavailable: {exc}"
    return "\n".join(
        line for line in result.stdout.splitlines() if re.match(r"\s*[0-9a-f]{6,8}:", line)
    )


def compact_lines(disasm: str, needles: list[str], *, context: int = 1) -> list[str]:
    lines = disasm.splitlines()
    picked: list[tuple[int, str]] = []
    for index, line in enumerate(lines):
        if any(needle in line for needle in needles):
            for i in range(max(0, index - context), min(len(lines), index + context + 1)):
                picked.append((i, lines[i]))
    seen: set[int] = set()
    out: list[str] = []
    for index, line in picked:
        if index in seen:
            continue
        seen.add(index)
        out.append(line)
    return out


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


def classify_va(va: int) -> str:
    ranges = [
        (0x0040BE38, 0x0040C2DF, "enemy init / not action producer"),
        (0x0040C513, 0x0040C660, "player action menu script branch"),
        (0x0040CB98, 0x0040CC30, "turn target prep / consumer"),
        (0x0040CCEA, 0x0040CE8D, "player skill growth after use"),
        (0x0040CF0A, 0x0040D04C, "display phase prep"),
        (0x0040F57C, 0x0040F6E8, "manual/player action execution"),
        (0x0040EF27, 0x0040F760, "script object temp/result field"),
        (0x0043329F, 0x004333C4, "player/menu action selector"),
    ]
    for start, stop, label in ranges:
        if start <= va < stop:
            return label
    return "unclassified static hit"


def scan_field_writes(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    text = next(section for section in sections if section["name"] == ".text")
    raw0 = int(text["raw"])
    raw1 = raw0 + int(text["raw_size"])
    data = exe[raw0:raw1]
    rows: list[dict[str, Any]] = []

    def add(pos: int, size: int, op: str, base: str, field: int, source: str) -> None:
        file_off = raw0 + pos
        va = file_offset_to_va(sections, file_off)
        if va is None:
            return
        rows.append(
            {
                "vaHex": hx(va),
                "fileOffsetHex": hx(file_off, 6),
                "size": size,
                "op": op,
                "base": base,
                "fieldOffsetHex": hx(field, 2),
                "fieldMeaning": FIELDS.get(field, ""),
                "source": source,
                "classification": classify_va(va),
            }
        )

    i = 0
    while i < len(data) - 2:
        op = data[i]
        modrm = data[i + 1]
        mod = modrm >> 6
        reg = (modrm >> 3) & 7
        rm = modrm & 7

        if op in (0x88, 0xC6, 0xFE) and mod in (1, 2):
            disp_len = 1 if mod == 1 else 4
            disp_pos = i + 2
            if rm == 4:
                # SIB byte exists; this narrow scanner is only for simple
                # base+disp patterns used by actor/script-object field writes.
                i += 1
                continue
            if disp_pos + disp_len <= len(data):
                if disp_len == 1:
                    field = data[disp_pos]
                    signed_disp = struct.unpack_from("b", data, disp_pos)[0]
                    base_field = field if signed_disp >= 0 else signed_disp
                else:
                    signed_disp = struct.unpack_from("<i", data, disp_pos)[0]
                    base_field = signed_disp
                if base_field in FIELDS:
                    if op == 0x88:
                        add(i, 2 + disp_len, "mov byte ptr [base+field], r8", REGS[rm], base_field, BYTE_REGS[reg])
                    elif op == 0xC6 and reg == 0 and disp_pos + disp_len < len(data):
                        imm = data[disp_pos + disp_len]
                        add(i, 3 + disp_len, "mov byte ptr [base+field], imm8", REGS[rm], base_field, hx(imm, 2))
                    elif op == 0xFE and reg == 0:
                        add(i, 2 + disp_len, "inc byte ptr [base+field]", REGS[rm], base_field, "")

        i += 1

    rows.sort(key=lambda row: int(row["vaHex"], 16))
    return rows


def scan_plain_skill_sequences(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Check whether obvious guide-order monster skill lists exist as raw bytes."""
    candidates = [
        ("early monkey rows dense", bytes([0x01, 0x01, 0x02, 0x01, 0x03, 0x01, 0x04, 0x01, 0x02])),
        ("early monkey rows zero-separated", bytes([0x01, 0x00, 0x01, 0x02, 0x01, 0x03, 0x01, 0x04])),
        ("boar shared action cluster", bytes([0x05, 0x05, 0x05, 0x06, 0x05, 0x06, 0x07])),
        ("slime shared action cluster", bytes([0x08, 0x09, 0x08, 0x0A, 0x0C, 0x08, 0x0A, 0x0B])),
        ("bat shared action cluster", bytes([0x2D, 0x2E, 0x2D, 0x2E, 0x2F, 0x2D, 0x2E, 0x2F])),
    ]
    rows = []
    for label, needle in candidates:
        offsets: list[str] = []
        start = 0
        while True:
            found = exe.find(needle, start)
            if found < 0:
                break
            va = file_offset_to_va(sections, found)
            offsets.append(hx(va) if va is not None else hx(found, 6))
            start = found + 1
        rows.append(
            {
                "label": label,
                "needleHex": " ".join(f"{b:02x}" for b in needle),
                "hitCount": len(offsets),
                "hits": offsets[:20],
                "interpretation": "plain guide-order byte sequence not present" if not offsets else "needs review",
            }
        )
    return rows


def read_handler_tables(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    tables = []
    for base in (VM_HANDLER_TABLE_VA, 0x00440540, 0x00440580, 0x00440700, 0x00440740, 0x00440780, 0x004407C0):
        off = va_to_offset(sections, base)
        entries = []
        if off is not None:
            for index in range(16):
                ptr = struct.unpack_from("<I", exe, off + index * 4)[0]
                entries.append({"index": index, "targetVaHex": hx(ptr), "label": handler_label(ptr)})
        tables.append({"tableVaHex": hx(base), "entries": entries})
    return tables


def handler_label(ptr: int) -> str:
    labels = {
        0x0040288A: "VM opcode 0x0a indexed jump",
        0x00402590: "generic script VM write",
        0x00402681: "generic script VM read/condition helper",
        0x004056DE: "VM opcode 0x2b RNG(range) -> ctx+0x58",
        0x0040BE38: "enemy actor init from table",
        0x0040C084: "enemy actor init from script stream",
        0x0040B84A: "script selection buffer helper",
        0x0040C513: "player action selection",
        0x0040CB98: "per-turn target prep",
        0x0040CCEA: "player skill growth",
        0x0040CF0A: "display phase prep",
        0x0040D080: "hit apply",
        0x0040DFB8: "reward/exp flow",
        0x0040239F: "no-op / shared empty handler",
    }
    return labels.get(ptr, "")


def read_u32_va(exe: bytes, sections: list[dict[str, Any]], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None or offset + 4 > len(exe):
        return None
    return struct.unpack_from("<I", exe, offset)[0]


def read_c_string_va(exe: bytes, sections: list[dict[str, Any]], va: int) -> str:
    offset = va_to_offset(sections, va)
    if offset is None:
        return ""
    return c_string(exe, offset)


def load_enemy_rows() -> list[dict[str, Any]]:
    if not ENEMY_STAT_TABLE_JSON.exists():
        return []
    try:
        data = json.loads(ENEMY_STAT_TABLE_JSON.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return []
    rows = data.get("rows") if isinstance(data, dict) else []
    return rows if isinstance(rows, list) else []


def load_shared_action_names() -> dict[int, dict[str, Any]]:
    if not SHARED_ACTION_REVIEW_JSON.exists():
        return {}
    try:
        data = json.loads(SHARED_ACTION_REVIEW_JSON.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}
    out: dict[int, dict[str, Any]] = {}
    for row in data.get("rows") or []:
        skill_id = row.get("skillId")
        if isinstance(skill_id, int):
            out[skill_id] = {
                "name": row.get("name") or "",
                "summary": row.get("summary") or "",
                "targetScope": ((row.get("units") or [{}])[0] or {}).get("targetScopeLabel", ""),
                "resultFamily": ((row.get("units") or [{}])[0] or {}).get("resultFamilyLabel", ""),
            }
    return out


def scan_vm_opcode10_assignments(
    exe: bytes,
    sections: list[dict[str, Any]],
    start_va: int,
    *,
    max_len: int = 0x700,
) -> dict[str, Any]:
    """Scan one descriptor script for active-object field writes.

    We intentionally restrict this scan to concrete `10 c0 field imm` bytecode.
    It is much narrower than arbitrary byte matching and corresponds directly to
    opcode 0x10's active-object immediate assignment mode.
    """
    start_off = va_to_offset(sections, start_va)
    if start_off is None:
        return {"scriptVaHex": hx(start_va), "assignments": [], "actionPairs": []}
    chunk = exe[start_off : min(len(exe), start_off + max_len)]
    assignments: list[dict[str, Any]] = []
    for pos in range(0, max(0, len(chunk) - 3)):
        if chunk[pos] != 0x10 or chunk[pos + 1] != 0xC0:
            continue
        field = chunk[pos + 2]
        value = chunk[pos + 3]
        if field not in FIELDS and field not in (0x60, 0x61, 0x62, 0x63, 0x67, 0x6B):
            continue
        va = start_va + pos
        assignments.append(
            {
                "vaHex": hx(va),
                "scriptOffsetHex": hx(pos, 4),
                "bytesHex": " ".join(f"{b:02x}" for b in chunk[pos : pos + 4]),
                "fieldOffset": field,
                "fieldOffsetHex": hx(field, 2),
                "fieldMeaning": FIELDS.get(field, ""),
                "value": value,
                "valueHex": hx(value, 2),
                "semantics": (
                    f"active object +{hx(field, 2)} = {hx(value, 2)}"
                ),
            }
        )

    pairs = decode_action_pairs_from_chunk(chunk, start_va, offset_key="scriptOffsetHex")
    for pair in pairs:
        pair["scriptVaHex"] = hx(start_va)
        pair["confidence"] = "paired-vm-opcode10-active-object-write"
    return {"scriptVaHex": hx(start_va), "assignments": assignments, "actionPairs": pairs}


def decode_action_pairs_from_chunk(
    chunk: bytes,
    base_va: int,
    *,
    offset_key: str,
    max_slot_block_scan: int = 0x30,
    scan_start: int = 0,
    scan_stop: int | None = None,
) -> list[dict[str, Any]]:
    pairs: list[dict[str, Any]] = []
    seen: set[tuple[int, int, int, str]] = set()
    stop = min(len(chunk), scan_stop if scan_stop is not None else len(chunk))
    start = max(0, scan_start)

    def add_pair(
        *,
        pair_order: str,
        start_pos: int,
        action_pos: int,
        slot_pos: int,
        action_id: int,
        slot: int,
        block_end: int,
        variant_index: int,
        variant_count: int,
        branch: dict[str, Any] | None = None,
    ) -> None:
        key = (action_pos, action_id, slot, pair_order)
        if key in seen:
            return
        seen.add(key)
        action_bytes = chunk[action_pos : min(len(chunk), action_pos + 4)]
        slot_bytes = chunk[slot_pos : min(len(chunk), slot_pos + 4)]
        bytes_end = min(len(chunk), max(start_pos + 8, min(block_end, action_pos + 4)))
        if pair_order.startswith("slot-first"):
            bytes_end = min(len(chunk), min(block_end, start_pos + 0x24))
        row = {
            "vaHex": hx(base_va + start_pos),
            "actionWriteVaHex": hx(base_va + action_pos),
            "slotWriteVaHex": hx(base_va + slot_pos),
            offset_key: hx(start_pos, 4),
            "actionWriteOffsetHex": hx(action_pos, 4),
            "slotWriteOffsetHex": hx(slot_pos, 4),
            "bytesHex": " ".join(f"{b:02x}" for b in chunk[start_pos:bytes_end]),
            "actionBytesHex": " ".join(f"{b:02x}" for b in action_bytes),
            "slotBytesHex": " ".join(f"{b:02x}" for b in slot_bytes),
            "pairOrder": pair_order,
            "sharedActionId": action_id,
            "sharedActionIdHex": hx(action_id, 2),
            "visibleSlot": slot,
            "visibleSlotHex": hx(slot, 2),
            "displayPhase": slot + 0x0A,
            "displayPhaseHex": hx(slot + 0x0A, 2),
            "variantIndex": variant_index,
            "variantCount": variant_count,
            "branchVariantStatus": (
                "conditional-shared-action-variant"
                if variant_count > 1
                else "direct-action-slot-pair"
            ),
        }
        if branch:
            row.update(branch)
        pairs.append(row)

    # Common form: action id write immediately followed by visible slot write.
    for i in range(start, max(start, stop - 7)):
        if (
            chunk[i : i + 3] == b"\x10\xc0\x59"
            and chunk[i + 4 : i + 7] == b"\x10\xc0\x5a"
        ):
            add_pair(
                pair_order="action-then-slot",
                start_pos=i,
                action_pos=i,
                slot_pos=i + 4,
                action_id=chunk[i + 3],
                slot=chunk[i + 7],
                block_end=i + 8,
                variant_index=0,
                variant_count=1,
            )

    # Conditional form found in btl_rsu-style scripts:
    # slot write first, branch/test bytecode, then one or more action id writes.
    for slot_pos in range(start, max(start, stop - 3)):
        if chunk[slot_pos : slot_pos + 3] != b"\x10\xc0\x5a":
            continue
        if slot_pos >= 4 and chunk[slot_pos - 4 : slot_pos - 1] == b"\x10\xc0\x59":
            # This slot is already the second half of the common form above.
            continue
        next_slot = len(chunk)
        for probe in range(slot_pos + 4, min(stop - 2, slot_pos + max_slot_block_scan)):
            if chunk[probe : probe + 3] == b"\x10\xc0\x5a":
                next_slot = probe
                break
        next_common_pair = len(chunk)
        for probe in range(slot_pos + 4, min(stop - 6, slot_pos + max_slot_block_scan)):
            if (
                chunk[probe : probe + 3] == b"\x10\xc0\x59"
                and chunk[probe + 4 : probe + 7] == b"\x10\xc0\x5a"
            ):
                next_common_pair = probe
                break
        block_end = min(next_slot, next_common_pair, stop, slot_pos + max_slot_block_scan)
        action_positions = [
            pos
            for pos in range(slot_pos + 4, max(slot_pos + 4, block_end - 3))
            if chunk[pos : pos + 3] == b"\x10\xc0\x59"
        ]
        if not action_positions:
            continue
        variant_count = len(action_positions)
        branch_opcode: dict[str, Any] | None = None
        if (
            slot_pos + 12 <= len(chunk)
            and chunk[slot_pos + 4 : slot_pos + 7] == b"\x13\xc1\x05"
        ):
            compare_value = chunk[slot_pos + 7]
            jump_target = struct.unpack_from("<I", chunk, slot_pos + 8)[0]
            branch_opcode = {
                "branchOpcode": "0x13",
                "branchOpcodeBytesHex": " ".join(f"{b:02x}" for b in chunk[slot_pos + 4 : slot_pos + 12]),
                "branchFieldOffset": 0x05,
                "branchFieldOffsetHex": hx(0x05, 2),
                "branchFieldMeaning": "active object identity byte / actor table id",
                "branchComparator": "==",
                "branchCompareValue": compare_value,
                "branchCompareValueHex": hx(compare_value, 2),
                "branchTrueTargetVaHex": hx(jump_target),
                "branchInterpretation": (
                    f"if active object +0x05 == {hx(compare_value, 2)} "
                    f"then jump to {hx(jump_target)}"
                ),
            }
        for variant_index, action_pos in enumerate(action_positions):
            branch = None
            if branch_opcode:
                true_action_pos = int(branch_opcode["branchTrueTargetVaHex"], 16) - base_va
                condition_kind = "eq" if action_pos == true_action_pos else "ne"
                branch = {
                    **branch_opcode,
                    "branchConditionKind": condition_kind,
                    "variantCondition": (
                        f"active object +0x05 == {branch_opcode['branchCompareValueHex']}"
                        if condition_kind == "eq"
                        else f"active object +0x05 != {branch_opcode['branchCompareValueHex']}"
                    ),
                }
            add_pair(
                pair_order=(
                    "slot-first-conditional"
                    if variant_count > 1
                    else "slot-first"
                ),
                start_pos=slot_pos,
                action_pos=action_pos,
                slot_pos=slot_pos,
                action_id=chunk[action_pos + 3],
                slot=chunk[slot_pos + 3],
                block_end=block_end,
                variant_index=variant_index,
                variant_count=variant_count,
                branch=branch,
            )

    pairs.sort(key=lambda row: (int(row.get("slotWriteOffsetHex", "0x0"), 16), int(row.get("actionWriteOffsetHex", "0x0"), 16), row["sharedActionId"]))
    return pairs


def read_action_pairs_near(
    exe: bytes,
    sections: list[dict[str, Any]],
    target_va: int,
    *,
    max_scan: int = 0x40,
) -> list[dict[str, Any]]:
    offset = va_to_offset(sections, target_va)
    if offset is None:
        return []
    chunk = exe[offset : min(len(exe), offset + max_scan)]
    normal_positions = [
        pos
        for pos in range(0, max(0, len(chunk) - 7))
        if (
            chunk[pos : pos + 3] == b"\x10\xc0\x59"
            and chunk[pos + 4 : pos + 7] == b"\x10\xc0\x5a"
        )
    ]
    slot_positions = [
        pos
        for pos in range(0, max(0, len(chunk) - 3))
        if chunk[pos : pos + 3] == b"\x10\xc0\x5a"
        and not (pos >= 4 and chunk[pos - 4 : pos - 1] == b"\x10\xc0\x59")
    ]
    first_normal = min(normal_positions) if normal_positions else None
    first_slot = min(slot_positions) if slot_positions else None
    if first_normal is None and first_slot is None:
        return []
    if first_slot is not None and (first_normal is None or first_slot < first_normal):
        next_slot = len(chunk)
        for probe in range(first_slot + 4, min(len(chunk) - 2, first_slot + 0x30)):
            if chunk[probe : probe + 3] == b"\x10\xc0\x5a":
                next_slot = probe
                break
        next_common_pair = len(chunk)
        for probe in range(first_slot + 4, min(len(chunk) - 6, first_slot + 0x30)):
            if (
                chunk[probe : probe + 3] == b"\x10\xc0\x59"
                and chunk[probe + 4 : probe + 7] == b"\x10\xc0\x5a"
            ):
                next_common_pair = probe
                break
        scan_stop = min(next_slot, next_common_pair, len(chunk), first_slot + 0x30)
        pairs = decode_action_pairs_from_chunk(
            chunk,
            target_va,
            offset_key="targetOffsetHex",
            scan_start=first_slot,
            scan_stop=scan_stop,
        )
    else:
        pairs = decode_action_pairs_from_chunk(
            chunk,
            target_va,
            offset_key="targetOffsetHex",
            scan_start=first_normal or 0,
            scan_stop=(first_normal or 0) + 8,
        )
    for pair in pairs:
        pair["targetVaHex"] = hx(target_va)
        pair["confidence"] = "script3-target-action-pair"
    return pairs


def extract_script3_target_table(
    exe: bytes,
    sections: list[dict[str, Any]],
    script3_va: int,
    *,
    max_len: int,
) -> dict[str, Any]:
    """Decode descriptor script3 pointer tables.

    Most selector scripts begin with a short header and then a table of dword
    pointers.  Conditional variants can contain extra branch bytecode before one
    or more `2b 00 ?? 00 0a 58 ?? 00` table markers.  Each table ends at the
    first pointed block.  Opcode 0x2b/0x0a confirms the repeated pointers as
    RNG jump-table weights inside that branch/table.
    """
    script_off = va_to_offset(sections, script3_va)
    if script_off is None:
        return {
            "script3VaHex": hx(script3_va),
            "tableCount": 0,
            "targetCount": 0,
            "uniqueTargetCount": 0,
            "targets": [],
            "uniqueTargets": [],
            "tables": [],
        }
    chunk = exe[script_off : min(len(exe), script_off + max_len)]
    marker_offsets: list[int] = []
    for pos in range(0, max(0, len(chunk) - 7)):
        if (
            chunk[pos] == 0x2B
            and chunk[pos + 1] == 0x00
            and chunk[pos + 3] == 0x00
            and chunk[pos + 4] == 0x0A
            and chunk[pos + 5] == 0x58
            and chunk[pos + 7] == 0x00
        ):
            marker_offsets.append(pos)
    if not marker_offsets and chunk[:4] == b"\x42\x00\x00\x00":
        marker_offsets.append(4)

    all_targets: list[int] = []
    tables: list[dict[str, Any]] = []
    for table_index, marker_pos in enumerate(marker_offsets):
        start = script3_va + marker_pos + 0x08
        cursor = start
        target_ptrs: list[int] = []
        min_target: int | None = None
        while cursor < script3_va + max_len:
            if min_target is not None and cursor >= min_target:
                break
            ptr = read_u32_va(exe, sections, cursor)
            if ptr is None or va_to_offset(sections, ptr) is None:
                break
            if not (script3_va <= ptr < script3_va + max_len):
                break
            target_ptrs.append(ptr)
            all_targets.append(ptr)
            min_target = ptr if min_target is None else min(min_target, ptr)
            cursor += 4

        counts: dict[int, int] = {}
        for ptr in target_ptrs:
            counts[ptr] = counts.get(ptr, 0) + 1
        unique_targets = [
            {
                "targetVaHex": hx(ptr),
                "weightCount": count,
                "weightPercent": round(count * 100 / len(target_ptrs), 2) if target_ptrs else 0,
                "weightDenominator": len(target_ptrs),
                "weightSemantics": "confirmed-rng-jump-table-pointer-count",
            }
            for ptr, count in sorted(counts.items())
        ]
        rng_range = chunk[marker_pos + 2] if marker_pos + 2 < len(chunk) else None
        jump_count = chunk[marker_pos + 6] if marker_pos + 6 < len(chunk) else None
        tables.append(
            {
                "tableIndex": table_index,
                "markerVaHex": hx(script3_va + marker_pos),
                "markerBytesHex": " ".join(f"{b:02x}" for b in chunk[marker_pos : marker_pos + 8]),
                "rngRange": rng_range,
                "jumpCount": jump_count,
                "choiceSemantics": (
                    f"opcode 0x2b writes RNG({rng_range}) to ctx+0x58; "
                    f"opcode 0x0a jumps through {jump_count} entries using ctx+0x58"
                    if rng_range is not None and jump_count is not None
                    else "pointer table marker semantics unavailable"
                ),
                "tableStartVaHex": hx(start),
                "tableEndVaHex": hx(cursor),
                "targetCount": len(target_ptrs),
                "uniqueTargetCount": len(unique_targets),
                "targets": [hx(ptr) for ptr in target_ptrs],
                "uniqueTargets": unique_targets,
            }
        )

    aggregate_counts: dict[int, int] = {}
    for ptr in all_targets:
        aggregate_counts[ptr] = aggregate_counts.get(ptr, 0) + 1
    aggregate_unique_targets = [
        {
            "targetVaHex": hx(ptr),
            "weightCount": count,
            "weightPercent": round(count * 100 / len(all_targets), 2) if all_targets else 0,
            "weightDenominator": len(all_targets),
            "weightSemantics": "aggregate-confirmed-rng-jump-table-pointer-count",
        }
        for ptr, count in sorted(aggregate_counts.items())
    ]
    return {
        "script3VaHex": hx(script3_va),
        "scanLengthHex": hx(max_len, 4),
        "tableCount": len(tables),
        "targetCount": len(all_targets),
        "uniqueTargetCount": len(aggregate_unique_targets),
        "targets": [hx(ptr) for ptr in all_targets],
        "uniqueTargets": aggregate_unique_targets,
        "tables": tables,
    }


def scan_monster_descriptor_scripts(exe: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    enemy_rows = load_enemy_rows()
    shared_actions = load_shared_action_names()
    descriptor_rows: list[dict[str, Any]] = []
    action_rows: list[dict[str, Any]] = []
    pair_count = 0
    max_entries = len(enemy_rows) + 4

    for actor_id in range(4, max_entries):
        descriptor_va = read_u32_va(exe, sections, ENEMY_DESCRIPTOR_TABLE_VA + actor_id * 4)
        if descriptor_va is None or va_to_offset(sections, descriptor_va) is None:
            continue
        scripts = []
        script_values: list[int] = []
        for script_index in range(5):
            script_va = read_u32_va(exe, sections, descriptor_va + script_index * 4)
            if script_va is None or va_to_offset(sections, script_va) is None:
                continue
            script_values.append(script_va)
        cns_name = read_c_string_va(exe, sections, descriptor_va + 0x14)
        if not cns_name.endswith(".cns"):
            # The first descriptor table entries include non-monster objects too.
            # Keep valid scripts only when the descriptor clearly names a CNS.
            continue
        enemy_index = actor_id - 4
        enemy = enemy_rows[enemy_index] if 0 <= enemy_index < len(enemy_rows) else {}
        descriptor_pairs: list[dict[str, Any]] = []

        # Keep raw assignment scans small and bounded by adjacent script
        # pointers.  The real action choices live behind script3's target table,
        # so this is evidence context rather than the primary producer list.
        sorted_script_values = sorted(set(script_values))
        script_scan_lengths: dict[int, int] = {}
        for script_index, script_va in enumerate(script_values):
            next_candidates = [va for va in sorted_script_values if va > script_va]
            max_len = min(0x120, (next_candidates[0] - script_va) if next_candidates else 0x80)
            script_scan_lengths[script_index] = max_len
            scanned = scan_vm_opcode10_assignments(exe, sections, script_va, max_len=max_len)
            scripts.append(
                {
                    "scriptIndex": script_index,
                    "scriptName": DESCRIPTOR_SCRIPT_NAMES[script_index],
                    "scriptVaHex": hx(script_va),
                    "scanLengthHex": hx(max_len, 4),
                    "assignmentCount": len(scanned["assignments"]),
                    "actionPairCount": len(scanned["actionPairs"]),
                    "assignments": scanned["assignments"],
                    "actionPairs": scanned["actionPairs"],
                }
            )

        script3_scan: dict[str, Any] | None = None
        if len(script_values) > 3:
            script3_scan = extract_script3_target_table(
                exe,
                sections,
                script_values[3],
                max_len=script_scan_lengths.get(3, 0x80),
            )
            for table in script3_scan["tables"]:
                total_targets = table["targetCount"]
                table_target_vas = sorted(
                    int(str(target["targetVaHex"]), 16)
                    for target in table["uniqueTargets"]
                )
                next_target_by_va: dict[int, int | None] = {}
                for target_index, target_ptr in enumerate(table_target_vas):
                    next_target_by_va[target_ptr] = (
                        table_target_vas[target_index + 1]
                        if target_index + 1 < len(table_target_vas)
                        else None
                    )
                for target in table["uniqueTargets"]:
                    target_va = int(str(target["targetVaHex"]), 16)
                    next_target_va = next_target_by_va.get(target_va)
                    scan_len = (
                        min(0x60, max(0x08, next_target_va - target_va))
                        if next_target_va is not None
                        else 0x40
                    )
                    pairs = read_action_pairs_near(exe, sections, target_va, max_scan=scan_len)
                    target["scanLengthHex"] = hx(scan_len, 4)
                    if not pairs:
                        target["actionPairStatus"] = "no-action-pair-at-target"
                        continue
                    target["actionPairStatus"] = "action-pair-grounded"
                    target["actionPairCount"] = len(pairs)
                    for pair in pairs:
                        if pair.get("branchFieldOffset") == 0x05 and pair.get("branchCompareValue") is not None:
                            actor_matches_branch = actor_id == pair["branchCompareValue"]
                            expected_kind = "eq" if actor_matches_branch else "ne"
                            if pair.get("branchConditionKind") != expected_kind:
                                continue
                            pair["branchResolution"] = "resolved-by-actor-table-id"
                            pair["branchResolutionActorTableIdHex"] = hx(actor_id, 2)
                            pair["branchResolutionMeaning"] = (
                                f"{cns_name} is shared; actor table id {hx(actor_id, 2)} selects "
                                f"{pair.get('variantCondition')}"
                            )
                        pair = {**pair, **target}
                        action = shared_actions.get(pair["sharedActionId"], {})
                        choice_weight_kind = (
                            "slot-block-weight-actor-id-resolved"
                            if pair.get("variantCount", 1) > 1
                            else "direct-target-weight"
                        )
                        enriched = {
                            **pair,
                            "actorTableId": actor_id,
                            "actorTableIdHex": hx(actor_id, 2),
                            "enemyStatIndex": enemy_index if enemy_index >= 0 else None,
                            "enemyName": enemy.get("cleanName") or enemy.get("name") or "",
                            "cns": cns_name,
                            "descriptorVaHex": hx(descriptor_va),
                            "scriptIndex": 3,
                            "scriptName": DESCRIPTOR_SCRIPT_NAMES[3],
                            "scriptVaHex": hx(script_values[3]),
                            "choiceTableIndex": table["tableIndex"],
                            "choiceTableMarkerVaHex": table["markerVaHex"],
                            "choiceTableMarkerBytesHex": table["markerBytesHex"],
                            "choiceTableRngRange": table.get("rngRange"),
                            "choiceTableJumpCount": table.get("jumpCount"),
                            "choiceTableSemantics": table.get("choiceSemantics"),
                            "sharedActionName": action.get("name", ""),
                            "sharedActionSummary": action.get("summary", ""),
                            "targetScope": action.get("targetScope", ""),
                            "resultFamily": action.get("resultFamily", ""),
                            "weightCount": target["weightCount"],
                            "weightPercent": target["weightPercent"],
                            "weightDenominator": total_targets,
                            "choiceWeightKind": choice_weight_kind,
                            "weightNote": (
                                "same RNG table target weight; inner actor-id branch has been resolved for this actor"
                                if pair.get("variantCount", 1) > 1
                                else "confirmed RNG jump-table pointer count"
                            ),
                        }
                        descriptor_pairs.append(enriched)
                        action_rows.append(enriched)
                        pair_count += 1
        descriptor_rows.append(
            {
                "actorTableId": actor_id,
                "actorTableIdHex": hx(actor_id, 2),
                "enemyStatIndex": enemy_index if enemy_index >= 0 else None,
                "enemyName": enemy.get("cleanName") or enemy.get("name") or "",
                "cns": cns_name,
                "descriptorVaHex": hx(descriptor_va),
                "scripts": scripts,
                "script3TargetTable": script3_scan,
                "actionPairs": descriptor_pairs,
                "uniqueSharedActionIds": sorted({pair["sharedActionId"] for pair in descriptor_pairs}),
                "uniqueVisibleSlots": sorted({pair["visibleSlot"] for pair in descriptor_pairs}),
            }
        )

    unique_monsters_with_pairs = sum(1 for row in descriptor_rows if row["actionPairs"])
    return {
        "descriptorTableVaHex": hx(ENEMY_DESCRIPTOR_TABLE_VA),
        "vmOpcode10VaHex": hx(VM_OPCODE10_VA),
        "status": "vm-opcode10-descriptor-script-producer-grounded",
        "descriptorCount": len(descriptor_rows),
        "descriptorWithActionPairs": unique_monsters_with_pairs,
        "actionPairCount": pair_count,
        "actionRows": action_rows,
        "descriptorRows": descriptor_rows,
        "interpretation": [
            "`10 c0 59 xx` writes shared action id xx to active actor/object +0x59.",
            "`10 c0 5a yy` writes visible local action slot yy to active actor/object +0x5a.",
            "The paired bytecode pattern appears at script3 target blocks reached through the enemy descriptor/vtable script pointer table, so the missing monster action producer is data-driven VM script rather than x86 direct field writes.",
            "`2b 00 NN 00` calls the EXE RNG helper and stores RNG(NN) in ctx +0x58; `0a 58 NN 00` consumes that value as an NN-entry indexed jump.",
            "Repeated pointers inside script3's target table are confirmed RNG jump-table weights.",
            "`13 c1 05 48 <addr>` conditionally jumps when active object +0x05 equals 0x48. In btl_rsu this resolves the shared descriptor into 린샹(actor 0x39) vs 바니-R(actor 0x48) action ids.",
            "The existing display phase bridge remains unchanged: enemy display phase = actor +0x5a + 0x0a.",
        ],
    }


def build() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    field_writes = scan_field_writes(exe, sections)
    sequence_scan = scan_plain_skill_sequences(exe, sections)
    tables = read_handler_tables(exe, sections)
    descriptor_script_scan = scan_monster_descriptor_scripts(exe, sections)

    action59_writes = [row for row in field_writes if row["fieldOffsetHex"] == "0x59"]
    visible5a_writes = [row for row in field_writes if row["fieldOffsetHex"] == "0x5a"]
    actor_like59 = [
        row
        for row in action59_writes
        if row["classification"]
        in {
            "player/menu action selector",
            "player skill growth after use",
            "display phase prep",
            "manual/player action execution",
        }
    ]

    function_rows = []
    for spec in FUNCTION_SPECS:
        start, stop = spec["range"]
        disasm = disassemble(start, stop)
        function_rows.append(
            {
                "label": spec["label"],
                "range": f"{hx(start)}..{hx(stop)}",
                "status": spec["status"],
                "meaning": spec["meaning"],
                "keyLines": compact_lines(disasm, spec["needles"], context=1),
            }
        )

    report = {
        "version": 1,
        "kind": "hwanse-battle-monster-action-selection-review",
        "title": "몬스터 행동 선택 정적 분석",
        "status": "static-vm-script-producer-grounded",
        "runtimeUsed": False,
        "source": "Hwanse2.exe",
        "summary": {
            "confirmed": [
                "몬스터/공용 기술 실행은 actor +0x59 shared action id -> 0x004d2494 shared action payload -> hit unit -> result-family dispatch로 이어진다.",
                "enemy 표시 단계는 actor +0x5a + 0x0a를 display selector로 사용한다. 즉 +0x59 payload id와 +0x5a visual slot은 별도 필드다.",
                "0x433402/0x433545 target selector는 이미 설정된 actor +0x59를 소비한다. monster action id를 생산하지 않는다.",
                "직접 field-write 스캔에서 actor +0x59를 생산하는 경로는 player/menu selector, player skill growth, manual/player execution 쪽뿐이다.",
                "generic VM opcode 0x10은 active object([ctx+0xa8]) field writer다. `10 c0 59 xx`와 `10 c0 5a yy`가 각각 actor +0x59/+0x5a를 세팅한다.",
                "몬스터 descriptor/vtable script 안에서 `10 c0 59 xx 10 c0 5a yy` pair가 발견된다. 따라서 몬스터 행동 선택자는 x86 direct write가 아니라 VM bytecode data path로 확정된다.",
                "descriptor script3의 `2b 00 NN 00 0a 58 NN 00`는 RNG(NN) 후 NN-entry jump table을 고르는 확정 패턴이다. 반복 포인터 수는 행동 선택 가중치다.",
                "`13 c1 05 48 <addr>`는 active object +0x05 == 0x48 조건 분기다. btl_rsu류 공유 descriptor에서 린샹(0x39)과 바니-R(0x48)의 action id를 이 분기로 확정한다.",
            ],
            "notFound": [
                "enemy actor init 두 경로에서 actor +0x58/+0x59/+0x5a action 선택 write는 발견되지 않았다.",
                "guide 순서와 비슷한 몬스터별 shared action id byte list는 EXE에서 plain sequence로 발견되지 않았다.",
                "actor +0x5a visible slot producer는 x86 direct write로는 없고, descriptor VM script pair로만 확인된다.",
            ],
            "currentConclusion": (
                "몬스터 행동 선택자는 descriptor/vtable script의 VM opcode 0x10 pair가 생산한다. "
                "script3의 0x2b/0x0a RNG jump table이 선택 block을 고르고, "
                "shared action id(+0x59)는 데미지/효과 payload를, visible slot(+0x5a)은 해당 몬스터 CNS의 local display phase(+0x0a)를 고른다. "
                "공유 descriptor의 조건부 variant는 active object +0x05 actor id 분기로 해소한다."
            ),
            "nextStaticTargets": [
                "몬스터별 action pair를 display frame table 및 shared payload와 결합해 monster-turn preview data로 승격",
            ],
        },
        "metrics": {
            "fieldWriteHits58_59_5a": len(field_writes),
            "action59WriteHits": len(action59_writes),
            "visible5aWriteHits": len(visible5a_writes),
            "actorLikeAction59WriteHits": len(actor_like59),
            "plainMonsterSkillSequenceHits": sum(row["hitCount"] for row in sequence_scan),
            "descriptorScriptCount": descriptor_script_scan["descriptorCount"],
            "descriptorWithActionPairs": descriptor_script_scan["descriptorWithActionPairs"],
            "descriptorVmActionPairCount": descriptor_script_scan["actionPairCount"],
        },
        "fieldWriteAudit": field_writes,
        "plainMonsterSkillSequenceScan": sequence_scan,
        "battleVmHandlerTables": tables,
        "descriptorScriptProducer": descriptor_script_scan,
        "functionEvidence": function_rows,
        "notes": [
            "script object temporary +0x58 writes are intentionally separated from battle actor +0x58/+0x59 writes.",
            "This report does not infer monster AI from guide annotations; it only records EXE-static evidence.",
            "Top-level descriptor script3 random choice weights are decoded through opcode 0x2b/0x0a. btl_rsu-style conditional variants are resolved by actor table id byte +0x05.",
        ],
    }
    return report


def render_md(report: dict[str, Any]) -> str:
    lines = [
        "# 몬스터 행동 선택 정적 분석",
        "",
        f"- 상태: `{report['status']}`",
        f"- 런타임 사용: `{report['runtimeUsed']}`",
        "",
        "## 결론",
    ]
    for item in report["summary"]["confirmed"]:
        lines.append(f"- {item}")
    lines += ["", "## 아직 발견되지 않은 것"]
    for item in report["summary"]["notFound"]:
        lines.append(f"- {item}")
    lines += ["", "## 현재 판단", report["summary"]["currentConclusion"], "", "## 직접 field write 스캔"]
    for key, value in report["metrics"].items():
        lines.append(f"- `{key}`: {value}")
    lines += ["", "### actor/action 관련 write hit"]
    for row in report["fieldWriteAudit"]:
        lines.append(
            f"- `{row['vaHex']}` `{row['op']}` `{row['base']}+{row['fieldOffsetHex']}` "
            f"from `{row['source']}` · {row['classification']}"
        )
    lines += ["", "## plain skill sequence scan"]
    for row in report["plainMonsterSkillSequenceScan"]:
        lines.append(f"- `{row['label']}`: {row['hitCount']} hit · `{row['needleHex']}`")
    producer = report.get("descriptorScriptProducer") or {}
    lines += [
        "",
        "## descriptor/vtable VM producer",
        f"- 상태: `{producer.get('status')}`",
        f"- descriptor table: `{producer.get('descriptorTableVaHex')}`",
        f"- opcode 0x10: `{producer.get('vmOpcode10VaHex')}`",
        f"- action pair count: `{producer.get('actionPairCount')}`",
        "",
        "| monster | CNS | script | shared action | visible slot | display phase | variant |",
        "|---|---|---:|---|---:|---:|---|",
    ]
    for row in (producer.get("actionRows") or [])[:160]:
        action = f"{row.get('sharedActionIdHex')} {row.get('sharedActionName') or ''}".strip()
        variant = f"{row.get('pairOrder') or ''} {row.get('variantIndex', 0) + 1}/{row.get('variantCount', 1)}".strip()
        lines.append(
            f"| {row.get('enemyName') or ''} | `{row.get('cns')}` | `{row.get('scriptName')}` | "
            f"`{action}` | `{row.get('visibleSlotHex')}` | `{row.get('displayPhaseHex')}` | {variant} |"
        )
    lines += ["", "## 주요 함수 근거"]
    for row in report["functionEvidence"]:
        lines += [f"### {row['label']}", f"- 범위: `{row['range']}`", f"- 상태: `{row['status']}`", f"- 의미: {row['meaning']}", "```asm"]
        lines.extend(row["keyLines"])
        lines.append("```")
    return "\n".join(lines)


def render_html(report: dict[str, Any]) -> str:
    summary_cards = "".join(
        f"<li>{esc(item)}</li>" for item in report["summary"]["confirmed"]
    )
    missing_cards = "".join(
        f"<li>{esc(item)}</li>" for item in report["summary"]["notFound"]
    )
    field_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['vaHex'])}</code></td>"
        f"<td><code>{esc(row['op'])}</code></td>"
        f"<td><code>{esc(row['base'])}+{esc(row['fieldOffsetHex'])}</code><br><span>{esc(row['fieldMeaning'])}</span></td>"
        f"<td><code>{esc(row['source'])}</code></td>"
        f"<td>{esc(row['classification'])}</td>"
        "</tr>"
        for row in report["fieldWriteAudit"]
    )
    sequence_rows = "".join(
        "<tr>"
        f"<td>{esc(row['label'])}</td>"
        f"<td><code>{esc(row['needleHex'])}</code></td>"
        f"<td>{row['hitCount']}</td>"
        f"<td>{esc(row['interpretation'])}</td>"
        "</tr>"
        for row in report["plainMonsterSkillSequenceScan"]
    )
    producer = report.get("descriptorScriptProducer") or {}
    producer_rows = "".join(
        "<tr>"
        f"<td>{esc(row.get('enemyName') or '')}</td>"
        f"<td><code>{esc(row.get('cns') or '')}</code></td>"
        f"<td><code>{esc(row.get('scriptName') or '')}</code><br><code>{esc(row.get('scriptVaHex') or '')}</code></td>"
        f"<td><code>{esc(row.get('sharedActionIdHex') or '')}</code> {esc(row.get('sharedActionName') or '')}</td>"
        f"<td><code>{esc(row.get('visibleSlotHex') or '')}</code></td>"
        f"<td><code>{esc(row.get('displayPhaseHex') or '')}</code></td>"
        f"<td>{esc(row.get('weightCount') or '')}/{esc(row.get('weightDenominator') or '')}<br>{esc(row.get('weightPercent') or '')}%<br><span>{esc(row.get('choiceWeightKind') or '')}</span></td>"
        f"<td>{esc(row.get('pairOrder') or '')}<br>{esc((row.get('variantIndex') or 0) + 1)}/{esc(row.get('variantCount') or 1)}</td>"
        f"<td><code>{esc(row.get('bytesHex') or '')}</code></td>"
        "</tr>"
        for row in (producer.get("actionRows") or [])[:500]
    )
    function_sections = "".join(
        f"""
        <section>
          <div class="section-head"><h2>{esc(row['label'])}</h2><span>{esc(row['status'])}</span></div>
          <div class="body">
            <p class="muted"><code>{esc(row['range'])}</code></p>
            <p>{esc(row['meaning'])}</p>
            <pre>{esc(chr(10).join(row['keyLines']))}</pre>
          </div>
        </section>
        """
        for row in report["functionEvidence"]
    )
    table_sections = "".join(
        "<article class='card'><h3><code>{}</code></h3><ul>{}</ul></article>".format(
            esc(table["tableVaHex"]),
            "".join(
                f"<li>{entry['index']:02d}: <code>{esc(entry['targetVaHex'])}</code> {esc(entry['label'])}</li>"
                for entry in table["entries"]
            ),
        )
        for table in report["battleVmHandlerTables"]
    )
    metrics = "".join(
        f"<div class='metric'><strong>{value}</strong><span>{esc(key)}</span></div>"
        for key, value in report["metrics"].items()
    )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>몬스터 행동 선택 정적 분석</title>
  <style>
    :root {{ --bg:#f6f7f9; --fg:#17202a; --muted:#607080; --line:#d8dee6; --head:#eef2f6; --link:#185abc; }}
    * {{ box-sizing:border-box; }}
    body {{ margin:0; background:var(--bg); color:var(--fg); font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
    main {{ max-width:1440px; margin:0 auto; padding:18px; }}
    header {{ display:flex; justify-content:space-between; gap:16px; align-items:flex-start; margin-bottom:14px; }}
    h1 {{ margin:0 0 6px; font-size:24px; letter-spacing:0; }}
    h2 {{ margin:0; font-size:17px; letter-spacing:0; }}
    h3 {{ margin:0; font-size:15px; letter-spacing:0; }}
    a {{ color:var(--link); text-decoration:none; }}
    a:hover {{ text-decoration:underline; }}
    nav {{ display:flex; flex-wrap:wrap; gap:8px; justify-content:flex-end; }}
    nav a {{ display:inline-flex; align-items:center; min-height:30px; padding:4px 9px; border:1px solid var(--line); border-radius:5px; background:white; font-size:13px; }}
    section, .card {{ background:white; border:1px solid var(--line); border-radius:8px; overflow:hidden; }}
    section {{ margin:14px 0; }}
    .section-head {{ display:flex; justify-content:space-between; gap:12px; padding:12px 14px; border-bottom:1px solid var(--line); background:var(--head); }}
    .body {{ padding:14px; }}
    .muted {{ color:var(--muted); }}
    .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(180px,1fr)); gap:10px; }}
    .metric {{ border:1px solid var(--line); border-radius:6px; background:#f8fafc; padding:10px; }}
    .metric strong {{ display:block; font-size:22px; }}
    .cards {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(260px,1fr)); gap:10px; }}
    .card {{ padding:12px; }}
    pre {{ margin:0; white-space:pre-wrap; overflow:auto; background:#101418; color:#eef6ff; border-radius:6px; padding:10px; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
    table {{ width:100%; border-collapse:collapse; }}
    th,td {{ padding:8px 10px; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; font-size:13px; }}
    th {{ background:#f8fafc; color:#344050; }}
    td code {{ font:12px ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
    .table-wrap {{ overflow:auto; }}
    @media (max-width:760px) {{ header,.section-head {{ display:block; }} nav {{ justify-content:flex-start; margin-top:10px; }} }}
  </style>
</head>
<body>
<main>
  <header>
    <div>
      <h1>몬스터 행동 선택 정적 분석</h1>
      <p class="muted">Wine/runtime 캡처 없이 EXE 바이트 스캔과 battle VM handler 디스어셈블만으로 정리한 결과.</p>
    </div>
    <nav>
      <a href="../web/battle_analysis.html">전투 분석 허브</a>
      <a href="battle_monster_action_selection_review.json">JSON</a>
      <a href="battle_monster_action_selection_review.md">MD</a>
    </nav>
  </header>
  <section>
    <div class="section-head"><h2>상태</h2><span>{esc(report['status'])}</span></div>
    <div class="body metrics">{metrics}</div>
  </section>
  <section>
    <div class="section-head"><h2>결론</h2><span>static only</span></div>
    <div class="body">
      <ul>{summary_cards}</ul>
      <h3>아직 발견되지 않은 것</h3>
      <ul>{missing_cards}</ul>
      <p><strong>현재 판단:</strong> {esc(report['summary']['currentConclusion'])}</p>
    </div>
  </section>
  <section>
    <div class="section-head"><h2>직접 field write 스캔</h2><span>+0x58/+0x59/+0x5a</span></div>
    <div class="body table-wrap">
      <table>
        <thead><tr><th>VA</th><th>명령</th><th>필드</th><th>소스</th><th>분류</th></tr></thead>
        <tbody>{field_rows}</tbody>
      </table>
    </div>
  </section>
  <section>
    <div class="section-head"><h2>몬스터 기술 plain sequence 스캔</h2><span>guide order check</span></div>
    <div class="body table-wrap">
      <table>
        <thead><tr><th>후보</th><th>바이트</th><th>hit</th><th>해석</th></tr></thead>
        <tbody>{sequence_rows}</tbody>
      </table>
    </div>
  </section>
  <section>
    <div class="section-head"><h2>Descriptor/VTable VM Producer</h2><span>{esc(producer.get('status') or '')}</span></div>
    <div class="body">
      <p class="muted">`10 c0 59 xx 10 c0 5a yy` = active actor +0x59 shared action id, +0x5a visible local slot.</p>
      <div class="table-wrap">
        <table>
          <thead><tr><th>몬스터</th><th>CNS</th><th>script</th><th>shared action</th><th>slot</th><th>phase</th><th>weight</th><th>variant</th><th>bytes</th></tr></thead>
          <tbody>{producer_rows}</tbody>
        </table>
      </div>
    </div>
  </section>
  <section>
    <div class="section-head"><h2>Battle VM Handler Tables</h2><span>selected labels</span></div>
    <div class="body cards">{table_sections}</div>
  </section>
  {function_sections}
</main>
</body>
</html>
"""


def main() -> None:
    report = build()
    OUT.mkdir(exist_ok=True)
    (OUT / "battle_monster_action_selection_review.json").write_text(
        json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (OUT / "battle_monster_action_selection_review.md").write_text(
        render_md(report), encoding="utf-8"
    )
    (OUT / "battle_monster_action_selection_review.html").write_text(
        render_html(report), encoding="utf-8"
    )
    print("wrote battle_monster_action_selection_review")


if __name__ == "__main__":
    main()
