#!/usr/bin/env python3
"""Build the right-side top-menu panel evidence payload.

The left status screen region (#6) is covered by
``status_menu_ui_expression_review``.  This JSON payload focuses on the right
menu column and the normal HUD info panel:

* region #3: top/right horizontal category window, template #2
* region #4: middle/right vertical detail window, template #3
* region #2: lower/right normal HUD info/selection window, template #4

The region/template binding is EXE-grounded through
``out/hud_normal_static_hint_review.json``.  The payload at
``0x004e8120..0x004e842e`` contains both normal-field right-menu evidence and
shared/action-menu evidence.  Its first six ``40 24`` rows are the normal
right-menu top icons in the exact icon.cns order ``#0,#1,#2,#3,#8,#4``.
Later ``40 24`` rows still remain shared/battle/action candidates.  The
right-menu content payload is now grounded as an attachment of the top-menu
object construction sequence.  The normal-field ESC/X opener that triggers that
object construction is still pending.

The standalone web review page was retired after ``hud_menu_preview.html`` became
the combined user-facing HUD/menu surface.  Keep this JSON as an input artifact.
"""
from __future__ import annotations

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

from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset


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

JSON_OUT = OUT / "menu_right_panel_ui_review.json"
ITEM_INVENTORY_JSON = OUT / "item_inventory_state_layout_review.json"

REGION_IDS = [3, 4, 2]
CONTEXT_REGION_IDS = [6, 1]

RIGHT_MENU_PAYLOAD_VA = 0x004E8120
RIGHT_MENU_PAYLOAD_END_VA = 0x004E842E
STATUS_PAYLOAD_VA = 0x004E842E

NORMAL_TOP_ICON_SEQUENCE = [0, 1, 2, 3, 8, 4]
NORMAL_TOP_ICON_LABELS = ["기술", "도구", "장비", "소지", "모드", "환경설정"]
CHARACTER_LABELS = ["아타호", "린샹", "스마슈"]
PLAYER_ACTION_DESC_BASE_VA = {
    "아타호": 0x004D24AC,
    "린샹": 0x004D26BC,
    "스마슈": 0x004D2864,
}
PLAYER_ACTION_DESC_GROUP_LABEL = {
    "아타호": "아타호 기술 설명",
    "린샹": "린샹 기술 설명",
    "스마슈": "스마슈 기술 설명",
}
SKILL_RANK_LABELS = {
    1: "필살기",
    2: "장기",
    3: "달인기",
    4: "신기",
}
SKILL_RANK_LABEL_POINTERS = [0x004EBBE8, 0x004EBBEC, 0x004EBBF6, 0x004EBC00, 0x004EBC0A]

SKILL_TABLE_VA = 0x004D2488
ITEM_TABLE_VA = 0x0048B97C
EQUIPMENT_TABLE_VA = 0x0048B1DC
MODE_TABLE_VA = 0x0048BCAA
CONFIG_TABLE_VA = 0x0048C012
CONFIG_TABLE_COUNT = 6
META_SHEET_GRIDS = {
    # EXE row meta high word 0x0005 selects icon.cns.  The confirmed icon
    # sheet is 640x128, 32x32 cells, 20 columns.
    5: {"cns": "icon.cns", "sheetKey": "icon", "columns": 20, "cellWidth": 32, "cellHeight": 32},
    # EXE row meta high word 0x0006 selects item.cns.  The confirmed item
    # sheet is 384x160, 32x32 cells, 12 columns.
    6: {"cns": "item.cns", "sheetKey": "item", "columns": 12, "cellWidth": 32, "cellHeight": 32},
}

STATUS_RECT_TABLE_VA = 0x0047E3E0
OBJECT_VM_CURSOR_SCAN_RANGE = (0x004DD000, 0x004DE900)
OBJECT_VM_CURSOR_SELECTORS = {0x2F, 0x30, 0x32, 0x33, 0x37, 0x38, 0x39, 0x3A}
OBJECT_VM_CURSOR_SELECTOR_LABELS = {
    0x2F: "top menu/category selector",
    0x30: "alternate top/category selector",
    0x32: "detail list row selector",
    0x33: "detail list row selector variant",
    0x37: "left/lower stack selector",
    0x38: "bottom/stack selector",
    0x39: "right detail selector variant",
    0x3A: "confirmation/config/detail row selector",
}
OBJECT_VM_SELECTOR_RELATED_OPCODES = {
    0x8A: "availability-array-fill",
    0x8B: "availability-branch",
    0x8C: "selector-next-enabled-producer",
    0x8D: "descriptor-match-selector-producer",
    0x8E: "descriptor-persistent-state",
    0x8F: "global-state-to-selector-producer",
    0xD5: "page-arrow/page-state-helper",
}
OBJECT_VM_AVAILABILITY_SOURCE_MODEL: dict[int, dict[str, Any]] = {
    0: {
        "label": "장비 하위 페이지",
        "userMenuName": "장비 > 무기/방어구",
        "handlerVaHex": "0x00410de5",
        "entryCount": 2,
        "evidence": "source 0은 fixed-count helper count=2이며 selector 0x34의 무기/방어구 pointer group과 같은 branch에서 쓰인다.",
        "confidence": "direct-code-backed",
    },
    1: {
        "label": "기술 하위 페이지",
        "userMenuName": "기술 > 기본기/개인공격기/전체공격기/특수기",
        "handlerVaHex": "0x00410de5",
        "entryCount": 4,
        "evidence": "source 1은 fixed-count helper count=4이며 selector 0x31의 4개 기술 분류 pointer group과 같은 branch에서 쓰인다.",
        "confidence": "direct-code-backed",
    },
    2: {
        "label": "모드/확인 5행 목록",
        "userMenuName": "모드 또는 확인/환경설정 계열 5행 선택 후보",
        "handlerVaHex": "0x00410de5",
        "entryCount": 5,
        "evidence": "source 2는 fixed-count helper count=5이며 mode/config/confirmation 계열 branch에서 selector 0x3a와 함께 쓰인다.",
        "confidence": "code-backed-role-pending",
    },
    3: {
        "label": "상단 6메뉴/공유 action 6분류",
        "userMenuName": "기술/도구/장비/소지/모드/환경설정 또는 action shared 6분류",
        "handlerVaHex": "0x00410de5",
        "entryCount": 6,
        "evidence": "source 3은 fixed-count helper count=6이며 top selector 0x2f, alternate selector 0x30 branch 양쪽에서 쓰인다.",
        "confidence": "direct-code-backed",
    },
    4: {
        "label": "활성 descriptor 수",
        "userMenuName": "캐릭터/상태 대상 수 또는 top branch 가용 대상 수",
        "handlerVaHex": "0x00410de5",
        "entryCount": "0x004576e8",
        "evidence": "source 4는 runtime active descriptor count 0x004576e8을 count로 써서 availability를 채운다.",
        "confidence": "code-backed-role-pending",
    },
    5: {
        "label": "기술 목록 6행",
        "userMenuName": "기술 하위 페이지의 현재 6행",
        "handlerVaHex": "0x00410e40",
        "entryCount": 6,
        "evidence": "source 5는 actor/category skill buffer 0x4577a6와 skill table의 습득/최소 level 계열 값을 읽고 selector 0x32 list와 함께 쓰인다.",
        "confidence": "direct-code-backed",
    },
    6: {
        "label": "도구/소모품 목록 6행",
        "userMenuName": "도구 메뉴 현재 6행",
        "handlerVaHex": "0x00410f23",
        "entryCount": 6,
        "evidence": "source 6은 item pair buffer 0x4576ec/0x4576ed를 읽어 0/1/2 상태를 만든다.",
        "confidence": "direct-code-backed",
    },
    7: {
        "label": "장비 목록 6행",
        "userMenuName": "장비 > 무기/방어구 현재 6행",
        "handlerVaHex": "0x00410fc4",
        "entryCount": 6,
        "evidence": "source 7은 active descriptor row와 0x45779a 장비 목록/page 상태를 읽어 availability를 만든다.",
        "confidence": "direct-code-backed",
    },
    8: {
        "label": "소지품 페이지",
        "userMenuName": "소지 메뉴 페이지 선택",
        "handlerVaHex": "0x00411068",
        "entryCount": 5,
        "evidence": "source 8은 0x4576f8 소지품 buffer의 보유 entry를 세고 ceil(count/6) page 수만 enabled로 만든다.",
        "confidence": "direct-code-backed",
    },
    9: {
        "label": "소지품 현재 페이지 6행",
        "userMenuName": "소지 메뉴 현재 6행",
        "handlerVaHex": "0x00411138",
        "entryCount": 6,
        "evidence": "source 9는 현재 page 0x59e345와 0x4576f8/0x4576f9 pair buffer를 읽어 현재 소지품 page 6행 상태를 만든다.",
        "confidence": "direct-code-backed",
    },
    10: {
        "label": "actor/stack 선택 후보",
        "userMenuName": "캐릭터/스택 선택 후보",
        "handlerVaHex": "0x004111e5",
        "entryCount": "0x004576e8",
        "evidence": "source 10은 0x59db30 active actor object와 helper 0x433a30 비교 결과로 selector 0x37 계열 availability를 만든다.",
        "confidence": "direct-code-backed-user-label-pending",
    },
    11: {
        "label": "compact stack/object 후보",
        "userMenuName": "하단 compact 스택/객체 후보",
        "handlerVaHex": "0x0041127d",
        "entryCount": "0x0059db28",
        "evidence": "source 11은 0x59db3c object pointer 배열을 돌며 object+0x62 bit7이 꺼진 항목만 enabled로 만든다. selector 0x38 계열과 함께 쓰인다.",
        "confidence": "direct-code-backed-user-label-pending",
    },
}
STATUS_CURSOR_MODEL = {
    "statusRectTableVaHex": "0x0047e3e0",
    "statusDrawOpcode": "40 22",
    "topCursor": {
        "role": "top-menu-selection-cursor",
        "sourceRectIndex": 3,
        "source": {"x": 96, "y": 0, "w": 16, "h": 16},
        "startDest": {"x": 440, "y": 72, "w": 16, "h": 16},
        "movement": "x = 440 + object+0xa8[0x2f] * 32, y = 72",
        "objectVmCommandVaHex": "0x004ddcf0",
        "objectVmCommandRawHex": "87 01 2f 00 b0 01 28 00",
        "objectVmHandlerVaHex": "0x0040af7e",
        "drawHelperVaHex": "0x00421406",
        "blitVaHex": "0x004175d3",
        "selectorSource": "object+0xa8[0x2f]",
        "baseBeforeHelper": {"x": 432, "y": 40},
        "helperOffset": {"x": 8, "y": 32},
        "evidenceLevel": "direct-code-backed",
    },
    "subCursor": {
        "role": "detail-menu-selection-cursor",
        "sourceRectIndex": 6,
        "source": {"x": 112, "y": 0, "w": 16, "h": 16},
        "startDest": {"x": 424, "y": 144, "w": 16, "h": 16},
        "movement": "x = 424, y = 144 + object+0xa8[0x32/0x33/0x3a] * 32",
        "objectVmCommandExamples": [
            {"vaHex": "0x004dde10", "rawHex": "87 01 32 01 b8 01 88 00"},
            {"vaHex": "0x004ddecc", "rawHex": "87 01 33 01 b8 01 88 00"},
            {"vaHex": "0x004ddf80", "rawHex": "87 01 3a 01 b8 01 88 00"},
        ],
        "objectVmHandlerVaHex": "0x0040af7e",
        "drawHelperVaHex": "0x00421406",
        "evidenceLevel": "direct-code-backed",
    },
    "pressedTopCursor": {
        "status": "pressed-source-direct-code-backed",
        "role": "top-menu-pressed-cursor-while-submenu-active",
        "sourceRectIndex": 4,
        "source": {"x": 96, "y": 16, "w": 16, "h": 16},
        "movement": "same as top cursor: x = 440 + object+0xa8[0x2f/0x30] * 32, y = 72",
        "objectVmCommandExamples": [
            {"vaHex": "0x004ddda0", "rawHex": "87 02 2f 00 b0 01 28 00"},
            {"vaHex": "0x004de528", "rawHex": "87 02 30 00 b0 01 28 00"},
        ],
        "note": "메인 메뉴에서 Enter로 하위 메뉴에 진입하면 상단 선택 항목이 눌린 상태로 유지된다. variant=2가 status.cns source index #4를 같은 상단 좌표 공식에 그리는 코드와 화면 동작이 일치한다.",
    },
    "pressedSubCursor": {
        "status": "pressed-source-direct-code-backed",
        "role": "detail-menu-pressed-cursor-while-confirmation-active",
        "sourceRectIndex": 7,
        "source": {"x": 112, "y": 16, "w": 16, "h": 16},
        "movement": "same as detail cursor: x = 424, y = 144 + object+0xa8[0x32/0x33/0x3a] * 32",
        "objectVmCommandExamples": [
            {"vaHex": "0x004dde6c", "rawHex": "87 02 32 01 b8 01 88 00"},
            {"vaHex": "0x004ddf24", "rawHex": "87 02 33 01 b8 01 88 00"},
            {"vaHex": "0x004de294", "rawHex": "87 02 3a 01 b8 01 88 00"},
            {"vaHex": "0x004de530", "rawHex": "87 02 3a 01 b8 01 88 00"},
        ],
        "note": "하위 메뉴 항목에서 한 단계 더 확인/선택창으로 들어가면 하위 커서가 눌린 상태로 유지된다. 게임 종료 확인/선택창 계층 관찰과 variant=2 source index #7 코드가 일치한다. 실제 확인 문구와 선택지 텍스트는 아직 EXE 연결 미확정이다.",
    },
    "pageArrows": {
        "role": "detail-page-left-right-hints",
        "source": "handler 0x0040f78a direct 0x4175d3 blits from status.cns encoded ids 0x000e000f..0x000e0012",
        "handlerVaHex": "0x0040f78a",
        "blitVaHex": "0x004175d3",
        "left": {
            "sourceRectIndex": 15,
            "encodedSourceIdHex": "0x000e000f",
            "source": {"x": 160, "y": 0, "w": 32, "h": 16},
            "evidenceVaHex": "0x0040f84b",
        },
        "leftAlt": {
            "sourceRectIndex": 16,
            "encodedSourceIdHex": "0x000e0010",
            "source": {"x": 160, "y": 16, "w": 32, "h": 16},
            "evidenceVaHex": "0x0040f84b",
            "note": "stream+1 bit0에 따라 #15/#16 중 하나를 선택한다. bit 의미는 아직 이름 확정 전.",
        },
        "right": {
            "sourceRectIndex": 17,
            "encodedSourceIdHex": "0x000e0011",
            "source": {"x": 192, "y": 0, "w": 32, "h": 16},
            "evidenceVaHex": "0x0040f7fa",
        },
        "rightAlt": {
            "sourceRectIndex": 18,
            "encodedSourceIdHex": "0x000e0012",
            "source": {"x": 192, "y": 16, "w": 32, "h": 16},
            "evidenceVaHex": "0x0040f7fa",
            "note": "stream+1 bit0에 따라 #17/#18 중 하나를 선택한다. bit 의미는 아직 이름 확정 전.",
        },
        "placement": {
            "leftDest": {"x": 424, "y": 328, "w": 32, "h": 16, "evidenceVaHex": "0x0040f84f/0x0040f854"},
            "rightDest": {"x": 600, "y": 328, "w": 32, "h": 16, "evidenceVaHex": "0x0040f7fe/0x0040f803"},
        },
        "visibility": {
            "left": "draw only if current page/index > 0; function branch 0x0040f82a..0x0040f878",
            "right": "draw only if current page/index + 1 < page count; function branch 0x0040f7cd..0x0040f827",
        },
        "switchCases": {
            "0": "0x0040f7a6 detail/shared page arrows",
            "1": "0x0040f7a6 detail/shared page arrows",
            "4": "0x0040f930 possession inventory page arrows; counts 0x004576f8 item slots and page index 0x0059e345",
            "5": "0x0040f930 possession inventory page arrows; counts 0x004576f8 item slots and page index 0x0059e345",
        },
        "evidenceLevel": "direct-code-backed",
        "note": "하위 메뉴 페이지 전환 화살표는 정적 40 22 row가 아니라 handler 0x0040f78a가 런타임 page index/page count를 검사한 뒤 status.cns source #15/#16/#17/#18을 직접 blit한다. switch case 4/5는 소지품 buffer 0x004576f8와 page index 0x0059e345를 사용해 같은 좌표/소스로 그린다.",
    },
    "stackArrows": {
        "role": "active-menu-stack-left-right-hints",
        "source": "handler 0x0040f78a branch 0x0040f880..0x0040f92b direct 0x4175d3 blits from status.cns encoded ids 0x000e0013..0x000e0016",
        "handlerVaHex": "0x0040f78a",
        "branchVaHex": "0x0040f880..0x0040f92b",
        "blitVaHex": "0x004175d3",
        "activeCountVaHex": "0x004576e8",
        "activeIndexVaHex": "0x0059e33e",
        "alternateFlagVaHex": "0x0059e34c",
        "leftPair": {
            "sourceRectIndices": [19, 20],
            "encodedSourceIdsHex": ["0x000e0013", "0x000e0014"],
            "sourceRects": [
                {"x": 224, "y": 0, "w": 32, "h": 16},
                {"x": 224, "y": 16, "w": 32, "h": 16},
            ],
            "evidenceVaHex": "0x0040f8fe..0x0040f928",
            "bitSelection": "0x0059e34c bit set -> #19, clear -> #20",
        },
        "rightPair": {
            "sourceRectIndices": [21, 22],
            "encodedSourceIdsHex": ["0x000e0015", "0x000e0016"],
            "sourceRects": [
                {"x": 256, "y": 0, "w": 32, "h": 16},
                {"x": 256, "y": 16, "w": 32, "h": 16},
            ],
            "evidenceVaHex": "0x0040f8bf..0x0040f8ec",
            "bitSelection": "0x0059e34c bit set -> #21, clear -> #22",
        },
        "placement": {
            "note": "0x4175d3 인자 0x00c00000/0x00000000 및 0x00c00000/0x01500000을 16.16 fixed 좌표로 해석한다. #6 왼쪽 상태 패널의 위/아래 actor stack 전환 화살표 위치와 일치한다.",
            "leftDest": {"x": 192, "y": 0, "w": 32, "h": 16, "evidenceVaHex": "0x0040f902/0x0040f904"},
            "rightDest": {"x": 192, "y": 336, "w": 32, "h": 16, "evidenceVaHex": "0x0040f8c3/0x0040f8c8"},
            "leftEncodedDestArgsHex": ["0x00000000", "0x00c00000"],
            "rightEncodedDestArgsHex": ["0x01500000", "0x00c00000"],
        },
        "visibility": {
            "left": "draw only if active slot index > 0; branch 0x0040f8ef..0x0040f92b",
            "right": "draw only if active slot index + 1 < active descriptor count; branch 0x0040f8a7..0x0040f8ec",
        },
        "switchCases": {
            "2": "0x0040f880 active stack/actor arrows",
            "3": "0x0040f880 active stack/actor arrows",
        },
        "evidenceLevel": "direct-code-backed",
        "note": "같은 handler 0x0040f78a가 하위 page arrow(#15~#18)와 별도로 active menu stack/index arrow(#19~#22)를 그린다. stack arrow 좌표는 0x4175d3 direct blit 인자의 16.16 fixed 값을 해석해 x=192, y=0/336으로 승격했다.",
    },
}

TOP_MENU_SELECTION_MODEL = {
    "status": "direct-code-backed",
    "handlerVaHex": "0x0040fb32",
    "activeSlotIndexVaHex": "0x0059e33e",
    "slotPointerArrayVaHex": "0x0059db30",
    "selectedFieldOffsetHex": "object+0x2a",
    "leftBranchVaHex": "0x0040fb85..0x0040fbc9",
    "rightBranchVaHex": "0x0040fbce..0x0040fc12",
    "range": "1..5 with wrap",
    "interpretation": (
        "상단 카테고리 선택값은 현재 active menu object의 +0x2a에 저장된다. "
        "handler 0x0040fb32가 stream+1의 좌/우 입력에 따라 값을 1..5 범위에서 감소/증가시키며 wrap한다. "
        "따라서 상단 icon row 자체가 캐릭터마다 바뀐다는 근거는 현재 없고, 선택 인덱스와 하위 payload가 바뀌는 구조가 더 강하다."
    ),
}

MENU_STATE_HELPER_MODEL = {
    "status": "direct-code-backed-list-state-helpers",
    "helpers": [
        {
            "vaHex": "0x00421d3d",
            "role": "equipment/character equipment-state classifier",
            "evidence": "equipment table 0x0048b1dc row +0x29를 읽고 actor/slot 쌍으로 분류한다.",
        },
        {
            "vaHex": "0x00421ef7",
            "role": "skill/equipment list membership check and insert",
            "evidence": "actor row +0x56 영역의 6-entry page buffer를 검사/삽입하고 0x00421f9c로 정렬한다.",
        },
        {
            "vaHex": "0x00422093",
            "role": "item count and possession list updater",
            "evidence": "item table 0x0048b97c row +0x10으로 수량형/보유형을 분기하고 0x4576ec/0x4576f8 buffers를 갱신한다.",
        },
        {
            "vaHex": "0x0042234e",
            "role": "equipment possession/equip-state updater",
            "evidence": "equipment table 0x0048b1dc row +0x29를 읽어 캐릭터/무기/방어구 계열 상태를 갱신한다.",
        },
    ],
    "interpretation": (
        "캐릭터마다 달라지는 내용은 상단 6개 아이콘 자체보다 하위 목록 구성에서 발생한다. "
        "기술/장비/도구 목록은 정적 row를 그대로 그리는 것이 아니라 actor row와 보유/착용 상태 buffer를 통과해 page buffer로 들어간다."
    ),
}

RIGHT_MENU_LAYOUT = {
    "topIcon": {"x": 432, "y": 40, "w": 32, "h": 32, "stride": 32},
    "currentModeLabel": {
        "x": 496,
        "y": 16,
        "fontPx": 16,
        "source": "40 2b @0x004e8148 / 0x004e83a2",
    },
    "detailTitle": {"defaultY": 104, "fontPx": 16},
    "skillMpHeader": {
        "x": 600,
        "y": 104,
        "w": 32,
        "h": 16,
        "sourceRect": {"x": 32, "y": 32, "w": 32, "h": 16},
        "sourceIdHex": "0x000e0001",
        "source": "40 24 @0x004e8294; origin 416,96 + cursor 184,8 -> absolute 600,104",
    },
    "detailList": {
        "x": 440,
        "y": 136,
        "cursorX": 424,
        "cursorY": 144,
        "rowStride": 32,
        "visibleRows": 6,
        "iconSize": 32,
        "textOnlyX": 440,
        "iconTextX": 478,
        "textYOffset": 8,
    },
    "titleAnchors": {
        "기본기": {"x": 480, "y": 104, "source": "40 0e @0x004e81ec"},
        "개인공격기": {"x": 480, "y": 104, "source": "40 0e @0x004e8216"},
        "전체공격기": {"x": 480, "y": 104, "source": "40 0e @0x004e8240"},
        "특수기": {"x": 480, "y": 104, "source": "40 0e @0x004e826a"},
        "도구": {"x": 504, "y": 104, "source": "40 0e @0x004e82a0"},
        "무기": {"x": 488, "y": 104, "source": "40 0e @0x004e82c0 + raw label"},
        "방어구": {"x": 488, "y": 104, "source": "40 0e @0x004e82c0 + raw label"},
        "소지": {"x": 504, "y": 104, "source": "40 0e @0x004e8304"},
        "모드": {"x": 496, "y": 104, "source": "40 0e @0x004e8324"},
        "환경설정": {"x": 488, "y": 104, "source": "40 0e @0x004e8346"},
    },
    "evidence": (
        "detail panel title uses origin 416,96 plus 40 0e title cursors; "
        "skill MP header uses 40 24 @0x004e8294 with status.cns encoded source 0x000e0001 at absolute 600,104; "
        "list table commands all start at 440,136; observed submenu cursor starts at 424,144."
    ),
}

TABLE_LABELS = {
    SKILL_TABLE_VA: "기술 테이블",
    ITEM_TABLE_VA: "도구/소지품 테이블",
    EQUIPMENT_TABLE_VA: "장비 테이블",
    MODE_TABLE_VA: "모드/상태 테이블",
    CONFIG_TABLE_VA: "환경설정 테이블",
}

OPCODE_LABELS = {
    0x24: "icon.cns marker/draw",
    0x25: "기술 목록 draw",
    0x26: "도구/소지품 목록 draw",
    0x27: "장비 목록 draw",
    0x28: "모드 목록 draw",
    0x29: "환경설정 목록 draw",
    0x2B: "모드/상단 상태 라벨 draw 후보",
}

MENU_MODEL = [
    {
        "key": "status",
        "label": "상태",
        "detail": ["아타호", "린샹", "스마슈"],
        "info": ["캐릭터 상태창 후보", "선택 시 좌측 region #6 상태 내용이 바뀌는 흐름"],
    },
    {
        "key": "item",
        "label": "도구",
        "detail": ["약초", "해독초", "리프레시 워터", "마법의 물약", "고급한방약", "마수석"],
        "info": ["도구 선택 후보", "사용 가능 여부/대상 선택 안내가 region #2에 표시되는 흐름"],
    },
    {
        "key": "weapon",
        "label": "무기",
        "detail": ["무기 착용자", "아타호", "린샹", "스마슈"],
        "info": ["장비 대상 선택 후보", "다음 단계에서 캐릭터별 장비 목록으로 전환"],
    },
    {
        "key": "armor",
        "label": "방어구",
        "detail": ["방어구 착용자", "아타호", "린샹", "스마슈"],
        "info": ["방어구 대상 선택 후보", "장비명/능력치 변화 설명이 region #2에 표시될 수 있음"],
    },
    {
        "key": "skill",
        "label": "기술",
        "detail": ["기본기", "개인공격기", "전체공격기", "특수기"],
        "info": ["기술 분류 메뉴 후보", "캐릭터 선택 후 기술 목록/설명으로 전환"],
    },
    {
        "key": "possess",
        "label": "소지",
        "detail": ["무투대회 안내장", "햄머", "부적", "인정서", "수면비약"],
        "info": ["소지품 확인 후보", "이벤트 아이템은 수량형이 아니라 보유/활성 상태에 가깝다"],
    },
    {
        "key": "mode",
        "label": "모드",
        "detail": ["보통", "돌격", "방어", "선제", "반격"],
        "info": ["전투 모드 후보", "필드 메뉴와 전투 메뉴의 소비처는 분리해서 검증 필요"],
    },
    {
        "key": "config",
        "label": "환경설정",
        "detail": ["메시지 속도", "소리", "조작", "닫기"],
        "info": ["환경설정 후보", "실제 소비처/설정 저장 위치는 아직 미확정"],
    },
    {
        "key": "quit",
        "label": "끝내기",
        "detail": ["네", "아니오"],
        "info": ["게임 종료 확인/선택창 후보", "region #2가 확인 메시지/선택지로 바뀌는 대표 케이스"],
        "finalDepth": {
            "index": 4,
            "selectorHex": "0x3a",
            "prompt": "정말로 좋습니까？",
            "choices": ["네", "아니오"],
            "defaultChoiceIndex": 1,
            "evidenceStatus": "fallback-mirrors-selector-0x3a",
        },
    },
]


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


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


def hx2(value: int | None) -> str:
    return "-" if value is None else f"0x{value:02x}"


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


def normal_top_icon_sequence_bytes() -> bytes:
    out = bytearray()
    for icon_id in NORMAL_TOP_ICON_SEQUENCE:
        out.extend(b"\x40\x24\x00\x00")
        out.extend(struct.pack("<H", icon_id))
        out.extend(b"\x05\x00")
    return bytes(out)


def find_previous_origin(exe: bytes, start: int, limit: int = 256) -> dict[str, Any] | None:
    lower = max(0, start - limit)
    best = None
    search = lower
    while True:
        hit = exe.find(b"\x40\x07", search, start)
        if hit < 0:
            break
        if hit + 8 <= len(exe):
            x = struct.unpack_from("<H", exe, hit + 2)[0]
            y = struct.unpack_from("<H", exe, hit + 4)[0]
            if x <= 640 and y <= 480:
                best = {"fileOffset": hit, "x": x, "y": y}
        search = hit + 1
    return best


def scan_status_22_rows(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Find compact status.cns indexed draw rows of the form 40 0e ... 40 22.

    The current evidence shows this opcode consumes status.cns source rect
    indices, but the occurrences we can confidently decode are normal-HUD
    rows, not the ESC/X top-menu cursor consumer.
    """
    rows: list[dict[str, Any]] = []
    search = 0
    while True:
        hit = exe.find(b"\x40\x0e", search)
        if hit < 0:
            break
        if hit + 12 <= len(exe) and exe[hit + 8 : hit + 10] == b"\x40\x22":
            x = struct.unpack_from("<H", exe, hit + 2)[0]
            y = struct.unpack_from("<H", exe, hit + 4)[0]
            if x <= 640 and y <= 480:
                origin = find_previous_origin(exe, hit)
                origin_x = int(origin["x"]) if origin else 0
                origin_y = int(origin["y"]) if origin else 0
                file_offset = hit
                va = offset_to_va(sections, file_offset)
                status_arg0 = exe[hit + 10]
                status_rect_index = exe[hit + 11]
                rows.append(
                    {
                        "fileOffsetHex": f"0x{file_offset:05x}",
                        "vaHex": hx(va),
                        "cursor": {"x": x, "y": y},
                        "origin": {"x": origin_x, "y": origin_y},
                        "absolute": {"x": origin_x + x, "y": origin_y + y},
                        "statusArg0": status_arg0,
                        "statusRectIndex": status_rect_index,
                        "rawHex": byte_hex(exe[hit : hit + 12]),
                        "classification": (
                            "status.cns indexed draw consumer; 현재 직접 검출 위치는 normal HUD/status-panel 쪽"
                        ),
                    }
                )
        search = hit + 1
    return rows


def object_vm_87_source_index(variant: int, mode: int) -> int:
    """Return the status.cns rect index selected by object VM opcode 0x87.

    Handler 0x0040af7e maps stream byte+1 to the first helper argument:
    variant 0 -> 2, variant 1 -> 0, variant 2 -> 1.  Helper 0x00421406 then
    selects firstArg+3 for mode 0 and firstArg+6 for mode 1/2.
    """
    first_arg = {0: 2, 1: 0, 2: 1}[variant]
    return first_arg + (3 if mode == 0 else 6)


def object_vm_87_dest_formula(selector: int, mode: int, base_x: int, base_y: int) -> dict[str, Any]:
    selector_expr = f"object+0xa8[0x{selector:02x}]"
    if mode == 0:
        return {
            "kind": "horizontal",
            "formula": f"x = {base_x + 8} + {selector_expr} * 32, y = {base_y + 32}",
            "startDest": {"x": base_x + 8, "y": base_y + 32},
            "step": {"x": 32, "y": 0},
        }
    if mode == 1:
        return {
            "kind": "vertical",
            "formula": f"x = {base_x - 16}, y = {base_y + 8} + {selector_expr} * 32",
            "startDest": {"x": base_x - 16, "y": base_y + 8},
            "step": {"x": 0, "y": 32},
        }
    return {
        "kind": "compact-vertical",
        "formula": f"x = {base_x}, y = {base_y} + {selector_expr} * 24",
        "startDest": {"x": base_x, "y": base_y},
        "step": {"x": 0, "y": 24},
    }


def classify_object_vm_87_cursor(selector: int, variant: int, mode: int, base_x: int, base_y: int) -> str:
    if selector in {0x2F, 0x30} and mode == 0 and base_x == 432 and base_y == 40:
        return "top-menu-cursor" if variant == 1 else "top-menu-cursor-alternate"
    if selector in {0x32, 0x33, 0x3A, 0x39} and mode == 1 and base_x == 440 and base_y == 136:
        return "right-detail-row-cursor" if variant == 1 else "right-detail-row-cursor-alternate"
    if selector == 0x38 and mode == 2 and base_x == 424 and base_y == 376:
        return "bottom/stack-compact-cursor"
    if selector == 0x37 and mode == 1 and base_x == 24 and base_y == 376:
        return "left/lower-row-cursor"
    return "cursor-like-status-draw"


def scan_object_vm_87_cursor_rows(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    min_va, max_va = OBJECT_VM_CURSOR_SCAN_RANGE
    for section in sections:
        if section["name"] not in {".data", ".rdata"}:
            continue
        start = section["raw"]
        end = start + section["raw_size"]
        pos = start
        while True:
            hit = exe.find(b"\x87", pos, end)
            if hit < 0:
                break
            raw = exe[hit : hit + 8]
            if len(raw) == 8:
                va = offset_to_va(sections, hit)
                variant = raw[1]
                selector = raw[2]
                mode = raw[3]
                if (
                    va is not None
                    and min_va <= va <= max_va
                    and variant in {0, 1, 2}
                    and selector in OBJECT_VM_CURSOR_SELECTORS
                    and mode in {0, 1, 2}
                ):
                    base_x = struct.unpack_from("<H", raw, 4)[0]
                    base_y = struct.unpack_from("<H", raw, 6)[0]
                    dest = object_vm_87_dest_formula(selector, mode, base_x, base_y)
                    source_index = object_vm_87_source_index(variant, mode)
                    rows.append(
                        {
                            "va": va,
                            "vaHex": hx(va),
                            "fileOffsetHex": f"0x{hit:05x}",
                            "rawHex": byte_hex(raw),
                            "variant": variant,
                            "selectorOffset": selector,
                            "selectorOffsetHex": hx2(selector),
                            "selectorLabel": OBJECT_VM_CURSOR_SELECTOR_LABELS.get(selector, "unknown selector"),
                            "mode": mode,
                            "modeKind": dest["kind"],
                            "base": {"x": base_x, "y": base_y},
                            "sourceRectIndex": source_index,
                            "destFormula": dest["formula"],
                            "startDest": dest["startDest"],
                            "step": dest["step"],
                            "classification": classify_object_vm_87_cursor(
                                selector, variant, mode, base_x, base_y
                            ),
                            "handlerVaHex": "0x0040af7e",
                            "drawHelperVaHex": "0x00421406",
                            "blitVaHex": "0x004175d3",
                        }
                    )
            pos = hit + 1
    return sorted(rows, key=lambda row: int(row["va"]))


def classify_object_vm_selector_state_command(raw: bytes) -> dict[str, Any] | None:
    """Classify selector-state object VM commands in the right-menu stream.

    This intentionally rejects loose byte matches inside little-endian pointers.
    Only command shapes that match the decoded handlers are accepted.
    """
    if len(raw) < 12:
        return None
    op = raw[0]
    if op == 0x8A and raw[1] in {0, 1} and raw[3] == 0:
        base = "0x0059e360" if raw[1] else "0x0059e370"
        source_id = raw[2]
        source_model = OBJECT_VM_AVAILABILITY_SOURCE_MODEL.get(
            source_id,
            {
                "label": "unknown availability source",
                "userMenuName": "unknown",
                "handlerVaHex": "0x00410c90",
                "entryCount": "?",
                "evidence": "unmapped source id",
                "confidence": "unmapped",
            },
        )
        return {
            "kind": "support",
            "role": OBJECT_VM_SELECTOR_RELATED_OPCODES[op],
            "handlerVaHex": "0x0040b49e",
            "targetOffset": None,
            "selectorOffset": None,
            "stateByte": source_id,
            "sourceId": source_id,
            "sourceIdHex": hx2(source_id),
            "sourceLabel": source_model["label"],
            "userMenuName": source_model["userMenuName"],
            "sourceHandlerVaHex": source_model["handlerVaHex"],
            "sourceEntryCount": source_model["entryCount"],
            "sourceConfidence": source_model["confidence"],
            "sourceEvidence": source_model["evidence"],
            "baseArray": base,
            "meaning": (
                f"{source_model['userMenuName']} availability: helper 0x00410c90 -> "
                f"{source_model['handlerVaHex']} 경로로 {base}를 채운다. "
                f"stream+2 값 {source_id}는 selector target이 아니라 list/page source id다."
            ),
            "evidenceLevel": "direct-code-backed-support",
        }
    if op == 0x8B and raw[2] in OBJECT_VM_CURSOR_SELECTORS and raw[3] == 0:
        branch_va = struct.unpack_from("<I", raw, 4)[0]
        min_va, max_va = OBJECT_VM_CURSOR_SCAN_RANGE
        if not (min_va <= branch_va <= max_va):
            return None
        base = "0x0059e360" if raw[1] else "0x0059e370"
        return {
            "kind": "consumer/check",
            "role": OBJECT_VM_SELECTOR_RELATED_OPCODES[op],
            "handlerVaHex": "0x0040b4e6",
            "targetOffset": None,
            "selectorOffset": raw[2],
            "stateByte": raw[1],
            "baseArray": base,
            "branchVaHex": hx(branch_va),
            "meaning": (
                f"object+0xa8[{hx2(raw[2])}] 값을 index로 삼아 {base}[index] != 1이면 "
                f"{hx(branch_va)}로 분기한다."
            ),
            "evidenceLevel": "direct-code-backed-consumer",
        }
    if op == 0x8C and raw[1] in {0, 1} and raw[2] in OBJECT_VM_CURSOR_SELECTORS and raw[3] == 0:
        base = "0x0059e360" if raw[1] else "0x0059e370"
        return {
            "kind": "producer",
            "role": OBJECT_VM_SELECTOR_RELATED_OPCODES[op],
            "handlerVaHex": "0x0040b55f",
            "targetOffset": raw[2],
            "selectorOffset": None,
            "stateByte": raw[1],
            "baseArray": base,
            "meaning": (
                f"{base}에서 enabled/nonzero인 다음 row를 찾아 "
                f"object+0xa8[{hx2(raw[2])}]에 쓴다. 좌우/상하 이동 후 cursor row를 갱신하는 직접 producer다."
            ),
            "evidenceLevel": "direct-code-backed-producer",
        }
    if op == 0x8D and raw[1] in {0, 1} and raw[2] in OBJECT_VM_CURSOR_SELECTORS and raw[3] == 0:
        return {
            "kind": "producer",
            "role": OBJECT_VM_SELECTOR_RELATED_OPCODES[op],
            "handlerVaHex": "0x0040b696",
            "targetOffset": raw[2],
            "selectorOffset": None,
            "stateByte": raw[1],
            "baseArray": "active descriptor row",
            "meaning": (
                "active descriptor row(0x00457750 + id*0xd8)의 +0x48/+0x4a 계열 값을 비교해 "
                f"일치하는 row index를 object+0xa8[{hx2(raw[2])}]에 쓴다."
            ),
            "evidenceLevel": "direct-code-backed-producer",
        }
    if op == 0x8E and raw[1] in {0, 1} and raw[2] == 0 and raw[3] == 0:
        return {
            "kind": "support",
            "role": OBJECT_VM_SELECTOR_RELATED_OPCODES[op],
            "handlerVaHex": "0x0040b752",
            "targetOffset": None,
            "selectorOffset": None,
            "stateByte": raw[1],
            "baseArray": "active descriptor persistent state",
            "meaning": (
                "stream+1 == 0이면 descriptor row +0x40/+0x41을 0x0059e340/0x0059e34a로 load, "
                "stream+1 == 1이면 반대로 save한다."
            ),
            "evidenceLevel": "direct-code-backed-support",
        }
    if op == 0x8F and raw[2] in OBJECT_VM_CURSOR_SELECTORS and raw[3] == 0:
        return {
            "kind": "producer",
            "role": OBJECT_VM_SELECTOR_RELATED_OPCODES[op],
            "handlerVaHex": "0x0040b84a",
            "targetOffset": raw[1],
            "selectorOffset": raw[2],
            "stateByte": raw[2],
            "baseArray": "0x00457744..0x00457749 global state bytes",
            "meaning": (
                f"object+0xa8[{hx2(raw[2])}] 값을 switch로 삼아 0x00457744..0x00457749 상태를 읽고 "
                f"object+0xa8[{hx2(raw[1])}]에 0/1 또는 상태 byte를 쓴다."
            ),
            "evidenceLevel": "direct-code-backed-producer",
        }
    if op == 0xD5 and raw[3] in {0, 1, 2, 3, 4}:
        return {
            "kind": "support/draw",
            "role": OBJECT_VM_SELECTOR_RELATED_OPCODES[op],
            "handlerVaHex": "0x0040f78a",
            "targetOffset": None,
            "selectorOffset": raw[2],
            "stateByte": raw[1],
            "baseArray": "page/stack arrow state",
            "meaning": (
                "page index/page count와 alternate flag를 검사해 status.cns 화살표를 직접 blit한다. "
                "일부 0x0059e34b/0x0059e34c 상태 flag도 갱신하지만 cursor selector byte 자체의 주 producer는 아니다."
            ),
            "evidenceLevel": "direct-code-backed-support",
        }
    return None


def scan_object_vm_selector_state_rows(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    min_va, max_va = OBJECT_VM_CURSOR_SCAN_RANGE
    start = va_to_offset(sections, min_va)
    end = va_to_offset(sections, max_va)
    if start is None or end is None:
        return rows
    for offset in range(start, end):
        raw = exe[offset : offset + 12]
        if not raw or raw[0] not in OBJECT_VM_SELECTOR_RELATED_OPCODES:
            continue
        classified = classify_object_vm_selector_state_command(raw)
        if not classified:
            continue
        va = offset_to_va(sections, offset)
        if va is None or not (min_va <= va <= max_va):
            continue
        row = {
            "va": va,
            "vaHex": hx(va),
            "fileOffsetHex": f"0x{offset:05x}",
            "opcodeHex": hx2(raw[0]),
            "rawHex": byte_hex(raw[:12]),
        }
        row.update(classified)
        if row.get("targetOffset") is not None:
            row["targetOffsetHex"] = hx2(int(row["targetOffset"]))
            row["targetLabel"] = OBJECT_VM_CURSOR_SELECTOR_LABELS.get(
                int(row["targetOffset"]), "non-cursor/local selector target"
            )
        if row.get("selectorOffset") is not None:
            row["selectorOffsetHex"] = hx2(int(row["selectorOffset"]))
            row["selectorLabel"] = OBJECT_VM_CURSOR_SELECTOR_LABELS.get(
                int(row["selectorOffset"]), "non-cursor/local selector"
            )
        rows.append(row)
    return sorted(rows, key=lambda row: int(row["va"]))


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


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


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


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 table_label(table_va: int | None) -> str:
    if table_va is None:
        return "-"
    return TABLE_LABELS.get(table_va, "unknown table")


def text_at_local_offset(commands: list[dict[str, Any]], offset: int) -> str:
    candidates = [
        command
        for command in commands
        if int(command.get("offset", -1)) == offset and command.get("rawTextNormalized")
    ]
    if candidates:
        return str(candidates[0]["rawTextNormalized"])
    candidates = [
        command
        for command in commands
        if int(command.get("offset", -1)) == offset and command.get("normalizedText")
    ]
    if candidates:
        return str(candidates[0]["normalizedText"])
    return ""


def parse_right_payload(data: bytes) -> dict[str, Any]:
    commands: list[dict[str, Any]] = []
    text_rows: list[dict[str, Any]] = []
    raw_text_rows: list[dict[str, Any]] = []
    pointer_groups: list[dict[str, Any]] = []
    icon_rows: list[dict[str, Any]] = []
    list_commands: list[dict[str, Any]] = []
    origins: list[dict[str, Any]] = []

    current_origin = {"x": 0, "y": 0}
    current_cursor = {"x": 0, "y": 0}
    pos = 0
    while pos < len(data):
        offset = pos
        va = RIGHT_MENU_PAYLOAD_VA + offset

        if data[pos] != 0x40:
            end = next_command_offset(data, pos)
            raw = data[pos:end]
            text = decode_cp949(raw)
            normalized = normalize_text(text)
            row = {
                "offset": offset,
                "va": va,
                "vaHex": hx(va),
                "kind": "raw-cp949-text-before-command",
                "rawHex": byte_hex(raw),
                "text": text,
                "rawTextNormalized": normalized,
            }
            commands.append(row)
            if normalized:
                raw_text_rows.append(row)
            pos = end
            continue

        if pos + 2 > len(data):
            break

        opcode = data[pos + 1]
        base: dict[str, Any] = {
            "offset": offset,
            "va": va,
            "vaHex": hx(va),
            "opcode": opcode,
            "opcodeHex": hx2(opcode),
            "origin": dict(current_origin),
            "cursor": dict(current_cursor),
        }

        if opcode == 0x13 and pos + 8 <= len(data):
            group = data[pos + 2]
            selector = data[pos + 3]
            count = struct.unpack_from("<I", data, pos + 4)[0]
            end = pos + 8 + count * 4
            if 0 <= count <= 128 and end <= len(data):
                pointers = [struct.unpack_from("<I", data, pos + 8 + index * 4)[0] for index in range(count)]
                row = {
                    **base,
                    "kind": "pointer-group",
                    "group": group,
                    "groupHex": hx2(group),
                    "selector": selector,
                    "selectorHex": hx2(selector),
                    "count": count,
                    "pointers": pointers,
                    "pointerHexes": [hx(pointer) for pointer in pointers],
                    "rawHex": byte_hex(data[pos:end]),
                }
                pointer_groups.append(row)
                commands.append(row)
                pos = end
                continue

        if opcode == 0x0E and pos + 8 <= len(data):
            x = struct.unpack_from("<H", data, pos + 2)[0]
            y = struct.unpack_from("<H", data, pos + 4)[0]
            end = next_command_offset(data, pos + 8)
            raw = data[pos + 8 : end]
            text = decode_cp949(raw)
            normalized = normalize_text(text)
            current_cursor = {"x": x, "y": y}
            row = {
                **base,
                "kind": "text-or-cursor",
                "x": x,
                "y": y,
                "absoluteX": current_origin["x"] + x,
                "absoluteY": current_origin["y"] + y,
                "text": text,
                "normalizedText": normalized,
                "rawTextHex": byte_hex(raw),
                "rawHex": byte_hex(data[pos:end]),
            }
            commands.append(row)
            if normalized:
                text_rows.append(row)
            pos = end
            continue

        if opcode == 0x07 and pos + 8 <= len(data):
            x = struct.unpack_from("<H", data, pos + 2)[0]
            y = struct.unpack_from("<H", data, pos + 4)[0]
            current_origin = {"x": x, "y": y}
            row = {
                **base,
                "kind": "set-origin",
                "x": x,
                "y": y,
                "rawHex": byte_hex(data[pos : pos + 8]),
            }
            origins.append(row)
            commands.append(row)
            pos += 8
            continue

        if opcode == 0x24 and pos + 8 <= len(data):
            arg0 = struct.unpack_from("<H", data, pos + 2)[0]
            icon_id = struct.unpack_from("<H", data, pos + 4)[0]
            mode = struct.unpack_from("<H", data, pos + 6)[0]
            row = {
                **base,
                "kind": "icon-marker-row",
                "arg0": arg0,
                "iconId": icon_id,
                "mode": mode,
                "absoluteX": current_origin["x"] + current_cursor["x"],
                "absoluteY": current_origin["y"] + current_cursor["y"],
                "rawHex": byte_hex(data[pos : pos + 8]),
                "interpretation": (
                    "40 24 row. id 값은 icon.cns 32x32 cell index와 대응한다. "
                    "첫 6개 row는 평상시 우측 상단 메뉴 아이콘으로 승격했고, "
                    "그 뒤 row는 공유/전투/action 후보로 유지한다."
                ),
            }
            icon_rows.append(row)
            commands.append(row)
            pos += 8
            continue

        if opcode in {0x25, 0x26, 0x27, 0x28, 0x29, 0x2B} and pos + 8 <= len(data):
            selector = struct.unpack_from("<H", data, pos + 2)[0]
            table_va = struct.unpack_from("<I", data, pos + 4)[0]
            row = {
                **base,
                "kind": "list-table-command",
                "selector": selector,
                "selectorHex": hx2(selector),
                "tableVa": table_va,
                "tableVaHex": hx(table_va),
                "tableLabel": table_label(table_va),
                "opcodeLabel": OPCODE_LABELS.get(opcode, "list draw"),
                "absoluteX": current_origin["x"] + current_cursor["x"],
                "absoluteY": current_origin["y"] + current_cursor["y"],
                "rawHex": byte_hex(data[pos : pos + 8]),
            }
            list_commands.append(row)
            commands.append(row)
            pos += 8
            continue

        if opcode in {0x01, 0x0D, 0x1F} and pos + 8 <= len(data):
            target = struct.unpack_from("<I", data, pos + 4)[0]
            row = {
                **base,
                "kind": "control-pointer" if opcode in {0x01, 0x1F} else "control-long",
                "arg0": struct.unpack_from("<H", data, pos + 2)[0],
                "targetVa": target,
                "targetVaHex": hx(target),
                "rawHex": byte_hex(data[pos : pos + 8]),
            }
            commands.append(row)
            pos += 8
            continue

        if opcode in {0x00, 0x03, 0x04, 0x08, 0x0B, 0x0F, 0x1B} and pos + 4 <= len(data):
            row = {
                **base,
                "kind": "control-short",
                "rawHex": byte_hex(data[pos : pos + 4]),
            }
            commands.append(row)
            pos += 4
            continue

        row = {
            **base,
            "kind": "unknown-or-unparsed",
            "rawHex": byte_hex(data[pos : min(len(data), pos + 8)]),
        }
        commands.append(row)
        pos += 1

    for group in pointer_groups:
        labels = []
        for pointer in group["pointers"]:
            local_offset = pointer - RIGHT_MENU_PAYLOAD_VA
            label = text_at_local_offset(commands, local_offset)
            if pointer == 0x004E81D4:
                label = "기술 (하위그룹)"
            elif pointer == 0x004E82C0:
                label = "장비 (하위그룹)"
            labels.append({"pointerVaHex": hx(pointer), "label": label or "(text 없음/하위그룹)"})
        group["labels"] = labels

    previous_icon_key: tuple[int, int, int] | None = None
    order = 0
    for row in icon_rows:
        key = (int(row["absoluteX"]), int(row["absoluteY"]), int(row["mode"]))
        if key != previous_icon_key:
            order = 0
            previous_icon_key = key
        row["rowOrderAtCursor"] = order
        row["previewX"] = int(row["absoluteX"]) + order * 32
        row["previewY"] = int(row["absoluteY"])
        order += 1

    normal_top_sequence = normal_top_icon_sequence_bytes()
    normal_top_offset = data.find(normal_top_sequence)
    normal_top_offsets: set[int] = set()
    normal_top_rows: list[dict[str, Any]] = []
    if normal_top_offset >= 0:
        normal_top_offsets = {
            normal_top_offset + index * 8 for index in range(len(NORMAL_TOP_ICON_SEQUENCE))
        }
        for row in icon_rows:
            offset = int(row["offset"])
            if offset not in normal_top_offsets:
                continue
            top_index = (offset - normal_top_offset) // 8
            row["classification"] = "normal-top-menu-icon"
            row["isNormalTopMenuIcon"] = True
            row["normalTopMenuIndex"] = top_index
            row["normalTopMenuLabel"] = NORMAL_TOP_ICON_LABELS[top_index]
            row["interpretation"] = (
                "평상시 우측 상단 메뉴 icon.cns 확정 row. "
                f"{NORMAL_TOP_ICON_LABELS[top_index]} 아이콘으로 해석한다."
            )
            normal_top_rows.append(row)

    for row in icon_rows:
        if int(row["offset"]) in normal_top_offsets:
            continue
        row["classification"] = "shared-or-battle-action-icon-candidate"
        row["isNormalTopMenuIcon"] = False

    shared_candidate_icon_rows = [
        row for row in icon_rows if row.get("classification") != "normal-top-menu-icon"
    ]

    return {
        "payloadVaHex": hx(RIGHT_MENU_PAYLOAD_VA),
        "payloadEndVaHex": hx(RIGHT_MENU_PAYLOAD_END_VA),
        "payloadSize": len(data),
        "commandCount": len(commands),
        "origins": origins,
        "textRows": text_rows,
        "rawTextRows": raw_text_rows,
        "pointerGroups": pointer_groups,
        "iconRows": icon_rows,
        "normalTopIconRows": normal_top_rows,
        "sharedCandidateIconRows": shared_candidate_icon_rows,
        "normalTopIconSequence": {
            "iconIds": NORMAL_TOP_ICON_SEQUENCE,
            "labels": NORMAL_TOP_ICON_LABELS,
            "payloadOffset": None if normal_top_offset < 0 else normal_top_offset,
            "vaHex": None if normal_top_offset < 0 else hx(RIGHT_MENU_PAYLOAD_VA + normal_top_offset),
            "rawHex": byte_hex(normal_top_sequence),
        },
        "listCommands": list_commands,
        "commands": commands,
        "interpretation": (
            "top menu descriptor stack의 0x2f attach payload가 가리키는 오른쪽 메뉴 표현부. "
            "40 13은 메뉴/세부항목 pointer group, 40 25~29/2b는 각 정적 테이블 목록 draw 명령, "
            "첫 6개 40 24 row는 평상시 우측 상단 icon.cns 메뉴 아이콘으로 승격했고, "
            "나머지 40 24 row는 공유/전투/action 후보로 유지했다."
        ),
    }


def current_mode_label_model(right_payload: dict[str, Any]) -> dict[str, Any]:
    rows = [
        row
        for row in right_payload.get("listCommands", [])
        if int(row.get("opcode", -1)) == 0x2B
        and int(row.get("tableVa", 0)) == MODE_TABLE_VA
        and int(row.get("absoluteX", -1)) == RIGHT_MENU_LAYOUT["currentModeLabel"]["x"]
        and int(row.get("absoluteY", -1)) == RIGHT_MENU_LAYOUT["currentModeLabel"]["y"]
    ]
    return {
        "status": "direct-display-vm-command-backed-producer-pending",
        "role": "현재 캐릭터 전투 모드/상태명 상단 라벨",
        "anchor": {
            "x": RIGHT_MENU_LAYOUT["currentModeLabel"]["x"],
            "y": RIGHT_MENU_LAYOUT["currentModeLabel"]["y"],
        },
        "tableVaHex": hx(MODE_TABLE_VA),
        "tableLabel": TABLE_LABELS.get(MODE_TABLE_VA, "모드/상태 테이블"),
        "opcode": "40 2b",
        "rows": rows,
        "interpretation": (
            "ESC/X 메뉴 우측 상단 패널 위의 현재 모드명 라벨은 40 2b 명령이 "
            "모드/상태 테이블 0x0048bcaa를 496,16 위치에 그리는 형태와 일치한다. "
            "보통/돌격/방어/선제/반격 중 어느 index를 선택하는 producer는 아직 별도 추적 대상이지만, "
            "라벨의 draw consumer와 위치는 direct EXE payload로 승격한다."
        ),
    }


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


def cp949_text_fragments_at(
    exe: bytes, sections: list[dict[str, Any]], va: int, limit: int = 360
) -> list[str]:
    """Decode 40 02-delimited text fragments from an EXE text pointer."""
    offset = va_to_offset(sections, va)
    if offset is None:
        return []
    data = exe[offset : offset + limit]
    fragments: list[str] = []
    pos = 0
    while pos < len(data):
        marker = data.find(b"\x40", pos)
        if marker < 0:
            break
        raw = data[pos:marker]
        if raw:
            text = normalize_text(decode_cp949(raw))
            if text:
                fragments.append(text)
        if marker + 4 <= len(data) and data[marker : marker + 2] == b"\x40\x02":
            pos = marker + 4
            continue
        break
    return fragments


def cp949_inline_texts_in_range(
    exe: bytes, sections: list[dict[str, Any]], va: int, size: int
) -> list[dict[str, Any]]:
    """Extract inline text payloads following display cursor command 40 0e."""
    data = read_bytes(exe, sections, va, size)
    rows: list[dict[str, Any]] = []
    pos = 0
    while True:
        hit = data.find(b"\x40\x0e", pos)
        if hit < 0:
            break
        if hit + 8 > len(data):
            break
        x = struct.unpack_from("<H", data, hit + 2)[0]
        y = struct.unpack_from("<H", data, hit + 4)[0]
        text_start = hit + 8
        text_end = data.find(b"\x40", text_start)
        if text_end < 0:
            text_end = len(data)
        raw = data[text_start:text_end]
        text = normalize_text(decode_cp949(raw))
        if text:
            rows.append(
                {
                    "vaHex": hx(va + hit),
                    "cursor": {"x": x, "y": y},
                    "text": text,
                    "rawHex": byte_hex(raw),
                }
            )
        pos = hit + 1
    return rows


def parse_pointer_group_at_va(
    exe: bytes,
    sections: list[dict[str, Any]],
    va: int,
    *,
    label: str,
    expected_selector: int | None = None,
    sample_count: int = 8,
    include_entries: bool = False,
) -> dict[str, Any]:
    offset = va_to_offset(sections, va)
    if offset is None or offset + 8 > len(exe):
        return {"label": label, "vaHex": hx(va), "status": "missing"}
    raw_header = exe[offset : offset + 8]
    if raw_header[:2] != b"\x40\x13":
        return {
            "label": label,
            "vaHex": hx(va),
            "status": "not-pointer-group",
            "rawHex": byte_hex(raw_header),
        }
    group = raw_header[2]
    selector = raw_header[3]
    count = struct.unpack_from("<I", raw_header, 4)[0]
    if expected_selector is not None and selector != expected_selector:
        status = "selector-mismatch"
    else:
        status = "ok"
    pointers: list[int] = []
    if count <= 512 and offset + 8 + count * 4 <= len(exe):
        pointers = [struct.unpack_from("<I", exe, offset + 8 + index * 4)[0] for index in range(count)]
    entries = []
    if include_entries:
        for index, pointer in enumerate(pointers):
            fragments = cp949_text_fragments_at(exe, sections, pointer)
            entries.append(
                {
                    "index": index,
                    "pointerVaHex": hx(pointer),
                    "text": " / ".join(fragments[:4]),
                    "fragments": fragments[:6],
                }
            )
    samples = []
    for index, pointer in enumerate(pointers[:sample_count]):
        fragments = cp949_text_fragments_at(exe, sections, pointer)
        samples.append(
            {
                "index": index,
                "pointerVaHex": hx(pointer),
                "text": " / ".join(fragments[:4]),
                "fragments": fragments[:6],
            }
        )
    return {
        "label": label,
        "vaHex": hx(va),
        "status": status,
        "groupHex": hx2(group),
        "selectorHex": hx2(selector),
        "count": count,
        "pointerStartVaHex": hx(va + 8),
        "samples": samples,
        **({"entries": entries} if include_entries else {}),
        "rawHeaderHex": byte_hex(raw_header),
    }


def parse_selector_group_at_va(
    exe: bytes,
    sections: list[dict[str, Any]],
    va: int,
    *,
    label: str,
    sample_count: int = 12,
) -> dict[str, Any]:
    group = parse_pointer_group_at_va(exe, sections, va, label=label, sample_count=0, include_entries=True)
    if group.get("status") not in {"ok", "selector-mismatch"}:
        return group
    group["samples"] = list(group.get("entries") or [])[:sample_count]
    return group


def build_skill_rank_label_model(exe: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    rows = []
    for index, pointer_va in enumerate(SKILL_RANK_LABEL_POINTERS):
        fragments = cp949_text_fragments_at(exe, sections, pointer_va)
        rows.append(
            {
                "index": index,
                "pointerVaHex": hx(pointer_va),
                "label": (fragments[0] if fragments else "").strip(),
                "fragments": fragments,
            }
        )
    return {
        "status": "direct-text-pointer-backed",
        "pointerTableVaHex": "0x004e8e9c",
        "labels": rows,
        "interpretation": "선택 기술의 숙련도명은 설명 본문이 아니라 하단 우측에 별도 출력되는 label pointer group이다.",
    }


def build_region2_output_model(exe: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    """Summarize direct producers that set origin to the lower-right panel."""
    origin_pattern = bytes.fromhex("40 07 a0 01 60 01 00 00")
    hits: list[dict[str, Any]] = []
    search_pos = 0
    while True:
        hit = exe.find(origin_pattern, search_pos)
        if hit < 0:
            break
        hits.append({"fileOffsetHex": f"0x{hit:05x}", "vaHex": hx(offset_to_va(sections, hit))})
        search_pos = hit + 1

    description_groups = [
        parse_pointer_group_at_va(
            exe,
            sections,
            0x004E8EC8,
            label="아타호 기술 설명",
            expected_selector=0x58,
            sample_count=10,
            include_entries=True,
        ),
        parse_pointer_group_at_va(
            exe,
            sections,
            0x004E8FD8,
            label="린샹 기술 설명",
            expected_selector=0x58,
            sample_count=10,
            include_entries=True,
        ),
        parse_pointer_group_at_va(
            exe,
            sections,
            0x004E90B4,
            label="스마슈 기술 설명",
            expected_selector=0x58,
            sample_count=10,
            include_entries=True,
        ),
        parse_pointer_group_at_va(
            exe,
            sections,
            0x004E915C,
            label="장비 설명",
            expected_selector=0x58,
            sample_count=12,
            include_entries=True,
        ),
        parse_pointer_group_at_va(
            exe,
            sections,
            0x004E91F8,
            label="도구/소지품 설명",
            expected_selector=0x58,
            sample_count=12,
            include_entries=True,
        ),
        parse_pointer_group_at_va(
            exe,
            sections,
            0x004E9280,
            label="모드/상태 설명",
            expected_selector=0x58,
            sample_count=12,
            include_entries=True,
        ),
        parse_pointer_group_at_va(
            exe,
            sections,
            0x004E92F4,
            label="환경설정 설명",
            expected_selector=0x58,
            sample_count=6,
            include_entries=True,
        ),
    ]
    skill_rank_labels = build_skill_rank_label_model(exe, sections)

    blocks = [
        {
            "vaHex": "0x004e807c",
            "role": "normal HUD lower-right information block",
            "origin": {"x": 416, "y": 352},
            "evidence": "원점 416,352 뒤에 status.cns row와 inline text가 이어진다. 평상시 HUD 우하단 정보 패널 후보.",
            "inlineTexts": cp949_inline_texts_in_range(exe, sections, 0x004E807C, 0x180),
            "selectorGroups": [
                parse_selector_group_at_va(exe, sections, 0x004E808C, label="HUD value selector 0x3d")
            ],
        },
        {
            "vaHex": "0x004e86cc",
            "role": "confirmation/config lower-right block",
            "origin": {"x": 416, "y": 352},
            "evidence": "확인/선택창 후보와 메시지 속도, BGM, 화면표시속도, 전투 금지 같은 환경설정/확인 문구가 같은 하단 패널 원점에서 나온다. 게임 종료의 실제 선택지 텍스트는 아직 이 블록과 직접 연결 미확정이다.",
            "inlineTexts": cp949_inline_texts_in_range(exe, sections, 0x004E86CC, 0x2E0),
            "selectorGroups": [
                parse_selector_group_at_va(exe, sections, 0x004E86E0, label="confirmation/config selector 0x3a")
            ],
        },
        {
            "vaHex": "0x004e89b2",
            "role": "save/load detail lower-right block",
            "origin": {"x": 416, "y": 352},
            "evidence": "데이터 없음, 요일 포인터 그룹, date/time 관련 draw command가 이어져 save/load detail 패널로 분류한다.",
            "inlineTexts": cp949_inline_texts_in_range(exe, sections, 0x004E89B2, 0x260),
            "selectorGroups": [
                parse_selector_group_at_va(exe, sections, 0x004E8A76, label="weekday selector 0x18")
            ],
        },
        {
            "vaHex": "0x004e8e24",
            "role": "selected item description lower-right block",
            "origin": {"x": 416, "y": 352},
            "evidence": "0x004e8e24 블록 안의 selector 0x2f가 기술/장비/도구/모드/환경설정 설명 pointer group으로 분기한다. region #2가 선택 항목 설명 패널임을 보여주는 핵심 근거.",
            "inlineTexts": cp949_inline_texts_in_range(exe, sections, 0x004E8E24, 0x140),
            "selectorGroups": [
                parse_selector_group_at_va(exe, sections, 0x004E8E70, label="description category selector 0x2f")
            ],
            "descriptionGroups": description_groups,
            "skillRankLabels": skill_rank_labels,
        },
    ]
    description_selector_model = {
        "status": "direct-display-vm-code-backed",
        "displayInterpreterVaHex": "0x0041b66d",
        "displayHandlerTableVaHex": "0x0047f1d8",
        "entryBlockVaHex": "0x004e8e24",
        "flow": [
            {
                "step": "panel origin",
                "bytes": "40 07 a0 01 60 01 00 00",
                "handlerVaHex": "0x0041b956",
                "meaning": "display object origin을 x=416, y=352로 설정한다.",
            },
            {
                "step": "pre-selector path",
                "bytes": "40 13 03 3d 04 00 ...",
                "handlerVaHex": "0x0041d15d",
                "meaning": "display object +0xa8+0x3d byte를 읽어 4개 path 중 하나를 고른다.",
                "paths": [
                    "0/2 -> 0x004e8e54: 40 2a로 runtime list state에서 object+0x58 설정",
                    "1 -> 0x004e8e60: 40 34로 menu mode/list cursor에서 object+0x58 설정",
                    "3 -> 0x004e8e6c: 40 33으로 dynamic list record에서 object+0x58 설정",
                ],
            },
            {
                "step": "description category",
                "bytes": "40 13 03 2f 06 00 ...",
                "handlerVaHex": "0x0041d15d",
                "meaning": "display object +0xa8+0x2f byte를 읽어 기술/도구/장비/소지/모드/환경설정 설명 그룹을 고른다.",
            },
            {
                "step": "skill actor branch",
                "bytes": "40 1f 00 00 c8 8e 4e 00 d8 8f 4e 00 b4 90 4e 00",
                "handlerVaHex": "0x0041d962",
                "meaning": "기술 설명일 때 active descriptor id를 사용해 아타호/린샹/스마슈 설명 그룹을 고른다.",
            },
            {
                "step": "final description index",
                "bytes": "40 13 02 58 <count> 00 ...",
                "handlerVaHex": "0x0041d15d",
                "meaning": "display object +0x58 byte가 pointer group entry index로 소비된다. 즉 현재 선택 항목 설명의 직접 latch다.",
            },
        ],
        "latchWriters": [
            {
                "command": "40 2a",
                "handlerVaHex": "0x0041e72d",
                "meaning": "0x0059e33f list type과 active descriptor/page cursor 상태를 보고 object+0x58을 설정한다.",
                "evidence": "0x4577a6/0x45779a/0x4576ec/0x4576f8 계열 list-id buffer를 읽는다.",
            },
            {
                "command": "40 33",
                "handlerVaHex": "0x0041fbaa",
                "meaning": "0x0059e2a8 dynamic list pointer와 0x0059e34a cursor row에서 record kind/item id를 읽어 object+0x58을 설정한다.",
            },
            {
                "command": "40 34",
                "handlerVaHex": "0x0041fc8a",
                "meaning": "0x0059e340 menu mode와 0x0059e34a cursor row를 기준으로 actor row/list buffer에서 object+0x58을 설정한다.",
                "cases": [
                    "mode 0 -> active actor row +0x56 + cursor",
                    "mode 1 -> active actor row +0x5c + cursor",
                    "mode 2 -> active actor row +0x62 + cursor",
                    "mode 3 -> 0x4576ec + cursor*2",
                    "mode 4 -> cursor 자체",
                    "mode 5 -> active actor row +0x68 + cursor",
                ],
            },
            {
                "command": "40 36",
                "handlerVaHex": "0x0041ff44",
                "meaning": "기술 설명 path에서 selected skill row +0x10 값을 읽어 기술 page/category branch를 고른다.",
            },
        ],
        "knownState": {
            "activeSlotIndex": "0x0059e33e",
            "activeDescriptorOrder": "0x004576e9",
            "currentDetailCursor": "0x0059e34a",
            "dynamicListPointer": "0x0059e2a8",
            "menuMode": "0x0059e340",
            "listType": "0x0059e33f",
            "selectedDescriptionIndex": "display object +0x58",
        },
        "inputContextBridgeModel": {
            "status": "object-vm-runtime-slot-bridge-backed",
            "handlerVaHex": "0x0040570c",
            "runtimeObjectPointerArray": "0x0059dd70",
            "scopeWarning": (
                "이 핸들러는 object construction VM의 opcode 0x28이다. "
                "display-VM selector handler 0x0041d15d와 같은 테이블로 섞어 해석하면 안 된다."
            ),
            "mode0Store": {
                "commandShape": "28 00 <slot> 00",
                "effect": "current/child object +0x58 pointer를 0x0059dd70[slot]에 저장한다.",
                "disassemblyEvidence": "0x00405728: mov eax,[object+0x58] -> mov [0x59dd70+slot*4],eax",
            },
            "mode1Load": {
                "commandShape": "28 01 <slot> 00",
                "effect": "0x0059dd70[slot] pointer를 current object +0xa8에 연결한다.",
                "disassemblyEvidence": "0x00405745: mov eax,[0x59dd70+slot*4] -> mov [object+0xa8],eax",
            },
            "topMenuSlots": [
                {
                    "slot": 7,
                    "childScriptVaHex": "0x004dee18",
                    "regionIndex": 6,
                    "visiblePosition": "0,0",
                    "templateIndex": 0,
                    "role": "left large status/menu child",
                },
                {
                    "slot": 8,
                    "childScriptVaHex": "0x004dee58",
                    "regionIndex": 3,
                    "visiblePosition": "416,0",
                    "templateIndex": 2,
                    "role": "right top menu child",
                },
                {
                    "slot": 9,
                    "childScriptVaHex": "0x004dee98",
                    "regionIndex": 4,
                    "visiblePosition": "416,96",
                    "templateIndex": 3,
                    "role": "right detail/list child",
                },
                {
                    "slot": 11,
                    "childScriptVaHex": "0x004deed8",
                    "regionIndex": 5,
                    "visiblePosition": "416,352",
                    "templateIndex": 4,
                    "role": "lower-right information/description child",
                    "note": "region table rect는 #5로 보이지만 initializer가 416,352에 배치하므로 화면상 region #2 위치의 template #4 child다.",
                },
                {
                    "slot": 12,
                    "childScriptVaHex": "0x004def0c",
                    "regionIndex": 8,
                    "visiblePosition": "324,128",
                    "templateIndex": 13,
                    "role": "small overlay child",
                },
            ],
            "descriptionSelectorDependency": (
                "display block 0x004e8e24의 40 13 03 3d / 40 13 03 2f selector는 "
                "display object +0xa8가 가리키는 linked child/context object의 +0x3d/+0x2f byte를 읽는다. "
                "따라서 +0xa8 자체는 구조체 본문이 아니라 object VM slot bridge로 연결된 포인터다."
            ),
            "selectorConsumerEvidence": {
                "commandVaHex": "0x004ddcf0",
                "commandRawHex": "87 01 2f 00 b0 01 28 00",
                "handlerVaHex": "0x0040af7e",
                "drawHelperVaHex": "0x00421406",
                "meaning": (
                    "linked child/context object의 +0x2f byte를 읽어 상단 메뉴 커서 좌표를 만든다. "
                    "x = 0x01b0 + value*0x20 + helper x offset 8, y = 0x0028 + helper y offset 32."
                ),
                "resolvedDestFormula": "status.cns #3 at x=440 + value*32, y=72",
                "classification": "consumer-not-producer",
            },
            "remaining": [
                "slot 11 child/context object의 +0x2f/+0x3d를 쓰는 정확한 object VM producer. 단 +0x32/+0x33/+0x37/+0x38/+0x3a producer는 0x8c/0x8d/0x8f command로 일부 승격됨.",
                "상단 +0x2f selector가 active menu object +0x2a와 어떤 bridge로 동기화되는지",
                "평상시 ESC/X opener root가 어떤 경로로 0x004ddc6c sequence를 여는지",
            ],
        },
        "descriptorPersistentState": {
            "status": "generic-opcode-0x8e-backed",
            "handlerVaHex": "0x0040b752",
            "rowBase": "0x00457750 + byte[0x004576e9 + 0x0059e33e] * 0x00d8",
            "loadCase": "stream byte+1 == 0: row +0x40/+0x41 -> 0x0059e340/0x0059e34a, or clear both if 0x00457744 == 0",
            "saveCase": "stream byte+1 == 1: 0x0059e340/0x0059e34a -> row +0x40/+0x41",
            "interpretation": (
                "0x0059e340/0x0059e34a는 일회성 임시값이 아니라 active descriptor별로 저장되는 "
                "메뉴 mode/page와 cursor 쌍이다. 따라서 region #2 설명은 visible menu/list 선택 상태와 같은 "
                "descriptor 상태를 재사용한다."
            ),
        },
        "visibleListSourceModel": {
            "status": "list-draw-and-description-share-source",
            "rows": [
                {
                    "command": "40 25",
                    "handlerVaHex": "0x0041dc33",
                    "source": "active descriptor row +0x56 + page*6 + row, skill table pointer from command+4",
                    "writes": "0x0059e370[row] availability/status",
                    "meaning": "기술 목록 6행을 그리고 같은 id buffer를 설명 index latch 경로가 사용한다.",
                },
                {
                    "command": "40 26",
                    "handlerVaHex": "0x0041e03e",
                    "source": "0x004576ec or 0x004576f8 list pairs depending command byte+3",
                    "writes": "0x0059e370[row] availability/status",
                    "meaning": "도구/소지품 또는 관련 list 6행을 그리고 같은 id pair를 설명 index latch 경로가 사용한다.",
                },
            ],
            "interpretation": (
                "목록 표시 opcode와 object+0x58 설명 index writer가 같은 row/id source를 읽는다. "
                "그러므로 region #2 설명 텍스트는 현재 화면에 보이는 항목 선택과 연결된 것으로 승격한다."
            ),
        },
        "attachmentSites": {
            "status": "wrapper-payload-data-refs-found",
            "wrapperVaHex": "0x004e8e10",
            "innerEntryVaHex": "0x004e8e24",
            "dataRefCount": 11,
            "sampleRefs": [
                "0x004ddaa4",
                "0x004dde1c",
                "0x004dded8",
                "0x004ddf98",
                "0x004de0c0",
                "0x004de148",
                "0x004de244",
                "0x004de498",
                "0x004deb60",
                "0x004dec08",
                "0x004dec44",
            ],
            "interpretation": (
                "0x004e8e10 wrapper block은 여러 menu/object stream에서 attach된다. "
                "아직 어떤 root/opener가 어떤 상태 byte(+0x2f/+0x3d)를 만드는지는 미확정이지만, "
                "설명 패널 표시 블록 자체는 재사용 payload로 확인된다."
            ),
        },
        "activeDescriptorRowsSample": [
            {
                "descriptor": "row 0",
                "likelyActor": "아타호",
                "+0x4a": [1, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0],
                "+0x56": [1, 2, 3, 4, 0, 0, 10, 0, 0, 0, 0, 0],
                "+0x5c": [10, 0, 0, 0, 0, 0, 35, 0, 0, 0, 0, 0],
                "+0x62": [35, 0, 0, 0, 0, 0, 58, 59, 0, 0, 0, 0],
                "+0x68": [58, 59, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
            },
            {
                "descriptor": "row 1",
                "likelyActor": "린샹",
                "+0x56": [1, 2, 3, 4, 5, 0, 9, 0, 0, 0, 0, 0],
                "+0x5c": [9, 0, 0, 0, 0, 0, 28, 0, 0, 0, 0, 0],
                "+0x62": [28, 0, 0, 0, 0, 0, 47, 48, 49, 0, 0, 0],
                "+0x68": [47, 48, 49, 0, 0, 0, 0, 0, 0, 0, 0, 0],
            },
            {
                "descriptor": "row 2",
                "likelyActor": "스마슈",
                "+0x56": [1, 0, 0, 0, 0, 0, 5, 0, 0, 0, 0, 0],
                "+0x5c": [5, 0, 0, 0, 0, 0, 20, 0, 0, 0, 0, 0],
                "+0x62": [20, 0, 0, 0, 0, 0, 35, 36, 0, 0, 0, 0],
                "+0x68": [35, 36, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
            },
        ],
        "remainingUnconfirmed": [
            "0x0059e340/0x0059e33f 각 mode 값의 전체 사용자 화면 명칭. 단, descriptor 저장/복원 구조와 일부 source buffer는 확정.",
            "slot bridge로 연결된 child/context object byte +0x2f/+0x3d를 쓰는 상위 object VM producer와 정확한 메뉴 상태명",
            "normal-field ESC/X opener root",
        ],
    }
    return {
        "status": "direct-lower-right-output-blocks-found",
        "originPatternHex": byte_hex(origin_pattern),
        "originHits": hits,
        "blocks": blocks,
        "descriptionGroups": description_groups,
        "skillRankLabels": skill_rank_labels,
        "descriptionSelectorModel": description_selector_model,
        "interpretation": (
            "region #2와 같은 화면 위치인 416,352를 원점으로 삼는 display-VM 블록이 4개 발견됐다. "
            "하나는 평상시 HUD 정보, 하나는 확인/환경설정, 하나는 저장 데이터 상세, 하나는 선택 항목 설명이다. "
            "특히 0x004e8e24는 기술/장비/도구/모드/환경설정 설명 pointer group을 직접 보유하므로 "
            "region #2 내부 출력이 단순 placeholder가 아니라 실제 정보/설명 패널임을 EXE 기반으로 승격한다. "
            "또한 display VM 핸들러 분석으로 현재 선택 설명 index가 object+0x58에 latch되고, "
            "각 pointer group의 40 13 02 58 selector가 이를 직접 소비하는 구조까지 확인됐다."
        ),
        "pending": [
            "0x0059e340/0x0059e33f mode 값의 전체 사용자 화면 명칭. descriptor-persistent selector state 자체는 확인됨",
            "display object +0xa8는 slot bridge pointer로 확인됨. 그 linked child/context object의 byte +0x2f/+0x3d를 세팅하는 상위 object VM producer",
            "확인/환경설정 selector 0x3a의 상태 write 대상",
            "평상시 HUD 0x004e807c의 각 status row가 돈/위치/상태 중 무엇에 대응하는지의 정확한 명칭",
        ],
    }


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


def region_bindings(hud: dict[str, Any]) -> list[dict[str, Any]]:
    return list(
        ((hud.get("exe_evidence") or {}).get("screen_region_rect_table") or {}).get("resource_bindings") or []
    )


def window_templates(hud: dict[str, Any]) -> list[dict[str, Any]]:
    return list(
        ((hud.get("exe_evidence") or {}).get("window_cns_template_table") or {}).get("templates") or []
    )


def select_region_rows(hud: dict[str, Any]) -> list[dict[str, Any]]:
    rows = []
    for binding in region_bindings(hud):
        index = int(binding.get("index", -1))
        if index not in {*REGION_IDS, *CONTEXT_REGION_IDS}:
            continue
        rows.append(
            {
                "index": index,
                "rect": binding.get("rect") or {},
                "resourceIdHex": binding.get("resource_id_hex"),
                "templateIndex": binding.get("template_index"),
                "role": binding.get("role"),
                "rectVaHex": hx(binding.get("rect_va")),
                "resourceVaHex": hx(binding.get("resource_va")),
            }
        )
    return sorted(rows, key=lambda row: int(row["index"]))


def right_payload_group(payload: dict[str, Any], selector: int) -> dict[str, Any] | None:
    return next(
        (group for group in payload.get("pointerGroups", []) if int(group.get("selector", -1)) == selector),
        None,
    )


def label_for_pointer(right_payload: dict[str, Any], pointer_hex: str) -> str:
    pointer_va = int(pointer_hex, 16)
    if pointer_va == 0x004E81D4:
        return "기술"
    if pointer_va == 0x004E82C0:
        return "장비"
    for row in right_payload.get("textRows", []):
        if int(row.get("va", 0)) == pointer_va:
            return str(row.get("normalizedText") or row.get("text") or "")
    for row in right_payload.get("rawTextRows", []):
        if int(row.get("va", 0)) == pointer_va:
            return str(row.get("rawTextNormalized") or row.get("text") or "")
    return ""


def table_records(ui_grid: dict[str, Any], key: str) -> list[dict[str, Any]]:
    for table_row in ui_grid.get("tables", []):
        if table_row.get("key") == key:
            return list(table_row.get("records", []))
    return []


def compact_grid(grid: dict[str, Any] | None) -> dict[str, Any] | None:
    if not grid:
        return None
    return {
        "cns": grid.get("cns"),
        "sheetKey": grid.get("sheetKey"),
        "cellIndex": grid.get("cellIndex"),
        "x": grid.get("x"),
        "y": grid.get("y"),
        "w": grid.get("w"),
        "h": grid.get("h"),
    }


def record_grid(record: dict[str, Any]) -> dict[str, Any] | None:
    if record.get("grid"):
        return compact_grid(record.get("grid"))
    candidates = record.get("displayIconCandidates") or []
    if candidates:
        return compact_grid(candidates[0])
    return None


def grid_from_meta(meta: int, *, source: str = "EXE record meta") -> dict[str, Any] | None:
    sheet_class = (meta >> 16) & 0xFFFF
    cell_index = meta & 0xFFFF
    grid = META_SHEET_GRIDS.get(sheet_class)
    if grid:
        columns = int(grid["columns"])
        cell_w = int(grid["cellWidth"])
        cell_h = int(grid["cellHeight"])
        return {
            "source": source,
            "cns": grid["cns"],
            "sheetKey": grid["sheetKey"],
            "cellIndex": cell_index,
            "x": (cell_index % columns) * cell_w,
            "y": (cell_index // columns) * cell_h,
            "w": cell_w,
            "h": cell_h,
        }
    return None


def menu_entry(
    label: str,
    *,
    source: str,
    icon: dict[str, Any] | None = None,
    children: list[dict[str, Any]] | None = None,
    summary: str = "",
    source_va_hex: str | None = None,
    **extra: Any,
) -> dict[str, Any]:
    row = {
        "label": label,
        "source": source,
        "sourceVaHex": source_va_hex,
        "icon": icon,
        "children": children or [],
        "summary": summary,
    }
    row.update(extra)
    return row


def record_entry(record: dict[str, Any], *, source: str, description_kind: str = "") -> dict[str, Any]:
    source_va_hex = record.get("recordVaHex") or record.get("textVaHex")
    extra: dict[str, Any] = {}
    if record.get("index") is not None:
        extra["tableIndex"] = record.get("index")
    if record.get("index1Based") is not None:
        extra["tableIndex1Based"] = record.get("index1Based")
    if description_kind == "item" and record.get("index1Based") is not None:
        extra["itemId"] = int(record.get("index1Based"))
        detail = record.get("detailRecord") or {}
        if detail.get("flag") is not None:
            extra["itemFlag"] = detail.get("flag")
            extra["itemFlagHex"] = detail.get("flagHex")
    return menu_entry(
        str(record.get("name") or ""),
        source=source,
        icon=record_grid(record),
        summary=str(
            record.get("summary")
            or record.get("description")
            or (record.get("detailRecord") or {}).get("summary")
            or ""
        ).replace("\n", " / "),
        source_va_hex=source_va_hex,
        **extra,
        **table_description_extra(description_kind, source_va_hex),
    )


def consumable_quantity_model(inventory_layout: dict[str, Any]) -> dict[str, Any]:
    layout = ((inventory_layout.get("storageLayout") or {}).get("consumables") or {})
    initial_slots = ((inventory_layout.get("initialSlots") or {}).get("consumables") or [])
    initial_by_item: dict[int, int] = {}
    for row in initial_slots:
        item_id = int(row.get("itemId") or 0)
        if item_id <= 0:
            continue
        initial_by_item[item_id] = int(row.get("value") or 0)
    return {
        "status": "exe-consumable-count-buffer-backed",
        "sourceArtifact": str(ITEM_INVENTORY_JSON.relative_to(ROOT)),
        "storageShape": layout.get("shape"),
        "idBaseVaHex": layout.get("idBaseVaHex"),
        "countBaseVaHex": layout.get("countBaseVaHex"),
        "slotAddressRule": layout.get("slotAddressRule"),
        "slotCount": len(initial_slots) or layout.get("capacity") or 6,
        "maxCount": layout.get("maxCount", 10),
        "initialSlots": initial_slots,
        "initialCountByItemId": {str(k): v for k, v in sorted(initial_by_item.items())},
        "runtimeListConsumer": {
            "opcode": "40 26",
            "availabilitySource": 6,
            "handlerVaHex": "0x00410f23",
            "idBufferVaHex": layout.get("idBaseVaHex") or "0x004576ec",
            "countBufferVaHex": layout.get("countBaseVaHex") or "0x004576ed",
            "meaning": (
                "#4 도구 목록 row는 item definition table만으로 완성되지 않고 "
                "0x4576ec/0x4576ed의 6개 {item id,count} slot을 통과한다. "
                "표시 수량은 같은 visible slot의 count byte다."
            ),
        },
        "interpretation": (
            "소모품은 전역 6-slot inventory buffer에 id/count가 분리 저장된다. "
            "초기 EXE 상태는 slot0=(리프레시 워터,3)이고 나머지는 비어 있다. "
            "정적 미리보기는 item id별 초기 count를 오른쪽 수량으로 표시한다."
        ),
    }


def attach_consumable_quantity(
    entry: dict[str, Any],
    item_record: dict[str, Any],
    quantity_model: dict[str, Any],
) -> dict[str, Any]:
    item_id = int(item_record.get("index1Based") or entry.get("itemId") or 0)
    initial_counts = quantity_model.get("initialCountByItemId") or {}
    preview_count = int(initial_counts.get(str(item_id), 0) or 0)
    entry.update(
        {
            "quantityKind": "consumable-count",
            "quantityPreview": preview_count,
            "quantityMax": quantity_model.get("maxCount", 10),
            "quantityDisplay": f"×{preview_count}",
            "quantityEvidence": {
                "status": quantity_model.get("status"),
                "sourceArtifact": quantity_model.get("sourceArtifact"),
                "itemId": item_id,
                "runtimeListConsumer": quantity_model.get("runtimeListConsumer"),
                "previewMeaning": "초기 EXE inventory buffer를 item id별로 매칭한 수량이다.",
            },
        }
    )
    return entry


def action_grid(row: dict[str, Any]) -> dict[str, Any] | None:
    meta = row.get("meta")
    if meta is None and row.get("metaHex"):
        meta = int(str(row["metaHex"]), 16)
    if meta is None:
        return None
    return grid_from_meta(int(meta), source="player action table meta")


def action_summary(rows: list[dict[str, Any]]) -> str:
    if not rows:
        return ""
    phases = ", ".join(str(row.get("phase")) for row in rows if row.get("phase") is not None)
    mps = "/".join(str(row.get("mpCost")) for row in rows if row.get("mpCost") is not None)
    counts = "/".join(str(row.get("effectCount")) for row in rows if row.get("effectCount") is not None)
    entries = ", ".join(str(row.get("entryVaHex")) for row in rows if row.get("entryVaHex"))
    parts = [f"EXE action rows {len(rows)}"]
    if phases:
        parts.append(f"phase {phases}")
    if mps:
        parts.append(f"MP {mps}")
    if counts:
        parts.append(f"타격/효과 {counts}")
    if entries:
        parts.append(entries)
    return " · ".join(parts)


def action_runtime_fields(rows: list[dict[str, Any]]) -> dict[str, Any]:
    """Return structured action-row fields used by menu/HUD previews.

    The textual summary is useful for humans, but the actual menu renderer
    should not parse it back to recover MP costs.  These values are promoted
    from the EXE-derived player action rows.
    """
    mp_costs = [
        int(row["mpCost"])
        for row in rows
        if row.get("mpCost") is not None
    ]
    effect_counts = [
        int(row["effectCount"])
        for row in rows
        if row.get("effectCount") is not None
    ]
    fields: dict[str, Any] = {}
    if mp_costs:
        fields["mpCosts"] = mp_costs
        fields["currentMpCost"] = mp_costs[0]
    if effect_counts:
        fields["effectCounts"] = effect_counts
    if rows:
        fields["actionRowVaHexes"] = [
            row.get("entryVaHex") for row in rows if row.get("entryVaHex")
        ]
        fields["actionRuntimeEvidence"] = "battle_action_mapping player action row mpCost/effectCount"
    return fields


def player_action_index(action_mapping: dict[str, Any]) -> dict[tuple[str, str], list[dict[str, Any]]]:
    by_name: dict[tuple[str, str], list[dict[str, Any]]] = {}
    for row in action_mapping.get("playerRows", []):
        character = str(row.get("ownerName") or "")
        name = str(row.get("name") or "")
        if character and name:
            by_name.setdefault((character, name), []).append(row)
    return by_name


def parse_hex_int(value: Any) -> int | None:
    if value is None:
        return None
    try:
        return int(str(value), 16)
    except (TypeError, ValueError):
        return None


def player_description_index(character: str, source_va_hex: Any) -> int | None:
    base = PLAYER_ACTION_DESC_BASE_VA.get(character)
    source_va = parse_hex_int(source_va_hex)
    if base is None or source_va is None or source_va < base:
        return None
    delta = source_va - base
    if delta % 8:
        return None
    return delta // 8 + 1


def attach_player_description_reference(entry: dict[str, Any], character: str, source_va_hex: Any) -> None:
    description_index = player_description_index(character, source_va_hex)
    group_label = PLAYER_ACTION_DESC_GROUP_LABEL.get(character)
    if description_index is None or group_label is None:
        return
    entry["descriptionGroupLabel"] = group_label
    entry["descriptionIndex"] = description_index
    entry["descriptionEvidence"] = (
        "player action table row offset -> region #2 selector 0x58 description index"
    )


def player_description_extra(character: str, source_va_hex: Any) -> dict[str, Any]:
    description_index = player_description_index(character, source_va_hex)
    group_label = PLAYER_ACTION_DESC_GROUP_LABEL.get(character)
    if description_index is None or group_label is None:
        return {}
    return {
        "descriptionGroupLabel": group_label,
        "descriptionIndex": description_index,
        "descriptionEvidence": "player action table row offset -> region #2 selector 0x58 description index",
    }


DESCRIPTION_TABLE_RULES = {
    "equipment": {
        "baseVa": EQUIPMENT_TABLE_VA,
        "groupLabel": "장비 설명",
        "indexBias": 0,
        "evidence": "equipment table row offset -> region #2 equipment description selector 0x58",
    },
    "item": {
        "baseVa": ITEM_TABLE_VA,
        "groupLabel": "도구/소지품 설명",
        "indexBias": 0,
        "evidence": "item table row offset -> region #2 item/possession description selector 0x58",
    },
    "mode": {
        "baseVa": MODE_TABLE_VA,
        "groupLabel": "모드/상태 설명",
        "indexBias": -1,
        "evidence": "mode/status table row offset -> region #2 mode/status description selector 0x58",
    },
    "config": {
        "baseVa": CONFIG_TABLE_VA,
        "groupLabel": "환경설정 설명",
        "indexBias": -1,
        "evidence": "config table row offset -> region #2 config description selector 0x58",
    },
}


def table_description_extra(kind: str, source_va_hex: Any) -> dict[str, Any]:
    rule = DESCRIPTION_TABLE_RULES.get(kind)
    source_va = parse_hex_int(source_va_hex)
    if not rule or source_va is None:
        return {}
    delta = source_va - int(rule["baseVa"])
    if delta < 0 or delta % 8:
        return {}
    description_index = delta // 8 + int(rule["indexBias"])
    if description_index < 0:
        return {}
    return {
        "descriptionGroupLabel": str(rule["groupLabel"]),
        "descriptionIndex": description_index,
        "descriptionEvidence": str(rule["evidence"]),
    }


def skill_rank_entries(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
    ranks: dict[int, dict[str, Any]] = {}
    for row in rows:
        level = row.get("levelOrFixed")
        if isinstance(level, int) and level in SKILL_RANK_LABELS:
            ranks.setdefault(
                level,
                {
                    "level": level,
                    "label": SKILL_RANK_LABELS[level],
                    "entryVaHex": row.get("entryVaHex"),
                    "payloadVaHex": row.get("payloadVaHex"),
                    "mpCost": row.get("mpCost"),
                    "effectCount": row.get("effectCount"),
                },
            )
    return [ranks[level] for level in sorted(ranks)]


def action_entry(
    label: str,
    rows: list[dict[str, Any]],
    *,
    source: str,
    character: str,
    summary_prefix: str = "",
    condition: str = "",
) -> dict[str, Any]:
    icon = action_grid(rows[0]) if rows else None
    summary = action_summary(rows)
    if summary_prefix:
        summary = f"{summary_prefix} / {summary}" if summary else summary_prefix
    entry = menu_entry(
        label,
        source=source,
        icon=icon,
        summary=summary,
        source_va_hex=rows[0].get("entryVaHex") if rows else None,
        character=character,
        evidenceStatus="exe-player-action-table",
        **action_runtime_fields(rows),
    )
    if condition:
        entry["condition"] = condition
    attach_player_description_reference(entry, character, rows[0].get("entryVaHex") if rows else None)
    ranks = skill_rank_entries(rows)
    if ranks:
        entry["skillRankLabels"] = ranks
        entry["currentRankLabel"] = ranks[0]["label"]
    return entry


def equipment_skill_entry(binding: dict[str, Any]) -> dict[str, Any]:
    icon = compact_grid(binding.get("skillActionIcon"))
    equipment_name = str(binding.get("equipmentName") or "")
    skill_name = str(binding.get("skillName") or "")
    # The in-game menu displays the skill name.  Equipment identity is carried
    # by the icon and by the activation condition, not appended to the label.
    label = skill_name
    summary = str(binding.get("payloadSummary") or "")
    if equipment_name:
        summary = f"{equipment_name} 착용 시 / {summary}" if summary else f"{equipment_name} 착용 시"
    entry = menu_entry(
        label,
        source="equipmentSkillBindings + player action table",
        icon=icon,
        summary=summary,
        source_va_hex=binding.get("skillRecordVaHex"),
        character=binding.get("character"),
        condition=f"{equipment_name} 착용" if equipment_name else "",
        equipmentName=equipment_name,
        skillName=skill_name,
        evidenceStatus=binding.get("bindingStatus") or "equipment-skill-binding",
    )
    if binding.get("mpCost") is not None:
        entry["mpCosts"] = [int(binding["mpCost"])]
        entry["currentMpCost"] = int(binding["mpCost"])
        entry["actionRuntimeEvidence"] = "equipmentSkillBindings mpCost promoted from EXE action payload"
    attach_player_description_reference(entry, str(binding.get("character") or ""), binding.get("skillRecordVaHex"))
    return entry


def append_if_missing(entries: list[dict[str, Any]], entry: dict[str, Any]) -> None:
    key = (entry.get("label"), entry.get("sourceVaHex"))
    for existing in entries:
        if (existing.get("label"), existing.get("sourceVaHex")) == key:
            return
    entries.append(entry)


def skill_reference_entries(
    ui_grid: dict[str, Any], action_mapping: dict[str, Any]
) -> dict[str, dict[str, list[dict[str, Any]]]]:
    by_category: dict[str, dict[str, list[dict[str, Any]]]] = {}
    action_rows = player_action_index(action_mapping)
    for row in ui_grid.get("skillReferences", []):
        category = str(row.get("category") or "")
        character = str(row.get("character") or "")
        if not category or not character:
            continue
        name = str(row.get("name") or "")
        rows = action_rows.get((character, name), [])
        icon = record_grid(row)
        if icon is None and rows:
            icon = action_grid(rows[0])
        summary_parts = [
            str(row.get("summary") or row.get("exeLevelPayloadSummary") or "").strip(),
            action_summary(rows),
        ]
        by_category.setdefault(category, {}).setdefault(character, []).append(
            menu_entry(
                name,
                source="skillReferences + player action table overlay",
                icon=icon,
                summary=" / ".join(part for part in summary_parts if part),
                character=character,
                source_va_hex=rows[0].get("entryVaHex") if rows else row.get("recordVaHex"),
                evidenceStatus="skill-reference-action-overlay" if rows else "skill-reference-only",
                skillRankLabels=skill_rank_entries(rows),
                currentRankLabel=(skill_rank_entries(rows)[0]["label"] if skill_rank_entries(rows) else ""),
                **action_runtime_fields(rows),
                **player_description_extra(character, rows[0].get("entryVaHex") if rows else row.get("recordVaHex")),
            )
        )

    # Common battle commands live in each character's player-action table, not
    # in the user-facing learned-skill reference list.  They must appear in the
    # special-technique page.
    for character in CHARACTER_LABELS:
        special_entries = by_category.setdefault("특수기", {}).setdefault(character, [])
        command_entries = []
        for command_name in ("도주", "방어"):
            rows = action_rows.get((character, command_name), [])
            if rows:
                command_entries.append(
                    action_entry(
                        command_name,
                        rows,
                        source="player action table common command",
                        character=character,
                    )
                )
        existing_keys = {(entry.get("label"), entry.get("sourceVaHex")) for entry in special_entries}
        prefixed = [
            entry
            for entry in command_entries
            if (entry.get("label"), entry.get("sourceVaHex")) not in existing_keys
        ]
        if prefixed:
            by_category["특수기"][character] = prefixed + special_entries

    # Equipment-granted skills are selected by the equipped weapon and are
    # stored separately from the canonical skill reference rows.
    for binding in ui_grid.get("equipmentSkillBindings", []):
        category = str(binding.get("skillSlotLabel") or "")
        character = str(binding.get("character") or "")
        if not category or not character:
            continue
        append_if_missing(
            by_category.setdefault(category, {}).setdefault(character, []),
            equipment_skill_entry(binding),
        )
    return by_category


def equipment_children(ui_grid: dict[str, Any]) -> dict[str, dict[str, list[dict[str, Any]]]]:
    children = {"무기": {}, "방어구": {}}
    for record in table_records(ui_grid, "equipment"):
        target = (record.get("detailRecord") or {}).get("equipTarget") or {}
        slot = str(target.get("slot") or "")
        character = str(target.get("character") or "")
        if slot in children:
            entry = record_entry(record, source="equipment table 0x0048b1dc", description_kind="equipment")
            entry["character"] = character
            entry["equipTarget"] = target
            children[slot].setdefault(character, []).append(entry)
    return children


def fixed_cp949_text(exe: bytes, sections: list[dict[str, Any]], va: int, size: int = 16) -> str:
    offset = va_to_offset(sections, va)
    if offset is None:
        return ""
    return normalize_text(decode_cp949(exe[offset : offset + size]))


def config_entries(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    offset = va_to_offset(sections, CONFIG_TABLE_VA)
    if offset is None:
        return []
    final_depth_model = confirmation_config_choice_model(exe, sections)
    final_depth_by_index = {
        int(row.get("index", -1)): row for row in final_depth_model.get("groups", [])
    }
    rows = []
    for index in range(1, CONFIG_TABLE_COUNT):
        text_va, meta = struct.unpack_from("<II", exe, offset + index * 8)
        label = fixed_cp949_text(exe, sections, text_va, 16)
        if not label:
            continue
        entry = menu_entry(
            label,
            source="config table 0x0048c012 selector 0",
            icon=grid_from_meta(meta, source="config table meta"),
            summary=f"환경설정 항목 #{index}. meta {hx(meta)}는 icon.cns #{meta & 0xffff}를 가리킨다.",
            source_va_hex=hx(CONFIG_TABLE_VA + index * 8),
            **table_description_extra("config", hx(CONFIG_TABLE_VA + index * 8)),
        )
        final_depth = final_depth_by_index.get(index - 1)
        if final_depth:
            entry["finalDepth"] = {
                **final_depth,
                "mappingEvidence": (
                    f"config table row #{index} ({label}) -> selector 0x3a entry #{index - 1} 순서 대응"
                ),
            }
        rows.append(
            entry
        )
    return rows


def mode_entries(mode_review: dict[str, Any]) -> list[dict[str, Any]]:
    rows = []
    for row in mode_review.get("modeRows", []):
        index = int(row.get("index", -1))
        if index < 1 or index > 5:
            continue
        meta = int(str(row.get("metaHex") or "0"), 16)
        rows.append(
            menu_entry(
                str(row.get("name") or ""),
                source="mode/status table 0x0048bcaa selector 0",
                icon=grid_from_meta(meta, source="mode/status table meta"),
                summary=(
                    f"공격 {row.get('attackCoef')} / 방어 {row.get('defenseCoef')} / "
                    f"명중·상태 {row.get('hitStatusCoef')} / 회피 {row.get('avoidCoef')} / "
                    f"행동순서 {row.get('actionSpeedCoef')} / 회심 {row.get('criticalCoef')}"
                ),
                source_va_hex=row.get("entryVaHex"),
                **table_description_extra("mode", row.get("entryVaHex")),
            )
        )
    return rows


def confirmation_config_choice_model(exe: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    group = parse_selector_group_at_va(
        exe,
        sections,
        0x004E86E0,
        label="confirmation/config selector 0x3a",
        sample_count=12,
    )
    groups = []
    for row in group.get("entries", []):
        fragments = [str(fragment).strip() for fragment in row.get("fragments", []) if str(fragment).strip()]
        if len(fragments) < 2:
            continue
        prompt = fragments[0]
        choices = fragments[1:]
        index = int(row.get("index", len(groups)))
        groups.append(
            {
                "index": index,
                "selectorHex": "0x3a",
                "pointerVaHex": row.get("pointerVaHex"),
                "prompt": prompt,
                "choices": choices,
                "defaultChoiceIndex": 1 if prompt in {"정말로 좋습니까？", "전투를 금지할까요？"} and len(choices) > 1 else 0,
                "text": row.get("text"),
                "fragments": fragments,
                "evidenceStatus": "exe-selector-0x3a-pointer-group",
            }
        )
    return {
        "status": "direct-selector-group-backed",
        "sourceVaHex": "0x004e86e0",
        "selectorHex": "0x3a",
        "groupCount": group.get("count"),
        "groups": groups,
        "unattachedGroupIndexes": [
            row["index"] for row in groups if row["index"] >= CONFIG_TABLE_COUNT - 1
        ],
        "interpretation": (
            "region #2 하단 선택/확인창 문구는 0x004e86e0의 selector 0x3a pointer group에서 온다. "
            "환경설정 table row 1..5는 selector group #0..#4와 순서로 대응한다. "
            "#5 전투 금지는 같은 selector group에 있지만 현재 평상시 환경설정 table row에는 직접 노출되지 않는다."
        ),
    }


def build_exe_menu_model(
    right_payload: dict[str, Any],
    ui_grid: dict[str, Any],
    action_mapping: dict[str, Any],
    mode_review: dict[str, Any],
    inventory_layout: dict[str, Any],
    exe: bytes,
    sections: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    skill_group = right_payload_group(right_payload, 0x31)
    equipment_group = right_payload_group(right_payload, 0x34)
    top_group = right_payload_group(right_payload, 0x2F)

    skill_children = skill_reference_entries(ui_grid, action_mapping)
    equip_children = equipment_children(ui_grid)
    item_records = table_records(ui_grid, "items")
    quantity_model = consumable_quantity_model(inventory_layout)
    consumables = [
        attach_consumable_quantity(
            record_entry(row, source="item table 0x0048b97c selector 0", description_kind="item"),
            row,
            quantity_model,
        )
        for row in item_records[:6]
    ]
    possessions = [
        record_entry(row, source="item table 0x0048b97c selector 1", description_kind="item")
        for row in item_records[6:]
    ]
    skill_detail = [
        menu_entry(
            str(row.get("label")),
            source="40 13 selector 0x31 pointer group",
            children=skill_children.get(str(row.get("label")), {}).get("아타호", []),
            childrenByActor=skill_children.get(str(row.get("label")), {}),
            actorScoped=True,
            source_va_hex=row.get("pointerVaHex"),
        )
        for row in (skill_group or {}).get("labels", [])
        if row.get("label")
    ]
    equipment_detail = [
        menu_entry(
            str(row.get("label")),
            source="40 13 selector 0x34 pointer group",
            children=equip_children.get(str(row.get("label")), {}).get("아타호", []),
            childrenByActor=equip_children.get(str(row.get("label")), {}),
            actorScoped=True,
            source_va_hex=row.get("pointerVaHex"),
        )
        for row in (equipment_group or {}).get("labels", [])
        if row.get("label")
    ]
    mode_detail = mode_entries(mode_review)
    config_detail = config_entries(exe, sections)
    pages_by_label = {
        "기술": skill_detail,
        "장비": equipment_detail,
    }
    detail_by_label = {
        "기술": skill_detail[0].get("children", []) if skill_detail else [],
        "도구": consumables,
        "장비": equipment_detail[0].get("children", []) if equipment_detail else [],
        "소지": possessions,
        "모드": mode_detail,
        "환경설정": config_detail,
    }
    info_by_label = {
        "기술": ["하위 4페이지(기본기/개인공격기/전체공격기/특수기)는 40 13 selector 0x31 포인터 그룹에서 직접 확인됨"],
        "도구": ["도구 목록 consumer는 40 26 opcode와 item table 0x0048b97c로 확인됨"],
        "장비": ["하위 2페이지(무기/방어구)는 40 13 selector 0x34 포인터 그룹에서 직접 확인됨"],
        "소지": ["도구와 같은 item table을 selector 1로 소비"],
        "모드": ["mode/status table 0x0048bcaa를 40 28로 소비하며 각 row meta는 icon.cns #28을 가리킨다."],
        "환경설정": ["config table 0x0048c012를 40 29로 소비하며 각 row meta는 icon.cns #67/#68/#70/#69/#71을 가리킨다."],
    }

    rows: list[dict[str, Any]] = []
    if top_group:
        for index, label_row in enumerate(top_group.get("labels", [])):
            label = label_for_pointer(right_payload, str(label_row.get("pointerVaHex") or "0x0"))
            if not label:
                label = str(label_row.get("label") or f"menu-{index + 1}")
            rows.append(
                {
                    "key": f"exe-{index + 1}",
                    "label": label,
                    "iconId": NORMAL_TOP_ICON_SEQUENCE[index] if index < len(NORMAL_TOP_ICON_SEQUENCE) else None,
                    "sourcePointerVaHex": label_row.get("pointerVaHex"),
                    "actorSensitive": label in {"기술", "장비"},
                    "actors": CHARACTER_LABELS if label in {"기술", "장비"} else [],
                    "pages": pages_by_label.get(label, []),
                    "pageNavigation": (
                        {
                            "kind": "left-right-subpage",
                            "statusCnsArrows": STATUS_CURSOR_MODEL["pageArrows"],
                            "pageCount": len(pages_by_label.get(label, [])),
                            "evidence": (
                                "하위 페이지 라벨은 EXE 40 13 pointer group에서 직접 확인된다. "
                                "좌우 이동 표시 화살표는 handler 0x0040f78a의 0x4175d3 직접 blit으로 확인했다."
                            ),
                        }
                        if label in pages_by_label
                        else None
                    ),
                    "detail": detail_by_label.get(label, [f"{label} 목록"]),
                    "info": info_by_label.get(label, ["EXE right-panel payload에서 유도한 메뉴 항목"]),
                }
            )
    return rows or MENU_MODEL


def build_payload() -> dict[str, Any]:
    hud = load_json(OUT / "hud_normal_static_hint_review.json", {})
    ui_grid = load_json(OUT / "ui_cns_grid_mappings.json", {})
    action_mapping = load_json(OUT / "battle_action_mapping.json", {})
    mode_review = load_json(OUT / "battle_mode_coefficient_review.json", {})
    inventory_layout = load_json(ITEM_INVENTORY_JSON, {})
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    right_payload_data = read_bytes(
        exe,
        sections,
        RIGHT_MENU_PAYLOAD_VA,
        RIGHT_MENU_PAYLOAD_END_VA - RIGHT_MENU_PAYLOAD_VA,
    )
    right_payload = parse_right_payload(right_payload_data)
    status_22_rows = scan_status_22_rows(exe, sections)
    object_vm_87_cursor_rows = scan_object_vm_87_cursor_rows(exe, sections)
    object_vm_selector_state_rows = scan_object_vm_selector_state_rows(exe, sections)
    region2_output_model = build_region2_output_model(exe, sections)
    final_depth_selection_model = confirmation_config_choice_model(exe, sections)
    normal_sequence = normal_top_icon_sequence_bytes()
    normal_sequence_matches: list[int] = []
    search_pos = 0
    while True:
        found = exe.find(normal_sequence, search_pos)
        if found < 0:
            break
        normal_sequence_matches.append(found)
        search_pos = found + 1
    right_payload["normalTopIconSequence"]["exeFileOffsetMatches"] = [
        f"0x{offset:05x}" for offset in normal_sequence_matches
    ]
    right_payload["normalTopIconSequence"]["exeMatchCount"] = len(normal_sequence_matches)
    quantity_model = consumable_quantity_model(inventory_layout)
    exe_menu_model = build_exe_menu_model(
        right_payload,
        ui_grid,
        action_mapping,
        mode_review,
        inventory_layout,
        exe,
        sections,
    )
    rows = select_region_rows(hud)
    templates = window_templates(hud)
    template_by_index = {int(row.get("index", -1)): row for row in templates}
    missing = [
        region["index"]
        for region in rows
        if int(region.get("templateIndex", -1)) not in template_by_index
    ]
    return {
        "version": 1,
        "kind": "hwanse-menu-right-panel-ui-review",
        "status": "top-menu-object-and-right-content-payload-grounded-opener-pending",
        "sourceArtifacts": {
            "hudNormalStaticHint": "out/hud_normal_static_hint_review.json",
            "menuDescriptorStack": "out/menu_descriptor_stack_review.json",
            "menuCancelWindowConsumer": "out/menu_cancel_window_consumer_review.json",
            "uiCnsGridMappings": "out/ui_cns_grid_mappings.json",
            "battleActionMapping": "out/battle_action_mapping.json",
            "battleModeCoefficientReview": "out/battle_mode_coefficient_review.json",
            "rightMenuPayload": "Hwanse2.exe 0x004e8120..0x004e842e",
            "region2OutputBlocks": "Hwanse2.exe lower-right origin 416,352 display-VM blocks",
            "itemInventoryStateLayout": str(ITEM_INVENTORY_JSON.relative_to(ROOT)),
        },
        "regionIds": REGION_IDS,
        "contextRegionIds": CONTEXT_REGION_IDS,
        "regions": rows,
        "templates": [
            template_by_index[index]
            for index in sorted(
                {
                    int(region.get("templateIndex", -1))
                    for region in rows
                    if int(region.get("templateIndex", -1)) in template_by_index
                }
            )
        ],
        "menuModel": exe_menu_model,
        "rightMenuLayout": RIGHT_MENU_LAYOUT,
        "rightMenuPayload": right_payload,
        "consumableQuantityModel": quantity_model,
        "currentModeLabelModel": current_mode_label_model(right_payload),
        "topMenuObjectConstruction": {
            "status": "direct-object-sequence-grounded-upstream-trigger-pending",
            "sequenceVaHex": "0x004ddc6c",
            "directRefVaHex": "0x0047e6b0",
            "source": "out/menu_descriptor_stack_review.json + direct EXE object-script bytes",
            "attachedPayloads": [
                {
                    "vaHex": "0x004e842e",
                    "role": "left/status payload",
                    "evidence": "object sequence 0x004ddc6c attaches this payload before the right-menu payload",
                },
                {
                    "vaHex": "0x004e8120",
                    "role": "right menu content payload",
                    "evidence": "same object sequence directly attaches the payload that contains the normal #0,#1,#2,#3,#8,#4 icon row",
                },
                {
                    "vaHex": "0x004e8190",
                    "role": "secondary/common menu payload",
                    "evidence": "same object sequence attaches this additional payload after child region setup",
                },
            ],
            "childRegions": [
                {
                    "slot": 7,
                    "scriptVaHex": "0x004dee18",
                    "regionIndex": 6,
                    "rect": {"x": 0, "y": 0, "w": 416, "h": 352},
                    "role": "left large status/menu window",
                },
                {
                    "slot": 8,
                    "scriptVaHex": "0x004dee58",
                    "regionIndex": 3,
                    "rect": {"x": 416, "y": 0, "w": 224, "h": 96},
                    "role": "right top horizontal menu window",
                },
                {
                    "slot": 9,
                    "scriptVaHex": "0x004dee98",
                    "regionIndex": 4,
                    "rect": {"x": 416, "y": 96, "w": 224, "h": 256},
                    "role": "right detail/list menu window",
                },
                {
                    "slot": 11,
                    "scriptVaHex": "0x004deed8",
                    "regionIndex": 5,
                    "rect": {"x": 416, "y": 352, "w": 224, "h": 128},
                    "initializerPosition": {"x": 416, "y": 352},
                    "regionTableRect": {"x": 416, "y": 480, "w": 224, "h": 128},
                    "templateIndex": 4,
                    "role": "visible lower-right template #4 child; initializer places region-index #5 at the same visible slot as region #2",
                },
                {
                    "slot": 12,
                    "scriptVaHex": "0x004def0c",
                    "regionIndex": 8,
                    "rect": {"x": 324, "y": 128, "w": 80, "h": 112},
                    "role": "small overlay child",
                },
            ],
            "notPromoted": [
                "which normal-field ESC/X opener script instantiates 0x004ddc6c",
                "whether the slot 11 region-index #5 should be named as an alias/variant of screen region #2",
            ],
        },
        "topMenuSelectionModel": TOP_MENU_SELECTION_MODEL,
        "menuStateHelperModel": MENU_STATE_HELPER_MODEL,
        "cursorModel": {
            **STATUS_CURSOR_MODEL,
            "objectVm87CursorRows": object_vm_87_cursor_rows,
            "objectVmSelectorStateRows": object_vm_selector_state_rows,
            "objectVmAvailabilitySourceModel": {
                hx2(source_id): model
                for source_id, model in sorted(OBJECT_VM_AVAILABILITY_SOURCE_MODEL.items())
            },
            "objectVmSelectorProducerSummary": {
                "status": "partial-direct-code-backed",
                "confirmedProducerOpcodes": {
                    "0x8c": "availability array 0x0059e360/0x0059e370에서 다음 enabled row를 찾아 object+0xa8[target]에 기록",
                    "0x8d": "active descriptor row +0x48/+0x4a 계열 비교 결과를 object+0xa8[target]에 기록",
                    "0x8f": "global state byte 0x00457744..0x00457749를 object+0xa8[target]에 기록",
                },
                "confirmedCursorTargets": sorted(
                    {
                        hx2(int(row["targetOffset"]))
                        for row in object_vm_selector_state_rows
                        if row.get("kind") == "producer"
                        and row.get("targetOffset") is not None
                        and int(row["targetOffset"]) in OBJECT_VM_CURSOR_SELECTORS
                    }
                ),
                "confirmedLocalTargets": sorted(
                    {
                        hx2(int(row["targetOffset"]))
                        for row in object_vm_selector_state_rows
                        if row.get("kind") == "producer" and row.get("targetOffset") is not None
                    }
                ),
                "stillPending": [
                    "상단 커서 +0x2f의 직접 producer는 이 object stream 범위에서 아직 미검출",
                    "description selector +0x3d producer는 아직 미검출",
                    "0x8a source 10/11은 actor/stack 계열로 확인했지만 정확한 인게임 사용자 화면명은 보류",
                ],
            },
            "status22Rows": status_22_rows,
            "status22RowsMatchingManualCursorSources": [
                row
                for row in status_22_rows
                if int(row.get("statusRectIndex", -1))
                in {
                    int(STATUS_CURSOR_MODEL["topCursor"]["sourceRectIndex"]),
                    int(STATUS_CURSOR_MODEL["subCursor"]["sourceRectIndex"]),
                }
            ],
            "interpretation": (
                "status.cns rect #3(96,0,16,16)과 #6(112,0,16,16)은 "
                "상단/하위 메뉴 커서 source와 맞는다. "
                "EXE 안에서는 40 22 status draw opcode가 rect index 0..6을 소비하는 "
                "normal HUD/status-panel 행을 확인했으므로 source rect 소비 방식은 강하다. "
                "하위 페이지 좌/우 화살표는 별도 handler 0x0040f78a가 0x4175d3 blit을 직접 호출해 "
                "status.cns rect #15/#16/#17/#18을 x=424/600, y=328에 그린다. "
                "같은 handler의 다른 branch는 active menu stack/index에 따라 status.cns rect #19/#20/#21/#22도 직접 그린다. "
                "상단 메뉴 선택 커서는 object VM 0x87 command가 object+0xa8[0x2f]를 읽어 "
                "status.cns #3을 x=440+value*32, y=72에 직접 그리는 코드로 확인했다. "
                "하위 선택 커서도 object VM 0x87 command가 object+0xa8[0x32/0x33/0x3a]를 읽어 "
                "status.cns #6을 x=424, y=144+value*32에 직접 그리는 코드로 확인했다. "
                "variant=2 계열은 같은 위치의 pressed source로 승격한다. #4는 메인에서 하위 메뉴로 들어간 동안의 상단 pressed 커서이고, #7은 하위에서 확인/선택창으로 들어간 동안의 하위 pressed 커서다. "
                "선택 byte 생산자는 일부 확인됐다. 0x8c는 enabled row 탐색 후 +0x32/+0x33/+0x37/+0x38/+0x3a를 쓰고, "
                "0x8d는 descriptor match로 +0x3a를 쓰며, 0x8f는 +0x3a 상태를 읽어 다른 local selector에 0/1 상태를 쓴다. "
                "다만 상단 +0x2f producer와 설명 +0x3d producer는 아직 분리하지 못했다."
            ),
        },
        "region2OutputModel": region2_output_model,
        "finalDepthSelectionModel": final_depth_selection_model,
        "rightMenuPayloadClassification": {
            "status": "normal-right-menu-content-payload-attached-by-top-object-sequence",
            "normalFieldPreviewUsesIconRows": True,
            "reason": (
                "top menu object sequence 0x004ddc6c가 0x004e8120 payload를 직접 attach한다. "
                "그 payload의 0x004e8158부터 40 24 icon marker row가 #0,#1,#2,#3,#8,#4 순서로 "
                "정확히 6개 이어지고, region #3 안의 32px 간격 상단 메뉴 위치와 맞는다. "
                "따라서 payload와 첫 6개 row는 normal-field top menu content로 승격한다. "
                "후속 40 24 row와 detail/list consumer는 아직 공유/전투/action 후보로 둔다."
            ),
        },
        "evidence": [
            "region #3/#4/#2 RECT와 window.cns template id는 EXE screen-region table 0x004548b0/0x00454b60에서 직접 온다.",
            "region #3은 416,0 224x96 template #2, region #4는 416,96 224x256 template #3, region #2는 416,352 224x128 template #4다.",
            "0x004e8158부터 40 24 row 6개가 icon.cns #0,#1,#2,#3,#8,#4 순서로 이어지며, 이 바이트열은 Hwanse2.exe 안에서 한 번만 발견된다.",
            "승격된 6개 icon row는 origin 416,0 / cursor 16,40 기준으로 x=432,464,496,528,560,592 y=40에 놓여 region #3 상단 패널 위치와 맞는다.",
            "top menu object sequence 0x004ddc6c가 payload 0x004e842e, 0x004e8120, 0x004e8190을 직접 attach한다. 따라서 0x004e8120은 더 이상 content-pending이 아니라 opener-pending 상태의 right menu content payload다.",
            "같은 object sequence가 child script 0x004dee18/#6, 0x004dee58/#3, 0x004dee98/#4를 slot 7/8/9에 구성하므로 좌측 대형창과 오른쪽 상/중단 메뉴창의 object-level 결합도 확인된다.",
            "slot 11 child 0x004deed8은 region index #5를 쓰지만 initializer 좌표가 416,352이고 template #4라서, 실제 화면에서는 region #2와 같은 하단 우측 정보창 위치에 놓이는 lower-right child로 승격한다.",
            "40 13 selector 0x2f/0x31/0x34 pointer group과 40 25/26/27/28/29/2b list 명령은 메뉴/list 계열 근거로 유지한다.",
            "기술 하위 4분류와 장비 하위 2분류는 40 13 pointer group에서 직접 온다. 도구/소지/모드/환경설정은 각각 40 26/28/29 list-table command와 기존 정적 테이블 해석을 연결해 미리보기한다.",
            "기술/장비의 하위 항목은 캐릭터별 경계를 유지한다. 기술은 skillReferences.character, 장비는 equipment record f25/equipTarget character+slot을 사용해 필터링한다.",
            "기술 목록의 아이콘과 도주/방어/장비 제공 기술은 battle_action_mapping의 player action row와 equipmentSkillBindings를 우선 근거로 사용한다. 웹 미리보기에서는 현재 착용 무기를 선택해 장비 조건 기술만 필터링한다.",
            "도구 #4 목록의 소모품 수량은 item table row가 아니라 0x004576ec/0x004576ed의 6개 {id,count} inventory slot에서 온다. source 6 handler 0x00410f23과 40 26 목록 consumer가 같은 버퍼를 읽는다.",
            "상단 메뉴 선택 인덱스는 handler 0x0040fb32가 현재 active menu object+0x2a를 좌우 입력으로 갱신하는 코드로 확인했다.",
            "캐릭터별 변화는 상단 아이콘 row보다 하위 목록 buffer 쪽에서 발생한다. 0x00421d3d/0x00421ef7/0x00422093/0x0042234e가 장비/아이템/목록 상태를 actor row와 보유/착용 상태에 맞춰 갱신한다.",
            "모드 하위 5항목은 0x0048bcaa mode/status table의 row 1..5이며, 각 row의 meta 0x0005001c는 icon.cns #28을 가리킨다.",
            "환경설정 하위 5항목은 0x0048c012 config table의 row 1..5이며, 각 row의 meta는 icon.cns #67/#68/#70/#69/#71을 가리킨다.",
            "환경설정의 마지막 선택창 문구/선택지는 0x004e86e0 selector 0x3a pointer group에서 직접 확인했다. config table row 1..5는 selector group #0..#4와 순서 대응한다.",
            "하위 패널 제목은 origin 416,96 기준 40 0e title cursor를 따르고, 목록 시작점은 모든 list command가 공유하는 440,136으로 맞춘다.",
            "하위 메뉴 페이지 전환 화살표는 handler 0x0040f78a가 page index/page count를 비교한 뒤 0x4175d3 blit에 status.cns encoded source #0x0f/#0x11과 dest x=424/600 y=328을 넘기는 코드로 직접 확인했다.",
            "handler 0x0040f78a의 별도 branch 0x0040f880..0x0040f92b는 active descriptor count 0x004576e8과 active slot 0x0059e33e를 비교해 status.cns #19/#20/#21/#22를 직접 blit한다.",
            "region #2와 같은 416,352 원점의 직접 display-VM 출력 블록 4개를 확인했다. 0x004e807c는 평상시 HUD 정보, 0x004e86cc는 확인/환경설정, 0x004e89b2는 저장 데이터 상세, 0x004e8e24는 선택 항목 설명 패널이다.",
            "0x004e8e24 아래의 selector 0x2f/0x58 그룹은 아타호/린샹/스마슈 기술, 장비, 도구/소지품, 모드/상태, 환경설정 설명 텍스트를 직접 가리킨다. 따라서 region #2 내부 출력은 text producer pending에서 selected-index latch pending으로 축소한다.",
            "하위 메뉴 커서는 object VM 0x87 command 0x004dde10/0x004ddecc/0x004ddf80 등이 status.cns #6을 x=424, y=144+selector*32에 직접 그리는 코드로 확인했다.",
            "후속 40 24 icon/marker row는 icon.cns 32x32 cell id와 맞지만 shared/action 성격이 섞여 있어 아직 normal-field detail preview로 승격하지 않는다.",
            "normal-field ESC/X 메뉴 opener는 descriptor stack/add-remove/input 경로까지는 잡혔지만, 어느 평상시 root가 0x004ddc6c object sequence를 여는지는 아직 pending이다.",
            "status.cns 상단 커서 source rect #3(96,0,16x16)은 object VM 0x87 command 0x004ddcf0과 helper 0x00421406으로 직접 확인된다. 좌표는 object+0xa8[0x2f]를 사용해 x=440+value*32, y=72가 된다.",
            "상단 pressed 계열은 object VM 0x87 variant=2가 status.cns #4를 같은 좌표 공식에 그리는 코드와 메인->하위 진입 관찰이 일치한다. 하위 pressed 계열은 status.cns #7을 같은 세로 공식에 그리며, 하위->확인창 진입 관찰과 일치한다.",
            "좌측 region #6 상태창 표현부는 status_menu_ui_expression_review에서 별도로 다룬다.",
        ],
        "pending": [
            "평상시 ESC/X 입력이 어떤 event/root에서 0x004ddc6c top-menu object sequence를 여는지",
            "region #2 선택 항목 설명 pointer group의 runtime selected-index latch",
            "selector 0x3a 마지막 선택창의 선택값 write 대상과 실제 설정/종료 실행 consumer",
            "마지막 선택창이 region #2만 쓰는지, 별도 compact region을 추가로 쓰는지",
            "0x00431fe8/0x00432541 stack add/remove를 호출하는 normal-field opener",
            "page arrow #15/#16/#17/#18 중 stream+1 bit0가 선택하는 alternate 상태의 정확한 UI 명칭",
        ],
        "missingTemplateRegions": missing,
    }


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:
    right_payload = payload.get("rightMenuPayload") or {}
    region_rows = [
        [
            f"#{row['index']}",
            f"{row['rect'].get('x')},{row['rect'].get('y')} · {row['rect'].get('w')}x{row['rect'].get('h')}",
            f"template #{row.get('templateIndex')}",
            f"<code>{h(row.get('resourceIdHex'))}</code>",
            h(row.get("role")),
        ]
        for row in payload["regions"]
    ]
    evidence_items = "".join(f"<li>{h(item)}</li>" for item in payload["evidence"])
    pending_items = "".join(f"<li>{h(item)}</li>" for item in payload["pending"])
    text_rows = [
        [
            f"<code>{h(row.get('vaHex'))}</code>",
            f"{row.get('origin', {}).get('x')},{row.get('origin', {}).get('y')}",
            f"{row.get('x')},{row.get('y')}",
            h(row.get("normalizedText") or row.get("text")),
            f"<code>{h(row.get('rawTextHex'))}</code>",
        ]
        for row in right_payload.get("textRows", [])
    ]
    raw_text_rows = [
        [
            f"<code>{h(row.get('vaHex'))}</code>",
            h(row.get("rawTextNormalized") or row.get("text")),
            f"<code>{h(row.get('rawHex'))}</code>",
        ]
        for row in right_payload.get("rawTextRows", [])
    ]
    pointer_group_rows = [
        [
            f"<code>{h(group.get('vaHex'))}</code>",
            f"<code>{h(group.get('selectorHex'))}</code>",
            h(group.get("count")),
            "<br>".join(
                f"<code>{h(label.get('pointerVaHex'))}</code> {h(label.get('label'))}"
                for label in group.get("labels", [])
            ),
        ]
        for group in right_payload.get("pointerGroups", [])
    ]
    list_command_rows = [
        [
            f"<code>{h(row.get('vaHex'))}</code>",
            f"<code>40 {h(str(row.get('opcodeHex')).replace('0x', ''))}</code>",
            h(row.get("opcodeLabel")),
            h(row.get("selector")),
            f"<code>{h(row.get('tableVaHex'))}</code>",
            h(row.get("tableLabel")),
            f"{row.get('absoluteX')},{row.get('absoluteY')}",
        ]
        for row in right_payload.get("listCommands", [])
    ]
    current_mode_label = payload.get("currentModeLabelModel") or {}
    current_mode_label_rows = [
        [
            f"<code>{h(row.get('vaHex'))}</code>",
            f"<code>{h(row.get('rawHex'))}</code>",
            h(row.get("opcodeLabel")),
            f"<code>{h(row.get('tableVaHex'))}</code>",
            f"{row.get('absoluteX')},{row.get('absoluteY')}",
        ]
        for row in current_mode_label.get("rows", [])
    ]
    region2_model = payload.get("region2OutputModel") or {}
    region2_block_rows = []
    for block in region2_model.get("blocks", []):
        inline = "<br>".join(
            f"<code>{h(row.get('vaHex'))}</code> {h(row.get('text'))}"
            for row in (block.get("inlineTexts") or [])[:10]
        )
        selectors = "<br>".join(
            f"<code>{h(group.get('vaHex'))}</code> {h(group.get('label'))} "
            f"selector <code>{h(group.get('selectorHex'))}</code> count {h(group.get('count'))}"
            for group in (block.get("selectorGroups") or [])[:6]
        )
        if block.get("descriptionGroups"):
            selectors += ("<br>" if selectors else "") + "<strong>description groups</strong>"
        region2_block_rows.append(
            [
                f"<code>{h(block.get('vaHex'))}</code>",
                h(block.get("role")),
                h(block.get("evidence")),
                inline or "-",
                selectors or "-",
            ]
        )
    region2_desc_group_rows = []
    for group in region2_model.get("descriptionGroups", []):
        samples = "<br>".join(
            f"#{h(sample.get('index'))} <code>{h(sample.get('pointerVaHex'))}</code> {h(sample.get('text'))}"
            for sample in (group.get("samples") or [])[:12]
        )
        region2_desc_group_rows.append(
            [
                f"<code>{h(group.get('vaHex'))}</code>",
                h(group.get("label")),
                f"<code>{h(group.get('selectorHex'))}</code>",
                h(group.get("count")),
                samples,
            ]
        )
    region2_origin_rows = [
        [f"<code>{h(row.get('vaHex'))}</code>", f"<code>{h(row.get('fileOffsetHex'))}</code>"]
        for row in region2_model.get("originHits", [])
    ]
    desc_selector_model = region2_model.get("descriptionSelectorModel") or {}
    desc_selector_flow_rows = []
    for row in desc_selector_model.get("flow", []):
        detail = h(row.get("meaning"))
        if row.get("paths"):
            detail += "<br>" + "<br>".join(f"- {h(item)}" for item in row.get("paths", []))
        desc_selector_flow_rows.append(
            [
                h(row.get("step")),
                f"<code>{h(row.get('bytes'))}</code>",
                f"<code>{h(row.get('handlerVaHex'))}</code>",
                detail,
            ]
        )
    desc_latch_rows = []
    for row in desc_selector_model.get("latchWriters", []):
        detail = h(row.get("meaning"))
        if row.get("evidence"):
            detail += f"<br><span class=\"muted\">{h(row.get('evidence'))}</span>"
        if row.get("cases"):
            detail += "<br>" + "<br>".join(f"- {h(item)}" for item in row.get("cases", []))
        desc_latch_rows.append(
            [
                f"<code>{h(row.get('command'))}</code>",
                f"<code>{h(row.get('handlerVaHex'))}</code>",
                detail,
            ]
        )
    desc_known_state_rows = [
        [h(key), f"<code>{h(value)}</code>"]
        for key, value in (desc_selector_model.get("knownState") or {}).items()
    ]
    input_bridge = desc_selector_model.get("inputContextBridgeModel") or {}
    selector_consumer = input_bridge.get("selectorConsumerEvidence") or {}
    input_bridge_rows = [
        ["status", h(input_bridge.get("status"))],
        ["handler", f"<code>{h(input_bridge.get('handlerVaHex'))}</code>"],
        ["runtime object pointer array", f"<code>{h(input_bridge.get('runtimeObjectPointerArray'))}</code>"],
        ["scope warning", h(input_bridge.get("scopeWarning"))],
        [
            "mode 0 store",
            f"<code>{h((input_bridge.get('mode0Store') or {}).get('commandShape'))}</code><br>"
            f"{h((input_bridge.get('mode0Store') or {}).get('effect'))}<br>"
            f"<span class=\"muted\">{h((input_bridge.get('mode0Store') or {}).get('disassemblyEvidence'))}</span>",
        ],
        [
            "mode 1 load",
            f"<code>{h((input_bridge.get('mode1Load') or {}).get('commandShape'))}</code><br>"
            f"{h((input_bridge.get('mode1Load') or {}).get('effect'))}<br>"
            f"<span class=\"muted\">{h((input_bridge.get('mode1Load') or {}).get('disassemblyEvidence'))}</span>",
        ],
        ["description selector dependency", h(input_bridge.get("descriptionSelectorDependency"))],
        [
            "selector consumer evidence",
            f"<code>{h(selector_consumer.get('commandVaHex'))}</code> "
            f"<code>{h(selector_consumer.get('commandRawHex'))}</code><br>"
            f"{h(selector_consumer.get('meaning'))}<br>"
            f"<span class=\"muted\">{h(selector_consumer.get('resolvedDestFormula'))} · "
            f"{h(selector_consumer.get('classification'))}</span>",
        ],
    ]
    input_bridge_slot_rows = [
        [
            h(row.get("slot")),
            f"<code>{h(row.get('childScriptVaHex'))}</code>",
            f"#{h(row.get('regionIndex'))}",
            f"template #{h(row.get('templateIndex'))}",
            h(row.get("visiblePosition")),
            h(row.get("role")),
            h(row.get("note")),
        ]
        for row in input_bridge.get("topMenuSlots", [])
    ]
    input_bridge_remaining_rows = [[h(item)] for item in input_bridge.get("remaining", [])]
    persistent_state = desc_selector_model.get("descriptorPersistentState") or {}
    persistent_state_rows = [
        ["status", h(persistent_state.get("status"))],
        ["handler", f"<code>{h(persistent_state.get('handlerVaHex'))}</code>"],
        ["row base", f"<code>{h(persistent_state.get('rowBase'))}</code>"],
        ["load case", h(persistent_state.get("loadCase"))],
        ["save case", h(persistent_state.get("saveCase"))],
        ["interpretation", h(persistent_state.get("interpretation"))],
    ]
    visible_list_model = desc_selector_model.get("visibleListSourceModel") or {}
    visible_list_rows = [
        [
            f"<code>{h(row.get('command'))}</code>",
            f"<code>{h(row.get('handlerVaHex'))}</code>",
            h(row.get("source")),
            h(row.get("writes")),
            h(row.get("meaning")),
        ]
        for row in visible_list_model.get("rows", [])
    ]
    attachment_sites = desc_selector_model.get("attachmentSites") or {}
    attachment_site_rows = [
        ["status", h(attachment_sites.get("status"))],
        ["wrapper", f"<code>{h(attachment_sites.get('wrapperVaHex'))}</code>"],
        ["inner entry", f"<code>{h(attachment_sites.get('innerEntryVaHex'))}</code>"],
        ["data refs", h(attachment_sites.get("dataRefCount"))],
        ["sample refs", "<br>".join(f"<code>{h(ref)}</code>" for ref in attachment_sites.get("sampleRefs", []))],
        ["interpretation", h(attachment_sites.get("interpretation"))],
    ]
    active_descriptor_sample_rows = []
    for row in desc_selector_model.get("activeDescriptorRowsSample", []):
        values = []
        for key, value in row.items():
            if key in {"descriptor", "likelyActor"}:
                continue
            values.append(f"{h(key)}: <code>{h(', '.join(str(v) for v in value))}</code>")
        active_descriptor_sample_rows.append(
            [
                h(row.get("descriptor")),
                h(row.get("likelyActor")),
                "<br>".join(values),
            ]
        )
    desc_remaining_rows = [[h(item)] for item in desc_selector_model.get("remainingUnconfirmed", [])]
    submenu_rows = []
    for row in payload.get("menuModel", []):
        detail = row.get("detail") or []
        labels = [entry.get("label") if isinstance(entry, dict) else str(entry) for entry in detail]
        sources = sorted(
            {
                str(entry.get("source"))
                for entry in detail
                if isinstance(entry, dict) and entry.get("source")
            }
        )
        submenu_rows.append(
            [
                h(row.get("label")),
                h(row.get("iconId")),
                h(len(detail)),
                h(", ".join(labels[:10]) + (" ..." if len(labels) > 10 else "")),
                "<br>".join(h(source) for source in sources[:4]),
            ]
        )
    top_selection = payload.get("topMenuSelectionModel") or {}
    top_selection_rows = [
        ["status", h(top_selection.get("status"))],
        ["handler", f"<code>{h(top_selection.get('handlerVaHex'))}</code>"],
        ["active slot", f"<code>{h(top_selection.get('activeSlotIndexVaHex'))}</code> → <code>{h(top_selection.get('slotPointerArrayVaHex'))}</code>"],
        ["selected field", f"<code>{h(top_selection.get('selectedFieldOffsetHex'))}</code>"],
        ["left/right", f"<code>{h(top_selection.get('leftBranchVaHex'))}</code><br><code>{h(top_selection.get('rightBranchVaHex'))}</code>"],
        ["range", h(top_selection.get("range"))],
        ["interpretation", h(top_selection.get("interpretation"))],
    ]
    helper_model = payload.get("menuStateHelperModel") or {}
    helper_rows = [
        [
            f"<code>{h(row.get('vaHex'))}</code>",
            h(row.get("role")),
            h(row.get("evidence")),
        ]
        for row in helper_model.get("helpers", [])
    ]
    top_object = payload.get("topMenuObjectConstruction") or {}
    top_object_attached_rows = [
        [
            f"<code>{h(row.get('vaHex'))}</code>",
            h(row.get("role")),
            h(row.get("evidence")),
        ]
        for row in top_object.get("attachedPayloads", [])
    ]
    top_object_child_rows = [
        [
            h(row.get("slot")),
            f"<code>{h(row.get('scriptVaHex'))}</code>",
            f"#{h(row.get('regionIndex'))}",
            f"template #{h(row.get('templateIndex', '-'))}",
            f"{row.get('rect', {}).get('x')},{row.get('rect', {}).get('y')} · {row.get('rect', {}).get('w')}x{row.get('rect', {}).get('h')}",
            (
                f"{row.get('regionTableRect', {}).get('x')},{row.get('regionTableRect', {}).get('y')} · "
                f"{row.get('regionTableRect', {}).get('w')}x{row.get('regionTableRect', {}).get('h')}"
                if row.get("regionTableRect")
                else "-"
            ),
            h(row.get("role")),
        ]
        for row in top_object.get("childRegions", [])
    ]
    top_object_pending_rows = [[h(item)] for item in top_object.get("notPromoted", [])]
    normal_top_icon_rows = [
        [
            f"<code>{h(row.get('vaHex'))}</code>",
            h(row.get("normalTopMenuIndex")),
            h(row.get("normalTopMenuLabel")),
            h(row.get("iconId")),
            h(row.get("mode")),
            f"{row.get('absoluteX')},{row.get('absoluteY')}",
            f"{row.get('previewX')},{row.get('previewY')}",
            f"<code>{h(row.get('rawHex'))}</code>",
        ]
        for row in right_payload.get("normalTopIconRows", [])
    ]
    shared_icon_rows = [
        [
            f"<code>{h(row.get('vaHex'))}</code>",
            h(row.get("iconId")),
            h(row.get("mode")),
            f"{row.get('absoluteX')},{row.get('absoluteY')}",
            f"{row.get('previewX')},{row.get('previewY')}",
            f"<code>{h(row.get('rawHex'))}</code>",
        ]
        for row in right_payload.get("sharedCandidateIconRows", [])
    ]
    cursor_model = payload.get("cursorModel") or {}
    top_cursor = cursor_model.get("topCursor") or {}
    top_cursor_rows = [
        ["source rect", f"#{h(top_cursor.get('sourceRectIndex'))} {h(top_cursor.get('source'))}"],
        ["dest formula", h(top_cursor.get("movement"))],
        ["object VM command", f"<code>{h(top_cursor.get('objectVmCommandVaHex'))}</code> <code>{h(top_cursor.get('objectVmCommandRawHex'))}</code>"],
        ["handler/helper", f"<code>{h(top_cursor.get('objectVmHandlerVaHex'))}</code> / <code>{h(top_cursor.get('drawHelperVaHex'))}</code> / <code>{h(top_cursor.get('blitVaHex'))}</code>"],
        ["selector source", f"<code>{h(top_cursor.get('selectorSource'))}</code>"],
        ["evidence", h(top_cursor.get("evidenceLevel"))],
    ]
    object_vm_87_rows = [
        [
            f"<code>{h(row.get('vaHex'))}</code>",
            f"<code>{h(row.get('rawHex'))}</code>",
            h(row.get("classification")),
            h(row.get("variant")),
            f"<code>{h(row.get('selectorOffsetHex'))}</code><br>{h(row.get('selectorLabel'))}",
            h(row.get("modeKind")),
            f"#{h(row.get('sourceRectIndex'))}",
            h(row.get("destFormula")),
        ]
        for row in cursor_model.get("objectVm87CursorRows", [])
    ]
    selector_summary = cursor_model.get("objectVmSelectorProducerSummary") or {}
    selector_state_rows = [
        [
            f"<code>{h(row.get('vaHex'))}</code>",
            f"<code>{h(row.get('opcodeHex'))}</code>",
            h(row.get("kind")),
            h(row.get("role")),
            f"<code>{h(row.get('sourceIdHex'))}</code><br>{h(row.get('userMenuName') or row.get('sourceLabel'))}",
            f"<code>{h(row.get('targetOffsetHex'))}</code><br>{h(row.get('targetLabel'))}",
            f"<code>{h(row.get('selectorOffsetHex'))}</code><br>{h(row.get('selectorLabel'))}",
            h(row.get("baseArray")),
            f"<code>{h(row.get('rawHex'))}</code>",
            h(row.get("meaning")),
        ]
        for row in cursor_model.get("objectVmSelectorStateRows", [])
    ]
    availability_source_rows = [
        [
            f"<code>{h(source_id_hex)}</code>",
            h(model.get("label")),
            h(model.get("userMenuName")),
            f"<code>{h(model.get('handlerVaHex'))}</code>",
            h(model.get("entryCount")),
            h(model.get("confidence")),
            h(model.get("evidence")),
        ]
        for source_id_hex, model in (cursor_model.get("objectVmAvailabilitySourceModel") or {}).items()
    ]
    selector_summary_rows = [
        ["status", h(selector_summary.get("status"))],
        [
            "confirmed producer opcodes",
            "<br>".join(
                f"<code>{h(opcode)}</code> {h(text)}"
                for opcode, text in (selector_summary.get("confirmedProducerOpcodes") or {}).items()
            ),
        ],
        ["confirmed cursor targets", h(", ".join(selector_summary.get("confirmedCursorTargets") or []))],
        ["confirmed local targets", h(", ".join(selector_summary.get("confirmedLocalTargets") or []))],
        ["still pending", "<br>".join(h(item) for item in selector_summary.get("stillPending") or [])],
    ]
    status_22_rows = [
        [
            f"<code>{h(row.get('vaHex'))}</code>",
            f"<code>{h(row.get('fileOffsetHex'))}</code>",
            f"{row.get('origin', {}).get('x')},{row.get('origin', {}).get('y')}",
            f"{row.get('cursor', {}).get('x')},{row.get('cursor', {}).get('y')}",
            f"{row.get('absolute', {}).get('x')},{row.get('absolute', {}).get('y')}",
            h(row.get("statusArg0")),
            h(row.get("statusRectIndex")),
            f"<code>{h(row.get('rawHex'))}</code>",
        ]
        for row in cursor_model.get("status22Rows", [])
    ]
    direct_arrow_rows: list[list[str]] = []
    page_arrows = cursor_model.get("pageArrows") or {}
    for key in ("left", "leftAlt", "right", "rightAlt"):
        arrow = page_arrows.get(key) or {}
        if arrow:
            source = arrow.get("source") or {}
            direct_arrow_rows.append(
                [
                    "detail page",
                    h(key),
                    h(arrow.get("sourceRectIndex")),
                    h(arrow.get("encodedSourceIdHex")),
                    f"{source.get('x')},{source.get('y')} {source.get('w')}x{source.get('h')}",
                    h(arrow.get("evidenceVaHex")),
                    h(page_arrows.get("visibility", {}).get("left" if key.startswith("left") else "right", "")),
                ]
            )
    stack_arrows = cursor_model.get("stackArrows") or {}
    for key in ("leftPair", "rightPair"):
        pair = stack_arrows.get(key) or {}
        ids = pair.get("sourceRectIndices") or []
        encoded = pair.get("encodedSourceIdsHex") or []
        rects = pair.get("sourceRects") or []
        rect_text = ", ".join(
            f"#{ids[i]} {rect.get('x')},{rect.get('y')} {rect.get('w')}x{rect.get('h')}"
            for i, rect in enumerate(rects)
            if i < len(ids)
        )
        direct_arrow_rows.append(
            [
                "active stack",
                h(key),
                h(", ".join(str(value) for value in ids)),
                h(", ".join(str(value) for value in encoded)),
                h(rect_text),
                h(pair.get("evidenceVaHex")),
                h(stack_arrows.get("visibility", {}).get("left" if key.startswith("left") else "right", "")),
            ]
        )
    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:#f5f6f8; --panel:#fff; --line:#d8dee8; --text:#20242b; --muted:#667085; --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; }}
    .links, .tools {{ display:flex; flex-wrap:wrap; gap:8px; margin:10px 0 14px; }}
    .chip, button {{ min-height:32px; padding:6px 10px; border:1px solid var(--line); border-radius:7px; background:#fff; color:var(--text); cursor:pointer; }}
    button.is-active {{ border-color:#2459a6; background:#e9f0ff; color:#173f78; font-weight:750; }}
    .chip {{ display:inline-flex; align-items:center; background:#eef2f7; }}
    .panel {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:12px; margin:12px 0; }}
    .canvas-box {{ display:inline-block; max-width:100%; padding:10px; border:1px solid var(--line); border-radius:8px; background:#111; overflow:auto; }}
    #preview {{ display:block; width:min(100%, 960px); height:auto; image-rendering:pixelated; }}
    .mobile-pad {{ display:flex; flex-wrap:wrap; align-items:center; gap:14px; margin:12px 0 0; }}
    .mobile-pad button {{ min-width:56px; min-height:44px; padding:8px 12px; font-weight:750; touch-action:manipulation; user-select:none; }}
    .dpad {{ display:grid; grid-template-columns:repeat(3,56px); grid-template-rows:repeat(3,44px); gap:6px; align-items:center; justify-items:center; }}
    .dpad .empty {{ width:56px; height:44px; }}
    .action-pad {{ display:flex; gap:8px; }}
    .action-pad button {{ min-width:72px; }}
    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; }}
    .muted {{ color:var(--muted); }}
    ul {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; margin:8px 0 16px; padding:12px 16px 12px 30px; }}
    .tag {{ display:inline-flex; align-items:center; min-height:24px; padding:3px 7px; border-radius:6px; border:1px solid var(--line); background:#eef2f7; color:#344054; font-size:12px; }}
    @media (max-width:840px) {{ main {{ width:min(100vw - 14px,1180px); }} table {{ display:block; overflow-x:auto; }} }}
  </style>
</head>
<body>
<main>
  <h1>우측 메뉴 패널 UI 리뷰</h1>
  <p>ESC/X 상단 메뉴 화면에서 오른쪽 상단/중단 메뉴 패널과 평상시 HUD 오른쪽 정보 패널을 같은 640x480 기준으로 검토합니다. 첫 6개의 <code>40 24</code> 행은 <code>icon.cns</code> #0,#1,#2,#3,#8,#4 상단 메뉴 아이콘으로 확정했습니다.</p>
  <div class="links">
    <a class="chip" href="index.html">홈</a>
    <a class="chip" href="status_menu_ui_expression_review.html">좌측 상태창</a>
    <a class="chip" href="ui_window_review.html">window.cns</a>
    <a class="chip" href="menu_descriptor_stack_review.html">descriptor stack</a>
    <a class="chip" href="../out/menu_right_panel_ui_review.json">JSON</a>
  </div>
  <section class="panel">
    <span class="tag">region/template EXE 확정</span>
    <span class="tag">상단 icon.cns 6개 확정</span>
    <span class="tag">0x004ddc6c payload attach 확인</span>
    <span class="tag">status.cns 커서 source 확인</span>
    <span class="tag">평상시 opener pending</span>
    <span class="tag">후속 40 24 후보 유지</span>
    <label class="chip"><input id="showSharedPayload" type="checkbox"> 공유/전투 후보 payload overlay</label>
    <label class="chip"><input id="showDebugRegions" type="checkbox"> region/debug overlay</label>
    <div class="tools" id="menuButtons"></div>
    <div class="tools" id="modeButtons"></div>
    <div class="canvas-box"><canvas id="preview" width="640" height="480"></canvas></div>
    <div class="mobile-pad" aria-label="모바일 입력 버튼">
      <div class="dpad">
        <span class="empty"></span>
        <button type="button" data-menu-command="up" aria-label="위">↑</button>
        <span class="empty"></span>
        <button type="button" data-menu-command="left" aria-label="왼쪽">←</button>
        <span class="empty"></span>
        <button type="button" data-menu-command="right" aria-label="오른쪽">→</button>
        <span class="empty"></span>
        <button type="button" data-menu-command="down" aria-label="아래">↓</button>
        <span class="empty"></span>
      </div>
      <div class="action-pad">
        <button type="button" data-menu-command="enter">Enter</button>
        <button type="button" data-menu-command="esc">Esc</button>
      </div>
    </div>
  </section>
  <h2>근거</h2>
  <ul>{evidence_items}</ul>
  <h2>상단 메뉴별 하위 항목</h2>
  {table(["menu", "icon", "count", "items", "source"], submenu_rows)}
  <h2>상단 선택 인덱스</h2>
  {table(["item", "evidence"], top_selection_rows)}
  <h2>목록/보유 상태 helper</h2>
  <p>{h(helper_model.get("interpretation"))}</p>
  {table(["VA", "role", "evidence"], helper_rows)}
  <h2>top menu object construction</h2>
  <p><code>{h(top_object.get("sequenceVaHex"))}</code> object sequence가 오른쪽 메뉴 payload와 각 window child를 직접 붙입니다. 다만 평상시 ESC/X 입력이 이 sequence를 여는 upstream root는 아직 미확정입니다.</p>
  {table(["payload", "role", "evidence"], top_object_attached_rows)}
  {table(["slot", "child script", "region", "template", "visible rect", "region table rect", "role"], top_object_child_rows)}
  {table(["not promoted"], top_object_pending_rows)}
  <h2>region binding</h2>
  {table(["region", "rect", "template", "resource", "role"], region_rows)}
  <h2>EXE text rows</h2>
  {table(["VA", "origin", "cursor", "text", "raw"], text_rows)}
  <h2>raw text labels</h2>
  {table(["VA", "text", "raw"], raw_text_rows)}
  <h2>40 13 pointer groups</h2>
  {table(["VA", "selector", "count", "labels"], pointer_group_rows)}
  <h2>list/table commands</h2>
  {table(["VA", "opcode", "role", "selector", "table", "table label", "abs"], list_command_rows)}
  <h2>상단 현재 모드 라벨</h2>
  <p>{h(current_mode_label.get("interpretation"))}</p>
  {table(["VA", "raw", "role", "table", "abs"], current_mode_label_rows)}
  <h2>region #2 하단 출력 블록</h2>
  <p>{h(region2_model.get("interpretation"))}</p>
  {table(["origin VA", "role", "evidence", "inline text", "selector groups"], region2_block_rows)}
  <h3>416,352 origin hits</h3>
  {table(["VA", "file offset"], region2_origin_rows)}
  <h3>선택 항목 설명 pointer groups</h3>
  {table(["VA", "label", "selector", "count", "samples"], region2_desc_group_rows)}
  <h3>선택 설명 selector chain</h3>
  <p>
    상태:
    <span class="tag">{h(desc_selector_model.get("status"))}</span>
    display interpreter <code>{h(desc_selector_model.get("displayInterpreterVaHex"))}</code>,
    handler table <code>{h(desc_selector_model.get("displayHandlerTableVaHex"))}</code>
  </p>
  {table(["step", "bytes", "handler", "meaning"], desc_selector_flow_rows)}
  <h3>object+0x58 description index writers</h3>
  {table(["command", "handler", "meaning"], desc_latch_rows)}
  <h3>description selector state</h3>
  {table(["state", "address / field"], desc_known_state_rows)}
  <h3>object+0xa8 input context bridge</h3>
  {table(["item", "evidence"], input_bridge_rows)}
  {table(["slot", "child script", "region", "template", "visible position", "role", "note"], input_bridge_slot_rows)}
  {table(["remaining"], input_bridge_remaining_rows)}
  <h3>descriptor-persistent selector state</h3>
  {table(["item", "evidence"], persistent_state_rows)}
  <h3>visible list source 공유</h3>
  <p>{h(visible_list_model.get("interpretation"))}</p>
  {table(["command", "handler", "source", "writes", "meaning"], visible_list_rows)}
  <h3>description wrapper attach sites</h3>
  {table(["item", "evidence"], attachment_site_rows)}
  <h3>active descriptor row sample</h3>
  {table(["descriptor", "likely actor", "menu/list id bytes"], active_descriptor_sample_rows)}
  <h3>description selector remaining unconfirmed</h3>
  {table(["remaining"], desc_remaining_rows)}
  <h2>상단 메뉴 icon.cns 확정 row</h2>
  <p>아래 6개 행은 같은 EXE 바이트열에서 연속으로 확인되며 region #3 상단 위치와 맞습니다.</p>
  {table(["VA", "index", "label", "icon id", "mode", "abs", "preview", "raw"], normal_top_icon_rows)}
  <h2>후속 40 24 공유/action 후보</h2>
  <p>아래 행도 <code>icon.cns</code> cell id와 맞지만, 아직 평상시 detail 메뉴 표시로 승격하지 않은 후보입니다.</p>
  {table(["VA", "icon id", "mode", "abs", "preview", "raw"], shared_icon_rows)}
  <h2>status.cns 커서/상태 draw 후보</h2>
  <p>{h(cursor_model.get("interpretation"))}</p>
  <h3>상단 메뉴 커서 직접 근거</h3>
  {table(["item", "evidence"], top_cursor_rows)}
  <h3>object VM 0x87 커서 명령</h3>
  <p>handler <code>0x0040af7e</code>가 linked context object의 selector byte를 읽어 <code>status.cns</code> cursor/source를 직접 그리는 명령입니다. 상단/하위 메뉴 커서 위치는 이 표의 공식으로 확정했습니다.</p>
  {table(["VA", "raw", "classification", "variant", "selector", "mode", "source rect", "dest formula"], object_vm_87_rows)}
  <h3>object VM selector producer/support 명령</h3>
  <p><code>0x87</code>은 소비자이므로, 아래 표는 같은 object stream 범위에서 selector byte를 만들거나 검사하는 명령만 command shape로 필터링한 것입니다.</p>
  {table(["item", "evidence"], selector_summary_rows)}
  <h3>0x8a availability source id 사용자 메뉴명</h3>
  {table(["source", "label", "user menu", "handler", "count", "confidence", "evidence"], availability_source_rows)}
  {table(["VA", "op", "kind", "role", "source menu", "target", "source selector", "base/global", "raw", "meaning"], selector_state_rows)}
  <h3>직접 blit 화살표</h3>
  {table(["family", "role", "rect index", "encoded source", "source rect", "evidence", "visibility"], direct_arrow_rows)}
  <h3>40 22 opcode 후보</h3>
  {table(["VA", "file", "origin", "cursor", "absolute", "arg0", "rect index", "raw"], status_22_rows)}
  <h2>보류</h2>
  <ul>{pending_items}</ul>
</main>
<script src="engine/cns/renderer.js"></script>
<script>
(function () {{
  const payload = {json.dumps(payload, ensure_ascii=False)};
  const canvas = document.getElementById("preview");
  const ctx = canvas.getContext("2d");
  ctx.imageSmoothingEnabled = false;
  let selectedMenu = 0;
  let selectedActor = "아타호";
  let selectedPage = 0;
  let selectedDetail = 0;
  let selectedWeaponByActor = {{"아타호": "맨주먹", "린샹": "고양이 발톱", "스마슈": "닌자도"}};
  let selectedModeByActor = {{"아타호": "보통", "린샹": "보통", "스마슈": "보통"}};
  let submenuActive = false;
  let finalDepthActive = false;
  let finalChoice = 0;
  const regionById = new Map(payload.regions.map((row) => [Number(row.index), row]));
  const templateById = new Map(payload.templates.map((row) => [Number(row.index), row]));
  let sheetCanvases = {{}};
  let showSharedPayload = false;
  let showDebugRegions = false;

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

  function drawTemplate(windowCanvas, template, x, y, clip) {{
    if (!windowCanvas || !template) return false;
    const cell = 16;
    const columns = Math.max(1, Math.floor(windowCanvas.width / cell));
    const width = Number(template.width_tiles || 0);
    const height = Number(template.height_tiles || 0);
    const ids = template.tile_ids || [];
    ctx.save();
    if (clip) {{
      ctx.beginPath();
      ctx.rect(clip.x, clip.y, clip.w, clip.h);
      ctx.clip();
    }}
    for (let row = 0; row < height; row += 1) {{
      for (let col = 0; col < width; col += 1) {{
        const id = Number(ids[row * width + col]);
        const sx = (id % columns) * cell;
        const sy = Math.floor(id / columns) * cell;
        ctx.drawImage(windowCanvas, sx, sy, cell, cell, x + col * cell, y + row * cell, cell, cell);
      }}
    }}
    ctx.restore();
    return true;
  }}

  function drawIconCell(iconCanvas, iconId, x, y, scale = 1) {{
    if (!iconCanvas || iconId == null) return;
    const cell = 32;
    const columns = Math.max(1, Math.floor(iconCanvas.width / cell));
    const id = Number(iconId);
    const sx = (id % columns) * cell;
    const sy = Math.floor(id / columns) * cell;
    ctx.drawImage(iconCanvas, sx, sy, cell, cell, x, y, cell * scale, cell * scale);
  }}

  function drawGridIcon(icon, x, y, size = 20) {{
    if (!icon) return false;
    const cns = icon.cns || "";
    const canvas = cns === "item.cns" ? sheetCanvases.item : cns === "status.cns" ? sheetCanvases.status : sheetCanvases.icon;
    if (!canvas) return false;
    const sx = Number(icon.x || 0);
    const sy = Number(icon.y || 0);
    const sw = Number(icon.w || 32);
    const sh = Number(icon.h || 32);
    ctx.drawImage(canvas, sx, sy, sw, sh, x, y, size, size);
    return true;
  }}

  function hasGridIcon(entry) {{
    return Boolean(entry && typeof entry === "object" && entry.icon);
  }}

  function drawStatusRect(rect, x, y) {{
    const statusCanvas = sheetCanvases.status;
    if (!statusCanvas || !rect) return false;
    const sx = Number(rect.x || 0);
    const sy = Number(rect.y || 0);
    const sw = Number(rect.w || 16);
    const sh = Number(rect.h || 16);
    ctx.drawImage(statusCanvas, sx, sy, sw, sh, x, y, sw, sh);
    return true;
  }}

  function entryLabel(entry) {{
    return typeof entry === "string" ? entry : String(entry?.label || "");
  }}

  function entrySummary(entry) {{
    return typeof entry === "string" ? "" : String(entry?.summary || "");
  }}

  function entrySource(entry) {{
    return typeof entry === "string" ? "" : String(entry?.source || "");
  }}

  function entryQuantityDisplay(entry) {{
    if (!entry || typeof entry !== "object" || entry.quantityKind !== "consumable-count") return "";
    if (entry.quantityDisplay !== undefined) return String(entry.quantityDisplay);
    if (entry.quantityPreview !== undefined) return `×${{Number(entry.quantityPreview || 0)}}`;
    return "";
  }}

  function drawEntryQuantity(entry, y, selected) {{
    const quantity = entryQuantityDisplay(entry);
    if (!quantity) return;
    const region = regionById.get(4)?.rect || {{ x: 416, y: 96, w: 224, h: 256 }};
    const font = "15px Gulim, 'Malgun Gothic', sans-serif";
    ctx.save();
    ctx.font = font;
    const width = ctx.measureText(quantity).width;
    ctx.restore();
    text(quantity, Number(region.x || 416) + Number(region.w || 224) - 18 - width, y + Number(payload.rightMenuLayout?.detailList?.textYOffset || 8), {{
      color: selected ? "#fff5a8" : "#f2ecd2",
      font,
    }});
  }}

  function wrapCanvasText(value, maxWidth, font) {{
    const rawLines = String(value || "").split(/\\s*\\/\\s*|\\n/g).filter(Boolean);
    const lines = [];
    ctx.save();
    ctx.font = font;
    rawLines.forEach((raw) => {{
      let line = "";
      Array.from(raw).forEach((char) => {{
        const next = line + char;
        if (line && ctx.measureText(next).width > maxWidth) {{
          lines.push(line);
          line = char;
        }} else {{
          line = next;
        }}
      }});
      if (line) lines.push(line);
    }});
    ctx.restore();
    return lines;
  }}

  function drawTopMenuIcons(iconCanvas) {{
    const rows = payload.rightMenuPayload?.normalTopIconRows || [];
    rows.forEach((row) => {{
      const x = Number(row.previewX || 0);
      const y = Number(row.previewY || 0);
      const index = Number(row.normalTopMenuIndex || 0);
      drawIconCell(iconCanvas, row.iconId, x, y);
    }});
  }}

  function drawCurrentModeLabel() {{
    const model = payload.currentModeLabelModel || {{}};
    const anchor = model.anchor || payload.rightMenuLayout?.currentModeLabel || {{ x: 496, y: 16 }};
    const layout = payload.rightMenuLayout?.currentModeLabel || {{}};
    text(currentMode(selectedActor), Number(anchor.x ?? layout.x ?? 496), Number(anchor.y ?? layout.y ?? 16), {{
      color: "#f2ecd2",
      font: `${{Number(layout.fontPx || 16)}}px Gulim, 'Malgun Gothic', sans-serif`,
    }});
  }}

  function detailVisibleStart(items) {{
    const visibleCount = Number(payload.rightMenuLayout?.detailList?.visibleRows || 6);
    return Math.max(0, Math.min(selectedDetail - 3, Math.max(0, items.length - visibleCount)));
  }}

  function currentMenuModel() {{
    return payload.menuModel[selectedMenu] || payload.menuModel[0] || {{}};
  }}

  function currentPages(model = currentMenuModel()) {{
    return Array.isArray(model.pages) ? model.pages : [];
  }}

  function currentPage(model = currentMenuModel()) {{
    const pages = currentPages(model);
    if (!pages.length) return null;
    const bounded = Math.max(0, Math.min(selectedPage, pages.length - 1));
    return pages[bounded] || null;
  }}

  function equipmentOptions(actor = selectedActor, slot = "무기") {{
    const equipmentMenu = (payload.menuModel || []).find((row) => row.label === "장비");
    const page = (equipmentMenu?.pages || []).find((row) => row.label === slot);
    return page?.childrenByActor?.[actor] || [];
  }}

  function currentWeapon(actor = selectedActor) {{
    const options = equipmentOptions(actor, "무기");
    const labels = options.map((row) => entryLabel(row));
    if (!labels.length) return "";
    if (!selectedWeaponByActor[actor] || !labels.includes(selectedWeaponByActor[actor])) {{
      selectedWeaponByActor[actor] = labels[0];
    }}
    return selectedWeaponByActor[actor];
  }}

  function actorOptions() {{
    const model = currentMenuModel();
    return model.actors || ["아타호", "린샹", "스마슈"];
  }}

  function selectActorDelta(delta) {{
    const options = actorOptions();
    const current = Math.max(0, options.indexOf(selectedActor));
    const next = (current + delta + options.length) % options.length;
    setSelectedActor(options[next]);
  }}

  function modeOptionLabels() {{
    const modeMenu = (payload.menuModel || []).find((row) => row.label === "모드");
    const detail = modeMenu?.detail || [];
    return detail.map((entry) => entryLabel(entry)).filter(Boolean).slice(0, 5);
  }}

  function currentMode(actor = selectedActor) {{
    const options = modeOptionLabels();
    if (!options.length) return selectedModeByActor[actor] || "보통";
    if (!selectedModeByActor[actor] || !options.includes(selectedModeByActor[actor])) {{
      selectedModeByActor[actor] = options[0];
    }}
    return selectedModeByActor[actor];
  }}

  function setCurrentMode(mode) {{
    const options = modeOptionLabels();
    if (mode && (!options.length || options.includes(mode))) {{
      selectedModeByActor[selectedActor] = mode;
    }}
  }}

  function equipmentFilteredItems(items, model = currentMenuModel()) {{
    if (model.label !== "기술") return items;
    const weapon = currentWeapon(selectedActor);
    return items.filter((entry) => {{
      if (!entry || typeof entry !== "object" || !entry.equipmentName) return true;
      return String(entry.equipmentName) === weapon;
    }});
  }}

  function currentDetailItems(model = currentMenuModel()) {{
    let items = [];
    const page = currentPage(model);
    if (page && page.childrenByActor && Array.isArray(page.childrenByActor[selectedActor])) {{
      items = page.childrenByActor[selectedActor];
    }} else if (page && Array.isArray(page.children)) {{
      items = page.children;
    }} else {{
      items = model.detail || [];
    }}
    return equipmentFilteredItems(items, model);
  }}

  function selectedDetailEntry(model = currentMenuModel()) {{
    return currentDetailItems(model)[selectedDetail] || null;
  }}

  function selectedFinalDepth(model = currentMenuModel()) {{
    const entry = selectedDetailEntry(model);
    return entry && typeof entry === "object" && entry.finalDepth ? entry.finalDepth : null;
  }}

  function finalDepthChoices(model = currentMenuModel()) {{
    const finalDepth = selectedFinalDepth(model);
    const choices = Array.isArray(finalDepth?.choices) ? finalDepth.choices : [];
    return choices.map((choice) => String(choice || "")).filter(Boolean);
  }}

  function resetFinalDepth() {{
    finalDepthActive = false;
    finalChoice = 0;
  }}

  function openFinalDepth(model = currentMenuModel()) {{
    const finalDepth = selectedFinalDepth(model);
    const choices = finalDepthChoices(model);
    if (!finalDepth || !choices.length) return false;
    finalDepthActive = true;
    finalChoice = Math.max(0, Math.min(Number(finalDepth.defaultChoiceIndex || 0), choices.length - 1));
    return true;
  }}

  function moveFinalChoice(delta, model = currentMenuModel()) {{
    const choices = finalDepthChoices(model);
    if (!choices.length) return;
    finalChoice = Math.max(0, Math.min(choices.length - 1, finalChoice + delta));
  }}

  function setSelectedMenu(index) {{
    selectedMenu = Math.max(0, Math.min(payload.menuModel.length - 1, index));
    selectedPage = 0;
    selectedDetail = 0;
    resetFinalDepth();
  }}

  function setSelectedPage(index) {{
    const pages = currentPages();
    if (!pages.length) return false;
    selectedPage = Math.max(0, Math.min(pages.length - 1, index));
    selectedDetail = 0;
    resetFinalDepth();
    return true;
  }}

  function setSelectedActor(actor) {{
    selectedActor = actor || "아타호";
    currentWeapon(selectedActor);
    selectedDetail = 0;
    resetFinalDepth();
  }}

  function drawMenuCursors() {{
    const cursorModel = payload.cursorModel || {{}};
    const layout = payload.rightMenuLayout || {{}};
    const listLayout = layout.detailList || {{}};
    const topCursor = cursorModel.topCursor || {{}};
    const pressedTopCursor = cursorModel.pressedTopCursor || {{}};
    const subCursor = cursorModel.subCursor || {{}};
    const pressedSubCursor = cursorModel.pressedSubCursor || {{}};
    const rows = payload.rightMenuPayload?.normalTopIconRows || [];
    const selectedTop = rows.find((row) => Number(row.normalTopMenuIndex || 0) === selectedMenu) || rows[0];
    const topSource = submenuActive && pressedTopCursor.source ? pressedTopCursor.source : topCursor.source;
    if (selectedTop && topSource) {{
      const baseX = Number(selectedTop.previewX || 0) + 8;
      const y = Number((topCursor.startDest || {{}}).y ?? 72);
      drawStatusRect(topSource, baseX, y);
    }}
    const model = currentMenuModel();
    if (!submenuActive) return;
    const items = currentDetailItems(model);
    const start = detailVisibleStart(items);
    const visibleIndex = selectedDetail - start;
    const visibleRows = Number(listLayout.visibleRows || 6);
    const subSource = finalDepthActive && pressedSubCursor.source ? pressedSubCursor.source : subCursor.source;
    if (visibleIndex >= 0 && visibleIndex < visibleRows && subSource) {{
      const x = Number((subCursor.startDest || {{}}).x ?? listLayout.cursorX ?? 424);
      const y = Number((subCursor.startDest || {{}}).y ?? listLayout.cursorY ?? 144) + visibleIndex * Number(listLayout.rowStride || 32);
      drawStatusRect(subSource, x, y);
    }}
  }}

  function drawRegion(windowCanvas, id) {{
    const region = regionById.get(Number(id));
    if (!region) return false;
    const rect = region.rect || {{}};
    const template = templateById.get(Number(region.templateIndex));
    return drawTemplate(windowCanvas, template, Number(rect.x || 0), Number(rect.y || 0), rect);
  }}

  function drawExeListAnchors() {{
    const rows = payload.rightMenuPayload?.listCommands || [];
    ctx.save();
    rows.forEach((row) => {{
      const x = Number(row.absoluteX || 0);
      const y = Number(row.absoluteY || 0);
      ctx.strokeStyle = "rgba(139, 192, 255, .5)";
      ctx.setLineDash([3, 2]);
      ctx.strokeRect(x + 0.5, y + 0.5, 88, 18);
      text(row.tableLabel || row.opcodeLabel || "", x + 2, y + 20, {{ color: "#8bc0ff", font: "11px system-ui, sans-serif" }});
    }});
    ctx.restore();
  }}

  function markerRegion(id, label) {{
    const region = regionById.get(Number(id));
    if (!region) return;
    const r = region.rect || {{}};
    ctx.save();
    ctx.strokeStyle = "rgba(255,220,90,.75)";
    ctx.setLineDash([4, 3]);
    ctx.strokeRect(Number(r.x) + 0.5, Number(r.y) + 0.5, Number(r.w) - 1, Number(r.h) - 1);
    ctx.restore();
    text(label || `#${{id}}`, Number(r.x) + 8, Number(r.y) + 6, {{ color: "#ffd95a", font: "12px system-ui, sans-serif" }});
  }}

  function currentDetailTitle(model) {{
    const page = currentPage(model);
    return page ? entryLabel(page) : model.label || "";
  }}

  function drawDetailTitle(model) {{
    const layout = payload.rightMenuLayout || {{}};
    const title = currentDetailTitle(model);
    const anchor = (layout.titleAnchors || {{}})[title] || (layout.titleAnchors || {{}})[model.label] || {{
      x: 488,
      y: Number(layout.detailTitle?.defaultY || 104),
    }};
    text(title || model.label, Number(anchor.x || 488), Number(anchor.y || 104), {{
      color: "#fff",
      font: `${{Number(layout.detailTitle?.fontPx || 16)}}px Gulim, 'Malgun Gothic', sans-serif`,
    }});
  }}

  function drawPageArrows(model) {{
    const pages = currentPages(model);
    if (pages.length <= 1) return;
    const arrows = payload.cursorModel?.pageArrows || {{}};
    const placement = arrows.placement || {{}};
    const leftDest = placement.leftDest || {{ x: 424, y: 328 }};
    const rightDest = placement.rightDest || {{ x: 600, y: 328 }};
    if (selectedPage > 0) {{
      drawStatusRect(arrows.left?.source, Number(leftDest.x || 424), Number(leftDest.y || 328));
    }}
    if (selectedPage < pages.length - 1) {{
      drawStatusRect(arrows.right?.source, Number(rightDest.x || 600), Number(rightDest.y || 328));
    }}
  }}

  function drawDetailMenu() {{
    const model = currentMenuModel();
    const layout = payload.rightMenuLayout?.detailList || {{}};
    drawDetailTitle(model);
    drawPageArrows(model);
    const items = currentDetailItems(model);
    const visibleCount = Number(layout.visibleRows || 6);
    const start = detailVisibleStart(items);
    items.slice(start, start + visibleCount).forEach((item, offset) => {{
      const index = start + offset;
      const y = Number(layout.y || 136) + offset * Number(layout.rowStride || 32);
      const selected = index === selectedDetail;
      let labelX = Number(layout.textOnlyX || 440);
      if (hasGridIcon(item) && drawGridIcon(item.icon, Number(layout.x || 440), y, Number(layout.iconSize || 32))) {{
        labelX = Number(layout.iconTextX || 478);
      }}
      text(entryLabel(item), labelX, y + Number(layout.textYOffset || 8), {{
        color: selected ? "#fff5a8" : "#f2ecd2",
        font: "15px Gulim, 'Malgun Gothic', sans-serif",
      }});
      drawEntryQuantity(item, y, selected);
    }});
    if (items.length > visibleCount) {{
      const region = regionById.get(4).rect;
      text(`${{selectedDetail + 1}}/${{items.length}}`, region.x + region.w - 52, region.y + 18, {{
        color: "#9fb2d7",
        font: "12px system-ui, sans-serif",
      }});
    }}
  }}

  function descriptionGroupByLabel(label) {{
    const groups = payload.region2OutputModel?.descriptionGroups || [];
    return groups.find((group) => group.label === label);
  }}

  function descriptionEntryFor(entry) {{
    if (!entry || entry.descriptionGroupLabel === undefined || entry.descriptionIndex === undefined) return null;
    const group = descriptionGroupByLabel(entry.descriptionGroupLabel);
    const rows = group?.entries || group?.samples || [];
    const target = Number(entry.descriptionIndex);
    return rows.find((row) => Number(row.index) === target) || null;
  }}

  function rankLabelForEntry(entry) {{
    if (!entry) return "";
    if (entry.currentRankLabel) return String(entry.currentRankLabel).trim();
    const labels = entry.skillRankLabels || [];
    if (labels.length) {{
      const first = labels[0];
      return String(first.label || first || "").trim();
    }}
    return "";
  }}

  function panelLineText(line) {{
    return String(line || "").replace(/　/g, " ").trim();
  }}

  function drawInfoPanel() {{
    const model = currentMenuModel();
    const region = regionById.get(2).rect;
    if (finalDepthActive) {{
      const finalDepth = selectedFinalDepth(model) || {{}};
      const choices = finalDepthChoices(model);
      const prompt = String(finalDepth.prompt || "확인/선택창");
      text(prompt, region.x + 18, region.y + 24, {{
        color: "#fff5a8",
        font: "15px Gulim, 'Malgun Gothic', sans-serif",
      }});
      choices.forEach((choice, index) => {{
        const y = region.y + 56 + index * 24;
        if (index === finalChoice) {{
          drawStatusRect((payload.cursorModel?.subCursor || {{}}).source, region.x + 18, y);
        }}
        text(choice, region.x + 42, y + 2, {{
          color: index === finalChoice ? "#fff5a8" : "#f2ecd2",
          font: "15px Gulim, 'Malgun Gothic', sans-serif",
        }});
      }});
      return;
    }}
    const page = currentPage(model);
    const selected = currentDetailItems(model)[selectedDetail] || "";
    const selectedLabel = entryLabel(selected);
    const font = "12px Gulim, 'Malgun Gothic', sans-serif";
    const maxWidth = Number(region.w || 224) - 36;
    const description = descriptionEntryFor(selected);
    const descriptionLines = (description?.fragments || []).map(panelLineText).filter(Boolean);
    if (descriptionLines.length) {{
      const bodyFont = "14px Gulim, 'Malgun Gothic', sans-serif";
      descriptionLines.slice(0, 4).forEach((line, index) => {{
        text(line, region.x + 18, region.y + 28 + index * 20, {{ color: "#f2ecd2", font: bodyFont }});
      }});
      const rank = rankLabelForEntry(selected);
      if (rank) {{
        const rankFont = "14px Gulim, 'Malgun Gothic', sans-serif";
        ctx.save();
        ctx.font = rankFont;
        const w = ctx.measureText(rank).width;
        ctx.restore();
        text(rank, region.x + region.w - 18 - w, region.y + region.h - 24, {{
          color: "#fff5a8",
          font: rankFont,
        }});
      }}
      return;
    }}

    text(selectedLabel ? selectedLabel : model.label, region.x + 18, region.y + 28, {{
      color: "#fff5a8",
      font: "15px Gulim, 'Malgun Gothic', sans-serif",
    }});
    const lines = [];
    const summary = entrySummary(selected);
    const source = entrySource(selected);
    if (summary) lines.push(summary);
    if (selected?.children?.length) {{
      lines.push(`다음 목록: ${{selected.children.slice(0, 6).map((row) => row.label).join(", ")}}${{selected.children.length > 6 ? " ..." : ""}}`);
    }}
    if (page) {{
      lines.push(`${{model.label}} 하위 페이지: ${{entryLabel(page)}}`);
    }}
    if (model.actorSensitive) {{
      lines.push(`현재 캐릭터: ${{selectedActor}}`);
    }}
    if (model.label === "기술") {{
      lines.push(`현재 무기: ${{currentWeapon(selectedActor)}}`);
    }}
    if (source) lines.push(source);
    const rendered = [];
    (lines.length ? lines : model.info || []).forEach((line) => {{
      wrapCanvasText(line, maxWidth, font).forEach((wrapped) => rendered.push(wrapped));
    }});
    rendered.slice(0, 4).forEach((line, index) => {{
      text(line, region.x + 18, region.y + 68 + index * 15, {{ color: "#f2ecd2", font }});
    }});
  }}

  function drawPendingNormalFieldContent() {{
    const detail = regionById.get(4)?.rect || {{}};
    const info = regionById.get(2)?.rect || {{}};
    text("우측 세부 패널", Number(detail.x || 0) + 18, Number(detail.y || 0) + 18, {{ color: "#fff5a8" }});
    text("detail/list cursor는 object VM 0x87로 확인", Number(detail.x || 0) + 18, Number(detail.y || 0) + 48, {{ color: "#f2ecd2", font: "13px Gulim, 'Malgun Gothic', sans-serif" }});
    text("체크박스에서 후보 payload를 겹쳐볼 수 있음", Number(detail.x || 0) + 18, Number(detail.y || 0) + 68, {{ color: "#f2ecd2", font: "13px Gulim, 'Malgun Gothic', sans-serif" }});
    text("정보/확인 패널", Number(info.x || 0) + 18, Number(info.y || 0) + 18, {{ color: "#fff5a8" }});
    text("평상시 설정 payload 추적 필요", Number(info.x || 0) + 18, Number(info.y || 0) + 44, {{ color: "#f2ecd2", font: "13px Gulim, 'Malgun Gothic', sans-serif" }});
  }}

  function syncButtons() {{
    const host = document.getElementById("menuButtons");
    host.innerHTML = "";
    payload.menuModel.forEach((row, index) => {{
      const button = document.createElement("button");
      button.type = "button";
      button.textContent = row.label;
      button.className = index === selectedMenu ? "is-active" : "";
      button.addEventListener("click", () => {{
        setSelectedMenu(index);
        submenuActive = false;
        render();
      }});
      host.appendChild(button);
    }});
    const note = document.createElement("span");
    note.className = "chip";
    const model = currentMenuModel();
    const pages = currentPages(model);
    note.textContent = showSharedPayload
      ? "후보 payload overlay 켜짐"
      : pages.length > 1
        ? `하위 페이지 ${{selectedPage + 1}}/${{pages.length}} · ←/→ 이동`
        : "EXE anchor 하위 메뉴";
    host.appendChild(note);
    if (model.actorSensitive) {{
      (model.actors || ["아타호", "린샹", "스마슈"]).forEach((actor) => {{
        const button = document.createElement("button");
        button.type = "button";
        button.textContent = actor;
        button.className = actor === selectedActor ? "is-active" : "";
        button.addEventListener("click", () => {{
          setSelectedActor(actor);
          render();
        }});
        host.appendChild(button);
      }});
      const actorNote = document.createElement("span");
      actorNote.className = "chip";
      actorNote.textContent = `캐릭터별 항목 · ${{selectedActor}}`;
      host.appendChild(actorNote);
    }}
    if (model.label === "기술") {{
      const weaponLabel = document.createElement("span");
      weaponLabel.className = "chip";
      weaponLabel.textContent = `착용 무기 · ${{currentWeapon(selectedActor)}}`;
      host.appendChild(weaponLabel);
      equipmentOptions(selectedActor, "무기").forEach((weapon) => {{
        const label = entryLabel(weapon);
        const button = document.createElement("button");
        button.type = "button";
        button.textContent = label;
        button.className = label === currentWeapon(selectedActor) ? "is-active" : "";
        button.addEventListener("click", () => {{
          selectedWeaponByActor[selectedActor] = label;
          selectedDetail = 0;
          render();
        }});
        host.appendChild(button);
      }});
    }}
    const modeHost = document.getElementById("modeButtons");
    modeHost.innerHTML = "";
    const actorNote = document.createElement("span");
    actorNote.className = "chip";
    actorNote.textContent = `현재 파티원 · ${{selectedActor}} · ↑/↓`;
    modeHost.appendChild(actorNote);
    actorOptions().forEach((actor) => {{
      const button = document.createElement("button");
      button.type = "button";
      button.textContent = actor;
      button.className = actor === selectedActor ? "is-active" : "";
      button.addEventListener("click", () => {{
        setSelectedActor(actor);
        submenuActive = false;
        render();
      }});
      modeHost.appendChild(button);
    }});
    const modeNote = document.createElement("span");
    modeNote.className = "chip";
    modeNote.textContent = `상단 현재 모드 · ${{currentMode(selectedActor)}}`;
    modeHost.appendChild(modeNote);
    modeOptionLabels().forEach((mode) => {{
      const button = document.createElement("button");
      button.type = "button";
      button.textContent = mode;
      button.className = mode === currentMode(selectedActor) ? "is-active" : "";
      button.addEventListener("click", () => {{
        setCurrentMode(mode);
        render();
      }});
      modeHost.appendChild(button);
    }});
  }}

  async function render() {{
    const [windowCanvas, iconCanvas, itemCanvas, statusCanvas] = await Promise.all([
      window.HWANSE_CNS_RENDERER.loadImageCanvas("window.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("icon.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("item.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("status.cns"),
    ]);
    sheetCanvases = {{ icon: iconCanvas, item: itemCanvas, status: statusCanvas }};
    ctx.clearRect(0, 0, canvas.width, canvas.height);
    ctx.fillStyle = "#050505";
    ctx.fillRect(0, 0, canvas.width, canvas.height);
    [6, 3, 4, 1, 2].forEach((id) => drawRegion(windowCanvas, id));
    if (showDebugRegions) {{
      markerRegion(6, "#6 좌측 상태/메뉴");
      markerRegion(3, "#3 상단 메뉴");
      markerRegion(4, "#4 세부 메뉴");
      markerRegion(2, "#2 정보/선택");
      markerRegion(1, "#1 평상시 HUD");
    }}
    drawCurrentModeLabel();
    drawTopMenuIcons(iconCanvas);
    drawDetailMenu();
    drawInfoPanel();
    drawMenuCursors();
    if (showSharedPayload) {{
      drawExeListAnchors();
    }}
    syncButtons();
    window.HWANSE_LAST_MENU_RIGHT_PANEL_UI_REVIEW = {{
      selectedMenu: payload.menuModel[selectedMenu]?.key || "",
      selectedActor,
      selectedMode: currentMode(selectedActor),
      selectedModeByActor,
      submenuActive,
      finalDepthActive,
      finalChoice,
      finalDepth: selectedFinalDepth(),
      selectedPage,
      selectedDetail,
      groundedRegions: payload.regionIds,
      infoRegion: 2,
      rightPayloadPromoted: false,
      normalTopIconsPromoted: true,
      showSharedPayload,
      showDebugRegions,
      rightMenuLayout: payload.rightMenuLayout,
      cursorModel: payload.cursorModel,
      topMenuSelectionModel: payload.topMenuSelectionModel,
      menuStateHelperModel: payload.menuStateHelperModel,
      rightPayloadClassification: payload.rightMenuPayloadClassification,
      iconConsumerPromoted: "first six 40 24 rows are normal top menu icons; later rows remain candidates",
      currentModeLabelModel: payload.currentModeLabelModel,
    }};
  }}

  document.getElementById("showSharedPayload").addEventListener("change", (event) => {{
    showSharedPayload = Boolean(event.currentTarget.checked);
    render();
  }});

  document.getElementById("showDebugRegions").addEventListener("change", (event) => {{
    showDebugRegions = Boolean(event.currentTarget.checked);
    render();
  }});

  function handleMenuCommand(command) {{
    const model = currentMenuModel();
    if (command === "left") {{
      if (finalDepthActive) {{
        moveFinalChoice(-1, model);
      }} else if (submenuActive) {{
        if (!setSelectedPage(selectedPage - 1)) submenuActive = false;
      }} else {{
        setSelectedMenu(selectedMenu - 1);
      }}
    }} else if (command === "right") {{
      if (finalDepthActive) {{
        moveFinalChoice(1, model);
      }} else if (submenuActive) {{
        if (!setSelectedPage(selectedPage + 1)) submenuActive = false;
      }} else {{
        setSelectedMenu(selectedMenu + 1);
      }}
    }} else if (command === "up") {{
      if (finalDepthActive) {{
        moveFinalChoice(-1, model);
      }} else if (submenuActive) {{
        selectedDetail = Math.max(0, selectedDetail - 1);
      }} else {{
        selectActorDelta(-1);
      }}
    }} else if (command === "down") {{
      if (finalDepthActive) {{
        moveFinalChoice(1, model);
      }} else if (submenuActive) {{
        selectedDetail = Math.min(currentDetailItems(model).length - 1, selectedDetail + 1);
      }} else {{
        selectActorDelta(1);
      }}
    }} else if (command === "enter") {{
      if (!submenuActive) {{
        submenuActive = true;
        selectedDetail = 0;
        resetFinalDepth();
      }} else if (finalDepthActive) {{
        resetFinalDepth();
      }} else if (model.label === "모드") {{
        const entry = currentDetailItems(model)[selectedDetail];
        setCurrentMode(entryLabel(entry));
      }} else if (!openFinalDepth(model)) {{
        // No additional depth for this row.
      }}
    }} else if (command === "esc") {{
      if (finalDepthActive) {{
        resetFinalDepth();
      }} else {{
        submenuActive = false;
      }}
    }} else {{
      return false;
    }}
    render();
    return true;
  }}

  window.addEventListener("keydown", (event) => {{
    const keyMap = {{
      ArrowLeft: "left",
      ArrowRight: "right",
      ArrowUp: "up",
      ArrowDown: "down",
      Enter: "enter",
      " ": "enter",
      Escape: "esc",
      Backspace: "esc",
    }};
    const command = keyMap[event.key];
    if (command && handleMenuCommand(command)) {{
      event.preventDefault();
    }}
  }});

  document.querySelectorAll("[data-menu-command]").forEach((button) => {{
    button.addEventListener("click", (event) => {{
      event.preventDefault();
      handleMenuCommand(button.dataset.menuCommand || "");
    }});
  }});

  render().catch((error) => {{
    ctx.fillStyle = "#fff";
    ctx.fillText(`preview error: ${{error.message}}`, 12, 24);
  }});
}}());
</script>
</body>
</html>
"""


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


def main() -> None:
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    payload = build_payload()
    write_json(JSON_OUT, payload)
    print(f"wrote {JSON_OUT}")


if __name__ == "__main__":
    main()
