#!/usr/bin/env python3
"""Build a focused review for the status/menu expression payload.

The ESC/X top menu uses the large left window region #6.  The region shell was
already grounded by ``menu_descriptor_stack_review``.  This report records the
payload that draws the status page content inside that shell: labels, actor
numeric fields, equipment-name draws, and portrait/status-panel candidates.
"""
from __future__ import annotations

import html
import json
import struct
from pathlib import Path
from typing import Any

from probe_exe_scene_tables import read_sections, va_to_offset


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

JSON_OUT = OUT / "status_menu_ui_expression_review.json"
WEB_HTML_OUT = WEB / "status_menu_ui_expression_review.html"

STATUS_PAYLOAD_VA = 0x004E842E
STATUS_PAYLOAD_END_VA = 0x004E85FC
TOP_MENU_OBJECT_SEQUENCE_VA = 0x004DDC6C
REGION6_CHILD_SCRIPT_VA = 0x004DEE18
REGION6_INDEX = 6
EQUIPMENT_TABLE_VA = 0x0048B1DC
ACTOR_ROW_BASE_VA = 0x00457750
ACTOR_ROW_STRIDE = 0xD8
STATUS_COMMENT_ROOT_VA = 0x004F8500
STATUS_COMMENT_SELECTOR_VA = 0x004F8510
STATUS_COMMENT_POINTER_TABLE_VA = 0x004F851C
STATUS_COMMENT_TEXT_END_VA = 0x004F866E
STATUS_COMMENT_POINTER_COUNT = 5
STATUS_COMMENT_GROUP_OPCODE = b"\x40\x13\x01"

REGION6 = {
    "index": REGION6_INDEX,
    "rect": [0, 0, 416, 352],
    "size": [416, 352],
    "templateIndex": 0,
    "role": "ESC/X 상태/메뉴 상단 좌측 26x22 대형 window 후보",
}

STAT_FIELD_LABELS = {
    0x06: "레벨",
    0x08: "현재 체력",
    0x0A: "최대 체력",
    0x0E: "현재 기력",
    0x10: "최대 기력",
    0x14: "현재 경험",
    0x16: "다음/필요 경험 후보",
    0x1C: "공격력",
    0x20: "방어력",
    0x22: "기술력",
    0x24: "순발력",
    0x26: "운",
}

ACTOR_FIELD_EVIDENCE = {
    0x06: "actor row +0x06 level",
    0x08: "actor row +0x08 current HP",
    0x0A: "actor row +0x0a max HP",
    0x0E: "actor row +0x0e current MP",
    0x10: "actor row +0x10 max MP",
    0x14: "actor row +0x14 current EXP",
    0x16: "actor row +0x16 next/required EXP candidate",
    0x1C: "actor row +0x1c attack",
    0x20: "actor row +0x20 defense",
    0x22: "actor row +0x22 technique",
    0x24: "actor row +0x24 agility",
    0x26: "actor row +0x26 luck",
}


def numeric_renderer_for_mode(mode: int) -> dict[str, Any]:
    """Return the status-menu digit sheet selected by 40 2e.

    The lower-level 0x0042119b helper has its own mode split, but 40 2e is a
    status-window VM opcode.  Its observed 0x04..0x07 color modes render the
    16x16 status/menu digits from num.cns, not the 8x8 lower-HUD digit strip.
    """
    if 0 <= mode <= 3:
        return {
            "mode": mode,
            "cns": "01234567.cns",
            "cell": [8, 8],
            "row": mode,
            "rowMeaning": ["white", "green", "yellow", "red"][mode],
            "selectorBaseHex": f"0x{0x00080000 + mode * 11:08x}",
            "evidence": "low 40 2e modes map to the small 8x8 digit sheet; retained for completeness.",
        }
    if 4 <= mode <= 7:
        return {
            "mode": mode,
            "cns": "num.cns",
            "cell": [16, 16],
            "row": mode - 4,
            "rowMeaning": ["white", "green", "yellow", "red"][mode - 4],
            "selectorBaseHex": f"0x{0x00070000 + (mode - 4) * 10:08x}",
            "evidence": "status-menu opcode 40 2e modes 4..7 select 16x16 num.cns color rows.",
        }
    if 8 <= mode <= 10:
        return {
            "mode": mode,
            "cns": "btl_etc.cns",
            "cell": [8, 16],
            "row": mode - 8,
            "rowMeaning": "battle damage/result digits",
            "selectorBaseHex": f"0x{0x00100036 + (0 if mode == 8 else 0):08x}",
            "evidence": "0x0042119b mode 8..10 selects battle digit/result sprites.",
        }
    return {
        "mode": mode,
        "cns": None,
        "cell": None,
        "row": None,
        "rowMeaning": "unknown",
        "selectorBaseHex": None,
        "evidence": "mode outside the known 0x0042119b number renderer cases.",
    }

LEVEL_GROWTH_RULES = {
    0: {
        "actor": "아타호",
        "hp": [3, 5],
        "mp": [2, 3],
        "attack": [3, 4],
        "defense": [3, 4],
        "technique": [2, 4],
        "agility": [2, 4],
    },
    1: {
        "actor": "린샹",
        "hp": [2, 4],
        "mp": [3, 4],
        "attack": [2, 3],
        "defense": [2, 4],
        "technique": [3, 4],
        "agility": [3, 4],
    },
    2: {
        "actor": "스마슈",
        "hp": [3, 5],
        "mp": [2, 3],
        "attack": [2, 4],
        "defense": [2, 4],
        "technique": [2, 4],
        "agility": [2, 4],
    },
}

EQUIPMENT_CATALOG = [
    {"id": 1, "actorSlot": 0, "slot": "weapon", "name": "맨주먹", "bonus": {}},
    {"id": 2, "actorSlot": 0, "slot": "weapon", "name": "술", "bonus": {"luck": 5}},
    {"id": 3, "actorSlot": 0, "slot": "weapon", "name": "노주", "bonus": {"luck": 10}},
    {"id": 4, "actorSlot": 0, "slot": "weapon", "name": "특급주", "bonus": {"luck": 15}},
    {"id": 5, "actorSlot": 0, "slot": "weapon", "name": "화주", "bonus": {"luck": 20}},
    {"id": 6, "actorSlot": 0, "slot": "weapon", "name": "명주·귀신살", "bonus": {"luck": 25}},
    {"id": 7, "actorSlot": 0, "slot": "armor", "name": "인민복", "bonus": {"defense": 10, "agility": 5}},
    {"id": 8, "actorSlot": 0, "slot": "armor", "name": "권법가 도복", "bonus": {"attack": 8, "defense": 15, "agility": 7}},
    {"id": 9, "actorSlot": 0, "slot": "armor", "name": "달인의 도복", "bonus": {"attack": 15, "defense": 20, "agility": 10}},
    {
        "id": 10,
        "actorSlot": 0,
        "slot": "armor",
        "name": "호랑이 도복",
        "bonus": {"attack": 30, "defense": 25, "technique": 5, "agility": 12, "luck": 5},
    },
    {
        "id": 11,
        "actorSlot": 0,
        "slot": "armor",
        "name": "나찰의 도복",
        "bonus": {"attack": 22, "defense": 42, "technique": 8, "agility": 8},
    },
    {
        "id": 12,
        "actorSlot": 0,
        "slot": "armor",
        "name": "백호 도복",
        "bonus": {"attack": 25, "defense": 32, "technique": 10, "agility": 16, "luck": 10},
    },
    {"id": 13, "actorSlot": 1, "slot": "weapon", "name": "고양이 발톱", "bonus": {"attack": 10}},
    {"id": 14, "actorSlot": 1, "slot": "weapon", "name": "곰발톱", "bonus": {"attack": 18, "technique": 5}},
    {"id": 15, "actorSlot": 1, "slot": "weapon", "name": "빙마조", "bonus": {"attack": 27, "technique": 10}},
    {"id": 16, "actorSlot": 1, "slot": "weapon", "name": "표범의 발톱", "bonus": {"attack": 35, "technique": 20}},
    {
        "id": 17,
        "actorSlot": 1,
        "slot": "weapon",
        "name": "팬톰크로우",
        "bonus": {"attack": 42, "technique": 15, "agility": 10},
    },
    {
        "id": 18,
        "actorSlot": 1,
        "slot": "weapon",
        "name": "호랑이발톱",
        "bonus": {"attack": 50, "technique": 5, "luck": 5},
    },
    {"id": 19, "actorSlot": 1, "slot": "armor", "name": "드레스", "bonus": {"defense": 10}},
    {"id": 20, "actorSlot": 1, "slot": "armor", "name": "프리티드레스", "bonus": {"defense": 18, "agility": 5}},
    {
        "id": 21,
        "actorSlot": 1,
        "slot": "armor",
        "name": "쿵푸드레스",
        "bonus": {"defense": 27, "technique": 10, "agility": 12},
    },
    {
        "id": 22,
        "actorSlot": 1,
        "slot": "armor",
        "name": "배틀드레스",
        "bonus": {"defense": 36, "technique": 5, "agility": 8},
    },
    {
        "id": 23,
        "actorSlot": 1,
        "slot": "armor",
        "name": "데몬드레스",
        "bonus": {"defense": 55, "technique": 10, "agility": 10},
    },
    {"id": 24, "actorSlot": 1, "slot": "armor", "name": "퀸드레스", "bonus": {"defense": 45, "agility": 15, "luck": 5}},
    {"id": 25, "actorSlot": 2, "slot": "weapon", "name": "닌자도", "bonus": {"attack": 20, "technique": 15}},
    {"id": 26, "actorSlot": 2, "slot": "weapon", "name": "청룡도", "bonus": {"attack": 28, "technique": 5}},
    {"id": 27, "actorSlot": 2, "slot": "weapon", "name": "불타는 마검", "bonus": {"attack": 33, "technique": 30}},
    {"id": 28, "actorSlot": 2, "slot": "weapon", "name": "그레이트소드", "bonus": {"attack": 41, "technique": 40}},
    {
        "id": 29,
        "actorSlot": 2,
        "slot": "weapon",
        "name": "나찰의 흉인",
        "bonus": {"attack": 60, "defense": 5, "technique": 20, "agility": 5, "luck": 5},
    },
    {"id": 30, "actorSlot": 2, "slot": "weapon", "name": "마인아수라", "bonus": {"attack": 50, "technique": 25}},
    {"id": 31, "actorSlot": 2, "slot": "armor", "name": "스마슈타이츠", "bonus": {"defense": 10, "agility": 10}},
    {"id": 32, "actorSlot": 2, "slot": "armor", "name": "가죽 갑옷", "bonus": {"defense": 18, "agility": 5}},
    {"id": 33, "actorSlot": 2, "slot": "armor", "name": "흑장속", "bonus": {"defense": 25, "agility": 15}},
    {"id": 34, "actorSlot": 2, "slot": "armor", "name": "철편 갑옷", "bonus": {"defense": 33, "technique": -4, "agility": -4}},
    {"id": 35, "actorSlot": 2, "slot": "armor", "name": "어설트 슈트", "bonus": {"defense": 42, "technique": -8, "agility": -8}},
    {"id": 36, "actorSlot": 2, "slot": "armor", "name": "투신의 갑옷", "bonus": {"defense": 50, "luck": 10}},
]

TEXT_OPCODE = 0x0E
ACTOR_NUMERIC_OPCODE = 0x2E
EQUIPMENT_NAME_OPCODE = 0x30
PORTRAIT_OPCODE = 0x24


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


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


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


def read_bytes(exe: bytes, sections: list[dict[str, Any]], va: int, size: int) -> bytes:
    off = va_to_offset(sections, va)
    if off is None:
        raise ValueError(f"VA outside file-backed sections: {hx(va)}")
    return exe[off : off + size]


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


def pointer_refs(exe: bytes, sections: list[dict[str, Any]], target_va: int) -> list[dict[str, Any]]:
    pattern = struct.pack("<I", target_va)
    refs: list[dict[str, Any]] = []
    pos = exe.find(pattern)
    while pos != -1:
        ref_va = file_offset_to_va(sections, pos)
        refs.append({"fileOffsetHex": hx(pos), "vaHex": hx(ref_va), "targetVaHex": hx(target_va)})
        pos = exe.find(pattern, pos + 1)
    return refs


def byte_hex(data: bytes) -> str:
    return " ".join(f"{byte:02x}" for byte in data)


def next_command_offset(data: bytes, start: int) -> int:
    pos = start
    while pos < len(data):
        if data[pos] == 0x40 and pos + 1 < len(data):
            return pos
        pos += 1
    return len(data)


def decode_cp949(data: bytes) -> str:
    return data.decode("cp949", errors="replace").replace("\x00", "")


def normalize_game_text(text: str) -> str:
    return text.replace("　", " ").replace("\u3000", " ").strip()


def split_status_comment_entry(data: bytes) -> list[dict[str, Any]]:
    commands = {
        b"\x40\x02\x00\x00": "line-continue",
        b"\x40\x0a\x00\x00": "entry-break",
    }
    chunks: list[dict[str, Any]] = []
    pos = 0
    while pos < len(data):
        found_pos = len(data)
        found_command: bytes | None = None
        for command in commands:
            command_pos = data.find(command, pos)
            if command_pos != -1 and command_pos < found_pos:
                found_pos = command_pos
                found_command = command

        raw_text = data[pos:found_pos]
        text = decode_cp949(raw_text)
        normalized = normalize_game_text(text)
        if normalized:
            chunks.append(
                {
                    "text": text,
                    "normalized": normalized,
                    "rawHex": byte_hex(raw_text),
                    "terminator": commands.get(found_command, "end"),
                    "terminatorHex": byte_hex(found_command or b""),
                }
            )

        if found_command is None:
            break
        pos = found_pos + len(found_command)
    return chunks


def hangul_count(text: str) -> int:
    return sum("\uac00" <= char <= "\ud7a3" for char in text)


def actor_role_for_comment_table(table_count: int, table_index: int) -> str:
    if table_count == 3:
        return ["아타호 후보", "린샹 후보", "스마슈 후보"][table_index]
    if table_count == 1:
        return "단일/current 후보"
    return f"actor-table-{table_index + 1} 후보"


def parse_status_comment_entries(
    exe: bytes,
    sections: list[dict[str, Any]],
    pointers: list[int],
    table_cap_va: int,
) -> list[dict[str, Any]]:
    entries: list[dict[str, Any]] = []
    boundary_values = sorted({value for value in [*pointers, table_cap_va] if value <= table_cap_va})
    seen: dict[int, int] = {}
    for index, start_va in enumerate(pointers, start=1):
        duplicate_of = seen.get(start_va)
        seen.setdefault(start_va, index)

        end_candidates = [value for value in boundary_values if value > start_va]
        end_va = end_candidates[0] if end_candidates else table_cap_va
        if start_va >= table_cap_va or end_va <= start_va:
            raw = b""
            chunks: list[dict[str, Any]] = []
        else:
            raw = read_bytes(exe, sections, start_va, end_va - start_va)
            chunks = split_status_comment_entry(raw)

        entries.append(
            {
                "index": index,
                "startVa": start_va,
                "startVaHex": hx(start_va),
                "endVa": end_va,
                "endVaHex": hx(end_va),
                "duplicateOf": duplicate_of,
                "lines": [chunk["text"] for chunk in chunks],
                "normalizedLines": [chunk["normalized"] for chunk in chunks],
                "display": " / ".join(chunk["normalized"] for chunk in chunks),
                "rawHex": byte_hex(raw),
                "chunks": chunks,
            }
        )
    return entries


def scan_status_comment_table_candidates(
    exe: bytes,
    sections: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    candidates: list[dict[str, Any]] = []
    for section in sections:
        if section.get("name") not in {".data", ".rdata"}:
            continue
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        pos = exe.find(STATUS_COMMENT_GROUP_OPCODE, start, end)
        while pos != -1:
            if pos + 8 <= len(exe):
                table_va = int(section["va"]) + (pos - start)
                table_id = exe[pos + 3]
                count = struct.unpack_from("<I", exe, pos + 4)[0]
                pointers_start = pos + 8
                pointers_end = pointers_start + count * 4
                if 2 <= count <= 80 and pointers_end <= len(exe):
                    raw_pointers = exe[pointers_start:pointers_end]
                    values = [struct.unpack_from("<I", raw_pointers, i * 4)[0] for i in range(count)]
                    if all(va_to_offset(sections, value) is not None for value in values):
                        end_va = values[0]
                        entry_pointers = values[1:]
                        if entry_pointers and all(value <= end_va for value in entry_pointers):
                            candidates.append(
                                {
                                    "tableVa": table_va,
                                    "tableVaHex": hx(table_va),
                                    "tableId": table_id,
                                    "tableIdHex": hx(table_id, 2),
                                    "count": count,
                                    "endVa": end_va,
                                    "endVaHex": hx(end_va),
                                    "entryPointers": entry_pointers,
                                    "entryPointerHexes": [hx(value) for value in entry_pointers],
                                    "rawHex": byte_hex(exe[pos:pointers_end]),
                                }
                            )
            pos = exe.find(STATUS_COMMENT_GROUP_OPCODE, pos + 1, end)
    return sorted(candidates, key=lambda row: int(row["tableVa"]))


def parse_status_comment_groups(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    candidates = scan_status_comment_table_candidates(exe, sections)
    starts = [int(row["tableVa"]) for row in candidates]
    parsed_candidates: list[dict[str, Any]] = []
    for index, row in enumerate(candidates):
        next_start_candidates = [start for start in starts if start > int(row["tableVa"]) and start < int(row["endVa"])]
        table_cap_va = min([int(row["endVa"]), *next_start_candidates])
        entries = parse_status_comment_entries(exe, sections, list(row["entryPointers"]), table_cap_va)
        joined = "\n".join(entry.get("display", "") for entry in entries)
        text_entry_count = sum(1 for entry in entries if entry.get("display"))
        if hangul_count(joined) < 10 or text_entry_count == 0:
            continue
        parsed_candidates.append(
            {
                **row,
                "tableCapVa": table_cap_va,
                "tableCapVaHex": hx(table_cap_va),
                "entryCount": len(entries),
                "textEntryCount": text_entry_count,
                "entries": entries,
                "sample": next((entry.get("display", "") for entry in entries if entry.get("display")), ""),
            }
        )

    groups_by_id: dict[int, list[dict[str, Any]]] = {}
    for row in parsed_candidates:
        groups_by_id.setdefault(int(row["tableId"]), []).append(row)

    groups: list[dict[str, Any]] = []
    for table_id, rows in sorted(groups_by_id.items()):
        rows = sorted(rows, key=lambda row: int(row["tableVa"]))
        actor_tables: list[dict[str, Any]] = []
        for table_index, row in enumerate(rows):
            actor_tables.append(
                {
                    "tableIndex": table_index,
                    "actorSlotCandidate": table_index if len(rows) == 3 else None,
                    "actorRoleCandidate": actor_role_for_comment_table(len(rows), table_index),
                    "tableVaHex": row["tableVaHex"],
                    "tableIdHex": row["tableIdHex"],
                    "count": row["count"],
                    "endVaHex": row["endVaHex"],
                    "tableCapVaHex": row["tableCapVaHex"],
                    "entryCount": row["entryCount"],
                    "textEntryCount": row["textEntryCount"],
                    "sample": row["sample"],
                    "entryPointerHexes": row["entryPointerHexes"],
                    "rawHex": row["rawHex"],
                    "entries": row["entries"],
                }
            )
        groups.append(
            {
                "tableId": table_id,
                "tableIdHex": hx(table_id, 2),
                "actorTableCount": len(rows),
                "maxEntryCount": max((int(row["entryCount"]) for row in rows), default=0),
                "samples": [row["sample"] for row in rows],
                "actorTables": actor_tables,
                "interpretation": (
                    "40 13 01 상태창 짧은 문구 그룹 후보. 3개 서브테이블이 있는 경우 "
                    "샘플 문체상 아타호/린샹/스마슈 순서 후보로 볼 수 있다. "
                    "어떤 시나리오 플래그가 table id를 고르는지는 아직 미확정이다."
                ),
            }
        )
    return groups


def parse_status_comment_table(exe: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    pointers_data = read_bytes(exe, sections, STATUS_COMMENT_POINTER_TABLE_VA, STATUS_COMMENT_POINTER_COUNT * 4)
    pointers = [struct.unpack_from("<I", pointers_data, index * 4)[0] for index in range(STATUS_COMMENT_POINTER_COUNT)]
    ends = pointers[1:] + [STATUS_COMMENT_TEXT_END_VA]

    entries: list[dict[str, Any]] = []
    for index, (start_va, end_va) in enumerate(zip(pointers, ends), start=1):
        raw = read_bytes(exe, sections, start_va, end_va - start_va)
        chunks = split_status_comment_entry(raw)
        entries.append(
            {
                "index": index,
                "startVaHex": hx(start_va),
                "endVaHex": hx(end_va),
                "lines": [chunk["text"] for chunk in chunks],
                "normalizedLines": [chunk["normalized"] for chunk in chunks],
                "display": " / ".join(chunk["normalized"] for chunk in chunks),
                "chunks": chunks,
            }
        )

    root_bytes = read_bytes(exe, sections, STATUS_COMMENT_ROOT_VA, 0x30)
    adjacent = read_bytes(exe, sections, STATUS_COMMENT_TEXT_END_VA, 0x60)
    adjacent_text = decode_cp949(adjacent)
    return {
        "status": "grounded-text-table-consumer-pending",
        "interpretation": (
            "아타호 상태창/상황 설명 문구로 보이는 텍스트 테이블. "
            "사용자가 제시한 1장 상태창 문구가 첫 엔트리와 일치하며, "
            "테이블 직후 cara_at1.cns 리소스 블록이 이어진다. "
            "정확한 화면 배치/선택 조건 consumer는 아직 별도 추적 대상이다."
        ),
        "rootVaHex": hx(STATUS_COMMENT_ROOT_VA),
        "selectorVaHex": hx(STATUS_COMMENT_SELECTOR_VA),
        "pointerTableVaHex": hx(STATUS_COMMENT_POINTER_TABLE_VA),
        "textEndVaHex": hx(STATUS_COMMENT_TEXT_END_VA),
        "pointerCount": STATUS_COMMENT_POINTER_COUNT,
        "pointerTableRawHex": byte_hex(pointers_data),
        "rootRawHex": byte_hex(root_bytes),
        "pointerRefs": {
            hx(target): pointer_refs(exe, sections, target)
            for target in [
                STATUS_COMMENT_SELECTOR_VA,
                *pointers,
                STATUS_COMMENT_TEXT_END_VA,
                0x004F8672,
                0x004F8676,
            ]
        },
        "entries": entries,
        "adjacentResourceHint": {
            "startVaHex": hx(STATUS_COMMENT_TEXT_END_VA),
            "decodedPreview": adjacent_text[:120],
            "contains": ["cara_at1.cns"] if "cara_at1.cns" in adjacent_text else [],
        },
    }


def parse_payload(data: bytes) -> dict[str, list[dict[str, Any]]]:
    text_draws: list[dict[str, Any]] = []
    stat_draws: list[dict[str, Any]] = []
    equipment_draws: list[dict[str, Any]] = []
    portrait_draws: list[dict[str, Any]] = []
    inline_texts: list[dict[str, Any]] = []

    pos = 0
    current_label = ""
    cursor_x: int | None = None
    cursor_y: int | None = None
    while pos < len(data) - 1:
        va = STATUS_PAYLOAD_VA + pos
        if data[pos] != 0x40:
            # Inline separator text, for example the fullwidth slash between
            # current/max HP, MP, and EXP.  Keep it as evidence but do not
            # promote it to a command.
            end = next_command_offset(data, pos)
            raw = data[pos:end]
            text = decode_cp949(raw).strip()
            if text:
                inline_texts.append({"va": va, "vaHex": hx(va), "rawHex": byte_hex(raw), "text": text})
            pos = end
            continue

        opcode = data[pos + 1]
        if opcode == TEXT_OPCODE and pos + 8 <= len(data):
            x = struct.unpack_from("<H", data, pos + 2)[0]
            y = struct.unpack_from("<H", data, pos + 4)[0]
            cursor_x = x
            cursor_y = y
            end = next_command_offset(data, pos + 8)
            raw_text = data[pos + 8 : end]
            text = decode_cp949(raw_text)
            stripped = text.strip()
            if stripped:
                current_label = stripped.replace("\u3000", "").replace("　", "")
                text_draws.append(
                    {
                        "va": va,
                        "vaHex": hx(va),
                        "opcode": "40 0e",
                        "x": x,
                        "y": y,
                        "text": text,
                        "normalized": current_label,
                        "rawHex": byte_hex(data[pos:end]),
                    }
                )
            pos = end
            continue

        if opcode == ACTOR_NUMERIC_OPCODE and pos + 8 <= len(data):
            number_mode = data[pos + 2]
            width_hint = data[pos + 3]
            field_offset = struct.unpack_from("<H", data, pos + 4)[0]
            stat_draws.append(
                {
                    "va": va,
                    "vaHex": hx(va),
                    "opcode": "40 2e",
                    "labelContext": current_label,
                    "cursorX": cursor_x,
                    "cursorY": cursor_y,
                    "numberMode": number_mode,
                    "numberRenderer": numeric_renderer_for_mode(number_mode),
                    "fieldOffset": field_offset,
                    "fieldOffsetHex": hx(field_offset, 2),
                    "fieldLabel": STAT_FIELD_LABELS.get(field_offset, "unknown actor field"),
                    "widthHint": width_hint,
                    "actorEvidence": ACTOR_FIELD_EVIDENCE.get(field_offset, "pending"),
                    "rawHex": byte_hex(data[pos : pos + 8]),
                }
            )
            pos += 8
            continue

        if opcode == EQUIPMENT_NAME_OPCODE and pos + 8 <= len(data):
            slot = struct.unpack_from("<H", data, pos + 2)[0]
            table_va = struct.unpack_from("<I", data, pos + 4)[0]
            equipment_draws.append(
                {
                    "va": va,
                    "vaHex": hx(va),
                    "opcode": "40 30",
                    "cursorX": cursor_x,
                    "cursorY": cursor_y,
                    "slot": slot,
                    "slotRole": "무기/술" if slot == 0 else "방어구" if slot == 1 else "unknown",
                    "tableVa": table_va,
                    "tableVaHex": hx(table_va),
                    "tableRole": "equipment table" if table_va == EQUIPMENT_TABLE_VA else "unknown table",
                    "interpretation": "draws equipped item icon and equipment name at the current 40 0e cursor",
                    "rawHex": byte_hex(data[pos : pos + 8]),
                }
            )
            pos += 8
            continue

        if opcode == PORTRAIT_OPCODE and pos + 8 <= len(data):
            arg0 = struct.unpack_from("<H", data, pos + 2)[0]
            face_or_actor = struct.unpack_from("<H", data, pos + 4)[0]
            arg2 = struct.unpack_from("<H", data, pos + 6)[0]
            portrait_draws.append(
                {
                    "va": va,
                    "vaHex": hx(va),
                    "opcode": "40 24",
                    "cursorX": cursor_x,
                    "cursorY": cursor_y,
                    "arg0": arg0,
                    "faceOrActorId": face_or_actor,
                    "arg2": arg2,
                    "interpretation": (
                        "draws a portrait/status marker at the current 40 0e cursor; "
                        "the trailing words are not x/y offsets"
                    ),
                    "rawHex": byte_hex(data[pos : pos + 8]),
                }
            )
            pos += 8
            continue

        # Most surrounding opcodes are branch/list/current-object setup.  They
        # matter for control flow, but not for the concrete status labels.
        pos += 1

    return {
        "textDraws": text_draws,
        "statDraws": stat_draws,
        "equipmentDraws": equipment_draws,
        "portraitDraws": portrait_draws,
        "inlineTexts": inline_texts,
    }


def opcode24_survey(exe: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    """Collect likely 40 24 command rows from file-backed data sections.

    Raw byte search also finds pointer-table low-byte collisions.  For this UI
    opcode survey, keep rows that look like an 8-byte command with arg0 == 0 and
    another VM command starting immediately after it.
    """
    rows: list[dict[str, Any]] = []
    pattern = b"\x40\x24"
    for section in sections:
        if section.get("name") not in {".data", ".rdata"}:
            continue
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        pos = exe.find(pattern, start, end)
        while pos != -1:
            if pos + 10 <= len(exe):
                va = int(section["va"]) + (pos - start)
                raw = exe[pos : pos + 10]
                arg0 = struct.unpack_from("<H", raw, 2)[0]
                arg1 = struct.unpack_from("<H", raw, 4)[0]
                arg2 = struct.unpack_from("<H", raw, 6)[0]
                next_is_command = raw[8] == 0x40
                if arg0 == 0 and next_is_command:
                    rows.append(
                        {
                            "va": va,
                            "vaHex": hx(va),
                            "arg0": arg0,
                            "arg1": arg1,
                            "arg1Hex": hx(arg1, 2),
                            "arg2": arg2,
                            "arg2Hex": hx(arg2, 2),
                            "nextOpcodeHex": hx(raw[9], 2),
                            "rawHex": byte_hex(raw[:8]),
                        }
                    )
            pos = exe.find(pattern, pos + 1, end)

    by_arg2: dict[str, int] = {}
    arg1_values: set[int] = set()
    for row in rows:
        by_arg2[row["arg2Hex"]] = by_arg2.get(row["arg2Hex"], 0) + 1
        arg1_values.add(int(row["arg1"]))
    return {
        "likelyCommandCount": len(rows),
        "arg1ValuesHex": [hx(value, 2) for value in sorted(arg1_values)],
        "arg2Histogram": by_arg2,
        "rows": rows,
        "interpretation": (
            "40 24 is the draw opcode. arg1 changes across face/marker ids; "
            "arg2 behaves like a display mode/source group. Status uses arg2=0x0d, "
            "top/party style rows use 0x0e, and list/save marker rows use 0x05."
        ),
    }


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


def equipment_icon_rows(ui_grid: dict[str, Any]) -> list[dict[str, Any]]:
    for table_row in ui_grid.get("tables", []):
        if table_row.get("key") != "equipment":
            continue
        rows: list[dict[str, Any]] = []
        for record in table_row.get("records", []):
            grid = record.get("grid") or {}
            if grid.get("sheetKey") != "item":
                continue
            rows.append(
                {
                    "equipmentId": record.get("index1Based"),
                    "name": record.get("name"),
                    "recordVaHex": record.get("recordVaHex"),
                    "metaHex": record.get("metaHex"),
                    "cellIndex": grid.get("cellIndex"),
                    "cellIndexHex": grid.get("cellIndexHex"),
                    "x": grid.get("x"),
                    "y": grid.get("y"),
                    "w": grid.get("w"),
                    "h": grid.get("h"),
                    "cns": grid.get("cns"),
                    "source": "out/ui_cns_grid_mappings.json equipment table confirmed-item-grid",
                }
            )
        return rows
    return []


def derive_status_comment_layout(hud_hint: dict[str, Any]) -> dict[str, Any]:
    """Find the nested message frame inside window template #0.

    Window template #0 is a 26x22 tile shell.  Its bottom area contains a
    second framed box made from window.cns tile ids 9..17:

      9, 10..., 11
      12, 13..., 14
      15, 16..., 17

    This gives the frame position even though the 40 13 01 text table records
    do not carry a render cursor by themselves.
    """
    line_pitch_evidence = (
        "EXE event/text opcode 0x02 handler 0x0041b771 resets cursor x to "
        "context+0xce and advances cursor y by font-row table[context+0xde]. "
        "0x55b010 stores runtime font metric pointers, not static constants. "
        "No branch on rendered line count was found; browser preview derives "
        "line pitch from the actual Canvas TextMetrics for its current font, "
        "with 16px as fallback."
    )
    templates = (
        ((hud_hint.get("exe_evidence") or {}).get("window_cns_template_table") or {}).get("templates") or []
    )
    template0 = next((row for row in templates if int(row.get("index", -1)) == 0), None)
    if not template0:
        return {
            "status": "text-table-grounded-position-unverified",
            "frameRect": [32, 256, 352, 80],
            "textRect": [48, 272, 320, 48],
            "coordinateSpace": "region #6 local pixels",
            "previewLinePitchMode": "browser-font-metric-derived",
            "previewLinePitchFallbackPx": 16,
            "linePitchEvidence": line_pitch_evidence,
            "evidence": "template #0 missing from hud hint artifact; fallback candidate only",
        }

    width_tiles = int(template0.get("width_tiles") or 0)
    height_tiles = int(template0.get("height_tiles") or 0)
    tiles = list(template0.get("tile_ids") or [])
    tile_size = 16
    if width_tiles <= 0 or height_tiles <= 0 or len(tiles) < width_tiles * height_tiles:
        return {
            "status": "text-table-grounded-position-unverified",
            "frameRect": [32, 256, 352, 80],
            "textRect": [48, 272, 320, 48],
            "coordinateSpace": "region #6 local pixels",
            "previewLinePitchMode": "browser-font-metric-derived",
            "previewLinePitchFallbackPx": 16,
            "linePitchEvidence": line_pitch_evidence,
            "evidence": "template #0 tile matrix malformed; fallback candidate only",
        }

    for top_y in range(height_tiles - 2):
        row = tiles[top_y * width_tiles : (top_y + 1) * width_tiles]
        for left_x in range(width_tiles - 2):
            if row[left_x] != 9:
                continue
            right_x = left_x + 1
            while right_x < width_tiles and row[right_x] == 10:
                right_x += 1
            if right_x <= left_x + 1 or right_x >= width_tiles or row[right_x] != 11:
                continue
            box_width = right_x - left_x + 1
            bottom_y = None
            for candidate_y in range(top_y + 2, height_tiles):
                bottom_row = tiles[candidate_y * width_tiles : (candidate_y + 1) * width_tiles]
                middle_rows = [
                    tiles[y * width_tiles : (y + 1) * width_tiles]
                    for y in range(top_y + 1, candidate_y)
                ]
                if (
                    bottom_row[left_x] == 15
                    and bottom_row[right_x] == 17
                    and all(value == 16 for value in bottom_row[left_x + 1 : right_x])
                    and all(
                        middle[left_x] == 12
                        and middle[right_x] == 14
                        and all(value == 13 for value in middle[left_x + 1 : right_x])
                        for middle in middle_rows
                    )
                ):
                    bottom_y = candidate_y
                    break
            if bottom_y is None:
                continue
            box_height = bottom_y - top_y + 1
            frame_rect = [left_x * tile_size, top_y * tile_size, box_width * tile_size, box_height * tile_size]
            text_rect = [
                (left_x + 1) * tile_size,
                (top_y + 1) * tile_size,
                max(0, (box_width - 2) * tile_size),
                max(0, (box_height - 2) * tile_size),
            ]
            return {
                "status": "template-derived-frame-position-text-consumer-unverified",
                "frameRect": frame_rect,
                "textRect": text_rect,
                "tileRect": [left_x, top_y, box_width, box_height],
                "padding": [tile_size, tile_size, tile_size, tile_size],
                "coordinateSpace": "region #6 local pixels",
                "source": "window template #0 nested frame tiles 9..17",
                "previewLinePitchMode": "browser-font-metric-derived",
                "previewLinePitchFallbackPx": 16,
                "linePitchEvidence": line_pitch_evidence,
                "evidence": (
                    "The frame rectangle is derived from EXE-grounded window.cns template #0. "
                    "The 40 13 01 text groups are grounded separately, but the exact text "
                    "consumer cursor remains unverified."
                ),
            }

    return {
        "status": "text-table-grounded-position-unverified",
        "frameRect": [32, 256, 352, 80],
        "textRect": [48, 272, 320, 48],
        "coordinateSpace": "region #6 local pixels",
        "previewLinePitchMode": "browser-font-metric-derived",
        "previewLinePitchFallbackPx": 16,
        "linePitchEvidence": line_pitch_evidence,
        "evidence": "no nested status-comment frame detected in template #0; fallback candidate only",
    }


def build_payload() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    data = read_bytes(exe, sections, STATUS_PAYLOAD_VA, STATUS_PAYLOAD_END_VA - STATUS_PAYLOAD_VA)
    parsed = parse_payload(data)
    survey24 = opcode24_survey(exe, sections)
    status_comment_table = parse_status_comment_table(exe, sections)
    status_comment_groups = parse_status_comment_groups(exe, sections)

    menu_stack = load_json(OUT / "menu_descriptor_stack_review.json", {})
    cancel_window = load_json(OUT / "menu_cancel_window_consumer_review.json", {})
    actor_stats = load_json(OUT / "battle_player_actor_stat_layout_review.json", {})
    state_layout = load_json(OUT / "skill_equipment_state_layout_review.json", {})
    ui_grid = load_json(OUT / "ui_cns_grid_mappings.json", {})
    hud_hint = load_json(OUT / "hud_normal_static_hint_review.json", {})
    status_comment_layout = derive_status_comment_layout(hud_hint)

    stat_offsets = sorted({row["fieldOffset"] for row in parsed["statDraws"]})
    expected_offsets = sorted(STAT_FIELD_LABELS)

    return {
        "version": 1,
        "kind": "hwanse-status-menu-ui-expression-review",
        "status": "status-window-expression-grounded-core",
        "summary": {
            "statusPayloadVaHex": hx(STATUS_PAYLOAD_VA),
            "statusPayloadEndVaHex": hx(STATUS_PAYLOAD_END_VA),
            "windowRegion": REGION6,
            "topMenuObjectSequenceVaHex": hx(TOP_MENU_OBJECT_SEQUENCE_VA),
            "region6ChildScriptVaHex": hx(REGION6_CHILD_SCRIPT_VA),
            "actorRowBaseVaHex": hx(ACTOR_ROW_BASE_VA),
            "actorRowStrideHex": hx(ACTOR_ROW_STRIDE, 2),
            "equipmentTableVaHex": hx(EQUIPMENT_TABLE_VA),
            "textOpcode": "40 0e",
            "actorNumericOpcode": "40 2e",
            "equipmentNameOpcode": "40 30",
            "portraitOpcode": "40 24",
            "statOffsetCoverage": f"{len(stat_offsets)}/{len(expected_offsets)}",
            "statOffsetsHex": [hx(offset, 2) for offset in stat_offsets],
        },
        "sourceArtifacts": {
            "menuDescriptorStack": "out/menu_descriptor_stack_review.json",
            "menuCancelWindowConsumer": "out/menu_cancel_window_consumer_review.json",
            "playerActorStatLayout": "out/battle_player_actor_stat_layout_review.json",
            "skillEquipmentStateLayout": "out/skill_equipment_state_layout_review.json",
        },
        "scriptBinding": {
            "topMenuObjectSequence": menu_stack.get("topMenuObjectSequence", {}),
            "cancelSummary": cancel_window.get("summary", {}),
            "region6": REGION6,
            "statusPayloadVaHex": hx(STATUS_PAYLOAD_VA),
            "role": (
                "region #6 shell creation is grounded by the menu descriptor stack; "
                "payload 0x004e842e supplies the visible status page labels and actor/equipment fields."
            ),
        },
        **parsed,
        "opcode24Survey": survey24,
        "statusCommentTable": status_comment_table,
        "statusCommentGroups": status_comment_groups,
        "statusCommentLayout": {
            **status_comment_layout,
            "textGroupEvidence": (
                "40 13 01 text groups are grounded, but the status payload 0x004e842e "
                "does not consume them directly and no adjacent cursor-setting opcode "
                "was found in the table records."
            ),
        },
        "actorLayoutCrossCheck": {
            "playerRows": actor_stats.get("playerRows", []),
            "actorLayout": state_layout.get("actorLayout", {}),
        },
        "equipmentIconRows": equipment_icon_rows(ui_grid),
        "equipmentCatalog": EQUIPMENT_CATALOG,
        "levelGrowthRules": LEVEL_GROWTH_RULES,
        "levelGrowthEvidence": {
            "source": "EXE level-up routine + user-supplied stat range cross-check",
            "rules": (
                "레벨 1 원시 능력치에서 목표 레벨까지 매 레벨 HP/MP/공격/방어/기술/순발 상승치를 "
                "캐릭터별 범위 안에서 랜덤 가산한다. 운은 레벨업 루틴이 +0xc8/+0x26에 "
                "rand(100)+level/2+1을 다시 쓰는 구조로 확인되었으므로 목표 레벨 기준으로 재굴림한다."
            ),
        },
        "conclusions": [
            "상태창은 브라우저에서 임의로 만든 배치가 아니라 EXE payload 0x004e842e 안에 표시 레이블이 직접 들어 있다.",
            "수치 값은 opcode 40 2e가 actor row offset을 받아 그리며, offset은 이미 승격된 player actor layout과 일치한다.",
            "무기/방어구 표시는 opcode 40 30이 equipment table 0x0048b1dc를 참조해 item.cns 아이콘과 장비명을 그리는 형태로 해석된다.",
            "대형 창 외곽은 top menu object sequence가 만든 region #6 (0,0 416x352, template 0)이다.",
            "아타호 1장 상태창 문구는 0x004f851c 포인터 테이블과 0x004f8530 텍스트 블록에서 확인된다.",
            "상태창 문구는 0x77 한 테이블만이 아니라 0x78~0x7f까지 같은 40 13 01 구조의 그룹으로 확장된다.",
            "상태창 문구 프레임 위치는 EXE 기반 window template #0의 하단 nested frame에서 산출했다. 텍스트 소비 cursor는 아직 미확정이다.",
        ],
        "pending": [
            "필드 화면에서 ESC/X를 눌렀을 때 top menu object sequence로 진입하는 정확한 opener는 아직 분리하지 못했다.",
            "opcode 40 24는 초상화/상태 패널 후보로 보이지만 세부 의미는 아직 완전히 이름 붙이지 않았다.",
            "상태창 짧은 문구의 텍스트 테이블 그룹은 분리했지만, 어떤 시나리오 플래그가 어떤 table id와 엔트리를 고르는지는 아직 미확정이다.",
            "상태창 짧은 문구의 실제 text consumer cursor는 아직 미확정이다. 40 13 01 그룹은 좌표가 아니라 텍스트 선택 구조다.",
        ],
    }


def write_json(path: Path, payload: Any) -> None:
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")


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


def build_html(payload: dict[str, Any]) -> str:
    summary = payload["summary"]
    hud_hint = load_json(OUT / "hud_normal_static_hint_review.json", {})
    template0 = (
        ((hud_hint.get("exe_evidence") or {}).get("window_cns_template_table") or {}).get("templates") or [{}]
    )[0]
    text_rows = [
        [
            code(row["vaHex"]),
            code(row["opcode"]),
            f"{row['x']},{row['y']}",
            code(row["text"]),
            code(row["rawHex"]),
        ]
        for row in payload["textDraws"]
    ]
    stat_rows = [
        [
            code(row["vaHex"]),
            row["labelContext"],
            code(row["fieldOffsetHex"]),
            row["fieldLabel"],
            code(row["widthHint"]),
            row["actorEvidence"],
            code(row["rawHex"]),
        ]
        for row in payload["statDraws"]
    ]
    equip_rows = [
        [
            code(row["vaHex"]),
            f"{row.get('cursorX')},{row.get('cursorY')}",
            row["slotRole"],
            code(row["slot"]),
            code(row["tableVaHex"]),
            row["tableRole"],
            row["interpretation"],
            code(row["rawHex"]),
        ]
        for row in payload["equipmentDraws"]
    ]
    equip_icon_rows = [
        [
            code(row["equipmentId"]),
            row["name"],
            code(row["cellIndexHex"]),
            f"{row['x']},{row['y']},{row['w']},{row['h']}",
            code(row["metaHex"]),
            row["source"],
        ]
        for row in payload.get("equipmentIconRows", [])
    ]
    portrait_rows = [
        [
            code(row["vaHex"]),
            f"{row.get('cursorX')},{row.get('cursorY')}",
            code(row["faceOrActorId"]),
            code(row["arg0"]),
            code(row["arg2"]),
            row["interpretation"],
            code(row["rawHex"]),
        ]
        for row in payload["portraitDraws"]
    ]
    survey_rows = [
        [
            code(row["vaHex"]),
            code(row["arg1Hex"]),
            code(row["arg2Hex"]),
            code(row["nextOpcodeHex"]),
            code(row["rawHex"]),
        ]
        for row in payload["opcode24Survey"]["rows"]
    ]
    comment_table = payload.get("statusCommentTable", {})
    comment_groups = payload.get("statusCommentGroups", [])
    comment_layout = payload.get("statusCommentLayout", {})
    comment_rows = [
        [
            code(row["index"]),
            code(row["startVaHex"]),
            code(row["endVaHex"]),
            "<br>".join(code(line) for line in row.get("normalizedLines", [])),
            code(row.get("display", "")),
        ]
        for row in comment_table.get("entries", [])
    ]
    comment_group_rows = [
        [
            code(group.get("tableIdHex")),
            code(group.get("actorTableCount")),
            code(group.get("maxEntryCount")),
            "<br>".join(
                f"{h(table.get('actorRoleCandidate'))}: {code(table.get('tableVaHex'))}"
                for table in group.get("actorTables", [])
            ),
            "<br>".join(code(sample) for sample in group.get("samples", []) if sample),
        ]
        for group in comment_groups
    ]
    comment_ref_rows = []
    for target, refs in (comment_table.get("pointerRefs") or {}).items():
        comment_ref_rows.append(
            [
                code(target),
                "<br>".join(code(ref.get("vaHex")) for ref in refs) if refs else "-",
                "<br>".join(code(ref.get("fileOffsetHex")) for ref in refs) if refs else "-",
            ]
        )
    pending_items = "".join(f"<li>{h(item)}</li>" for item in payload["pending"])
    conclusion_items = "".join(f"<li>{h(item)}</li>" for item in payload["conclusions"])

    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>상태창 UI 표현부 리뷰</title>
  <style>
    :root {{ color-scheme: light; --bg:#f6f7f9; --panel:#fff; --line:#d9dee7; --text:#20242b; --muted:#677080; --accent:#2459a6; }}
    * {{ box-sizing: border-box; }}
    body {{ margin:0; background:var(--bg); color:var(--text); font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
    main {{ width:min(1180px, calc(100vw - 24px)); margin:0 auto; padding:22px 0 38px; }}
    h1 {{ margin:0 0 6px; font-size:26px; }}
    h2 {{ margin:22px 0 8px; font-size:18px; }}
    p {{ margin:0 0 10px; color:var(--muted); }}
    a {{ color:var(--accent); text-decoration:none; }}
    a:hover {{ text-decoration:underline; }}
    .summary {{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin:14px 0; }}
    .card {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:10px 12px; }}
    .label {{ color:var(--muted); font-size:12px; }}
    .value {{ font-weight:700; margin-top:3px; word-break:break-all; }}
    table {{ width:100%; border-collapse:collapse; background:var(--panel); border:1px solid var(--line); margin:8px 0 16px; }}
    th, td {{ border-top:1px solid var(--line); padding:7px 8px; text-align:left; vertical-align:top; font-size:13px; }}
    th {{ background:#eef2f7; border-top:0; }}
    code {{ color:#0b4f80; }}
    button {{ min-height:32px; padding:6px 10px; border:1px solid var(--line); border-radius:7px; background:#fff; color:var(--text); cursor:pointer; }}
    button:hover {{ border-color:#9fb3d5; background:#f5f8ff; }}
    button.is-active {{ border-color:#2459a6; background:#e9f0ff; color:#173f78; font-weight:700; }}
    .preview-tools {{ display:flex; flex-wrap:wrap; gap:8px; margin:8px 0; }}
    .tool-label {{ width:100%; color:var(--muted); font-size:12px; margin-top:4px; }}
    .select-row {{ display:grid; grid-template-columns:120px minmax(0, 1fr); gap:8px; align-items:center; width:100%; max-width:920px; }}
    select, input[type="number"] {{ width:100%; min-height:34px; border:1px solid var(--line); border-radius:7px; background:#fff; color:var(--text); padding:5px 8px; }}
    .build-controls {{ display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; width:100%; max-width:920px; }}
    .build-actions {{ display:flex; flex-wrap:wrap; gap:8px; align-items:center; width:100%; }}
    .mini-note {{ width:100%; color:var(--muted); font-size:12px; }}
    .canvas-box {{ display:inline-block; padding:10px; border:1px solid var(--line); border-radius:8px; background:#111; }}
    #statusPreview {{ display:block; width:min(100%, 832px); height:auto; image-rendering:pixelated; }}
    .links {{ display:flex; flex-wrap:wrap; gap:8px; margin:10px 0 14px; }}
    .chip {{ display:inline-flex; align-items:center; min-height:30px; padding:5px 9px; border:1px solid var(--line); border-radius:7px; background:#eef2f7; color:var(--text); }}
    ul {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; margin:8px 0 16px; padding:12px 16px 12px 30px; }}
    @media (max-width: 840px) {{ .summary {{ grid-template-columns:1fr 1fr; }} main {{ width:min(100vw - 14px,1180px); }} table {{ display:block; overflow-x:auto; }} }}
  </style>
</head>
<body>
<main>
  <h1>상태창 UI 표현부 리뷰</h1>
  <p>ESC/X 메뉴의 좌측 대형 window region #6 안에 그려지는 상태창 텍스트, actor 수치 필드, 장비명 draw 명령을 EXE 페이로드 기준으로 정리한 자료입니다.</p>
  <div class="links">
    <a class="chip" href="index.html">홈</a>
    <a class="chip" href="menu_descriptor_stack_review.html">descriptor stack</a>
    <a class="chip" href="menu_cancel_window_consumer_review.html">cancel/window consumer</a>
    <a class="chip" href="skill_equipment_state_layout_review.html">state layout</a>
  </div>
  <section class="summary">
    <div class="card"><div class="label">status payload</div><div class="value">{code(summary['statusPayloadVaHex'])}</div></div>
    <div class="card"><div class="label">window region</div><div class="value">#{REGION6_INDEX} · 416x352</div></div>
    <div class="card"><div class="label">actor row</div><div class="value">{code(summary['actorRowBaseVaHex'])} + {code(summary['actorRowStrideHex'])}</div></div>
    <div class="card"><div class="label">stat coverage</div><div class="value">{h(summary['statOffsetCoverage'])}</div></div>
  </section>

  <h2>결론</h2>
  <ul>{conclusion_items}</ul>

  <h2>상태창 문구 테이블</h2>
  <p>사용자가 제시한 1장 아타호 상태창 문구가 첫 엔트리와 일치합니다. 이후 같은 <code>40 13 01</code> 구조의 문구 그룹을 추가 스캔했으며, 3개 서브테이블이 있는 그룹은 문체상 아타호/린샹/스마슈 순서 후보로 볼 수 있습니다. 시나리오 플래그별 table id 선택 조건은 아직 별도 추적 대상입니다.</p>
  <p><strong>좌표 주의:</strong> <code>40 13 01</code> 그룹은 텍스트 선택 구조입니다. 문구 프레임 위치는 EXE 기반 window template #0 하단 nested frame에서 산출했지만, 텍스트를 실제로 소비하는 cursor 명령은 아직 직접 확인하지 못했습니다.</p>
  <section class="summary">
    <div class="card"><div class="label">root</div><div class="value">{code(comment_table.get('rootVaHex'))}</div></div>
    <div class="card"><div class="label">selector</div><div class="value">{code(comment_table.get('selectorVaHex'))}</div></div>
    <div class="card"><div class="label">pointer table</div><div class="value">{code(comment_table.get('pointerTableVaHex'))}</div></div>
    <div class="card"><div class="label">adjacent resource</div><div class="value">{h(', '.join((comment_table.get('adjacentResourceHint') or {}).get('contains', [])) or '-')}</div></div>
  </section>
  <h3>문구 그룹 후보</h3>
  {table(['table id','actor tables','max entries','subtable VA','samples'], comment_group_rows)}
  <h3>기존 0x77 아타호 테이블</h3>
  {table(['#','start','end','lines','display'], comment_rows)}
  <details>
    <summary>문구 테이블 포인터 참조</summary>
    {table(['target','ref VA','file offset'], comment_ref_rows)}
    <p><code>{h(comment_table.get('pointerTableRawHex'))}</code></p>
  </details>

  <h2>고정 텍스트 draw</h2>
  {table(['VA','opcode','x,y','text','raw'], text_rows)}

  <h2>수치 필드 draw</h2>
  {table(['VA','label context','actor offset','field','width','evidence','raw'], stat_rows)}

  <h2>장비명 draw</h2>
  {table(['VA','cursor x,y','slot','slot id','table','table role','interpretation','raw'], equip_rows)}

  <h2>장비 아이콘 cell</h2>
  <p>장비 record의 meta high word <code>0x0006</code>은 <code>item.cns</code>, low word는 32x32 cell index입니다. 상태창 미리보기는 이 확정 매핑으로 착용 장비 아이콘을 그립니다.</p>
  {table(['equipment id','name','item.cns cell','rect','meta','source'], equip_icon_rows)}

  <h2>초상화/상태 패널 후보</h2>
  {table(['VA','cursor x,y','face/actor arg','arg0','arg2','interpretation','raw'], portrait_rows)}

  <h2>상태창 미리보기</h2>
  <p>window.cns template #0, face_01.cns 80x80 grid, actor row 초기값을 조합한 검토용 렌더링입니다. 문구는 선택한 table id 안에서 현재 캐릭터에 대응되는 서브테이블만 사용합니다. 해당 캐릭터 테이블이 없으면 빈 상태로 표시합니다.</p>
  <div class="preview-tools">
    <div class="tool-label">캐릭터</div>
    <button type="button" data-actor="0">아타호</button>
    <button type="button" data-actor="1">린샹</button>
    <button type="button" data-actor="2">스마슈</button>
    <div class="tool-label">장비/레벨</div>
    <div class="build-controls">
      <label><span class="label">레벨</span><input id="levelInput" type="number" min="1" max="99" step="1" value="1"></label>
      <label><span class="label">무기/술</span><select id="weaponSelect"></select></label>
      <label><span class="label">방어구</span><select id="armorSelect"></select></label>
    </div>
    <div class="build-actions">
      <button type="button" id="randomizeBuild">레벨+장비 랜덤</button>
      <span class="mini-note">레벨 1 능력치는 고정입니다. 레벨을 바꾸면 해당 레벨까지 캐릭터별 상승 범위를 누적하고, 운은 <code>rand(100)+level/2+1</code> 구조로 결정합니다.</span>
    </div>
    <div class="tool-label">상태창 문구 그룹/엔트리</div>
    <label class="select-row"><span>그룹</span><select id="commentGroupSelect"></select></label>
    <label class="select-row"><span>엔트리</span><select id="commentEntrySelect"></select></label>
    <div class="tool-label">문구 프레임 위치 · template #0 기반</div>
    <label class="select-row"><span>x</span><input id="commentX" type="range" min="0" max="416" value="24"></label>
    <label class="select-row"><span>y</span><input id="commentY" type="range" min="0" max="352" value="258"></label>
    <label class="select-row"><span>w</span><input id="commentW" type="range" min="80" max="416" value="368"></label>
    <label class="select-row"><span>h</span><input id="commentH" type="range" min="24" max="120" value="76"></label>
    <label class="select-row"><span>표시</span><label><input id="showCommentCandidate" type="checkbox" checked> 문구 프레임/텍스트 표시</label></label>
  </div>
  <div class="canvas-box"><canvas id="statusPreview" width="416" height="352"></canvas></div>
  <p>프레임: <code>{h(comment_layout.get('frameRect'))}</code> · 텍스트 영역: <code>{h(comment_layout.get('textRect'))}</code> · 상태: <code>{h(comment_layout.get('status'))}</code></p>
  <p class="muted">줄피치: EXE <code>0x02</code> 줄이동 opcode는 줄 수 분기 없이 런타임 font metric 값을 y에 더한다. 이 미리보기는 웹의 실제 Canvas 폰트 metric을 읽고, 실패 시 <code>{h(comment_layout.get('previewLinePitchFallbackPx'))}px</code>를 fallback으로 쓴다.</p>

  <h2>40 24 인자 survey</h2>
  <p>전체 data/rdata에서 8바이트 command 형태로 보이는 <code>40 24</code>만 모은 것입니다. 상태창은 <code>arg2=0x0d</code> 3개를 사용합니다.</p>
  <p>count <code>{h(payload['opcode24Survey']['likelyCommandCount'])}</code> · arg1 values <code>{h(', '.join(payload['opcode24Survey']['arg1ValuesHex']))}</code> · arg2 histogram <code>{h(payload['opcode24Survey']['arg2Histogram'])}</code></p>
  {table(['VA','arg1','arg2','next opcode','raw'], survey_rows)}

  <h2>보류</h2>
  <ul>{pending_items}</ul>
</main>
<script src="engine/cns/renderer.js"></script>
<script>
window.HWANSE_STATUS_MENU_UI_EXPRESSION_REVIEW = {json.dumps({
    "status": payload["status"],
    "payload": summary["statusPayloadVaHex"],
    "region": REGION6_INDEX,
    "statCoverage": summary["statOffsetCoverage"],
}, ensure_ascii=False)};

(function () {{
  const actorRows = {json.dumps(payload.get("actorLayoutCrossCheck", {}).get("playerRows", []), ensure_ascii=False)};
  const faceOrderByActorSlot = {{ 0: 0, 1: 2, 2: 1 }};
  const labels = {json.dumps(payload["textDraws"], ensure_ascii=False)};
  const statDraws = {json.dumps(payload["statDraws"], ensure_ascii=False)};
  const equipmentDraws = {json.dumps(payload["equipmentDraws"], ensure_ascii=False)};
  const equipmentIconRows = {json.dumps(payload.get("equipmentIconRows", []), ensure_ascii=False)};
  const equipmentCatalog = {json.dumps(payload.get("equipmentCatalog", []), ensure_ascii=False)};
  const levelGrowthRules = {json.dumps(payload.get("levelGrowthRules", {}), ensure_ascii=False)};
  const statusCommentGroups = {json.dumps(comment_groups, ensure_ascii=False)};
  const statusCommentLayout = {json.dumps(comment_layout, ensure_ascii=False)};
  const template0 = {json.dumps(template0, ensure_ascii=False)};
  const equipmentIconsById = new Map(equipmentIconRows.map((row) => [Number(row.equipmentId), row]));
  const equipmentById = new Map(equipmentCatalog.map((row) => [Number(row.id), row]));
  const canvas = document.getElementById("statusPreview");
  const ctx = canvas.getContext("2d");
  const levelInput = document.getElementById("levelInput");
  const weaponSelect = document.getElementById("weaponSelect");
  const armorSelect = document.getElementById("armorSelect");
  const randomizeBuild = document.getElementById("randomizeBuild");
  const commentGroupSelect = document.getElementById("commentGroupSelect");
  const commentEntrySelect = document.getElementById("commentEntrySelect");
  const commentX = document.getElementById("commentX");
  const commentY = document.getElementById("commentY");
  const commentW = document.getElementById("commentW");
  const commentH = document.getElementById("commentH");
  const showCommentCandidate = document.getElementById("showCommentCandidate");
  ctx.imageSmoothingEnabled = false;
  let selectedActor = 0;
  let selectedCommentGroup = Math.max(0, statusCommentGroups.findIndex((group) => group.tableIdHex === "0x77"));
  let selectedCommentEntry = 0;
  const commentRect = statusCommentLayout.frameRect || [32, 256, 352, 80];
  const commentTextRect = statusCommentLayout.textRect || [48, 272, 320, 48];
  const commentPadding = [
    commentTextRect[0] - commentRect[0],
    commentTextRect[1] - commentRect[1],
    Math.max(0, commentRect[0] + commentRect[2] - (commentTextRect[0] + commentTextRect[2])),
    Math.max(0, commentRect[1] + commentRect[3] - (commentTextRect[1] + commentTextRect[3])),
  ];
  commentX.value = String(commentRect[0] ?? 32);
  commentY.value = String(commentRect[1] ?? 256);
  commentW.value = String(commentRect[2] ?? 352);
  commentH.value = String(commentRect[3] ?? 80);
  const statKeys = ["attack", "defense", "technique", "agility", "luck"];
  const statLabels = {{ attack: "공", defense: "방", technique: "기", agility: "순", luck: "운" }};

  function randIntInclusive(min, max) {{
    const lo = Math.ceil(Number(min));
    const hi = Math.floor(Number(max));
    return lo + Math.floor(Math.random() * (hi - lo + 1));
  }}

  function clampLevel(value) {{
    return Math.max(1, Math.min(99, Math.floor(Number(value) || 1)));
  }}

  function baseStat(row, name, fallback = 0) {{
    const cap = name.charAt(0).toUpperCase() + name.slice(1);
    return Number(row[`base${{cap}}`] ?? row[name] ?? fallback);
  }}

  function growthRule(row) {{
    return levelGrowthRules[String(Number(row.slot || 0))] || {{}};
  }}

  function rollRawStats(row, level) {{
    const targetLevel = clampLevel(level);
    const rule = growthRule(row);
    const stats = {{
      maxHp: baseStat(row, "hp", row.maxHp),
      maxMp: baseStat(row, "mp", row.maxMp),
      attack: baseStat(row, "attack", row.actorAttack),
      defense: baseStat(row, "defense", row.actorDefense),
      technique: baseStat(row, "technique", row.actorTechnique),
      agility: baseStat(row, "agility", row.actorAgility),
      luck: baseStat(row, "luck", row.actorLuck),
    }};
    for (let levelNo = 2; levelNo <= targetLevel; levelNo += 1) {{
      for (const key of ["hp", "mp", "attack", "defense", "technique", "agility"]) {{
        const range = rule[key] || [0, 0];
        const outKey = key === "hp" ? "maxHp" : key === "mp" ? "maxMp" : key;
        stats[outKey] += randIntInclusive(range[0], range[1]);
      }}
    }}
    if (targetLevel > 1) {{
      stats.luck = randIntInclusive(0, 99) + Math.floor(targetLevel / 2) + 1;
    }}
    return stats;
  }}

  function itemOptions(actorSlot, slot) {{
    return equipmentCatalog.filter((row) => Number(row.actorSlot) === Number(actorSlot) && row.slot === slot);
  }}

  function firstOptionId(actorSlot, slot) {{
    return Number((itemOptions(actorSlot, slot)[0] || {{ id: 0 }}).id || 0);
  }}

  function makeActorState(row) {{
    const level = clampLevel(row.level || 1);
    return {{
      level,
      exp: Number(row.exp || 0),
      weaponId: Number(row.weaponId || firstOptionId(row.slot, "weapon")),
      armorId: Number(row.armorId || firstOptionId(row.slot, "armor")),
      raw: rollRawStats(row, level),
    }};
  }}

  const actorStates = actorRows.map((row) => makeActorState(row));

  function statDeltaText(bonus = {{}}) {{
    const parts = [];
    for (const key of statKeys) {{
      const value = Number(bonus[key] || 0);
      if (!value) continue;
      parts.push(`${{statLabels[key]}} ${{value > 0 ? "+" : ""}}${{value}}`);
    }}
    return parts.join(" ");
  }}

  function addBonus(total, bonus = {{}}) {{
    for (const key of statKeys) {{
      total[key] += Number(bonus[key] || 0);
    }}
    return total;
  }}

  function buildActorPreview(row, state) {{
    const weapon = equipmentById.get(Number(state.weaponId)) || {{}};
    const armor = equipmentById.get(Number(state.armorId)) || {{}};
    const total = addBonus(addBonus({{ ...state.raw }}, weapon.bonus), armor.bonus);
    return {{
      ...row,
      level: state.level,
      hp: state.raw.maxHp,
      maxHp: state.raw.maxHp,
      mp: state.raw.maxMp,
      maxMp: state.raw.maxMp,
      exp: state.exp,
      weaponId: Number(weapon.id || state.weaponId),
      weaponName: weapon.name || row.weaponName,
      armorId: Number(armor.id || state.armorId),
      armorName: armor.name || row.armorName,
      actorAttack: total.attack,
      actorDefense: total.defense,
      actorTechnique: total.technique,
      actorAgility: total.agility,
      actorLuck: total.luck,
      rawStats: state.raw,
      weaponBonus: weapon.bonus || {{}},
      armorBonus: armor.bonus || {{}},
    }};
  }}

  function textWidthCells(text) {{
    return Array.from(String(text || "")).length * 16;
  }}

  function numberRendererCell(renderer) {{
    return renderer?.cns === "num.cns" ? 16 : 8;
  }}

  function drawNumberRendererValue(numberCanvas, smallNumberCanvas, value, x, y, widthHint = 3, renderer = null) {{
    const mode = Number(renderer?.mode ?? 4);
    const useLarge = renderer?.cns === "num.cns";
    const sheet = useLarge ? numberCanvas : smallNumberCanvas;
    if (!sheet) {{
      drawText(value, x, y);
      return;
    }}
    const cell = useLarge ? 16 : 8;
    const row = Math.max(0, Math.min(3, Number(renderer?.row ?? (mode >= 4 ? mode - 4 : mode)) || 0));
    const digits = Math.max(1, Number(widthHint || 1));
    const maxValue = Math.pow(10, Math.min(digits, 6)) - 1;
    const textValue = String(Math.max(0, Math.min(maxValue, Math.floor(Number(value || 0))))).padStart(digits, " ");
    Array.from(textValue).forEach((char, index) => {{
      if (char < "0" || char > "9") return;
      const digit = Number(char);
      ctx.drawImage(sheet, digit * cell, row * cell, cell, cell, x + index * cell, y, cell, cell);
    }});
  }}

  function statValue(actor, offset) {{
    const map = {{
      0x06: actor.level,
      0x08: actor.hp,
      0x0a: actor.maxHp,
      0x0e: actor.mp,
      0x10: actor.maxMp,
      0x14: actor.exp,
      0x16: 100,
      0x1c: actor.actorAttack,
      0x20: actor.actorDefense,
      0x22: actor.actorTechnique,
      0x24: actor.actorAgility,
      0x26: actor.actorLuck,
    }};
    return map[offset] ?? "";
  }}

  function drawWindowTemplate(windowCanvas) {{
    const tileSize = 16;
    const columns = 18;
    for (let i = 0; i < template0.tile_ids.length; i += 1) {{
      const tile = template0.tile_ids[i];
      const sx = (tile % columns) * tileSize;
      const sy = Math.floor(tile / columns) * tileSize;
      const dx = (i % template0.width_tiles) * tileSize;
      const dy = Math.floor(i / template0.width_tiles) * tileSize;
      ctx.drawImage(windowCanvas, sx, sy, tileSize, tileSize, dx, dy, tileSize, tileSize);
    }}
  }}

  function drawText(text, x, y, options = {{}}) {{
    ctx.save();
    ctx.font = options.font || "16px Gulim, 'Malgun Gothic', sans-serif";
    ctx.fillStyle = options.color || "#fff";
    ctx.shadowColor = "rgba(0,0,0,.75)";
    ctx.shadowOffsetX = 1;
    ctx.shadowOffsetY = 1;
    ctx.fillText(String(text || ""), x, y + 15);
    ctx.restore();
  }}

  function measureCurrentLinePitch(ctx) {{
    const metrics = ctx.measureText("가나다ABC012");
    const fontBox = Number(metrics.fontBoundingBoxAscent || 0) + Number(metrics.fontBoundingBoxDescent || 0);
    const actualBox = Number(metrics.actualBoundingBoxAscent || 0) + Number(metrics.actualBoundingBoxDescent || 0);
    const emBox = Number(metrics.emHeightAscent || 0) + Number(metrics.emHeightDescent || 0);
    const measured = Math.ceil(Math.max(fontBox, actualBox, emBox));
    const fallback = Number(statusCommentLayout.previewLinePitchFallbackPx || 16);
    return Math.max(1, measured || fallback);
  }}

  function drawTextBox(lines, x, y, w, h) {{
    ctx.save();
    ctx.beginPath();
    ctx.rect(x, y, w, h);
    ctx.clip();
    ctx.font = "14px Gulim, 'Malgun Gothic', sans-serif";
    ctx.fillStyle = "#fff";
    ctx.shadowColor = "rgba(0,0,0,.75)";
    ctx.shadowOffsetX = 1;
    ctx.shadowOffsetY = 1;
    const lineHeight = measureCurrentLinePitch(ctx);
    const baselineOffset = 14;
    for (let i = 0; i < lines.length; i += 1) {{
      ctx.fillText(String(lines[i] || ""), x, y + i * lineHeight + baselineOffset);
    }}
    ctx.restore();
  }}

  function drawEquipment(itemCanvas, equipmentId, name, x, y) {{
    const icon = equipmentIconsById.get(Number(equipmentId));
    if (icon) {{
      ctx.drawImage(itemCanvas, icon.x, icon.y, icon.w, icon.h, x, y, 32, 32);
    }}
    drawText(name || "-", x + 40, y + 8);
  }}

  function currentCommentGroup() {{
    return statusCommentGroups[selectedCommentGroup] || statusCommentGroups[0] || null;
  }}

  function currentActorCommentTable() {{
    const group = currentCommentGroup();
    if (!group) return null;
    const tables = group.actorTables || [];
    return tables[selectedActor] || null;
  }}

  function currentCommentEntries() {{
    const table = currentActorCommentTable();
    return table ? (table.entries || []) : [];
  }}

  function drawStatusComment() {{
    if (!showCommentCandidate.checked) return;
    const x = Number(commentX.value || 32);
    const y = Number(commentY.value || 256);
    const w = Number(commentW.value || 352);
    const h = Number(commentH.value || 80);
    ctx.save();
    ctx.strokeStyle = "rgba(255, 204, 0, 0.75)";
    ctx.setLineDash([4, 3]);
    ctx.strokeRect(x + 0.5, y + 0.5, Math.max(1, w - 1), Math.max(1, h - 1));
    ctx.restore();
    const textX = x + commentPadding[0];
    const textY = y + commentPadding[1];
    const textW = Math.max(1, w - commentPadding[0] - commentPadding[2]);
    const textH = Math.max(1, h - commentPadding[1] - commentPadding[3]);
    const table = currentActorCommentTable();
    if (!table) {{
      drawText("이 캐릭터 문구 테이블 없음", textX, textY, {{ color: "#9aa5b5", font: "14px Gulim, 'Malgun Gothic', sans-serif" }});
      return;
    }}
    const entries = table.entries || [];
    const entry = entries[selectedCommentEntry] || entries.find((row) => row.display) || null;
    if (!entry) return;
    const lines = (entry.normalizedLines || []).filter(Boolean);
    if (!lines.length) {{
      drawText("빈 문구 엔트리", textX, textY, {{ color: "#9aa5b5", font: "14px Gulim, 'Malgun Gothic', sans-serif" }});
      return;
    }}
    drawTextBox(lines, textX, textY, textW, textH);
  }}

  function syncButtons() {{
    document.querySelectorAll("[data-actor]").forEach((button) => {{
      button.classList.toggle("is-active", Number(button.dataset.actor || 0) === selectedActor);
    }});
  }}

  function fillEquipmentSelect(select, slot) {{
    const row = actorRows[selectedActor] || actorRows[0] || {{}};
    const state = actorStates[selectedActor];
    const currentId = slot === "weapon" ? Number(state.weaponId) : Number(state.armorId);
    const options = itemOptions(row.slot, slot);
    select.innerHTML = "";
    for (const item of options) {{
      const option = document.createElement("option");
      option.value = String(item.id);
      const delta = statDeltaText(item.bonus || {{}});
      option.textContent = `${{item.name}}${{delta ? ` · ${{delta}}` : ""}}`;
      select.appendChild(option);
    }}
    const validIds = new Set(options.map((item) => Number(item.id)));
    if (!validIds.has(currentId) && options.length) {{
      if (slot === "weapon") state.weaponId = Number(options[0].id);
      if (slot === "armor") state.armorId = Number(options[0].id);
    }}
    select.value = String(slot === "weapon" ? state.weaponId : state.armorId);
  }}

  function syncBuildControls() {{
    const state = actorStates[selectedActor];
    levelInput.value = String(state.level);
    fillEquipmentSelect(weaponSelect, "weapon");
    fillEquipmentSelect(armorSelect, "armor");
  }}

  function syncGroupSelect() {{
    commentGroupSelect.innerHTML = "";
    statusCommentGroups.forEach((group, index) => {{
      const option = document.createElement("option");
      option.value = String(index);
      option.textContent = `${{group.tableIdHex}} · ${{group.actorTableCount}} tables · ${{(group.samples || [])[0] || ""}}`;
      commentGroupSelect.appendChild(option);
    }});
    commentGroupSelect.value = String(selectedCommentGroup);
  }}

  function syncEntrySelect() {{
    const entries = currentCommentEntries();
    if (selectedCommentEntry >= entries.length) selectedCommentEntry = 0;
    commentEntrySelect.innerHTML = "";
    if (!entries.length) {{
      const option = document.createElement("option");
      option.value = "0";
      option.textContent = "이 캐릭터 문구 테이블 없음";
      commentEntrySelect.appendChild(option);
      commentEntrySelect.disabled = true;
      return;
    }}
    commentEntrySelect.disabled = false;
    entries.forEach((entry, index) => {{
      const option = document.createElement("option");
      option.value = String(index);
      option.textContent = `#${{entry.index}} ${{entry.display || "(빈 엔트리)"}}`;
      commentEntrySelect.appendChild(option);
    }});
    commentEntrySelect.value = String(selectedCommentEntry);
  }}

  function syncControls() {{
    syncButtons();
    syncBuildControls();
    syncGroupSelect();
    syncEntrySelect();
  }}

  async function render() {{
    const actorBase = actorRows[selectedActor] || actorRows[0] || {{}};
    const actor = buildActorPreview(actorBase, actorStates[selectedActor]);
    const [windowCanvas, faceCanvas, itemCanvas, numberCanvas, smallNumberCanvas] = await Promise.all([
      window.HWANSE_CNS_RENDERER.loadImageCanvas("window.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("face_01.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("item.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("num.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("01234567.cns"),
    ]);
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "#060606";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    drawWindowTemplate(windowCanvas);

    // The status payload sets cursor 56,40 with 40 0e, then calls 40 24.
    // It repeats the branch for actor slots 0,1,2, but the face ids are 0,2,1.
    drawText(actor.name || "", 16, 16);
    const faceId = faceOrderByActorSlot[selectedActor] ?? selectedActor;
    ctx.drawImage(faceCanvas, (faceId % 8) * 80, Math.floor(faceId / 8) * 80, 80, 80, 56, 40, 80, 80);

    for (const row of labels) {{
      const normalized = String(row.normalized || "").replace(/　/g, "");
      if (!normalized) continue;
      drawText(row.text, row.x, row.y);
    }}

    const weaponDraw = equipmentDraws.find((row) => Number(row.slot) === 0) || {{ cursorX: 32, cursorY: 152 }};
    const armorDraw = equipmentDraws.find((row) => Number(row.slot) === 1) || {{ cursorX: 32, cursorY: 216 }};
    drawEquipment(itemCanvas, actor.weaponId, actor.weaponName, Number(weaponDraw.cursorX ?? 32), Number(weaponDraw.cursorY ?? 152));
    drawEquipment(itemCanvas, actor.armorId, actor.armorName, Number(armorDraw.cursorX ?? 32), Number(armorDraw.cursorY ?? 216));

    const statByContext = new Map();
    for (const row of statDraws) {{
      const key = String(row.labelContext || "");
      if (!statByContext.has(key)) statByContext.set(key, []);
      statByContext.get(key).push(row);
    }}
    for (const label of labels) {{
      const context = String(label.normalized || "").replace(/　/g, "");
      const rows = statByContext.get(context) || [];
      if (!rows.length) continue;
      let x = label.x + textWidthCells(label.text);
      const values = rows.map((row) => statValue(actor, Number(row.fieldOffset)));
      if (values.length === 2) {{
        const left = rows[0] || {{}};
        const right = rows[1] || {{}};
        const leftWidth = Number(left.widthHint || 4);
        drawNumberRendererValue(numberCanvas, smallNumberCanvas, values[0], x, label.y, leftWidth, left.numberRenderer);
        const slashX = x + leftWidth * numberRendererCell(left.numberRenderer);
        drawText("／", slashX, label.y);
        drawNumberRendererValue(numberCanvas, smallNumberCanvas, values[1], slashX + 16, label.y, Number(right.widthHint || 3), right.numberRenderer);
      }} else {{
        const row = rows[0] || {{}};
        drawNumberRendererValue(numberCanvas, smallNumberCanvas, values[0], x, label.y, Number(row.widthHint || 4), row.numberRenderer);
      }}
    }}
    drawStatusComment();
    syncControls();
  }}

  document.querySelectorAll("[data-actor]").forEach((button) => {{
    button.addEventListener("click", () => {{
      selectedActor = Number(button.dataset.actor || 0);
      selectedCommentEntry = 0;
      render();
    }});
  }});
  levelInput.addEventListener("change", () => {{
    const row = actorRows[selectedActor] || actorRows[0] || {{}};
    const state = actorStates[selectedActor];
    state.level = clampLevel(levelInput.value);
    state.raw = rollRawStats(row, state.level);
    state.exp = state.level >= 99 ? 0 : Math.min(Number(state.exp || 0), 99);
    render();
  }});
  weaponSelect.addEventListener("change", () => {{
    actorStates[selectedActor].weaponId = Number(weaponSelect.value || 0);
    render();
  }});
  armorSelect.addEventListener("change", () => {{
    actorStates[selectedActor].armorId = Number(armorSelect.value || 0);
    render();
  }});
  randomizeBuild.addEventListener("click", () => {{
    const row = actorRows[selectedActor] || actorRows[0] || {{}};
    const state = actorStates[selectedActor];
    const weapons = itemOptions(row.slot, "weapon");
    const armors = itemOptions(row.slot, "armor");
    state.level = randIntInclusive(1, 99);
    if (weapons.length) state.weaponId = Number(weapons[randIntInclusive(0, weapons.length - 1)].id);
    if (armors.length) state.armorId = Number(armors[randIntInclusive(0, armors.length - 1)].id);
    state.raw = rollRawStats(row, state.level);
    state.exp = 0;
    render();
  }});
  commentGroupSelect.addEventListener("change", () => {{
    selectedCommentGroup = Number(commentGroupSelect.value || 0);
    selectedCommentEntry = 0;
    render();
  }});
  commentEntrySelect.addEventListener("change", () => {{
    selectedCommentEntry = Number(commentEntrySelect.value || 0);
    render();
  }});
  [commentX, commentY, commentW, commentH, showCommentCandidate].forEach((input) => {{
    input.addEventListener("input", render);
    input.addEventListener("change", render);
  }});
  syncControls();
  render().catch((error) => {{
    ctx.fillStyle = "#fff";
    ctx.fillText(`preview error: ${{error.message}}`, 12, 24);
  }});
}}());
</script>
</body>
</html>
"""


def main() -> None:
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    payload = build_payload()
    write_json(JSON_OUT, payload)
    html_text = build_html(payload)
    WEB_HTML_OUT.write_text(html_text, encoding="utf-8")
    print(f"wrote {JSON_OUT}")
    print(f"wrote {WEB_HTML_OUT}")


if __name__ == "__main__":
    main()
