#!/usr/bin/env python3
"""Promote 0xbd battle helper ids to their EXE dispatch table/body evidence.

The lower-level VM decode proves that opcode 0xbd advances by four bytes and
calls a helper dispatcher.  This report follows that dispatcher into the EXE:

* 0xbd handler at 0x0040eab3 calls 0x00411730.
* 0x00411730 reads script byte +1 and dispatches through table 0x00454c10.
* Each helper id therefore maps to a concrete function body.

The report intentionally stays evidence-first.  It does not claim every visual
effect is understood, but it separates no-op/shared helper slots, child display
spawners, target-range helpers, player skill references, and monster action
references.
"""
from __future__ import annotations

import html
import json
import re
import struct
import subprocess
from collections import Counter, defaultdict
from pathlib import Path
from typing import Any


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
EXE = ROOT / "Hwanse2.exe"
HELPER_JSON = OUT / "battle_helper_opcode_review.json"
SEMANTICS_JSON = OUT / "battle_effect_semantics_review.json"
MONSTER_CATALOG_JSON = OUT / "battle_monster_action_catalog.json"

IMAGE_BASE = 0x400000
HELPER_HANDLER_VA = 0x0040EAB3
HELPER_DISPATCHER_VA = 0x00411730
HELPER_TABLE_VA = 0x00454C10
CHILD_SCRIPT_TABLE_PTR_VA = 0x00442DA1

KNOWN_CALLS = {
    0x00411730: "0xbd helper dispatcher",
    0x004166CE: "display/script helper",
    0x00416CE2: "resolve display/effect rect",
    0x004175D3: "spawn positioned visual primitive",
    0x00417879: "prepare/copy visual buffer",
    0x0041B480: "blit/effect renderer",
    0x0042119B: "child display motion/timer setup",
    0x00433C99: "target range begin",
    0x00433DC0: "target range end",
    0x00435B5B: "allocate display object",
}


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


def load_json(path: Path) -> dict[str, Any]:
    return json.loads(path.read_text(encoding="utf-8"))


def pe_sections(blob: bytes) -> tuple[int, list[dict[str, int | str]]]:
    pe_offset = struct.unpack_from("<I", blob, 0x3C)[0]
    opt_size = struct.unpack_from("<H", blob, pe_offset + 20)[0]
    image_base = struct.unpack_from("<I", blob, pe_offset + 52)[0]
    section_count = struct.unpack_from("<H", blob, pe_offset + 6)[0]
    section_base = pe_offset + 24 + opt_size
    sections: list[dict[str, int | str]] = []
    for index in range(section_count):
        offset = section_base + index * 40
        name = blob[offset : offset + 8].rstrip(b"\0").decode("ascii", "replace")
        virtual_size, virtual_address, raw_size, raw_ptr = struct.unpack_from("<IIII", blob, offset + 8)
        sections.append(
            {
                "name": name,
                "virtualAddress": virtual_address,
                "virtualSize": virtual_size,
                "rawSize": raw_size,
                "rawPtr": raw_ptr,
            }
        )
    return image_base, sections


def va_to_offset(va: int, sections: list[dict[str, int | str]], image_base: int = IMAGE_BASE) -> int | None:
    rva = va - image_base
    for section in sections:
        start = int(section["virtualAddress"])
        span = max(int(section["virtualSize"]), int(section["rawSize"]))
        if start <= rva < start + span:
            return int(section["rawPtr"]) + (rva - start)
    return None


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


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}"
    lines = []
    for line in result.stdout.splitlines():
        if re.match(r"\s*[0-9a-f]{6,8}:", line):
            lines.append(line)
    return "\n".join(lines)


def extract_calls(disasm: str) -> list[dict[str, Any]]:
    calls = []
    for line in disasm.splitlines():
        match = re.search(r"\bcall\s+(?:DWORD PTR )?(?:.*?0x)?([0-9a-f]{6,8})\b", line)
        if not match:
            continue
        target = int(match.group(1), 16)
        calls.append({"targetVaHex": f"0x{target:08x}", "meaning": KNOWN_CALLS.get(target, "unknown"), "line": line.strip()})
    return calls


def script_table_offsets(disasm: str) -> list[str]:
    offsets = []
    lines = disasm.splitlines()
    for index, line in enumerate(lines):
        if "0x442da1" not in line:
            continue
        window = "\n".join(lines[index : index + 4])
        for match in re.finditer(r"\[e(?:a|c)x(?:\+ecx\*4)?(?:\+0x([0-9a-f]+))?\]", window):
            value = match.group(1)
            offsets.append("0x00" if value is None else f"0x{int(value, 16):02x}")
    return sorted(set(offsets), key=lambda item: int(item, 16))


def function_features(disasm: str) -> dict[str, Any]:
    calls = extract_calls(disasm)
    call_counts = Counter(call["targetVaHex"] for call in calls)
    known_call_names = sorted({call["meaning"] for call in calls if call["meaning"] != "unknown"})
    alloc_count = call_counts.get("0x00435b5b", 0)
    target_range = bool(call_counts.get("0x00433c99") or call_counts.get("0x00433dc0"))
    function = {
        "instructionLines": len([line for line in disasm.splitlines() if line.strip()]),
        "calls": calls,
        "knownCalls": known_call_names,
        "allocationCount": alloc_count,
        "targetRangeLoop": target_range,
        "usesHelperIdAsScriptTableIndex": bool(re.search(r"8a\s+48\s+01", disasm) and "0x442da1" in disasm),
        "scriptTableOffsets": script_table_offsets(disasm),
        "readsChildScriptTablePtr": f"0x{CHILD_SCRIPT_TABLE_PTR_VA:06x}" in disasm or "0x442da1" in disasm,
        "setsActorBusyFlag0x02": "or     DWORD PTR [eax+0x5c],0x2" in disasm,
        "setsActorFlowFlag0x100": "0x100" in disasm and "[eax+0x5c]" in disasm,
        "writesDisplayScriptPtr": "[ecx+0x40]" in disasm or "[eax+0x40]" in disasm,
        "writesDisplayXy": "[ecx+0x1c]" in disasm or "[eax+0x1c]" in disasm or "[ecx+0x20]" in disasm or "[eax+0x20]" in disasm,
        "writesVisualIndex0x28": "[ecx+0x28]" in disasm or "[eax+0x28]" in disasm,
        "writesDisplaySizeE6E7": "0xe6" in disasm or "0xe7" in disasm,
        "constants": sorted(set(re.findall(r"0x(?:1000[0-9a-f]+|300[0-9a-f]+|[0-9a-f]{6,8})", disasm)))[:24],
    }
    return function


def classify_body(ptr: int, features: dict[str, Any]) -> str:
    if ptr == 0x00411772:
        return "no-op helper slot"
    if features["targetRangeLoop"] and features["allocationCount"]:
        return "target-range child visual spawner"
    if "child display motion/timer setup" in features["knownCalls"]:
        return "timed child visual/motion setup"
    if "blit/effect renderer" in features["knownCalls"]:
        return "bitmap/effect blit spawner"
    if "spawn positioned visual primitive" in features["knownCalls"]:
        return "positioned visual primitive spawner"
    if features["allocationCount"]:
        return "child display script spawner"
    if features["setsActorBusyFlag0x02"] or features["setsActorFlowFlag0x100"]:
        return "actor flag/helper marker"
    return "unclassified helper body"


def skill_label(row: dict[str, Any]) -> str:
    return f"{row.get('ownerName')} {row.get('skillName')} {row.get('skillIdHex')}"


def monster_action_label(row: dict[str, Any]) -> str:
    return f"{row.get('enemyName')} {row.get('sharedActionName')} {row.get('sharedActionIdHex')} slot {row.get('visibleSlotHex')}"


def build() -> dict[str, Any]:
    blob = EXE.read_bytes()
    image_base, sections = pe_sections(blob)
    helper_report = load_json(HELPER_JSON)
    semantics_report = load_json(SEMANTICS_JSON)
    monster_catalog = load_json(MONSTER_CATALOG_JSON) if MONSTER_CATALOG_JSON.exists() else {}
    child_script_table_base = read_u32(blob, sections, CHILD_SCRIPT_TABLE_PTR_VA)

    semantics_by_key = {
        (row.get("ownerKey"), str(row.get("skillIdHex")).lower()): row
        for row in semantics_report.get("rows") or []
    }
    player_helper_ids = {
        helper_id
        for row in helper_report.get("rows") or []
        for helper_id in row.get("helperIds") or []
    }
    monster_helper_ids = {
        int(call.get("helperId"))
        for row in monster_catalog.get("actions") or []
        for call in (row.get("displayEntry") or {}).get("helperCalls") or []
        if call.get("helperId") is not None
    }
    used_helper_ids = sorted(player_helper_ids | monster_helper_ids)
    table_rows = []
    ptr_to_ids: defaultdict[int, list[int]] = defaultdict(list)
    for helper_id in used_helper_ids:
        ptr = read_u32(blob, sections, HELPER_TABLE_VA + helper_id * 4)
        ptr_to_ids[ptr].append(helper_id)

    unique_ptrs = sorted(ptr_to_ids)
    function_stop: dict[int, int] = {}
    for index, ptr in enumerate(unique_ptrs):
        next_ptr = unique_ptrs[index + 1] if index + 1 < len(unique_ptrs) else ptr + 0x300
        function_stop[ptr] = min(next_ptr, ptr + 0x500)

    function_groups = []
    features_by_ptr: dict[int, dict[str, Any]] = {}
    disasm_excerpt_by_ptr: dict[int, str] = {}
    for ptr in unique_ptrs:
        disasm = disassemble(ptr, function_stop[ptr])
        features = function_features(disasm)
        classification = classify_body(ptr, features)
        features_by_ptr[ptr] = features | {"classification": classification}
        disasm_excerpt_by_ptr[ptr] = "\n".join(disasm.splitlines()[:32])

    helper_to_rows: defaultdict[int, list[dict[str, Any]]] = defaultdict(list)
    for row in helper_report.get("rows") or []:
        for helper_id in row.get("helperIds") or []:
            helper_to_rows[helper_id].append(row)
    helper_to_monster_rows: defaultdict[int, list[dict[str, Any]]] = defaultdict(list)
    for row in monster_catalog.get("actions") or []:
        for call in (row.get("displayEntry") or {}).get("helperCalls") or []:
            if call.get("helperId") is not None:
                helper_to_monster_rows[int(call["helperId"])].append(row)

    for ptr, helper_ids in sorted(ptr_to_ids.items()):
        rows = [row for helper_id in helper_ids for row in helper_to_rows.get(helper_id, [])]
        monster_rows = [row for helper_id in helper_ids for row in helper_to_monster_rows.get(helper_id, [])]
        semantic_rows = [
            semantics_by_key.get((row.get("ownerKey"), str(row.get("skillIdHex")).lower()), {})
            for row in rows
        ]
        feature = features_by_ptr[ptr]
        function_groups.append(
            {
                "functionVaHex": f"0x{ptr:08x}",
                "helperIds": helper_ids,
                "classification": feature["classification"],
                "skills": sorted({skill_label(row) for row in rows}),
                "monsterActions": sorted({monster_action_label(row) for row in monster_rows}),
                "renderTracks": sorted({str(row.get("renderTrack")) for row in semantic_rows if row.get("renderTrack")}),
                "positionClasses": sorted({str(row.get("positionClass")) for row in rows if row.get("positionClass")}),
                "effectWlkNos": sorted({wlk for row in rows for wlk in row.get("effectWlkNos") or []}),
                "resultWlkNos": sorted({wlk for row in rows for wlk in row.get("rawResultWlkNos") or []}),
                "childScriptVas": sorted(
                    {
                        f"0x{read_u32(blob, sections, child_script_table_base + helper_id * 4):08x}"
                        for helper_id in helper_ids
                        if va_to_offset(child_script_table_base + helper_id * 4, sections) is not None
                        and read_u32(blob, sections, child_script_table_base + helper_id * 4) != 0
                    }
                ),
                "features": feature,
                "disasmExcerpt": disasm_excerpt_by_ptr[ptr],
            }
        )

    helper_rows = []
    for helper_id in used_helper_ids:
        ptr = read_u32(blob, sections, HELPER_TABLE_VA + helper_id * 4)
        rows = helper_to_rows.get(helper_id, [])
        monster_rows = helper_to_monster_rows.get(helper_id, [])
        semantic_rows = [
            semantics_by_key.get((row.get("ownerKey"), str(row.get("skillIdHex")).lower()), {})
            for row in rows
        ]
        feature = features_by_ptr[ptr]
        child_script_va = read_u32(blob, sections, child_script_table_base + helper_id * 4)
        child_script_offset = va_to_offset(child_script_va, sections) if child_script_va else None
        helper_rows.append(
            {
                "helperId": helper_id,
                "functionVaHex": f"0x{ptr:08x}",
                "childScriptVaHex": f"0x{child_script_va:08x}" if child_script_va else "",
                "childScriptHeadBytes": blob[child_script_offset : child_script_offset + 16].hex(" ") if child_script_offset is not None else "",
                "bodyClass": feature["classification"],
                "skillRows": [
                    {
                        "ownerName": row.get("ownerName"),
                        "skillName": row.get("skillName"),
                        "skillIdHex": row.get("skillIdHex"),
                        "levelOrFixed": row.get("levelOrFixed"),
                        "positionClass": row.get("positionClass"),
                        "renderTrack": semantic.get("renderTrack"),
                        "effectWlkNos": row.get("effectWlkNos") or [],
                        "resultWlkNos": row.get("rawResultWlkNos") or [],
                    }
                    for row, semantic in zip(rows, semantic_rows)
                ],
                "monsterRows": [
                    {
                        "enemyName": row.get("enemyName"),
                        "cns": row.get("cns"),
                        "sharedActionName": row.get("sharedActionName"),
                        "sharedActionIdHex": row.get("sharedActionIdHex"),
                        "visibleSlotHex": row.get("visibleSlotHex"),
                        "targetScope": row.get("targetScope"),
                        "resultFamily": row.get("resultFamily"),
                    }
                    for row in monster_rows
                ],
                "featureSummary": {
                    "allocationCount": feature["allocationCount"],
                    "targetRangeLoop": feature["targetRangeLoop"],
                    "usesHelperIdAsScriptTableIndex": feature["usesHelperIdAsScriptTableIndex"],
                    "scriptTableOffsets": feature["scriptTableOffsets"],
                    "knownCalls": feature["knownCalls"],
                    "setsActorBusyFlag0x02": feature["setsActorBusyFlag0x02"],
                    "setsActorFlowFlag0x100": feature["setsActorFlowFlag0x100"],
                    "writesDisplayXy": feature["writesDisplayXy"],
                    "writesVisualIndex0x28": feature["writesVisualIndex0x28"],
                },
            }
        )

    progression_groups = []
    by_family: defaultdict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
    for row in helper_report.get("rows") or []:
        if row.get("helperIds"):
            by_family[(str(row.get("ownerName")), str(row.get("familyName") or row.get("skillName")))].append(row)
    for (owner, family), rows in sorted(by_family.items()):
        if len(rows) < 2:
            continue
        progression_groups.append(
            {
                "ownerName": owner,
                "familyName": family,
                "skillIds": [row.get("skillIdHex") for row in rows],
                "helperIds": [row.get("helperIds") or [] for row in rows],
                "functionVas": [
                    [f"0x{read_u32(blob, sections, HELPER_TABLE_VA + helper_id * 4):08x}" for helper_id in row.get("helperIds") or []]
                    for row in rows
                ],
                "effectWlkNos": [row.get("effectWlkNos") or [] for row in rows],
                "resultWlkNos": [row.get("rawResultWlkNos") or [] for row in rows],
                "positionClass": rows[0].get("positionClass"),
            }
        )

    return {
        "version": 1,
        "kind": "hwanse-battle-helper-body-review",
        "source": [
            "Hwanse2.exe",
            "out/battle_helper_opcode_review.json",
            "out/battle_effect_semantics_review.json",
            "out/battle_monster_action_catalog.json",
        ],
        "status": "0xbd-dispatch-table-and-function-body-evidence",
        "runtimeUsed": False,
        "dispatcherEvidence": {
            "handlerVaHex": f"0x{HELPER_HANDLER_VA:08x}",
            "dispatcherVaHex": f"0x{HELPER_DISPATCHER_VA:08x}",
            "tableVaHex": f"0x{HELPER_TABLE_VA:08x}",
            "childScriptTablePtrVaHex": f"0x{CHILD_SCRIPT_TABLE_PTR_VA:08x}",
            "childScriptTableBaseVaHex": f"0x{child_script_table_base:08x}",
            "rule": "0xbd handler calls 0x411730; dispatcher reads script[1] and calls dword ptr [0x454c10 + id*4].",
            "idWidth": "low byte at opcode +1; observed ids are 0..126 in current battle skills",
        },
        "summary": {
            "usedHelperIds": len(used_helper_ids),
            "playerHelperIds": len(player_helper_ids),
            "monsterHelperIds": len(monster_helper_ids),
            "uniqueFunctionBodies": len(function_groups),
            "bodyClassCounts": dict(Counter(group["classification"] for group in function_groups)),
            "helpersUsingSharedScriptTableIndex": sum(1 for row in helper_rows if row["featureSummary"]["usesHelperIdAsScriptTableIndex"]),
            "helpersWithTargetRangeLoop": sum(1 for row in helper_rows if row["featureSummary"]["targetRangeLoop"]),
            "helpersWithChildScriptVa": sum(1 for row in helper_rows if row["childScriptVaHex"]),
        },
        "interpretationNotes": [
            "0xbd helper ids are concrete EXE dispatch-table entries, not anonymous cleanup markers.",
            "Shared function pointers mean several helper ids run the same code body but often use the id as a script/effect table index.",
            "Many helper bodies attach a spawned child display object to a script VA from the child script table stored at 0x442da1.",
            "This report uses static helper ids from player display scripts and monster display scripts; no Wine/runtime observation is consumed.",
            "Actor-only frame runners should not fake helper-heavy skills. These rows need child visual/effect spawning from the helper body.",
            "This report promotes helper dispatch and body classes, but not yet the internal child script frame sequence for every spawned object.",
        ],
        "functionGroups": function_groups,
        "helperRows": helper_rows,
        "progressionGroups": progression_groups,
    }


def vec(values: list[Any], prefix: str = "") -> str:
    return ", ".join(f"{prefix}{value}" for value in values) if values else "-"


def wlk_vec(values: list[Any]) -> str:
    return ", ".join(f"WLK id {int(value):02d}" for value in values) if values else "-"


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Helper Body Review",
        "",
        f"- status: `{report['status']}`",
        f"- helper ids: `{report['summary']['usedHelperIds']}`",
        f"- function bodies: `{report['summary']['uniqueFunctionBodies']}`",
        f"- player helper ids: `{report['summary']['playerHelperIds']}`",
        f"- monster helper ids: `{report['summary']['monsterHelperIds']}`",
        "",
        "## Dispatcher Evidence",
        "",
        f"- handler: `{report['dispatcherEvidence']['handlerVaHex']}`",
        f"- dispatcher: `{report['dispatcherEvidence']['dispatcherVaHex']}`",
        f"- table: `{report['dispatcherEvidence']['tableVaHex']}`",
        f"- child script table pointer: `{report['dispatcherEvidence']['childScriptTablePtrVaHex']}` -> `{report['dispatcherEvidence']['childScriptTableBaseVaHex']}`",
        f"- rule: {report['dispatcherEvidence']['rule']}",
        "",
        "## Interpretation",
        "",
    ]
    lines.extend(f"- {note}" for note in report["interpretationNotes"])
    lines.extend(
        [
            "",
            "## Function Groups",
            "",
            "| function | ids | class | calls | player skills | monster actions |",
            "| --- | --- | --- | --- | --- | --- |",
        ]
    )
    for group in report["functionGroups"]:
        calls = vec(group["features"]["knownCalls"])
        lines.append(
            f"| `{group['functionVaHex']}` | {vec(group['helperIds'])} | `{group['classification']}` | "
            f"{calls}; child scripts {vec(group['childScriptVas'])} | {'; '.join(group['skills'][:8])} | {'; '.join(group['monsterActions'][:8])} |"
        )
    lines.extend(
        [
            "",
            "## Helper Rows",
            "",
            "| id | function | child script | body | player skills | monster actions | feature summary |",
            "| ---: | --- | --- | --- | --- | --- | --- |",
        ]
    )
    for row in report["helperRows"]:
        skills = "; ".join(f"{item['ownerName']} {item['skillName']} {item['skillIdHex']}" for item in row["skillRows"])
        monsters = "; ".join(f"{item['enemyName']} {item['sharedActionName']} {item['visibleSlotHex']}" for item in row["monsterRows"])
        features = row["featureSummary"]
        summary = (
            f"alloc={features['allocationCount']}, range={features['targetRangeLoop']}, "
            f"idIndex={features['usesHelperIdAsScriptTableIndex']}, calls={vec(features['knownCalls'])}"
        )
        lines.append(
            f"| {row['helperId']} | `{row['functionVaHex']}` | `{row['childScriptVaHex'] or '-'}` | "
            f"`{row['bodyClass']}` | {skills} | {monsters} | {summary} |"
        )
    return "\n".join(lines) + "\n"


def html_page(report: dict[str, Any]) -> str:
    summary_rows = "".join(f"<tr><td>{esc(k)}</td><td><code>{esc(v)}</code></td></tr>" for k, v in report["summary"].items())
    notes = "".join(f"<li>{esc(note)}</li>" for note in report["interpretationNotes"])
    function_rows = []
    for group in report["functionGroups"]:
        function_rows.append(
            "<tr>"
            f"<td><code>{esc(group['functionVaHex'])}</code></td>"
            f"<td>{esc(vec(group['helperIds']))}</td>"
            f"<td><code>{esc(group['classification'])}</code></td>"
            f"<td>{esc(vec(group['childScriptVas']))}</td>"
            f"<td>{esc(vec(group['renderTracks']))}</td>"
            f"<td>{esc(vec(group['positionClasses']))}</td>"
            f"<td>{esc(wlk_vec(group['effectWlkNos']))}</td>"
            f"<td>{esc(wlk_vec(group['resultWlkNos']))}</td>"
            f"<td>{esc(vec(group['features']['knownCalls']))}</td>"
            f"<td><details><summary>{esc(len(group['skills']))} skills</summary>{esc('; '.join(group['skills']))}</details></td>"
            f"<td><details><summary>{esc(len(group['monsterActions']))} monster actions</summary>{esc('; '.join(group['monsterActions']))}</details></td>"
            f"<td><details><summary>excerpt</summary><pre>{esc(group['disasmExcerpt'])}</pre></details></td>"
            "</tr>"
        )
    helper_rows = []
    for row in report["helperRows"]:
        skills = "<br>".join(
            f"{esc(item['ownerName'])} {esc(item['skillName'])} <code>{esc(item['skillIdHex'])}</code> "
            f"<span>{esc(item.get('renderTrack') or '-')}</span>"
            for item in row["skillRows"]
        ) or "-"
        monsters = "<br>".join(
            f"{esc(item['enemyName'])} {esc(item['sharedActionName'])} <code>{esc(item['visibleSlotHex'])}</code> "
            f"<span>{esc(item.get('targetScope') or '-')} / {esc(item.get('resultFamily') or '-')}</span>"
            for item in row["monsterRows"]
        ) or "-"
        features = row["featureSummary"]
        helper_rows.append(
            "<tr>"
            f"<td><code>{esc(row['helperId'])}</code></td>"
            f"<td><code>{esc(row['functionVaHex'])}</code></td>"
            f"<td><code>{esc(row['childScriptVaHex'] or '-')}</code><br>{esc(row['childScriptHeadBytes'])}</td>"
            f"<td><code>{esc(row['bodyClass'])}</code></td>"
            f"<td>{skills}</td>"
            f"<td>{monsters}</td>"
            f"<td>alloc={esc(features['allocationCount'])}<br>range={esc(features['targetRangeLoop'])}<br>"
            f"id-index={esc(features['usesHelperIdAsScriptTableIndex'])}<br>script offsets={esc(vec(features['scriptTableOffsets']))}<br>"
            f"calls={esc(vec(features['knownCalls']))}</td>"
            "</tr>"
        )
    progression_rows = []
    for group in report["progressionGroups"]:
        progression_rows.append(
            "<tr>"
            f"<td>{esc(group['ownerName'])}</td>"
            f"<td>{esc(group['familyName'])}</td>"
            f"<td>{esc(vec(group['skillIds']))}</td>"
            f"<td>{esc(' / '.join(vec(ids) for ids in group['helperIds']))}</td>"
            f"<td>{esc(' / '.join(vec(ids) for ids in group['functionVas']))}</td>"
            f"<td>{esc(' / '.join(wlk_vec(ids) for ids in group['effectWlkNos']))}</td>"
            f"<td>{esc(' / '.join(wlk_vec(ids) for ids in group['resultWlkNos']))}</td>"
            f"<td>{esc(group.get('positionClass') or '-')}</td>"
            "</tr>"
        )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="icon" href="../favicon.ico">
  <title>Battle Helper Body Review</title>
  <style>
    body {{ margin: 20px; background: #101114; color: #f1f3f5; font-family: system-ui, sans-serif; }}
    a {{ color: #9ecbff; }} code {{ color: #ffd37a; }} pre {{ white-space: pre-wrap; font-size: 11px; line-height: 1.35; }}
    table {{ width: 100%; border-collapse: collapse; margin: 14px 0 24px; }}
    th, td {{ border: 1px solid #30343d; padding: 6px 8px; font-size: 12px; vertical-align: top; }}
    th {{ background: #1a1d24; color: #bac2cf; position: sticky; top: 0; z-index: 2; }}
    tr:nth-child(even) td {{ background: #141820; }}
    .grid {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; }}
    .panel {{ border: 1px solid #30343d; border-radius: 8px; padding: 12px; background: #151821; }}
    .wide {{ overflow: auto; max-height: 78vh; border: 1px solid #30343d; }}
  </style>
</head>
<body>
  <h1>Battle Helper Body Review</h1>
  <p><a href="../web/index.html">홈</a> · <a href="../web/battle_simulator.html">전투 기술 실행</a> · <a href="battle_helper_body_review.json">JSON</a> · <a href="battle_helper_opcode_review.html">helper opcode</a> · <a href="battle_effect_semantics_review.html">이펙트 의미</a></p>
  <div class="grid">
    <section class="panel"><h2>Summary</h2><table><tbody>{summary_rows}</tbody></table></section>
    <section class="panel"><h2>Dispatcher</h2><p><code>{esc(report['dispatcherEvidence']['handlerVaHex'])}</code> -> <code>{esc(report['dispatcherEvidence']['dispatcherVaHex'])}</code> -> <code>{esc(report['dispatcherEvidence']['tableVaHex'])}</code></p><p>child script table <code>{esc(report['dispatcherEvidence']['childScriptTablePtrVaHex'])}</code> -> <code>{esc(report['dispatcherEvidence']['childScriptTableBaseVaHex'])}</code></p><p>{esc(report['dispatcherEvidence']['rule'])}</p></section>
    <section class="panel"><h2>Interpretation</h2><ul>{notes}</ul></section>
  </div>
  <h2>Function Groups</h2>
  <div class="wide"><table><thead><tr><th>function</th><th>ids</th><th>class</th><th>child scripts</th><th>tracks</th><th>positions</th><th>0x24</th><th>0xc2</th><th>known calls</th><th>skills</th><th>monster actions</th><th>disasm</th></tr></thead><tbody>{''.join(function_rows)}</tbody></table></div>
  <h2>Helper Rows</h2>
  <div class="wide"><table><thead><tr><th>id</th><th>function</th><th>child script</th><th>body</th><th>skills</th><th>monster actions</th><th>features</th></tr></thead><tbody>{''.join(helper_rows)}</tbody></table></div>
  <h2>Level / Family Progressions</h2>
  <div class="wide"><table><thead><tr><th>actor</th><th>family</th><th>skill ids</th><th>helper ids</th><th>functions</th><th>0x24</th><th>0xc2</th><th>position</th></tr></thead><tbody>{''.join(progression_rows)}</tbody></table></div>
</body>
</html>
"""


def main() -> None:
    report = build()
    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / "battle_helper_body_review.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    (OUT / "battle_helper_body_review.html").write_text(html_page(report), encoding="utf-8")
    print("wrote out/battle_helper_body_review.{json,html}")


if __name__ == "__main__":
    main()
