#!/usr/bin/env python3
"""Build a compact static-analysis note from the normal HUD trace sheet."""
from __future__ import annotations

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


ROOT = Path(__file__).resolve().parents[1]
EXE = ROOT / "Hwanse2.exe"
STATE = ROOT / "data" / "hud_trace_state.json"
OUT_JSON = ROOT / "out" / "hud_normal_static_hint_review.json"

IMAGE_BASE = 0x400000


SECTIONS = [
    {"name": ".text", "va": 0x1000, "raw": 0x400, "size": 0x39800},
    {"name": ".rdata", "va": 0x3B000, "raw": 0x39C00, "size": 0x400},
    {"name": ".data", "va": 0x3C000, "raw": 0x3A000, "size": 0x11DE00},
]


RESOURCE_RECORDS = {
    "frame.cns": {"record_va": 0x0047E954, "id": 0x00021001, "meta_va": 0x0047E3D0},
    "status.cns": {"record_va": 0x0047E984, "id": 0x000E1001, "meta_va": 0x0047E3E0},
    "num.cns": {"record_va": 0x0047E990, "id": 0x00071002, "cell": [16, 16]},
    "01234567.cns": {"record_va": 0x0047E99C, "id": 0x00081002, "cell": [8, 8]},
    "window.cns": {"record_va": 0x0047E9A8, "id": 0x00091007, "cell": [16, 16], "extra_va": 0x004DC520},
}


STATUS_BAR_TABLE_VA = 0x00487148
NORMAL_HUD_STATUS_RECORD_TABLE_VA = 0x004870A8
NORMAL_HUD_STATUS_RECORD_STRIDE = 0x0C
NORMAL_HUD_STATUS_RECORD_COUNT = 0x9C // NORMAL_HUD_STATUS_RECORD_STRIDE
NORMAL_HUD_STATUS_PRESENCE_CONSUMER_VA = 0x00420980
NORMAL_HUD_STATUS_DRAW_CONSUMER_VA = 0x004209EE
NORMAL_HUD_GAUGE_CONSUMER_VA = 0x00420B1A
NORMAL_HUD_NUMBER_CONSUMER_VA = 0x00420EBC
ACTOR_POINTER_TABLE_VA = 0x0059DB30
ACTIVE_PARTY_COUNT_VA = 0x004576E8
ACTIVE_PARTY_ORDER_VA = 0x004576E9
ACTOR_SLOT_BASE_VA = 0x00457750
ACTOR_SLOT_STRIDE = 0x00D8
ACTIVE_PARTY_ADD_ROUTINE_VA = 0x0043215C
ACTIVE_PARTY_REBUILD_ROUTINE_VA = 0x00432323
ACTIVE_PARTY_REMOVE_ROUTINE_VA = 0x00432541
NORMAL_HUD_SCRIPT_REBUILD_VA = 0x004DDA94
NORMAL_HUD_SCRIPT_REFRESH_VA = 0x004DDA98
TEXT_VM_HANDLER_TABLE_VA = 0x0047F1D8
TEXT_VM_DISPATCHER_VA = 0x0041B687
TEXT_VM_DISPATCH_CALL_VA = 0x0041B6CD
NORMAL_HUD_ACTOR_NAME_BRIDGE_VA = 0x0041E93F
NORMAL_HUD_ACTOR_NAME_BRIDGE_ENTRY_VA = 0x0047F260
NORMAL_HUD_ACTOR_NAME_BRIDGE_OPCODE = (
    NORMAL_HUD_ACTOR_NAME_BRIDGE_ENTRY_VA - TEXT_VM_HANDLER_TABLE_VA
) // 4
ACTOR_NAME_TABLE_VA = 0x004EC280
ACTOR_NAME_MAIN_PARTY = [
    {"actor_id": 0, "name": "아타호", "text_va": 0x004EC298},
    {"actor_id": 1, "name": "린샹", "text_va": 0x004EC2A2},
    {"actor_id": 2, "name": "스마슈", "text_va": 0x004EC2AA},
]
ACTOR_NAME_SAMPLE_MONSTERS = [
    {"name": "？？？", "text_va": 0x004EC408},
    {"name": "원숭이", "text_va": 0x004EC412},
    {"name": "취한 원숭이", "text_va": 0x004EC41C},
    {"name": "견원이", "text_va": 0x004EC42C},
    {"name": "언원이", "text_va": 0x004EC436},
]
TEXT_VM_SET_PEN_HANDLER_VA = 0x0041BD4F
TEXT_VM_SET_PEN_ENTRY_VA = 0x0047F210
TEXT_VM_SET_PEN_OPCODE = (TEXT_VM_SET_PEN_ENTRY_VA - TEXT_VM_HANDLER_TABLE_VA) // 4
NORMAL_HUD_ACTOR_NAME_POSITION_STREAM_VA = 0x004E803C
NORMAL_HUD_ACTOR_NAME_POSITION_ROWS = [
    {
        "display_slot": 0,
        "set_pen_va": 0x004E803C,
        "x": 0x18,
        "y": 0x20,
        "name_opcode_va": 0x004E8044,
        "name_opcode_bytes": "40 22 01 00",
    },
    {
        "display_slot": 1,
        "set_pen_va": 0x004E8048,
        "x": 0x18,
        "y": 0x40,
        "name_opcode_va": 0x004E8050,
        "name_opcode_bytes": "40 22 01 01",
    },
    {
        "display_slot": 2,
        "set_pen_va": 0x004E8054,
        "x": 0x18,
        "y": 0x60,
        "name_opcode_va": 0x004E805C,
        "name_opcode_bytes": "40 22 01 02",
    },
]
NORMAL_HUD_ACTOR_NAME_FALLBACK_POSITION_ROWS = [
    {"display_slot": 3, "set_pen_va": 0x004E80E8, "x": 0x18, "y": 0x18, "name_opcode_va": 0x004E80F0, "name_opcode_bytes": "40 22 01 03"},
    {"display_slot": 4, "set_pen_va": 0x004E80F4, "x": 0x18, "y": 0x30, "name_opcode_va": 0x004E80FC, "name_opcode_bytes": "40 22 01 04"},
    {"display_slot": 5, "set_pen_va": 0x004E8100, "x": 0x18, "y": 0x48, "name_opcode_va": 0x004E8108, "name_opcode_bytes": "40 22 01 05"},
    {"display_slot": 6, "set_pen_va": 0x004E810C, "x": 0x18, "y": 0x60, "name_opcode_va": 0x004E8114, "name_opcode_bytes": "40 22 01 06"},
]
NORMAL_HUD_REGION1_ORIGIN = {"region": 1, "x": 0, "y": 352}
WINDOW_TEMPLATE_TABLE_VA = 0x004DC520
SCREEN_REGION_TABLE_VA = 0x004548B0
SCREEN_REGION_RESOURCE_TABLE_VA = 0x00454B60
SCREEN_REGION_DISPATCH_TABLE_VA = 0x00454C10
SCREEN_REGION_INIT_CALL_VA = 0x00411399
SCREEN_REGION_DRAW_FUNCTION_VA = 0x0041B579
SCREEN_REGION_TEMPLATE_BLIT_VA = 0x004175D3
STATUS_BAR_SOURCE_RECTS = [
    {"name": "current_fill_bar", "source_va": 0x00487148},
    {"name": "latched_delta_bar", "source_va": 0x00487158},
    {"name": "gauge_back_bar", "source_va": 0x00487168},
    {"name": "number_back_bar", "source_va": 0x00487178},
]

STATUS_RECORD_TYPE_NAMES = {
    0: "HP",
    1: "MP",
    2: "EXP",
}

STATUS_RECORD_ACTOR_FIELD_OFFSETS = {
    0: {"current": 0x08, "max": 0x0A},
    1: {"current": 0x0E, "max": 0x10},
    2: {"current": 0x14, "max": 0x16},
}


def va_to_off(va: int) -> int:
    rva = va - IMAGE_BASE
    for sec in SECTIONS:
        if sec["va"] <= rva < sec["va"] + sec["size"]:
            return sec["raw"] + (rva - sec["va"])
    raise ValueError(f"VA outside known sections: 0x{va:08x}")


def u32(data: bytes, off: int) -> int:
    return struct.unpack_from("<I", data, off)[0]


def read_cp949_text_atom(data: bytes, va: int, limit: int = 96) -> str:
    off = va_to_off(va)
    raw = bytearray()
    for b in data[off : off + limit]:
        raw.append(b)
        if b == 0:
            raw.pop()
            break
        if len(raw) >= 2 and raw[-2:] == b"@\n":
            break
    return bytes(raw).decode("cp949", errors="replace")


def rect_xyxy(data: bytes, va: int) -> dict[str, int]:
    off = va_to_off(va)
    x0, y0, x1, y1 = struct.unpack_from("<4I", data, off)
    return {"x": x0, "y": y0, "w": x1 - x0, "h": y1 - y0, "x1": x1, "y1": y1}


def read_window_templates(data: bytes) -> list[dict]:
    templates = []
    off = va_to_off(WINDOW_TEMPLATE_TABLE_VA)
    for index in range(64):
        dim, ptr = struct.unpack_from("<II", data, off + index * 8)
        width = dim & 0xFFFF
        height = dim >> 16
        if not (0x004DC000 <= ptr <= 0x004DE500):
            break
        ptr_off = va_to_off(ptr)
        count = width * height
        tile_ids = list(struct.unpack_from("<" + "H" * count, data, ptr_off))
        templates.append(
            {
                "index": index,
                "dim": f"0x{dim:08x}",
                "width_tiles": width,
                "height_tiles": height,
                "template_va": ptr,
                "tile_ids": tile_ids,
                "unique_tile_ids": sorted(set(tile_ids)),
                "first_rows": [
                    tile_ids[y * width : (y + 1) * width]
                    for y in range(min(height, 3))
                ],
            }
        )
    return templates


def read_screen_region_rects(data: bytes, count: int = 16) -> list[dict]:
    rects = []
    for index in range(count):
        va = SCREEN_REGION_TABLE_VA + index * 16
        rect = rect_xyxy(data, va)
        rects.append({"index": index, "source_va": va, "rect": rect})
    return rects


def region_role(index: int, rect: dict[str, int], resource_id: int) -> str:
    template_index = resource_id & 0xFFFF
    coords = (rect["x"], rect["y"], rect["x1"], rect["y1"])
    if coords == (0, 352, 416, 480) and template_index == 1:
        return "confirmed normal HUD left panel, window template #01 26x8"
    if coords == (416, 352, 640, 480) and template_index == 4:
        return "confirmed normal HUD right panel, window template #04 14x8"
    if index == 6 and template_index == 0:
        return "large 26x22 window region candidate, likely full/map-side menu background"
    if 9 <= index <= 13 and 6 <= template_index <= 10:
        return "22-wide variable-height prompt/dialog window candidate"
    if index == 14 and template_index == 11:
        return "19x15 menu/status window candidate"
    if 15 <= index <= 17 and template_index == 12:
        return "repeated 8x5 small panel candidate"
    if index == 8 and template_index == 13:
        return "5x7 solid-fill/blank patch window candidate"
    if index == 39 and template_index == 14:
        return "14x3 short caption/skill-name frame candidate"
    if index == 40 and template_index == 15:
        return "14x3 alternate short caption frame candidate"
    if index == 42 and template_index == 16:
        return "11x4 compact window candidate"
    if (resource_id >> 16) == 0x0009:
        return f"window.cns template #{template_index:02d} region candidate"
    return "no window template; likely raw text/glyph/fill sub-region"


def read_screen_region_bindings(data: bytes) -> list[dict]:
    # 0x4548b0..0x454b5f holds 43 RECTs. 0x454b60 is a parallel table
    # consumed by 0x41b579 to select the CNS/template for the same index.
    rect_count = (SCREEN_REGION_RESOURCE_TABLE_VA - SCREEN_REGION_TABLE_VA) // 16
    bindings = []
    for index in range(rect_count):
        rect_va = SCREEN_REGION_TABLE_VA + index * 16
        resource_va = SCREEN_REGION_RESOURCE_TABLE_VA + index * 4
        rect = rect_xyxy(data, rect_va)
        resource_id = u32(data, va_to_off(resource_va))
        resource_group = resource_id >> 16
        template_index = resource_id & 0xFFFF
        bindings.append(
            {
                "index": index,
                "rect_va": rect_va,
                "resource_va": resource_va,
                "rect": rect,
                "resource_id": resource_id,
                "resource_id_hex": f"0x{resource_id:08x}",
                "resource_group": resource_group,
                "template_index": template_index,
                "role": region_role(index, rect, resource_id),
            }
        )
    return bindings


def read_dispatch_table(data: bytes, count: int = 32) -> list[dict]:
    rows = []
    for index in range(count):
        va = SCREEN_REGION_DISPATCH_TABLE_VA + index * 4
        target = u32(data, va_to_off(va))
        rows.append({"index": index, "entry_va": va, "target_va": target})
    return rows


def find_refs(data: bytes, va: int, start_va: int = 0x00401000, end_va: int = 0x0043A7FF) -> list[int]:
    needle = struct.pack("<I", va)
    start = va_to_off(start_va)
    end = va_to_off(end_va) + 1
    refs: list[int] = []
    idx = data.find(needle, start, end)
    while idx != -1:
        refs.append(IMAGE_BASE + (idx - SECTIONS[0]["raw"] + SECTIONS[0]["va"]))
        idx = data.find(needle, idx + 1, end)
    return refs


def find_call_refs(data: bytes, target_va: int) -> list[int]:
    refs: list[int] = []
    text = SECTIONS[0]
    start = text["raw"]
    end = start + text["size"]
    pos = start
    while pos < end - 4:
        if data[pos] == 0xE8:
            rel = struct.unpack_from("<i", data, pos + 1)[0]
            call_va = IMAGE_BASE + text["va"] + (pos - text["raw"])
            if call_va + 5 + rel == target_va:
                refs.append(call_va)
        pos += 1
    return refs


def read_status_record_table(data: bytes) -> list[dict]:
    rows = []
    for index in range(NORMAL_HUD_STATUS_RECORD_COUNT):
        va = NORMAL_HUD_STATUS_RECORD_TABLE_VA + index * NORMAL_HUD_STATUS_RECORD_STRIDE
        off = va_to_off(va)
        raw = data[off : off + NORMAL_HUD_STATUS_RECORD_STRIDE]
        enabled, slot, stat_type, b3, b4, b5 = struct.unpack_from("<6B", raw, 0)
        x, y, latched_value = struct.unpack_from("<3H", raw, 6)
        field_offsets = STATUS_RECORD_ACTOR_FIELD_OFFSETS.get(stat_type, {})
        rows.append(
            {
                "index": index,
                "record_va": va,
                "record_va_hex": f"0x{va:08x}",
                "raw_hex": raw.hex(" "),
                "enabled_byte": enabled,
                "actor_slot": slot,
                "stat_type": stat_type,
                "stat": STATUS_RECORD_TYPE_NAMES.get(stat_type, f"type{stat_type}"),
                "unknown_b3": b3,
                "unknown_b4": b4,
                "unknown_b5": b5,
                "x": x,
                "y": y,
                "latched_value": latched_value,
                "number_current_dest": {"x": x + 0x20, "y": y - 8},
                "number_max_dest": {"x": x + 0x50, "y": y - 8},
                "gauge_back_dest": {"x": x, "y": y, "w": 88, "h": 8},
                "gauge_fill_dest": {"x": x + 2, "y": y, "max_w": 84, "h": 8},
                "actor_current_field_offset": field_offsets.get("current"),
                "actor_max_field_offset": field_offsets.get("max"),
                "region_guess": "#1 lower-left party HUD" if slot <= 2 else "#2/right-side auxiliary gauge rows",
            }
        )
    return rows


def compact_placements(placements: list[dict]) -> dict:
    by_cns: dict[str, list[dict]] = defaultdict(list)
    for p in placements:
        by_cns[p.get("cns", "")].append(p)

    summaries = {}
    for cns, rows in sorted(by_cns.items()):
        sources = Counter(
            (
                row.get("source", {}).get("x"),
                row.get("source", {}).get("y"),
                row.get("source", {}).get("w"),
                row.get("source", {}).get("h"),
            )
            for row in rows
        )
        summaries[cns] = {
            "count": len(rows),
            "unique_source_rects": [
                {"source": list(src), "count": count}
                for src, count in sources.most_common(12)
            ],
        }
    return summaries


def classify_hud_piece(cns: str) -> str:
    if cns == "frame.cns":
        return "fixed_chrome"
    if cns == "window.cns":
        return "procedural_window_chrome"
    if cns == "status.cns":
        return "dynamic_status_source"
    if cns in {"num.cns", "01234567.cns"}:
        return "dynamic_number_text_source"
    return "unknown_or_sheet_hint"


def compact_rect(rect: dict | None) -> dict[str, int]:
    rect = rect or {}
    return {
        "x": int(round(rect.get("x", 0))),
        "y": int(round(rect.get("y", 0))),
        "w": int(round(rect.get("w", 0))),
        "h": int(round(rect.get("h", 0))),
    }


def normalize_placement(row: dict) -> dict:
    cns = row.get("cns", "")
    stem = cns[:-4] if cns.lower().endswith(".cns") else cns
    return {
        "id": row.get("id", ""),
        "kind": row.get("kind", "cns"),
        "cns": cns,
        "asset_key": stem,
        "source": compact_rect(row.get("source")),
        "dest": compact_rect(row.get("dest")),
        "z": int(row.get("z") or 0),
        "repeat_group_id": row.get("repeatGroupId") or "",
        "note": row.get("note") or "",
    }


def build_report() -> dict:
    exe = EXE.read_bytes()
    state = json.loads(STATE.read_text(encoding="utf-8"))
    sheets_raw = state.get("sheets", [])
    sheets = sheets_raw.values() if isinstance(sheets_raw, dict) else sheets_raw
    sheet = next((s for s in sheets if s.get("name") == "평상시"), None)
    if not sheet:
        raise SystemExit("HUD sheet named '평상시' was not found")

    placement_summary = compact_placements(sheet.get("placements", []))
    placements = sorted(
        (normalize_placement(row) for row in sheet.get("placements", [])),
        key=lambda row: (row["z"], row["id"]),
    )
    bottom_placement_count = sum(
        1 for row in placements
        if row["dest"]["y"] < 480 and row["dest"]["y"] + row["dest"]["h"] > 352
    )
    classification = {
        cns: {
            "class": classify_hud_piece(cns),
            "placement_count": summary["count"],
            "note": {
                "fixed_chrome": "고정 화면 장식으로 볼 수 있음",
                "procedural_window_chrome": "16x16 셀을 반복 조립하는 창 장식 후보",
                "dynamic_status_source": "HP/MP/EXP 게이지처럼 값에 따라 잘라 그리는 동적 소스",
                "dynamic_number_text_source": "돈/숫자/글자처럼 런타임 값에 따라 glyph를 고르는 소스",
                "unknown_or_sheet_hint": "시트 힌트는 있으나 별도 분류 필요",
            }[classify_hud_piece(cns)],
        }
        for cns, summary in placement_summary.items()
    }

    resource_records = {}
    for cns, record in RESOURCE_RECORDS.items():
        rec = dict(record)
        try:
            rec_off = va_to_off(record["record_va"])
            rec["record_file_offset"] = rec_off
            rec["u32_at_record"] = u32(exe, rec_off)
        except ValueError:
            pass
        resource_records[cns] = rec

    frame_rect = rect_xyxy(exe, RESOURCE_RECORDS["frame.cns"]["meta_va"])
    status_rects = [
        {"index": idx, "source_va": RESOURCE_RECORDS["status.cns"]["meta_va"] + idx * 16, "rect": rect_xyxy(exe, RESOURCE_RECORDS["status.cns"]["meta_va"] + idx * 16)}
        for idx in range(23)
    ]
    status_bar_rects = [
        {
            **entry,
            "rect": rect_xyxy(exe, entry["source_va"]),
            "text_refs": [f"0x{va:08x}" for va in find_refs(exe, entry["source_va"])],
        }
        for entry in STATUS_BAR_SOURCE_RECTS
    ]
    status_records = read_status_record_table(exe)
    status_record_call_refs = {
        "presence_check": [f"0x{va:08x}" for va in find_call_refs(exe, NORMAL_HUD_STATUS_PRESENCE_CONSUMER_VA)],
        "draw_slot": [f"0x{va:08x}" for va in find_call_refs(exe, NORMAL_HUD_STATUS_DRAW_CONSUMER_VA)],
        "draw_gauge": [f"0x{va:08x}" for va in find_call_refs(exe, NORMAL_HUD_GAUGE_CONSUMER_VA)],
        "draw_numbers": [f"0x{va:08x}" for va in find_call_refs(exe, NORMAL_HUD_NUMBER_CONSUMER_VA)],
    }
    actor_name_annotations = [
        {
            "note": row.get("note", ""),
            "rect": compact_rect(row),
        }
        for row in sheet.get("annotations", [])
        if row.get("note") in {"아타호", "린샹", "스마슈"}
    ]
    actor_name_main_party = [
        {
            **item,
            "text": read_cp949_text_atom(exe, item["text_va"]),
            "text_va_hex": f"0x{item['text_va']:08x}",
        }
        for item in ACTOR_NAME_MAIN_PARTY
    ]
    actor_name_sample_monsters = [
        {
            **item,
            "text": read_cp949_text_atom(exe, item["text_va"]),
            "text_va_hex": f"0x{item['text_va']:08x}",
        }
        for item in ACTOR_NAME_SAMPLE_MONSTERS
    ]
    window_templates = read_window_templates(exe)
    bottom_window_candidates = [
        t for t in window_templates if (t["width_tiles"], t["height_tiles"]) in {(26, 8), (14, 8)}
    ]
    screen_region_rects = read_screen_region_rects(exe)
    screen_region_bindings = read_screen_region_bindings(exe)
    dispatch_table = read_dispatch_table(exe)
    bottom_screen_regions = [
        r for r in screen_region_rects if (r["rect"]["x"], r["rect"]["y"], r["rect"]["x1"], r["rect"]["y1"]) in {(0, 352, 416, 480), (416, 352, 640, 480)}
    ]

    return {
        "marker": "HWANSE_HUD_NORMAL_STATIC_HINT_REVIEW_READY",
        "source_sheet": {
            "name": sheet["name"],
            "id": sheet["id"],
            "placement_count": len(sheet.get("placements", [])),
            "annotation_count": len(sheet.get("annotations", [])),
            "screenshot": sheet.get("screenshot", {}).get("path") or sheet.get("screenshot", {}).get("url"),
        },
        "manual_annotations": sheet.get("annotations", []),
        "manual_static_trace": {
            "coordinate_space": "640x480 screen pixels",
            "draw_order": "ascending z",
            "source": "data/hud_trace_state.json sheet '평상시'",
            "placements": placements,
            "bottom_hud_placement_count": bottom_placement_count,
            "runtime_note": "frame.cns placement is a fixed upper chrome. game.html draws the top frame separately and filters placements that overlap the lower 352..480 HUD band.",
        },
        "placement_summary": placement_summary,
        "classification": classification,
        "exe_evidence": {
            "resource_records": resource_records,
            "frame_cns_source_rect": {
                "source_va": RESOURCE_RECORDS["frame.cns"]["meta_va"],
                "rect": frame_rect,
                "interpretation": "frame.cns 640x352 단일 source rect. 평상시 상단 맵 프레임의 고정 근거.",
            },
            "status_cns_source_rect_table": {
                "base_va": RESOURCE_RECORDS["status.cns"]["meta_va"],
                "count": len(status_rects),
                "rects": status_rects,
                "interpretation": "status.cns의 아이콘/상태 표시 source rect table. destination은 별도 draw code에서 계산.",
            },
            "status_bar_dynamic_sources": {
                "base_va": STATUS_BAR_TABLE_VA,
                "rects": status_bar_rects,
                "interpretation": "88x8 bar source rects are referenced by draw code around 0x00420a6a..0x00420e51. 0x00420b1a clips fill width to min(0x54, value*0x54/max), so HUD trace tiles here are value-dependent hints.",
            },
            "normal_hud_status_record_table": {
                "base_va": NORMAL_HUD_STATUS_RECORD_TABLE_VA,
                "stride": NORMAL_HUD_STATUS_RECORD_STRIDE,
                "count": NORMAL_HUD_STATUS_RECORD_COUNT,
                "actor_pointer_table_va": ACTOR_POINTER_TABLE_VA,
                "consumer_vas": {
                    "presence_check": NORMAL_HUD_STATUS_PRESENCE_CONSUMER_VA,
                    "draw_slot": NORMAL_HUD_STATUS_DRAW_CONSUMER_VA,
                    "draw_gauge": NORMAL_HUD_GAUGE_CONSUMER_VA,
                    "draw_numbers": NORMAL_HUD_NUMBER_CONSUMER_VA,
                },
                "call_refs": status_record_call_refs,
                "records": status_records,
                "field_mapping": {
                    "type0_HP": {"current": "actor+0x08", "max": "actor+0x0a"},
                    "type1_MP": {"current": "actor+0x0e", "max": "actor+0x10"},
                    "type2_EXP": {"current": "actor+0x14", "max": "actor+0x16"},
                },
                "draw_formula": {
                    "record_filter": "0x004209ee(slot) loops 0x004870a8..+0x9c and only draws records whose actor_slot equals the argument and whose actor pointer at 0x0059db30[slot] is non-null.",
                    "number_current": "0x00420ebc draws current value with the ones digit top-left at record.x+0x20, record.y-8, using up to 3 right-aligned 01234567.cns top-row 8x8 white glyphs.",
                    "number_max": "0x00420ebc draws max value with the ones digit top-left at record.x+0x50, record.y-8, using up to 3 right-aligned 01234567.cns top-row 8x8 white glyphs.",
                    "gauge_back": "0x004209ee draws source 0x00487168 to record.x, record.y, then 0x00420b1a draws fill.",
                    "gauge_fill": "0x00420b1a draws source 0x00487148+2 with width min(0x54, current*0x54/max) to record.x+2, record.y.",
                    "latched_delta": "If record+0x0a is greater than current, 0x00420b1a first draws source 0x00487158+2 with width min(0x54, latch*0x54/max).",
                },
                "interpretation": "This table is the exact dynamic consumer for the normal lower HUD gauges and numbers. Records 0..8 are party slots 0..2 × HP/MP/EXP, and the number renderer uses 01234567.cns top-row 8x8 white glyphs with record-derived ones-digit anchors, so #1 lower HUD placement and numeric pixels are EXE-table-backed rather than trace-only.",
            },
            "normal_hud_active_party_slot_model": {
                "status": "exe-grounded",
                "active_count_va": ACTIVE_PARTY_COUNT_VA,
                "active_order_va": ACTIVE_PARTY_ORDER_VA,
                "runtime_actor_pointer_table_va": ACTOR_POINTER_TABLE_VA,
                "actor_static_base_va": ACTOR_SLOT_BASE_VA,
                "actor_static_stride": ACTOR_SLOT_STRIDE,
                "script_entry_vas": {
                    "rebuild": NORMAL_HUD_SCRIPT_REBUILD_VA,
                    "refresh": NORMAL_HUD_SCRIPT_REFRESH_VA,
                },
                "routines": {
                    "add_actor_to_active_display_slots": {
                        "va": ACTIVE_PARTY_ADD_ROUTINE_VA,
                        "evidence": [
                            "0x00432167 writes incoming actor id to 0x004576e9[count].",
                            "0x0043217b computes actor row as 0x00457750 + actorId * 0x00d8.",
                            "0x00432188 writes that actor row pointer to 0x0059db30[count].",
                            "0x0043218f increments 0x004576e8.",
                        ],
                    },
                    "rebuild_active_display_slots": {
                        "va": ACTIVE_PARTY_REBUILD_ROUTINE_VA,
                        "evidence": [
                            "Rebuilds 0x0059db30 display-slot pointers from 0x004576e9 active order bytes.",
                            "Then runs script 0x004dda94 to redraw/refresh the normal HUD objects.",
                        ],
                    },
                    "remove_actor_from_active_display_slots": {
                        "va": ACTIVE_PARTY_REMOVE_ROUTINE_VA,
                        "evidence": [
                            "Finds a matching actor id in 0x004576e9, shifts following order bytes and 0x0059db30 pointers down.",
                            "0x004326c2 decrements 0x004576e8 and clears the final 0x0059db30 entry.",
                        ],
                    },
                    "normal_hud_draw_script_handler": {
                        "va": 0x00406B63,
                        "evidence": [
                            "Case 0 loops i < byte[0x004576e8], checks 0x0042065b(i), then calls 0x004209ee(i).",
                            "Case 2 refresh path loops display slots and calls 0x004209ee(i) without fixed actor identity.",
                            "This proves #1 rows are display slots, not hard-coded actor rows.",
                        ],
                    },
                    "command_draw_selected_slots": {
                        "va": 0x0041E93F,
                        "evidence": [
                            "Can draw all active slots from 0x004576e9 or draw a direct slot from stream+3.",
                            "If the direct slot has no 0x0059db30 pointer, it falls back to drawing slots 3..6.",
                        ],
                    },
                },
                "interpretation": "Scenario-dependent party composition is represented by active display-slot count/order and a runtime display-slot-to-actor pointer table. A lower HUD row is a display slot; the actual actor identity comes from 0x0059db30[displaySlot], so actor-name rendering must use this active-slot model rather than fixed row labels.",
            },
            "normal_hud_actor_name_text": {
                "status": "source-and-position-exe-grounded; glyph-rasterizer-pending",
                "source": "EXE object+0xb0 VM bridge plus data/hud_trace_state.json placement annotations",
                "trace_annotations": actor_name_annotations,
                "object_b0_vm_dispatcher": {
                    "dispatcher_va": TEXT_VM_DISPATCHER_VA,
                    "handler_table_va": TEXT_VM_HANDLER_TABLE_VA,
                    "dispatch_call_va": TEXT_VM_DISPATCH_CALL_VA,
                    "verified_rule": "0x0041b687 reads object/context+0xb0. If byte0 is 0x40, byte1 is used as an index into 0x0047f1d8 and the selected handler is called.",
                },
                "bridge_handler": {
                    "handler_va": NORMAL_HUD_ACTOR_NAME_BRIDGE_VA,
                    "table_entry_va": NORMAL_HUD_ACTOR_NAME_BRIDGE_ENTRY_VA,
                    "opcode_hex": f"0x{NORMAL_HUD_ACTOR_NAME_BRIDGE_OPCODE:02x}",
                    "evidence": [
                        "Handler pointer 0x0041e93f is stored at 0x0047f260, so it is object+0xb0 VM opcode 0x22.",
                        "Mode 0 loops i < byte[0x004576e8], reads 0x004576e9[i], then calls 0x004209ee for the active display slots.",
                        "Mode 1 reads stream+3 as a display slot, checks dword[0x0059db30 + slot*4], and calls 0x004209ee(slot).",
                        "After mode 1 slot draw, actor = dword[0x0059db30 + slot*4]; actor+0x04 selects dword[0x004ec280 + actor[4]*4]; actor+0x05 selects the final CP949 text pointer from that subtable; object+0xb0 is replaced with that text pointer.",
                    ],
                },
                "position_stream": {
                    "status": "exe-grounded",
                    "stream_va": NORMAL_HUD_ACTOR_NAME_POSITION_STREAM_VA,
                    "set_pen_handler_va": TEXT_VM_SET_PEN_HANDLER_VA,
                    "set_pen_table_entry_va": TEXT_VM_SET_PEN_ENTRY_VA,
                    "set_pen_opcode_hex": f"0x{TEXT_VM_SET_PEN_OPCODE:02x}",
                    "region_origin": NORMAL_HUD_REGION1_ORIGIN,
                    "rows": NORMAL_HUD_ACTOR_NAME_POSITION_ROWS,
                    "screen_rows": [
                        {
                            **row,
                            "screen_x": NORMAL_HUD_REGION1_ORIGIN["x"] + row["x"],
                            "screen_y": NORMAL_HUD_REGION1_ORIGIN["y"] + row["y"],
                        }
                        for row in NORMAL_HUD_ACTOR_NAME_POSITION_ROWS
                    ],
                    "row_spacing_px": 0x20,
                    "fallback_rows": NORMAL_HUD_ACTOR_NAME_FALLBACK_POSITION_ROWS,
                    "fallback_row_spacing_px": 0x18,
                    "evidence": [
                        "0x0041bd4f is handler-table entry 0x0e. It reads stream+2/stream+4 as x/y and writes object+0xd6/object+0xda after adding object+0xce/object+0xd2.",
                        "0x004e803c, 0x004e8048, 0x004e8054 are three consecutive '40 0e x y 00 00' set-pen commands, each immediately followed by '40 22 01 slot'.",
                        "The three main rows use x=0x18 and y=0x20/0x40/0x60, so #1 actor-name row spacing is 0x20 bytes/pixels in the EXE stream.",
                        "For region #1 origin (0,352), the screen positions are x=24, y=384/416/448.",
                        "The later slot 3..6 group at 0x004e80e8 uses 24px spacing and is kept separate as fallback/auxiliary, not the normal #1 party row model.",
                    ],
                },
                "actor_name_table": {
                    "base_va": ACTOR_NAME_TABLE_VA,
                    "selection_formula": "name_text = dword[dword[0x004ec280 + actor[0x04]*4] + actor[0x05]*4]",
                    "main_party": actor_name_main_party,
                    "sample_monsters": actor_name_sample_monsters,
                    "interpretation": "The lower HUD name content is selected from the same active display-slot actor row used by HP/MP/EXP. Therefore name text must follow party composition, not fixed row labels.",
                },
                "negative_evidence": [
                    "0x004209ee consumes the 0x004870a8 table and draws only status bar sources, number values, and gauge fills for the selected actor slot.",
                    "0x00420ebc is the number renderer reached from the #1 status consumer; it does not identify actor-name destination coordinates.",
                    "The generic object+0x40 runner/table at 0x00402321/0x00440538 is not the confirmed path for these names; the grounded path is object+0xb0 -> 0x0041b687 -> 0x0041e93f.",
                ],
                "pending": "The exact glyph rasterizer that consumes the raw CP949 name string after object+0xb0 is switched is still not fully isolated. The set-pen x/y and row spacing are now EXE-grounded.",
                "policy": "Promote actor-name source selection, active-slot identity, and #1 name x/y row spacing as EXE-confirmed. Keep only the final glyph rasterizer/kerning details pending.",
            },
            "window_cns_template_table": {
                "base_va": WINDOW_TEMPLATE_TABLE_VA,
                "templates": window_templates,
                "bottom_hud_candidates": bottom_window_candidates,
                "interpretation": "window.cns descriptor 0x00091007 points to a template table. It contains separate 26x8 and 14x8 box templates; together they make a 40x8 lower HUD width. The exact window-template draw consumer is not yet isolated, but the screen-region RECT table below independently confirms the same lower HUD split.",
            },
            "screen_region_rect_table": {
                "base_va": SCREEN_REGION_TABLE_VA,
                "resource_table_va": SCREEN_REGION_RESOURCE_TABLE_VA,
                "dispatch_table_va": SCREEN_REGION_DISPATCH_TABLE_VA,
                "init_call_va": SCREEN_REGION_INIT_CALL_VA,
                "region_draw_function_va": SCREEN_REGION_DRAW_FUNCTION_VA,
                "template_blit_function_va": SCREEN_REGION_TEMPLATE_BLIT_VA,
                "text_refs": [f"0x{va:08x}" for va in find_refs(exe, SCREEN_REGION_TABLE_VA)],
                "rects": screen_region_rects,
                "resource_bindings": screen_region_bindings,
                "dispatch_table": dispatch_table,
                "bottom_hud_regions": bottom_screen_regions,
                "interpretation": "0x00411399 pushes 0x004548b0 and 0x00454b60 into the 640x608 screen/region initializer. Function 0x0041b579 copies RECT index n from 0x004548b0, reads resource/template id index n from 0x00454b60, and calls 0x004175d3. This directly binds screen regions to window.cns templates.",
            },
            "draw_call_hint": {
                "candidate_function_va": 0x00417750,
                "known_calls": ["0x00420a9b", "0x00420afc", "0x00420cd2", "0x00420eaf"],
                "interpretation": "Calls push CNS resource id 0x0e and a status source rect pointer before drawing. The surrounding consumers now identify the destination record table and amount logic.",
            },
        },
        "static_analysis_result": [
            "평상시 시트의 frame.cns 배치는 EXE source rect 0x0047e3d0과 직접 일치하므로 고정 chrome으로 승격 가능.",
            "window.cns는 EXE resource descriptor 0x00091007이 16x16 cell sheet와 template table 0x004dc520을 가리킨다. 이 table에는 26x8과 14x8 box template이 따로 있어 하단 HUD는 단일 40x8 창이 아니라 두 영역으로 조립된다는 근거가 생겼다.",
            "추가로 EXE screen-region table 0x004548b0에는 (0,352)-(416,480), (416,352)-(640,480) RECT가 직접 들어 있다. 네가 다시 분리한 HUD 경계 x=416, y=352와 정확히 맞으므로 하단 영역 분리는 EXE 근거로 승격한다.",
            "0x0041b579 소비자 함수는 region index로 0x004548b0 RECT와 0x00454b60 window resource id를 함께 읽는다. 따라서 추가 UI 창도 region/template 단위로 더 찾을 수 있다.",
            "같은 binding table에서 #05~#10은 22열 대화/프롬프트 높이 변형, #14/#15는 14x3 짧은 기술명/캡션 프레임, #16은 11x4 compact window 후보로 분류된다. 아직 각 후보의 게임 상태별 호출처는 dispatcher/스크립트 쪽 추적이 더 필요하다.",
            "status.cns는 시트에 타일로 놓여 있어도 HP/MP/EXP 게이지와 아이콘의 런타임 소스다. 0x004870a8 record table과 0x004209ee/0x00420b1a/0x00420ebc 소비자가 #1 하단 HUD의 게이지/숫자 위치와 값을 직접 결정한다.",
            "0x004870a8의 앞 9개 레코드는 actor slot 0..2 × HP/MP/EXP이다. 좌표는 HP x=128, MP x=224, EXP x=320이고 actor row y=392/424/456이다. 현재값 숫자는 x+32/y-8, 최대값 숫자는 x+80/y-8, 게이지 fill은 x+2/y부터 최대 84px이다.",
            "#1 하단 HUD의 행은 고정된 아타호/린샹/스마슈 row가 아니라 active display slot이다. 0x004576e8 count, 0x004576e9 order, 0x0059db30 display-slot pointer table이 시나리오 동료 조합에 따라 실제 actor row를 연결한다.",
            "#1 하단 HUD의 캐릭터 이름 source와 위치는 EXE 근거로 승격한다. object+0xb0 VM opcode 0x22 handler 0x0041e93f가 active display slot의 이름을 고르고, 직전 0x40 0e set-pen stream이 x=24, y=384/416/448 및 32px 행간을 지정한다. 단, 최종 glyph rasterizer/kerning은 아직 보류다.",
            "num.cns/01234567.cns는 숫자와 8x8 glyph source sheet다. 돈/현재 위치/수치 텍스트는 런타임 값에 따라 glyph 선택이 바뀌므로 고정 UI로 승격하지 않는다.",
        ],
        "next_targets": [
            "object+0xb0 raw CP949 string glyph rasterizer/kerning을 분리해 #1 하단 HUD actor name의 최종 글자 폭/렌더러까지 확정.",
            "0x004870a8 뒤쪽 slot 3..6 레코드가 #2/right-side auxiliary gauge인지 battle/extra actor status인지 호출 문맥으로 분리.",
            "0x00454c10 dispatcher와 event/UI script opcode를 연결해 region #39/#40/#42 같은 short window가 전투 기술명, 선택지, 메뉴 중 어디서 호출되는지 확정.",
            "num.cns resource id 0x07, 01234567.cns resource id 0x08 호출부를 찾아 돈/지역명/수치 text renderer 분리.",
        ],
    }


def render_html(report: dict) -> str:
    def esc(v) -> str:
        return html.escape(str(v), quote=True)

    rows = []
    for cns, summary in report["placement_summary"].items():
        cls = report["classification"][cns]
        sources = "<br>".join(
            f"{esc(src['source'])} x {src['count']}" for src in summary["unique_source_rects"][:6]
        )
        rows.append(
            f"<tr><td>{esc(cns)}</td><td>{summary['count']}</td><td>{esc(cls['class'])}</td><td>{esc(cls['note'])}</td><td><code>{sources}</code></td></tr>"
        )

    status_rows = []
    for item in report["exe_evidence"]["status_bar_dynamic_sources"]["rects"]:
        refs = ", ".join(item["text_refs"]) or "-"
        rect = item["rect"]
        status_rows.append(
            f"<tr><td><code>0x{item['source_va']:08x}</code></td><td>{esc(item['name'])}</td><td>{rect['x']},{rect['y']} · {rect['w']}x{rect['h']}</td><td><code>{esc(refs)}</code></td></tr>"
        )
    status_record_rows = []
    for item in report["exe_evidence"]["normal_hud_status_record_table"]["records"]:
        cur = item["number_current_dest"]
        maxv = item["number_max_dest"]
        fill = item["gauge_fill_dest"]
        status_record_rows.append(
            f"<tr><td>{item['index']}</td><td><code>{esc(item['record_va_hex'])}</code></td><td>{item['actor_slot']}</td><td>{esc(item['stat'])}</td><td>{item['x']},{item['y']}</td><td>{cur['x']},{cur['y']}</td><td>{maxv['x']},{maxv['y']}</td><td>{fill['x']},{fill['y']} · max {fill['max_w']}px</td><td><code>{esc(item['raw_hex'])}</code></td><td>{esc(item['region_guess'])}</td></tr>"
        )
    actor_name = report["exe_evidence"]["normal_hud_actor_name_text"]
    actor_name_rows = []
    for item in actor_name["actor_name_table"]["main_party"]:
        text_atom = item["text"].replace("\n", "\\n")
        actor_name_rows.append(
            f"<tr><td>{item['actor_id']}</td><td>{esc(item['name'])}</td><td><code>{esc(item['text_va_hex'])}</code></td><td><code>{esc(text_atom)}</code></td></tr>"
        )
    actor_name_sample_rows = []
    for item in actor_name["actor_name_table"]["sample_monsters"]:
        text_atom = item["text"].replace("\n", "\\n")
        actor_name_sample_rows.append(
            f"<tr><td>{esc(item['name'])}</td><td><code>{esc(item['text_va_hex'])}</code></td><td><code>{esc(text_atom)}</code></td></tr>"
        )
    actor_name_position_rows = []
    for item in actor_name.get("position_stream", {}).get("screen_rows", []):
        actor_name_position_rows.append(
            f"<tr><td>{item['display_slot']}</td><td><code>0x{item['set_pen_va']:08x}</code></td><td>{item['x']},{item['y']}</td><td>{item['screen_x']},{item['screen_y']}</td><td><code>0x{item['name_opcode_va']:08x}</code></td><td><code>{esc(item['name_opcode_bytes'])}</code></td></tr>"
        )
    window_rows = []
    for item in report["exe_evidence"]["window_cns_template_table"]["templates"]:
        window_rows.append(
            f"<tr><td>{item['index']}</td><td><code>{esc(item['dim'])}</code></td><td>{item['width_tiles']}x{item['height_tiles']}</td><td><code>0x{item['template_va']:08x}</code></td><td>{esc(item['unique_tile_ids'])}</td></tr>"
        )
    screen_region_rows = []
    binding_by_index = {
        item["index"]: item
        for item in report["exe_evidence"]["screen_region_rect_table"]["resource_bindings"]
    }
    for item in report["exe_evidence"]["screen_region_rect_table"]["rects"]:
        rect = item["rect"]
        binding = binding_by_index.get(item["index"], {})
        role = ""
        if (rect["x"], rect["y"], rect["x1"], rect["y1"]) == (0, 352, 416, 480):
            role = "하단 HUD 좌측 26x8"
        elif (rect["x"], rect["y"], rect["x1"], rect["y1"]) == (416, 352, 640, 480):
            role = "하단 HUD 우측 14x8"
        elif binding.get("role"):
            role = binding["role"]
        screen_region_rows.append(
            f"<tr><td>{item['index']}</td><td><code>0x{item['source_va']:08x}</code></td><td>{rect['x']},{rect['y']} - {rect['x1']},{rect['y1']}</td><td>{rect['w']}x{rect['h']}</td><td><code>{esc(binding.get('resource_id_hex', '-'))}</code></td><td>{esc(role)}</td></tr>"
        )
    region_binding_rows = []
    for item in report["exe_evidence"]["screen_region_rect_table"]["resource_bindings"]:
        if item["resource_id"] == 0:
            continue
        rect = item["rect"]
        region_binding_rows.append(
            f"<tr><td>{item['index']}</td><td>{rect['x']},{rect['y']} - {rect['x1']},{rect['y1']}</td><td>{rect['w']}x{rect['h']}</td><td><code>{esc(item['resource_id_hex'])}</code></td><td>{item['template_index']}</td><td>{esc(item['role'])}</td></tr>"
        )

    bullets = "\n".join(f"<li>{esc(line)}</li>" for line in report["static_analysis_result"])
    targets = "\n".join(f"<li>{esc(line)}</li>" for line in report["next_targets"])
    annotations = "\n".join(
        f"<li>{esc(a.get('note', ''))}: {a.get('x')},{a.get('y')} · {a.get('w')}x{a.get('h')}</li>"
        for a in report["manual_annotations"]
    )

    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>평상시 HUD 정적 분석 힌트</title>
  <style>
    body {{ margin: 0; padding: 18px; background: #f6f7f9; color: #17202a; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.45; }}
    main {{ max-width: 1240px; margin: 0 auto; }}
    h1 {{ margin: 0 0 6px; font-size: 24px; }}
    h2 {{ margin: 24px 0 8px; font-size: 18px; }}
    .sub {{ color: #607080; margin: 0 0 14px; }}
    table {{ width: 100%; border-collapse: collapse; background: white; border: 1px solid #d8dee6; }}
    th, td {{ border-bottom: 1px solid #d8dee6; padding: 8px 10px; text-align: left; vertical-align: top; }}
    th {{ background: #eef2f6; color: #344050; }}
    code {{ white-space: normal; color: #233; }}
    .panel {{ background: white; border: 1px solid #d8dee6; border-radius: 8px; padding: 12px 14px; margin: 12px 0; }}
    .marker {{ color: #607080; font-size: 12px; }}
  </style>
</head>
<body>
<main>
  <h1>평상시 HUD 정적 분석 힌트</h1>
  <p class="sub">HUD 트레이싱 시트 <strong>{esc(report['source_sheet']['name'])}</strong>를 EXE resource/rect 근거와 대조한 결과. 동적 숫자/게이지 타일은 고정 chrome으로 승격하지 않는다.</p>
  <p class="marker">{esc(report['marker'])}</p>

  <section class="panel">
    <h2>요약</h2>
    <ul>{bullets}</ul>
  </section>

  <section>
    <h2>시트 배치 분류</h2>
    <table>
      <thead><tr><th>CNS</th><th>배치 수</th><th>분류</th><th>판단</th><th>주요 source rect</th></tr></thead>
      <tbody>{''.join(rows)}</tbody>
    </table>
  </section>

  <section>
    <h2>수동 주석</h2>
    <div class="panel"><ul>{annotations}</ul></div>
  </section>

  <section>
    <h2>EXE 근거</h2>
    <div class="panel">
      <p><strong>frame.cns</strong>: source <code>0x{report['exe_evidence']['frame_cns_source_rect']['source_va']:08x}</code>, rect {report['exe_evidence']['frame_cns_source_rect']['rect']['x']},{report['exe_evidence']['frame_cns_source_rect']['rect']['y']} · {report['exe_evidence']['frame_cns_source_rect']['rect']['w']}x{report['exe_evidence']['frame_cns_source_rect']['rect']['h']}</p>
      <p><strong>status.cns</strong>: source rect table <code>0x{report['exe_evidence']['status_cns_source_rect_table']['base_va']:08x}</code>, {report['exe_evidence']['status_cns_source_rect_table']['count']} entries.</p>
      <p><strong>window.cns</strong>: template table <code>0x{report['exe_evidence']['window_cns_template_table']['base_va']:08x}</code>. 26x8과 14x8 box template이 따로 있어 하단 HUD는 두 영역 조립 근거가 있다.</p>
      <p><strong>screen region</strong>: RECT table <code>0x{report['exe_evidence']['screen_region_rect_table']['base_va']:08x}</code>, resource table <code>0x{report['exe_evidence']['screen_region_rect_table']['resource_table_va']:08x}</code>, init <code>0x{report['exe_evidence']['screen_region_rect_table']['init_call_va']:08x}</code>. 소비자 <code>0x{report['exe_evidence']['screen_region_rect_table']['region_draw_function_va']:08x}</code>가 같은 index로 RECT와 window resource/template id를 같이 읽는다.</p>
      <p><strong>draw call 후보</strong>: <code>0x{report['exe_evidence']['draw_call_hint']['candidate_function_va']:08x}</code>, known calls {esc(', '.join(report['exe_evidence']['draw_call_hint']['known_calls']))}</p>
    </div>
    <h2>화면 영역 + window template binding</h2>
    <table>
      <thead><tr><th>#</th><th>RECT VA</th><th>좌표</th><th>크기</th><th>resource</th><th>판단</th></tr></thead>
      <tbody>{''.join(screen_region_rows)}</tbody>
    </table>
    <h2>window.cns가 직접 묶인 region</h2>
    <table>
      <thead><tr><th>region</th><th>좌표</th><th>크기</th><th>resource id</th><th>template</th><th>해석</th></tr></thead>
      <tbody>{''.join(region_binding_rows)}</tbody>
    </table>
    <h2>window.cns 템플릿 근거</h2>
    <table>
      <thead><tr><th>#</th><th>dim</th><th>타일 크기</th><th>template VA</th><th>tile ids</th></tr></thead>
      <tbody>{''.join(window_rows)}</tbody>
    </table>
    <h2>status.cns 동적 바 근거</h2>
    <table>
      <thead><tr><th>source VA</th><th>역할 후보</th><th>source rect</th><th>.text 직접 참조</th></tr></thead>
      <tbody>{''.join(status_rows)}</tbody>
    </table>
    <h2>#1 하단 HUD status record table</h2>
    <div class="panel">
      <p><strong>base</strong> <code>0x{report['exe_evidence']['normal_hud_status_record_table']['base_va']:08x}</code>, stride {report['exe_evidence']['normal_hud_status_record_table']['stride']} bytes, count {report['exe_evidence']['normal_hud_status_record_table']['count']}.</p>
      <p>{esc(report['exe_evidence']['normal_hud_status_record_table']['interpretation'])}</p>
      <p>{esc(report['exe_evidence']['normal_hud_status_record_table']['draw_formula']['gauge_fill'])}</p>
    </div>
    <table>
      <thead><tr><th>#</th><th>record</th><th>slot</th><th>type</th><th>bar xy</th><th>current xy</th><th>max xy</th><th>fill</th><th>raw</th><th>판단</th></tr></thead>
      <tbody>{''.join(status_record_rows)}</tbody>
    </table>
    <h2>#1 actor name source 선택</h2>
    <div class="panel">
      <p><strong>상태</strong>: {esc(actor_name['status'])}</p>
      <p><strong>VM dispatch</strong>: dispatcher <code>0x{actor_name['object_b0_vm_dispatcher']['dispatcher_va']:08x}</code>, table <code>0x{actor_name['object_b0_vm_dispatcher']['handler_table_va']:08x}</code>, call <code>0x{actor_name['object_b0_vm_dispatcher']['dispatch_call_va']:08x}</code></p>
      <p><strong>bridge</strong>: handler <code>0x{actor_name['bridge_handler']['handler_va']:08x}</code>, table entry <code>0x{actor_name['bridge_handler']['table_entry_va']:08x}</code>, opcode <code>{esc(actor_name['bridge_handler']['opcode_hex'])}</code></p>
      <p><strong>set pen</strong>: handler <code>0x{actor_name['position_stream']['set_pen_handler_va']:08x}</code>, opcode <code>{esc(actor_name['position_stream']['set_pen_opcode_hex'])}</code>, row spacing {actor_name['position_stream']['row_spacing_px']}px.</p>
      <p><strong>선택식</strong>: <code>{esc(actor_name['actor_name_table']['selection_formula'])}</code></p>
      <p><strong>보류</strong>: {esc(actor_name['pending'])}</p>
    </div>
    <table>
      <thead><tr><th>display slot</th><th>set pen VA</th><th>relative xy</th><th>screen xy</th><th>name opcode VA</th><th>opcode bytes</th></tr></thead>
      <tbody>{''.join(actor_name_position_rows)}</tbody>
    </table>
    <table>
      <thead><tr><th>actor id</th><th>이름</th><th>text VA</th><th>raw text atom</th></tr></thead>
      <tbody>{''.join(actor_name_rows)}</tbody>
    </table>
    <h2>actor name table 샘플</h2>
    <table>
      <thead><tr><th>이름</th><th>text VA</th><th>raw text atom</th></tr></thead>
      <tbody>{''.join(actor_name_sample_rows)}</tbody>
    </table>
  </section>

  <section class="panel">
    <h2>다음 추적 대상</h2>
    <ul>{targets}</ul>
  </section>
</main>
</body>
</html>
"""


def main() -> None:
    report = build_report()
    OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print(f"wrote {OUT_JSON.relative_to(ROOT)}")


if __name__ == "__main__":
    main()
