#!/usr/bin/env python3
"""Map EXE item/equipment/action records onto UI CNS grid cells."""
from __future__ import annotations

import argparse
import html
import json
import re
import struct
import sys
from pathlib import Path

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

from probe_exe_scene_tables import read_sections, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
NAME_BYTES = 16

SHEETS = {
    0x0005: {
        "key": "icon",
        "cns": "icon.cns",
        "png": None,
        "webPath": "../extract_fld/icon.cns",
        "width": 640,
        "height": 128,
        "cellWidth": 32,
        "cellHeight": 32,
        "columns": 20,
        "rows": 4,
        "cellCount": 80,
    },
    0x0006: {
        "key": "item",
        "cns": "item.cns",
        "png": None,
        "webPath": "../extract_fld/item.cns",
        "width": 384,
        "height": 160,
        "cellWidth": 32,
        "cellHeight": 32,
        "columns": 12,
        "rows": 5,
        "cellCount": 60,
    },
}

TABLE_SPECS = [
    {
        "key": "equipment",
        "title": "장비",
        "kind": "equipment",
        "startVa": 0x0048B1E4,
        "count": 36,
        "status": "confirmed-item-grid",
        "note": "EXE record meta high word 0x0006 points to item.cns; low word is the 32x32 cell index.",
    },
    {
        "key": "items",
        "title": "아이템/중요품",
        "kind": "item",
        "startVa": 0x0048B984,
        "count": 31,
        "status": "confirmed-item-grid",
        "note": "EXE record meta high word 0x0006 points to item.cns; low word is the 32x32 cell index.",
    },
    {
        "key": "atahoActions",
        "title": "아타호 기본 액션",
        "kind": "skill-action",
        "startVa": 0x004D24AC,
        "count": 9,
        "status": "direct-icon-grid",
        "note": "These action records carry icon.cns cell indices in the low word. The icon is a command/category/equipped-source icon, not necessarily a unique per-skill icon.",
    },
    {
        "key": "actionSetA",
        "title": "격투/권법 액션",
        "kind": "skill-action",
        "startVa": 0x004D26BC,
        "count": 8,
        "status": "direct-icon-grid",
        "note": "These action records carry icon.cns cell indices in the low word. The icon is a command/category/equipped-source icon, not necessarily a unique per-skill icon.",
    },
    {
        "key": "actionSetB",
        "title": "검/베기 액션",
        "kind": "skill-action",
        "startVa": 0x004D2864,
        "count": 4,
        "status": "direct-icon-grid",
        "note": "These action records carry icon.cns cell indices in the low word. The icon is a command/category/equipped-source icon, not necessarily a unique per-skill icon.",
    },
    {
        "key": "battleCommands",
        "title": "전투 커맨드/특수 액션",
        "kind": "skill-command",
        "startVa": 0x004D2974,
        "count": 5,
        "status": "direct-icon-grid",
        "note": "The first command/action rows carry icon.cns cell indices. These are command/category icons; later skill-name rows switch to the default cell.",
    },
    {
        "key": "skillNamesA",
        "title": "고유 아이콘 없는 기술명/효과명 테이블 A",
        "kind": "skill-name",
        "startVa": 0x004D2A34,
        "count": 60,
        "status": "no-direct-icon-in-name-table",
        "note": "The records keep meta 0x00050000. This table is name/payload evidence; it is not expected to provide unique per-skill icons.",
    },
    {
        "key": "skillNamesB",
        "title": "고유 아이콘 없는 기술명/효과명 테이블 B",
        "kind": "skill-name",
        "startVa": 0x004D2C04,
        "count": 49,
        "status": "no-direct-icon-in-name-table",
        "note": "The records keep meta 0x00050000. This table is name/payload evidence; it is not expected to provide unique per-skill icons.",
    },
    {
        "key": "magicNames",
        "title": "고유 아이콘 없는 마법/특수 이름 테이블",
        "kind": "skill-name",
        "startVa": 0x004D2D64,
        "count": 5,
        "status": "no-direct-icon-in-name-table",
        "note": "The records keep meta 0x00050000. This table is name/payload evidence; it is not expected to provide unique per-skill icons.",
    },
]

DESCRIPTION_TABLE_SPECS = {
    "equipment": {
        "tableVa": 0x004E915C,
        "rowCount": 36,
        "skipPointers": 1,
        "status": "exe-description-pointer-table",
        "note": "0x004e915c has one default pointer followed by 36 equipment description pointers.",
    },
    "items": {
        "tableVa": 0x004E91F8,
        "rowCount": 31,
        "skipPointers": 1,
        "status": "exe-description-pointer-table",
        "note": "0x004e91f8 has one default pointer followed by 31 item description pointers.",
    },
}

EQUIPMENT_STAT_FIELDS = [
    (0, "공격력"),
    (1, "방어력"),
    (2, "기술력"),
    (3, "순발력"),
    (4, "운"),
]

EQUIPMENT_TARGET_TABLES = {
    0x01: {
        "character": "아타호",
        "baseVa": 0x004D24A4,
        "endVa": 0x004D26B4,
    },
    0x02: {
        "character": "린샹",
        "baseVa": 0x004D26B4,
        "endVa": 0x004D285C,
    },
    0x04: {
        "character": "스마슈",
        "baseVa": 0x004D285C,
        "endVa": 0x004D299C,
    },
}

EQUIPMENT_SKILL_FIELDS = [
    (26, "기본기"),
    (27, "개인공격기"),
    (28, "전체공격기"),
    (29, "특수기"),
]

EQUIPMENT_BATTLE_MODIFIER_OFFSETS = range(5, 24)

CONSUMABLE_ITEM_REFERENCE = {
    "약초": {
        "source": "user-wiki-reference",
        "priceGold": 10,
        "effectSummary": "HP +50",
        "effectNotes": "HP를 50 회복",
    },
    "해독초": {
        "source": "user-wiki-reference",
        "priceGold": 15,
        "effectSummary": "독/마비 회복",
        "effectNotes": "독 또는 마비 회복; 상태 이상이 없으면 HP/MP를 극소량 회복",
    },
    "리프레시 워터": {
        "source": "user-wiki-reference",
        "priceGold": 100,
        "effectSummary": "기절 회복, HP +200",
        "effectNotes": "기절 상태 회복 및 HP 200 회복",
    },
    "마법의 물약": {
        "source": "user-wiki-reference",
        "priceGold": 200,
        "effectSummary": "MP +50",
        "effectNotes": "MP를 50 회복",
    },
    "고급한방약": {
        "source": "user-wiki-reference",
        "priceGold": 500,
        "effectSummary": "HP +100, MP +100",
        "effectNotes": "HP와 MP를 각각 100 회복",
    },
    "마수석": {
        "source": "user-wiki-reference",
        "priceGold": None,
        "effectSummary": "기절 회복, HP/MP 완전 회복",
        "effectNotes": "기절 상태 회복 및 HP/MP 완전 회복; 상점 구매 불가",
    },
}

CONSUMABLE_EFFECT_DISPATCH_TABLE_VA = 0x00546A38
CONSUMABLE_EFFECT_DISPATCH_REFS = [0x00433F6C, 0x0043572A]
CONSUMABLE_EFFECT_HP_HELPER_VA = 0x00435295
CONSUMABLE_EFFECT_MP_HELPER_VA = 0x00435356

CONSUMABLE_ITEM_EXE_EFFECTS = {
    "약초": {
        "effectIndex": 1,
        "handlerVa": 0x004357E6,
        "effectSummary": "HP +50",
        "hpRecover": 50,
        "mpRecover": None,
        "revive": False,
        "statusRecovery": False,
        "fullHp": False,
        "fullMp": False,
        "evidenceSummary": "handler 0x004357e6 pushes 0x32 into HP recover helper 0x00435295.",
    },
    "해독초": {
        "effectIndex": 2,
        "handlerVa": 0x00435810,
        "effectSummary": "독/마비 회복",
        "hpRecover": None,
        "mpRecover": None,
        "revive": False,
        "statusRecovery": True,
        "fullHp": False,
        "fullMp": False,
        "evidenceSummary": "handler 0x00435810 calls status recovery routine 0x0043497d; no-status path also gives a small HP/MP recovery candidate.",
    },
    "리프레시 워터": {
        "effectIndex": 3,
        "handlerVa": 0x0043583C,
        "effectSummary": "기절 회복, HP +200",
        "hpRecover": 200,
        "mpRecover": None,
        "revive": True,
        "statusRecovery": False,
        "fullHp": False,
        "fullMp": False,
        "evidenceSummary": "handler 0x0043583c clears KO/death flags then pushes 0xc8 into HP recover helper 0x00435295.",
    },
    "마법의 물약": {
        "effectIndex": 4,
        "handlerVa": 0x004358AB,
        "effectSummary": "MP +50",
        "hpRecover": None,
        "mpRecover": 50,
        "revive": False,
        "statusRecovery": False,
        "fullHp": False,
        "fullMp": False,
        "evidenceSummary": "handler 0x004358ab pushes 0x32 into MP recover helper 0x00435356.",
    },
    "고급한방약": {
        "effectIndex": 5,
        "handlerVa": 0x004358D5,
        "effectSummary": "HP +100, MP +100",
        "hpRecover": 100,
        "mpRecover": 100,
        "revive": False,
        "statusRecovery": False,
        "fullHp": False,
        "fullMp": False,
        "evidenceSummary": "handler 0x004358d5 pushes 0x64 into both HP helper 0x00435295 and MP helper 0x00435356.",
    },
    "마수석": {
        "effectIndex": 6,
        "handlerVa": 0x0043590D,
        "effectSummary": "기절 회복, HP/MP 완전 회복",
        "hpRecover": None,
        "mpRecover": None,
        "revive": True,
        "statusRecovery": False,
        "fullHp": True,
        "fullMp": True,
        "evidenceSummary": "handler 0x0043590d clears KO/death flags then passes max HP/MP fields into the recovery helpers.",
    },
}

CONSUMABLE_ITEM_COMMON_REFERENCE = {
    "maxStack": 10,
    "inventoryScope": "캐릭터 간 공유",
    "usableStates": ["비전투", "전투"],
    "source": "user-wiki-reference",
}

KEY_ITEM_REFERENCE = {
    "무투대회 안내장": {
        "source": "user-wiki-reference",
        "alias": "武闘大会案内書",
        "useSummary": "무투대회 참가를 위해 맹호권도장에 제출",
        "useNotes": "아타호의 거처 머리맡 항아리에서 얻는 안내장",
    },
    "스마슈": {
        "source": "user-wiki-reference",
        "alias": "スマッシュ",
        "useSummary": "부상당한 스마슈를 해변마을 병원으로 운반",
        "useNotes": "해변에 떠내려온 스마슈를 아타호가 업어서 병원에 데려간다",
    },
    "햄머": {
        "source": "user-wiki-reference",
        "alias": "ハンマー",
        "useSummary": "금 간 벽과 동작석상을 파괴",
        "useNotes": "스마슈의 술창고 아르바이트에서 획득 가능",
    },
    "고급술병": {
        "source": "user-wiki-reference",
        "alias": "高級酒ビン",
        "useSummary": "술집 주인의 도난당한 고급 술병",
        "useNotes": "스마슈가 찾아주지만 벽을 부숴 아르바이트비 1000G는 받지 못한다",
    },
    "암청수": {
        "source": "user-wiki-reference",
        "alias": "岩清水",
        "useSummary": "호랑이동굴 비밀통로 진입 이벤트에 사용",
        "useNotes": "마시면 혼절하지만 이후 비밀통로에 들어갈 수 있게 된다",
    },
    "정호수": {
        "source": "user-wiki-reference",
        "useSummary": "암청수 눈속임용 우물물",
        "useNotes": "백호권사범에게 가져가지만 들켜서 버려진다",
    },
    "화령석": {
        "source": "user-wiki-reference",
        "alias": "火霊石",
        "useSummary": "불/열 관련 상자와 횃불 조합에 사용",
        "useNotes": "주작의 시련 보상; 전기 상자의 천 조각을 태우고 목봉과 조합해 불을 밝힌다",
    },
    "수령석": {
        "source": "user-wiki-reference",
        "alias": "水霊石",
        "useSummary": "뜨거운 보물상자의 열을 식힘",
        "useNotes": "창룡의 시련 보상",
    },
    "지령석": {
        "source": "user-wiki-reference",
        "alias": "地霊石",
        "useSummary": "얼어붙은 보물상자를 깨트림",
        "useNotes": "현무의 시련 보상",
    },
    "풍령석": {
        "source": "user-wiki-reference",
        "alias": "風霊石",
        "useSummary": "보물상자의 쐐기를 잘라 개방",
        "useNotes": "인정서 획득 후 백호의 시련 권법가들에게서 얻는다",
    },
    "횃불": {
        "source": "user-wiki-reference",
        "alias": "タイマツ / 목봉",
        "useSummary": "화령석과 조합해 어두운 공간을 밝힘",
        "useNotes": "현무의 시련의 기름 묻은 목봉; 화령석 없이 불타는 마검만 있으면 타버린다",
    },
    "횃불세트": {
        "source": "user-wiki-reference",
        "alias": "タイマツセット",
        "useSummary": "화령석 없이 어두운 공간을 밝힘",
        "useNotes": "진 호혈 지하 1층 상점 또는 지옥 보물상자에서 획득",
    },
    "부적 １장": {
        "source": "user-wiki-reference",
        "alias": "呪符",
        "useSummary": "봉인 해제용 부적 1장",
        "useNotes": "4수 시련에서 얻고 마수 봉인 또는 맹호권도장 지하 입구 개방에 사용",
    },
    "부적 ２장": {
        "source": "user-wiki-reference",
        "alias": "呪符",
        "useSummary": "봉인 해제용 부적 2장",
        "useNotes": "4수 시련에서 얻고 마수 봉인 또는 맹호권도장 지하 입구 개방에 사용",
    },
    "부적 ３장": {
        "source": "user-wiki-reference",
        "alias": "呪符",
        "useSummary": "봉인 해제용 부적 3장",
        "useNotes": "유적 지하 5층에서 마수 봉인을 최대 3장까지 해제할 수 있다",
    },
    "부적 ４장": {
        "source": "user-wiki-reference",
        "alias": "呪符",
        "useSummary": "봉인 해제용 부적 4장",
        "useNotes": "마수 봉인 및 맹호권도장 지하 봉인 입구 개방에 쓰이는 누적 상태",
    },
    "나찰의 돌": {
        "source": "user-wiki-reference",
        "alias": "羅刹の石",
        "useSummary": "진 호혈 지하 666층 진입",
        "useNotes": "지하 3층 스위치 3개가 모두 꺼져 있을 때 보물상자에서 획득",
    },
    "호랑이굴 인정서": {
        "source": "user-wiki-reference",
        "alias": "虎の穴認定証",
        "useSummary": "호랑이동굴 수련 완료 및 탈출 증명",
        "useNotes": "진 호혈 지하 5층에서 세 성령을 이기면 획득",
    },
    "○○책": {
        "source": "user-wiki-reference",
        "alias": "Ｈな本",
        "useSummary": "스마슈 특수기 눈요기 사용 조건",
        "useNotes": "6장 도입부에서 화장실에 간 스마슈가 줍는다",
    },
    "수면약": {
        "source": "user-wiki-reference",
        "alias": "眠りの秘薬 / 수면비약",
        "useSummary": "병원 이벤트 후 맹호룬룬권 습득 조건",
        "useNotes": "유적 지하 2층에서 획득; 간호사에게 전달하면 권법가가 수술을 받는다",
    },
    "황금돼지두루마리": {
        "source": "user-wiki-reference",
        "alias": "黄金のブタの巻物",
        "useSummary": "다리오스에게 주고 어설트 슈트 획득",
        "useNotes": "유적 지하 6층 보물창고에서 획득",
    },
    "서류": {
        "source": "user-wiki-reference",
        "alias": "書類",
        "useSummary": "대회 주최자 전달 이벤트",
        "useNotes": "암각권 총통에게 패배한 맹호권사범이 전달을 요청한 서류",
    },
    "지옥 인정서１": {
        "source": "user-wiki-reference",
        "alias": "奈落の底認定証１",
        "useSummary": "지옥 간단한 코스 클리어 인정서",
        "useNotes": "지옥의 밑바닥 간단한 코스 클리어 보상",
    },
    "지옥 인정서２": {
        "source": "user-wiki-reference",
        "alias": "奈落の底認定証２",
        "useSummary": "지옥 적당한 코스 클리어 인정서",
        "useNotes": "지옥의 밑바닥 적당한 코스 클리어 보상",
    },
    "지옥 인정서３": {
        "source": "user-wiki-reference",
        "alias": "奈落の底認定証３",
        "useSummary": "지옥 초난관 코스 클리어 인정서",
        "useNotes": "지옥의 밑바닥 초난관 코스 클리어 보상",
    },
}

ATAHO_SKILL_REFERENCE = [
    {
        "character": "아타호",
        "category": "기본기",
        "name": "정권",
        "alias": "",
        "mpCost": "0",
        "acquisition": "기본",
        "description": "주먹으로 가격하는 정통파 공격기술로 안정감이 좋다.",
        "notes": "일반기 중 명중률이 좋아 체력이 적은 상대 마무리에 적합.",
    },
    {
        "character": "아타호",
        "category": "기본기",
        "name": "돌려차기",
        "alias": "",
        "mpCost": "0",
        "acquisition": "기본",
        "description": "파워를 중시하는 동작 큰 차기공격.",
        "notes": "틈이 많은 편.",
    },
    {
        "character": "아타호",
        "category": "기본기",
        "name": "던지기",
        "alias": "",
        "mpCost": "0",
        "acquisition": "기본",
        "description": "붙잡아 던지는 기술로 대미지는 크지만 명중률은 떨어진다.",
        "notes": "명중하면 초반 원숭이를 한 방에 잡을 수 있을 정도로 강하다.",
    },
    {
        "character": "아타호",
        "category": "기본기",
        "name": "다리후리기",
        "alias": "",
        "mpCost": "0",
        "acquisition": "기본",
        "description": "서 있는 적을 넘어트리는 기술.",
        "notes": "공중의 적에게는 효과가 없다.",
    },
    {
        "character": "아타호",
        "category": "개인공격기",
        "name": "호격권",
        "alias": "虎撃拳",
        "mpCost": "10→10→9→8",
        "acquisition": "기본 제공",
        "description": "기 모아 사용하는 강력한 펀치공격. 명중률이 높다.",
        "notes": "MP 대비 대미지와 명중률이 좋아 초중반 주력 개인기.",
    },
    {
        "character": "아타호",
        "category": "개인공격기",
        "name": "맹호비상각",
        "alias": "猛虎飛翔脚",
        "mpCost": "12→12→11→10",
        "acquisition": "맹호권 사범과 승부 직후",
        "description": "잽싸게 점프해서 그대로 차기공격. 명중률은 낮은 편.",
        "notes": "선공 판정이 빠르고 휙 날아감 유도가 가능.",
    },
    {
        "character": "아타호",
        "category": "개인공격기",
        "name": "폭전축",
        "alias": "爆転蹴",
        "mpCost": "15→15→14→12",
        "acquisition": "백호권 사범 수련",
        "description": "공중에서 호를 그리며 회전차기. 틈은 많지만 효과적인 대공기.",
        "notes": "기술 레벨에 따라 1/1/2/3연속기가 되며 공중 몬스터에 유리.",
    },
    {
        "character": "아타호",
        "category": "개인공격기",
        "name": "맹호스페셜",
        "alias": "猛虎スペシャル",
        "mpCost": "20→20→18→16",
        "acquisition": "호랑이동굴 클리어 후 백호권 사범 대련",
        "description": "때리고, 차고! 노도와 같은 연속 공격기.",
        "notes": "기술 레벨에 따라 3/3/4/7타. 최강급 개인 공격기.",
    },
    {
        "character": "아타호",
        "category": "개인공격기",
        "name": "비기·맹호광파참",
        "alias": "奥義・猛虎光波斬",
        "mpCost": "30→30→27→24",
        "acquisition": "무술대회 종료 후 맹호권 도장 오른쪽 방 상자",
        "description": "신성한 빛의 검으로 악을 멸하는 기술. 파괴력이 강력하다.",
        "notes": "명중률이 높고 타수가 늘지만 시전 뒤 방어 취약점이 크다.",
    },
    {
        "character": "아타호",
        "category": "전체공격기",
        "name": "맹호난무",
        "alias": "猛虎乱舞",
        "mpCost": "10→10→9→8",
        "acquisition": "기본 제공",
        "description": "잽싸게 여러번 적 전체를 공격해 충격을 주는 기술.",
        "notes": "MP 효율이 좋아 후반까지도 쓸 수 있는 기본 전체기.",
    },
    {
        "character": "아타호",
        "category": "전체공격기",
        "name": "맹호의 울부짖음",
        "alias": "猛虎の雄叫び",
        "mpCost": "5→5→5→4",
        "acquisition": "맹호권 사범과 승부 직후",
        "description": "우렁찬 소리로 위협해 주눅시키는 전체 공격기. 동물에게 효과가 크다.",
        "notes": "소모 MP가 낮고 동물형 또는 저HP 고회피 몬스터 상대에 유용.",
    },
    {
        "character": "아타호",
        "category": "전체공격기",
        "name": "호포권",
        "alias": "虎砲拳",
        "mpCost": "15→15→14→12",
        "acquisition": "백호권 사범 수련",
        "description": "기를 모은 뢰격탄을 적 전체에게 공격. 공격 속도는 느리다.",
        "notes": "풍뢰속성 전체기. 우선도가 낮지만 속성 몬스터 상대로 쓸 수 있다.",
    },
    {
        "character": "아타호",
        "category": "전체공격기",
        "name": "맹호룬룬권",
        "alias": "猛虎ルンルン拳",
        "mpCost": "20→20→18→16",
        "acquisition": "수면비약 병원 이벤트 후 강권사와 대화",
        "description": "가벼운 스텝으로 가뿐히 적 전체에게 큰 대미지를 줌.",
        "notes": "아타호의 최강급 전체공격기.",
    },
    {
        "character": "아타호",
        "category": "전체공격기",
        "name": "비기·맹호유성각",
        "alias": "奥義・猛虎流星脚",
        "mpCost": "30→30→27→24",
        "acquisition": "맹호권 도장 지하 5층 나찰전 후",
        "description": "계속 별똥별을 뿌리고 연속으로 날아 차는 기술.",
        "notes": "선타로 나가며 일정 확률로 넘어짐을 유도할 수 있다.",
    },
    {
        "character": "아타호",
        "category": "특수기",
        "name": "기합일발",
        "alias": "気合一発",
        "mpCost": "5",
        "acquisition": "백호권 사범 수련",
        "description": "혈관이 터질만큼 기합을 넣어, 정신력으로 부상을 치료하는 기술.",
        "notes": "아타호 자신만 회복 가능.",
    },
]

LINXIANG_SKILL_REFERENCE = [
    {
        "character": "린샹",
        "category": "기본기",
        "name": "손톱공격",
        "alias": "",
        "mpCost": "0",
        "acquisition": "기본",
        "description": "할퀴기 공격. 보기보단 아프니까 당하지 않게 주의.",
        "notes": "",
    },
    {
        "character": "린샹",
        "category": "기본기",
        "name": "하이킥",
        "alias": "",
        "mpCost": "0",
        "acquisition": "기본",
        "description": "공중의 적에게도 높은 적중률을 지닌 상단차기. 위력이 강하다!",
        "notes": "슬라임이나 뱀형 몬스터에게는 빗나갈 수 있다.",
    },
    {
        "character": "린샹",
        "category": "기본기",
        "name": "미들킥",
        "alias": "",
        "mpCost": "0",
        "acquisition": "기본",
        "description": "허벅지 부분을 집중 공격하는 강력한 공격기술.",
        "notes": "",
    },
    {
        "character": "린샹",
        "category": "기본기",
        "name": "로 킥",
        "alias": "",
        "mpCost": "0",
        "acquisition": "기본",
        "description": "적의 발밑을 공격해 단숨에 쓰러트리는 하단차기.",
        "notes": "다운 상태를 유발할 수 있지만 공중의 적에게는 미스 확률이 높다.",
    },
    {
        "character": "린샹",
        "category": "기본기",
        "name": "도발",
        "alias": "",
        "mpCost": "0",
        "acquisition": "기본",
        "description": "적을 도발한 후 스스로 사기를 높여, 모드를 바꾸는 이상한 기술.",
        "notes": "위압적인 자세, 피곤함, 노발충천, 여왕님, 무뚝뚝한 표정 중 하나로 변화.",
    },
    {
        "character": "린샹",
        "category": "개인공격기",
        "name": "안면백조권",
        "alias": "顔面百爪拳",
        "mpCost": "10→10→9→8",
        "acquisition": "기본 제공",
        "description": "얼굴이 걸레가 될 때까지 할퀴는 히스테릭한 공격기.",
        "notes": "3타 연속기이며 신기에서 4타로 증가.",
    },
    {
        "character": "린샹",
        "category": "개인공격기",
        "name": "선렬각",
        "alias": "鮮烈脚",
        "mpCost": "15→15→14→12",
        "acquisition": "백호권 도장 수련",
        "description": "상·중하단으로 나눠서 하는 연속차기공격.",
        "notes": "린샹의 대표 연속 발차기 기술.",
    },
    {
        "character": "린샹",
        "category": "개인공격기",
        "name": "열화폭염권",
        "alias": "烈火爆炎拳",
        "mpCost": "12→12→11→10",
        "acquisition": "주작의 시련 상자",
        "description": "주작권의 필살기. 폭염으로 공격하는 화염계 개인공격.",
        "notes": "단타 화염계 개인기. 수경에 막히는 점을 이용해 숙련도 작업 가능.",
    },
    {
        "character": "린샹",
        "category": "개인공격기",
        "name": "암각·영상승룡파",
        "alias": "暗刻・嶺上昇龍波",
        "mpCost": "30→30→27→24",
        "acquisition": "암각권 도장에서 적이 된 린샹 격파",
        "description": "용이 상승하면서 여러번 대미지를 주는 초필살개인공격기.",
        "notes": "린샹의 최종 주력 개인기. 1~2타 대미지 비중이 크다.",
    },
    {
        "character": "린샹",
        "category": "전체공격기",
        "name": "고양이달래기",
        "alias": "猫だまし",
        "mpCost": "5→5→5→4",
        "acquisition": "기본 제공",
        "description": "상대를 놀래켜서 행동을 정지시키는 특수 전체공격기.",
        "notes": "동물계 외에는 피해가 낮고 행동정지 확률도 낮은 편.",
    },
    {
        "character": "린샹",
        "category": "전체공격기",
        "name": "유미쌍조",
        "alias": "流美双爪",
        "mpCost": "10→10→9→8",
        "acquisition": "백호권 도장 수련",
        "description": "잽싼 움직임으로 적의 숨통을 끊는 대지&대공기.",
        "notes": "소모 MP가 낮고 후반까지 쓰기 좋은 전체기.",
    },
    {
        "character": "린샹",
        "category": "전체공격기",
        "name": "대폭진",
        "alias": "大爆震",
        "mpCost": "15→15→14→12",
        "acquisition": "현무의 시련 상자",
        "description": "현무권의 필살기로 적을 넘어뜨리는 전체 공격기술.",
        "notes": "넘어짐 유도에 유용하지만 비행 적에게는 미스율이 높다.",
    },
    {
        "character": "린샹",
        "category": "전체공격기",
        "name": "암각·영상뢰화",
        "alias": "暗刻・嶺上雷花",
        "mpCost": "30→30→27→24",
        "acquisition": "암각권 도장에서 적이 된 린샹 격파",
        "description": "요사스러운 춤으로 뢰운을 불러들여 적전체에 뢰격공격.",
        "notes": "단타 뢰격 전체기. 여러 린샹 모션을 재활용한다.",
    },
    {
        "character": "린샹",
        "category": "특수기",
        "name": "기공회복",
        "alias": "気功回復",
        "mpCost": "5",
        "acquisition": "기본 제공",
        "description": "자기 자신과 아군1명을 회복시키는 기공기술이다!!",
        "notes": "실제로는 지정 대상 1명만 HP 회복하며 자신도 지정 가능.",
    },
    {
        "character": "린샹",
        "category": "특수기",
        "name": "기공독치료",
        "alias": "気功毒治療",
        "mpCost": "3",
        "acquisition": "백호권 도장 수련",
        "description": "몸의 독소를 중화시키는 회복계의 전투 전용기. 단 대상은 한명뿐!!",
        "notes": "",
    },
    {
        "character": "린샹",
        "category": "특수기",
        "name": "기공대회복",
        "alias": "気功大回復",
        "mpCost": "10",
        "acquisition": "6장 유적 또는 7장 론 대화",
        "description": "아군전원의 HP를 회복시키는 회복기술!!",
        "notes": "아군 전체를 크게 회복시키는 지옥 수련장 핵심 회복기.",
    },
    {
        "character": "린샹",
        "category": "특수기",
        "name": "수경",
        "alias": "水鏡",
        "mpCost": "15",
        "acquisition": "창룡의 시련 상자",
        "description": "창룡권의 필살기. 화·수·뢰 공격에 대한 방어력 상승.",
        "notes": "화·수·뢰 대미지를 0으로 고정하지만 본인에게만 적용된다.",
    },
]

SMASH_SKILL_REFERENCE = [
    {
        "character": "스마슈",
        "category": "기본기",
        "name": "베기",
        "alias": "",
        "mpCost": "0",
        "acquisition": "기본",
        "description": "칼을 사용해 순식간에 베는 스마슈의 보통공격.",
        "notes": "돌격 모드에서 초반 원숭이를 한 방에 잡을 수 있을 정도로 강하다.",
    },
    {
        "character": "스마슈",
        "category": "개인공격기",
        "name": "대타격",
        "alias": "大打撃",
        "mpCost": "10→10→9→8",
        "acquisition": "기본 제공",
        "description": "호쾌한 일격으로 적 하나에 큰 타격을 주는 필살기.",
        "notes": "신기가 되면 먼지를 휘날리며 돌진해서 더 강하게 벤다.",
    },
    {
        "character": "스마슈",
        "category": "개인공격기",
        "name": "쾌진격",
        "alias": "快進撃",
        "mpCost": "15→15→14→12",
        "acquisition": "진·호혈 지하 666층 나찰전 후",
        "description": "기를 모아 단번에 방출, 빛의 형태로 적에게 돌진하는 개인공격필살기.",
        "notes": "휙 날아감을 유도할 수 있으며 기술 레벨에 따라 속도와 별 이펙트가 증가.",
    },
    {
        "character": "스마슈",
        "category": "개인공격기",
        "name": "비검·목단미인",
        "alias": "秘剣・木っ端微刃",
        "mpCost": "20→20→18→16",
        "acquisition": "암각권 도장 오른쪽 방 비밀통로 상자",
        "description": "빙글빙글 회전하는 힘을 이용하여 적 한명을 없애는 기술.",
        "notes": "신기가 되면 적 앞이 아니라 적을 둘러싸며 회전한다.",
    },
    {
        "character": "스마슈",
        "category": "전체공격기",
        "name": "백인일섬",
        "alias": "白刃一閃",
        "mpCost": "10→10→9→8",
        "acquisition": "기본 제공",
        "description": "한줄기 흰 섬광을 남기며 적전체를 공격하는 필살기.",
        "notes": "후반까지 유용한 전체기지만 사용 직후 빈틈이 큰 편.",
    },
    {
        "character": "스마슈",
        "category": "전체공격기",
        "name": "인법·분신술",
        "alias": "忍法・分身の術",
        "mpCost": "15→15→14→12",
        "acquisition": "유적 지하 2층 해골 퀴즈",
        "description": "으앗, 스마슈가 여러 명…?! 적 전체를 공격하는 분신공격기인가?!",
        "notes": "기술 레벨에 따라 분신 수가 2/4/6/8명으로 늘지만 명중률 문제가 크다.",
    },
    {
        "character": "스마슈",
        "category": "전체공격기",
        "name": "비검·시공단",
        "alias": "秘剣・時空断",
        "mpCost": "20→20→18→16",
        "acquisition": "맹호권 도장 지하 5층 나찰전 후",
        "description": "검에서 뿜어내는 압력으로 시공을 뒤틀어버리는 초위력의 전체공격기.",
        "notes": "행동정지를 유도할 수 있고 백인일섬의 상위호환에 가까운 후반 주력 전체기.",
    },
    {
        "character": "스마슈",
        "category": "특수기",
        "name": "눈요기",
        "alias": "",
        "mpCost": "0",
        "acquisition": "6장 해변마을 화장실에서 ○○책 획득",
        "description": "느긋하고 편안할 때 읽는 ○○책!!! 읽으면 공격력 상승. 전투중에는 안돼요!",
        "notes": "흥분→분노→폭발직전→대폭발→푸쉬 순서로 상태가 변한다.",
    },
    {
        "character": "스마슈",
        "category": "특수기",
        "name": "인법·몸감추기",
        "alias": "忍法・木の葉隠れ",
        "mpCost": "1",
        "acquisition": "술집 아르바이트 원숭이 도둑 찾기 중 동굴 상자",
        "description": "회피력이 뛰어난 닌자다운 기술! 피하기만 해서야 별 의미가 없지!",
        "notes": "몸을 감추어 적의 공격을 더 잘 회피할 수 있게 한다.",
    },
]

PLAYER_SKILL_REFERENCE = ATAHO_SKILL_REFERENCE + LINXIANG_SKILL_REFERENCE + SMASH_SKILL_REFERENCE

SKILL_LEVEL_LABELS = {
    1: "필살기",
    2: "장기",
    3: "달인기",
    4: "신기",
}

SKILL_EXTRA_PAYLOAD_TERMS = {
    "폭전축": ["퍽전축"],
}

PAYLOAD_SCOPE_LABELS = {
    0x01: "자기/보조",
    0x06: "전체",
    0x0A: "개인",
}

PAYLOAD_ATTACK_FAMILY_LABELS = {
    0x10: "타격/무속성",
    0x11: "돌격/물기",
    0x12: "베기/공중",
    0x13: "화염",
    0x14: "냉기",
    0x15: "풍뢰",
    0x16: "기공/광파",
    0x17: "울부짖음/충격",
    0x18: "독/악취",
    0x19: "장궁/환영",
    0x20: "회복/방어",
    0x21: "MP회복",
    0x23: "HP 대회복",
    0x24: "술 상태",
    0x25: "술 상태",
    0x26: "술취함 상태 변경",
    0x27: "술 상태",
    0x28: "술 상태",
    0x29: "술 상태",
    0x2A: "술취함 상태 변경",
    0x2C: "술취함 상태 변경",
}

PAYLOAD_STATUS_LABELS = {
    0x00: "없음",
    0x01: "넘어짐",
    0x02: "휙날아감",
    0x03: "행동정지",
    0x04: "독",
    0x05: "마비",
}


def attack_family_label(code: int) -> str:
    return PAYLOAD_ATTACK_FAMILY_LABELS.get(code, f"family {code:#04x}")


def js_json(data: object) -> str:
    return json.dumps(data, ensure_ascii=False, separators=(",", ":"))


def hex32(value: int) -> str:
    return f"0x{value:08x}"


def consumable_exe_effect_for(item_name: str) -> dict | None:
    effect = CONSUMABLE_ITEM_EXE_EFFECTS.get(item_name)
    if not effect:
        return None
    result = dict(effect)
    result.update({
        "status": "exe-consumable-effect-handler-confirmed",
        "dispatchTableVaHex": hex32(CONSUMABLE_EFFECT_DISPATCH_TABLE_VA),
        "dispatchRefVaHexes": [hex32(value) for value in CONSUMABLE_EFFECT_DISPATCH_REFS],
        "handlerVaHex": hex32(effect["handlerVa"]),
        "hpHelperVaHex": hex32(CONSUMABLE_EFFECT_HP_HELPER_VA),
        "mpHelperVaHex": hex32(CONSUMABLE_EFFECT_MP_HELPER_VA),
    })
    return result


def clean_name(text: str) -> str:
    text = text.replace("\u3000", " ")
    text = "".join(char if ord(char) >= 32 else " " for char in text)
    return re.sub(r"\s+", " ", text).strip()


def clean_description(text: str) -> str:
    lines = []
    for line in text.replace("\u3000", " ").splitlines():
        line = re.sub(r"\s+", " ", line).strip()
        if line:
            lines.append(line)
    return "\n".join(lines)


def read_name(exe: bytes, sections: list[dict], text_va: int, field_bytes: int = 16) -> str:
    offset = va_to_offset(sections, text_va)
    if offset is None:
        return ""
    blob = exe[offset : offset + field_bytes]
    stops = [index for index, byte in enumerate(blob) if byte in (0x00, 0xFF)]
    if stops:
        blob = blob[: stops[0]]
    if not blob:
        return ""
    try:
        return clean_name(blob.decode("cp949"))
    except UnicodeDecodeError:
        return clean_name(blob.decode("cp949", "ignore"))


def read_script_description(exe: bytes, sections: list[dict], text_va: int, max_bytes: int = 512) -> str:
    offset = va_to_offset(sections, text_va)
    if offset is None:
        return ""
    chunks: list[bytes] = []
    current = bytearray()
    cursor = offset
    end = min(len(exe), offset + max_bytes)
    while cursor < end:
        if cursor + 4 <= len(exe) and exe[cursor] == 0x40:
            opcode = exe[cursor + 1]
            if opcode == 0x00:
                break
            if opcode == 0x02:
                chunks.append(bytes(current))
                current.clear()
                cursor += 4
                continue
            # Unknown text-control marker. Stop instead of swallowing following data.
            break
        current.append(exe[cursor])
        cursor += 1
    if current:
        chunks.append(bytes(current))
    text = "\n".join(chunk.decode("cp949", "ignore") for chunk in chunks)
    return clean_description(text)


def read_description_pointer_table(exe: bytes, sections: list[dict], spec: dict) -> list[dict]:
    offset = va_to_offset(sections, spec["tableVa"])
    if offset is None:
        return []
    header, count = struct.unpack_from("<II", exe, offset)
    pointer_count = count
    pointers_offset = offset + 8
    rows = []
    for pointer_index in range(pointer_count):
        pointer_va = struct.unpack_from("<I", exe, pointers_offset + pointer_index * 4)[0]
        rows.append({
            "pointerIndex": pointer_index,
            "pointerVa": pointer_va,
            "pointerVaHex": hex32(pointer_va),
            "text": read_script_description(exe, sections, pointer_va) if pointer_va else "",
        })
    return rows


def build_description_tables(exe: bytes, sections: list[dict]) -> dict[str, dict]:
    result = {}
    for key, spec in DESCRIPTION_TABLE_SPECS.items():
        rows = read_description_pointer_table(exe, sections, spec)
        usable = rows[spec["skipPointers"] : spec["skipPointers"] + spec["rowCount"]]
        result[key] = {
            "key": key,
            "tableVaHex": hex32(spec["tableVa"]),
            "status": spec["status"],
            "note": spec["note"],
            "rowCount": spec["rowCount"],
            "pointerCount": len(rows),
            "skipPointers": spec["skipPointers"],
            "rows": rows,
            "usableRows": usable,
        }
    return result


def signed8(value: int) -> int:
    return value - 0x100 if value >= 0x80 else value


def cp949_hit_offset_values(exe: bytes, text: str, limit: int = 8) -> list[int]:
    if not text:
        return []
    try:
        needle = text.encode("cp949")
    except UnicodeEncodeError:
        return []
    offsets = []
    start = 0
    while True:
        index = exe.find(needle, start)
        if index < 0:
            break
        offsets.append(index)
        if len(offsets) >= limit:
            break
        start = index + 1
    return offsets


def cp949_hit_offsets(exe: bytes, text: str, limit: int = 8) -> list[str]:
    return [f"0x{offset:06x}" for offset in cp949_hit_offset_values(exe, text, limit)]


def read_fixed_name_at_offset(exe: bytes, offset: int, field_bytes: int = NAME_BYTES) -> str:
    if offset < 0 or offset + field_bytes > len(exe):
        return ""
    blob = exe[offset : offset + field_bytes]
    stops = [index for index, byte in enumerate(blob) if byte in (0x00, 0xFF)]
    if stops:
        blob = blob[: stops[0]]
    if not blob:
        return ""
    try:
        return clean_name(blob.decode("cp949"))
    except UnicodeDecodeError:
        return clean_name(blob.decode("cp949", "ignore"))


def decode_action_payload_at_offset(exe: bytes, offset: int) -> dict | None:
    if offset < 0 or offset + NAME_BYTES + 6 > len(exe):
        return None
    name = read_fixed_name_at_offset(exe, offset)
    if not name:
        return None
    raw = exe[offset : offset + 256]
    mp_cost = raw[NAME_BYTES + 1]
    count = raw[NAME_BYTES + 2]
    expected_length = NAME_BYTES + 6 + count * 8
    if not (1 <= count <= 12) or expected_length > len(raw):
        return None
    prefix = list(raw[NAME_BYTES + 3 : NAME_BYTES + 6])
    unit_bytes = raw[NAME_BYTES + 6 : NAME_BYTES + 6 + count * 8]
    units = [
        decode_payload_unit(unit_bytes[index : index + 8])
        for index in range(0, len(unit_bytes), 8)
    ]
    if not all(unit.get("valid") for unit in units):
        return None
    result = {
        "status": "action-payload-decoded-from-text-hit",
        "name": name,
        "fileOffsetHex": f"0x{offset:06x}",
        "levelByte": raw[NAME_BYTES],
        "levelByteHex": f"0x{raw[NAME_BYTES]:02x}",
        "mpCost": mp_cost,
        "sequenceCount": count,
        "prefixBytes": prefix,
        "prefixBytesHex": " ".join(f"{value:02x}" for value in prefix),
        "units": units,
        "expectedLength": expected_length,
    }
    result["summary"] = payload_summary(result)
    return result


def action_payload_hits_for_terms(exe: bytes, terms: list[str]) -> list[dict]:
    rows = []
    seen_offsets = set()
    for term in terms:
        for offset in cp949_hit_offset_values(exe, term, limit=16):
            if offset in seen_offsets:
                continue
            payload = decode_action_payload_at_offset(exe, offset)
            if not payload:
                continue
            if payload.get("name") != term:
                continue
            seen_offsets.add(offset)
            rows.append({
                "term": term,
                "fileOffsetHex": payload["fileOffsetHex"],
                "levelByte": payload["levelByte"],
                "levelByteHex": payload["levelByteHex"],
                "mpCost": payload["mpCost"],
                "sequenceCount": payload["sequenceCount"],
                "summary": payload["summary"],
            })
    return rows


def parse_mp_sequence(value: str) -> list[int]:
    parts = re.split(r"\s*→\s*", str(value or "").strip())
    if len(parts) <= 1:
        return []
    result = []
    for part in parts:
        if not re.fullmatch(r"\d+", part):
            return []
        result.append(int(part))
    return result


def skill_level_payloads(ref: dict, payload_hits: list[dict]) -> dict:
    expected_mp = parse_mp_sequence(ref.get("mpCost", ""))
    if not expected_mp:
        return {
            "status": "not-level-skill",
            "expectedMp": [],
            "levels": [],
            "summary": "-",
            "confirmed": False,
        }

    by_level: dict[int, dict] = {}
    for hit in sorted(payload_hits, key=lambda row: int(str(row["fileOffsetHex"]), 16)):
        level = int(hit.get("levelByte") or 0)
        if level not in SKILL_LEVEL_LABELS:
            continue
        by_level.setdefault(level, hit)

    levels = []
    for level, expected in enumerate(expected_mp, start=1):
        hit = by_level.get(level)
        if hit:
            mp = int(hit.get("mpCost") or 0)
            attack_count = int(hit.get("sequenceCount") or 0)
            levels.append({
                "level": level,
                "label": SKILL_LEVEL_LABELS.get(level, f"Lv{level}"),
                "fileOffsetHex": hit.get("fileOffsetHex"),
                "levelByteHex": hit.get("levelByteHex"),
                "mpCost": mp,
                "expectedMpCost": expected,
                "attackCount": attack_count,
                "summary": hit.get("summary") or "",
                "mpMatches": mp == expected,
                "sourceTerm": hit.get("term"),
            })
        else:
            levels.append({
                "level": level,
                "label": SKILL_LEVEL_LABELS.get(level, f"Lv{level}"),
                "expectedMpCost": expected,
                "missing": True,
                "mpMatches": False,
            })

    found_count = sum(1 for row in levels if not row.get("missing"))
    complete = found_count == len(levels)
    mp_matches = all(row.get("mpMatches") for row in levels)
    if complete and mp_matches:
        status = "exe-level-payload-confirmed"
    elif found_count:
        status = "exe-level-payload-partial"
    else:
        status = "exe-level-payload-missing"
    summary_parts = []
    for row in levels:
        if row.get("missing"):
            summary_parts.append(f"{row['label']} MP {row['expectedMpCost']} / missing")
        else:
            summary_parts.append(f"{row['label']} MP {row['mpCost']} / {row['attackCount']}타")
    return {
        "status": status,
        "expectedMp": expected_mp,
        "levels": levels,
        "summary": " · ".join(summary_parts),
        "confirmed": status == "exe-level-payload-confirmed",
        "foundCount": found_count,
    }


def grid_candidate_from_record(row: dict, source: str, reason: str) -> dict | None:
    grid = row.get("grid") or {}
    if not row.get("isDirectGridMapping") or not grid.get("inSheet"):
        return None
    return {
        "source": source,
        "reason": reason,
        "tableKey": row.get("tableKey"),
        "tableTitle": row.get("tableTitle"),
        "rowName": row.get("name"),
        "recordVaHex": row.get("recordVaHex"),
        "metaHex": row.get("metaHex"),
        "cns": grid.get("cns"),
        "sheetKey": grid.get("sheetKey"),
        "cellIndex": grid.get("cellIndex"),
        "cellIndex1Based": grid.get("cellIndex1Based"),
        "x": grid.get("x"),
        "y": grid.get("y"),
        "w": grid.get("w"),
        "h": grid.get("h"),
    }


def grid_candidate_from_grid(
    grid: dict | None,
    source: str,
    reason: str,
    **extra: object,
) -> dict | None:
    if not grid or not grid.get("inSheet") or not grid.get("sheetKey"):
        return None
    candidate = {
        "source": source,
        "reason": reason,
        "cns": grid.get("cns"),
        "sheetKey": grid.get("sheetKey"),
        "cellIndex": grid.get("cellIndex"),
        "cellIndex1Based": grid.get("cellIndex1Based"),
        "x": grid.get("x"),
        "y": grid.get("y"),
        "w": grid.get("w"),
        "h": grid.get("h"),
    }
    candidate.update({key: value for key, value in extra.items() if value not in (None, "")})
    return candidate


def grid_candidate_summary(candidate: dict | None) -> str:
    if not candidate:
        return "-"
    return (
        f"{candidate.get('cns')} #{candidate.get('cellIndex')} "
        f"({candidate.get('x')},{candidate.get('y')},"
        f"{candidate.get('w')},{candidate.get('h')})"
    )


def skill_display_icon_info(ref: dict, match_rows: list[dict], records: list[dict]) -> dict:
    candidates = []
    seen = set()

    def add(candidate: dict | None) -> None:
        if not candidate:
            return
        key = (
            candidate.get("source"),
            candidate.get("cns"),
            candidate.get("cellIndex"),
            candidate.get("rowName"),
        )
        if key in seen:
            return
        seen.add(key)
        candidates.append(candidate)

    for row in match_rows:
        if (row.get("grid") or {}).get("sheetKey") == "icon":
            add(grid_candidate_from_record(
                row,
                "direct-action-or-command-icon",
                "EXE action/command record carries this icon.cns cell directly.",
            ))

    for row in records:
        if row.get("tableKey") != "equipment":
            continue
        detail = row.get("detailRecord") or {}
        for skill_ref in detail.get("equipmentSkillRefs") or []:
            if skill_ref.get("name") != ref["name"]:
                continue
            action_candidate = grid_candidate_from_grid(
                skill_ref.get("actionGrid"),
                "equipment-action-icon",
                "Equipment action record carries this icon.cns cell for the granted command.",
                equipmentName=row.get("name"),
                grantLabel=skill_ref.get("label"),
                recordVaHex=skill_ref.get("recordVaHex"),
                metaHex=skill_ref.get("metaHex"),
            )
            if action_candidate and action_candidate.get("cellIndex") != 0:
                action_candidate["grantedSkillPayloadSummary"] = skill_ref.get("payloadSummary")
                add(action_candidate)
            candidate = grid_candidate_from_record(
                row,
                "equipment-source-item-icon",
                "Equipment-granted skills can display the source equipment item.cns cell.",
            )
            if candidate:
                candidate["grantLabel"] = skill_ref.get("label")
                candidate["grantedSkillPayloadSummary"] = skill_ref.get("payloadSummary")
                add(candidate)

    if candidates:
        status = "cns-bound"
        summary = "; ".join(
            f"{candidate['cns']} #{candidate['cellIndex']} via {candidate['source']}"
            for candidate in candidates[:3]
        )
        if len(candidates) > 3:
            summary = f"{summary}; +{len(candidates) - 3} more"
    elif ref.get("category") in {"개인공격기", "전체공격기", "특수기"}:
        status = "category-icon-unbound"
        summary = f"{ref['category']} 공용 아이콘 규칙은 예상되지만 CNS 셀은 아직 미확정"
    else:
        status = "unbound"
        summary = "표시 아이콘 CNS 셀 미확정"

    return {
        "status": status,
        "summary": summary,
        "candidateCount": len(candidates),
        "candidates": candidates,
    }


def equipment_skill_binding_records(records: list[dict]) -> list[dict]:
    rows = []
    for equipment_row in records:
        if equipment_row.get("tableKey") != "equipment":
            continue
        detail = equipment_row.get("detailRecord") or {}
        target = detail.get("equipTarget") or {}
        source_icon = grid_candidate_from_record(
            equipment_row,
            "equipment-source-item-icon",
            "Source equipment item.cns cell.",
        )
        for skill_ref in detail.get("equipmentSkillRefs") or []:
            action_icon = grid_candidate_from_grid(
                skill_ref.get("actionGrid"),
                "equipment-action-icon",
                "Granted equipment action icon.cns cell.",
                recordVaHex=skill_ref.get("recordVaHex"),
                metaHex=skill_ref.get("metaHex"),
            )
            candidates = [
                candidate for candidate in (action_icon, source_icon) if candidate
            ]
            payload_fields = payload_field_summary(skill_ref.get("payload"))
            rows.append({
                "index": len(rows),
                "index1Based": len(rows) + 1,
                "character": target.get("character") or "",
                "slot": target.get("slot") or "",
                "equipmentName": equipment_row.get("name"),
                "equipmentRecordVaHex": equipment_row.get("recordVaHex"),
                "equipmentMetaHex": equipment_row.get("metaHex"),
                "sourceEquipmentIcon": source_icon,
                "skillSlotLabel": skill_ref.get("label"),
                "skillFieldKey": skill_ref.get("key"),
                "skillTableIndex": skill_ref.get("index"),
                "skillName": skill_ref.get("name"),
                "skillRecordVaHex": skill_ref.get("recordVaHex"),
                "skillTextVaHex": skill_ref.get("textVaHex"),
                "skillMetaHex": skill_ref.get("metaHex"),
                "skillActionIcon": action_icon,
                "payloadSummary": skill_ref.get("payloadSummary") or "",
                "payloadFields": payload_fields,
                "mpCost": payload_fields.get("mpCost"),
                "attackCount": payload_fields.get("attackCount"),
                "targetSummary": payload_fields.get("targetSummary"),
                "familySummary": payload_fields.get("familySummary"),
                "statusSummary": payload_fields.get("statusSummary"),
                "specialSummary": payload_fields.get("specialSummary"),
                "isAttackPayload": payload_fields.get("isAttackPayload"),
                "displayIconSummary": "; ".join(grid_candidate_summary(candidate) for candidate in candidates),
                "bindingStatus": "cns-bound" if candidates else "unbound",
            })
    return rows


def skill_reference_records(exe: bytes, records: list[dict]) -> list[dict]:
    rows = []
    skill_records = [
        row for row in records
        if row.get("kind") in {"skill-action", "skill-command", "skill-name"}
    ]
    for index, ref in enumerate(PLAYER_SKILL_REFERENCE):
        name = ref["name"]
        match_rows = [row for row in skill_records if row.get("name") == name]
        hit_terms = [name]
        if name.startswith("비기·"):
            hit_terms.append(name.replace("비기·", "", 1))
        if name == "맹호의 울부짖음":
            hit_terms.append("울부짖음")
        hit_terms.extend(SKILL_EXTRA_PAYLOAD_TERMS.get(name, []))
        payload_hits = action_payload_hits_for_terms(exe, hit_terms)
        level_payloads = skill_level_payloads(ref, payload_hits)
        text_hits = []
        for term in hit_terms:
            text_hits.extend(cp949_hit_offsets(exe, term))
        deduped_hits = list(dict.fromkeys(text_hits))
        if match_rows:
            evidence_status = "exe-name-record + reference"
        elif payload_hits:
            evidence_status = "exe-action-payload + reference"
        elif deduped_hits:
            evidence_status = "exe-text-hit + reference"
        else:
            evidence_status = "reference-only"
        icon_info = skill_display_icon_info(ref, match_rows, records)
        rows.append({
            "index": index,
            "index1Based": index + 1,
            "source": "user-wiki-reference",
            "character": ref["character"],
            "category": ref["category"],
            "name": name,
            "alias": ref["alias"],
            "mpCost": ref["mpCost"],
            "acquisition": ref["acquisition"],
            "description": ref["description"],
            "notes": ref["notes"],
            "summary": (
                f"{ref['character']} / {ref['category']} / MP {ref['mpCost']} / "
                f"{ref['description']}"
            ),
            "matchedRecordCount": len(match_rows),
            "matchedRecords": [
                {
                    "tableKey": row["tableKey"],
                    "tableTitle": row["tableTitle"],
                    "index1Based": row["index1Based"],
                    "recordVaHex": row["recordVaHex"],
                    "metaHex": row["metaHex"],
                    "mappingStatus": row["mappingStatus"],
                }
                for row in match_rows
            ],
            "actionPayloadHitCount": len(payload_hits),
            "actionPayloadHits": payload_hits,
            "exeLevelPayloadStatus": level_payloads["status"],
            "exeLevelPayloadSummary": level_payloads["summary"],
            "exeLevelPayloadConfirmed": level_payloads["confirmed"],
            "exeLevelPayloads": level_payloads["levels"],
            "exeTextHitCount": len(deduped_hits),
            "exeTextHitOffsets": deduped_hits,
            "evidenceStatus": evidence_status,
            "skillDisplayIconStatus": icon_info["status"],
            "skillDisplayIconSummary": icon_info["summary"],
            "displayIconCandidateCount": icon_info["candidateCount"],
            "displayIconCandidates": icon_info["candidates"],
        })
    return rows


def decode_equipment_target(value: int) -> dict:
    character_mask = value & 0x7F
    target = EQUIPMENT_TARGET_TABLES.get(character_mask)
    slot = "방어구" if value & 0x80 else "무기"
    character = target["character"] if target else f"unknown-{character_mask:#04x}"
    return {
        "offset": 25,
        "key": "f25",
        "raw": value,
        "rawHex": f"0x{value:02x}",
        "signed": signed8(value),
        "characterMask": character_mask,
        "characterMaskHex": f"0x{character_mask:02x}",
        "character": character,
        "slot": slot,
        "label": f"{character} {slot}",
        "actionTableBaseVaHex": hex32(target["baseVa"]) if target else None,
        "actionTableEndVaHex": hex32(target["endVa"]) if target else None,
    }


def decode_payload_unit(unit: bytes | bytearray) -> dict:
    values = list(unit)
    if len(values) != 8:
        return {"bytes": values, "valid": False}
    scope_code = values[4]
    family_code = values[5]
    status_code = values[7]
    return {
        "bytes": values,
        "bytesHex": " ".join(f"{value:02x}" for value in values),
        "valid": True,
        "coefficients": values[:4],
        "scopeCode": scope_code,
        "scopeLabel": PAYLOAD_SCOPE_LABELS.get(scope_code, f"scope {scope_code:#04x}"),
        "attackFamilyCode": family_code,
        "attackFamilyLabel": attack_family_label(family_code),
        "auxCode": values[6],
        "statusCode": status_code,
        "statusLabel": PAYLOAD_STATUS_LABELS.get(status_code, f"status {status_code:#04x}"),
    }


def payload_summary(payload: dict | None) -> str:
    if not payload:
        return ""
    units = payload.get("units") or []
    effect_units = [
        unit for unit in units
        if not (
            unit.get("coefficients") == [0, 0, 0, 0]
            and unit.get("scopeCode") == 0x01
            and unit.get("statusCode") == 0x00
            and 0x24 <= int(unit.get("attackFamilyCode", -1)) <= 0x2C
        )
    ]
    first = (effect_units or units)[0] if units else {}
    if not first:
        return f"MP {payload.get('mpCost', 0)}"
    statuses = []
    for unit in effect_units or units:
        status = unit.get("statusLabel") or "없음"
        if status != "없음" and status not in statuses:
            statuses.append(status)
    status_part = "" if not statuses else f", {'/'.join(statuses)}"
    count = len(effect_units) if effect_units else 0
    count_part = f", {count}타" if count > 1 else ""
    if (
        not effect_units
        and first.get("coefficients") == [0, 0, 0, 0]
        and first.get("scopeCode") == 0x01
        and 0x24 <= int(first.get("attackFamilyCode", -1)) <= 0x2C
    ):
        return f"MP {payload.get('mpCost', 0)}, 술취함 상태 변경"
    return (
        f"MP {payload.get('mpCost', 0)}, {first.get('scopeLabel')}, "
        f"{first.get('attackFamilyLabel')}{count_part}{status_part}"
    )


def payload_effect_units(payload: dict | None) -> list[dict]:
    if not payload:
        return []
    units = payload.get("units") or []
    return [
        unit for unit in units
        if not (
            unit.get("coefficients") == [0, 0, 0, 0]
            and unit.get("scopeCode") == 0x01
            and unit.get("statusCode") == 0x00
            and 0x24 <= int(unit.get("attackFamilyCode", -1)) <= 0x2C
        )
    ]


def payload_field_summary(payload: dict | None) -> dict:
    if not payload:
        return {
            "mpCost": None,
            "attackCount": None,
            "targetSummary": "-",
            "familySummary": "-",
            "statusSummary": "-",
            "specialSummary": "-",
            "isAttackPayload": False,
        }
    units = payload.get("units") or []
    effect_units = payload_effect_units(payload)
    first = (effect_units or units)[0] if units else {}
    if not first:
        return {
            "mpCost": payload.get("mpCost"),
            "attackCount": None,
            "targetSummary": "-",
            "familySummary": "-",
            "statusSummary": "-",
            "specialSummary": "-",
            "isAttackPayload": False,
        }
    if not effect_units and (
        first.get("coefficients") == [0, 0, 0, 0]
        and first.get("scopeCode") == 0x01
        and 0x24 <= int(first.get("attackFamilyCode", -1)) <= 0x2C
    ):
        return {
            "mpCost": payload.get("mpCost"),
            "attackCount": None,
            "targetSummary": "-",
            "familySummary": "-",
            "statusSummary": "-",
            "specialSummary": "술취함 상태 변경",
            "isAttackPayload": False,
        }
    targets = []
    families = []
    statuses = []
    for unit in effect_units or units:
        target = unit.get("scopeLabel") or "-"
        family = unit.get("attackFamilyLabel") or "-"
        status = unit.get("statusLabel") or "없음"
        if target not in targets:
            targets.append(target)
        if family not in families:
            families.append(family)
        if status != "없음" and status not in statuses:
            statuses.append(status)
    is_attack = any(unit.get("scopeCode") in (0x06, 0x0A) for unit in effect_units or units)
    return {
        "mpCost": payload.get("mpCost"),
        "attackCount": len(effect_units or units) if is_attack else None,
        "targetSummary": "/".join(targets) if targets else "-",
        "familySummary": "/".join(families) if families else "-",
        "statusSummary": "/".join(statuses) if statuses else "-",
        "specialSummary": "-" if is_attack or statuses else "보조/회복",
        "isAttackPayload": is_attack,
    }


def decode_action_payload(exe: bytes, sections: list[dict], pointer_va: int) -> dict | None:
    offset = va_to_offset(sections, pointer_va)
    if offset is None:
        return None
    raw = exe[offset : offset + 256]
    if len(raw) < NAME_BYTES + 3:
        return None
    count = raw[NAME_BYTES + 2]
    expected_length = NAME_BYTES + 6 + count * 8
    if expected_length > len(raw):
        return None
    prefix = list(raw[NAME_BYTES + 3 : NAME_BYTES + 6])
    unit_bytes = raw[NAME_BYTES + 6 : NAME_BYTES + 6 + count * 8]
    units = [
        decode_payload_unit(unit_bytes[index : index + 8])
        for index in range(0, len(unit_bytes), 8)
    ]
    result = {
        "status": "action-payload-decoded",
        "separatorHex": raw[NAME_BYTES : NAME_BYTES + 1].hex(" "),
        "mpCost": raw[NAME_BYTES + 1],
        "sequenceCount": count,
        "prefixBytes": prefix,
        "prefixBytesHex": " ".join(f"{value:02x}" for value in prefix),
        "units": units,
        "expectedLength": expected_length,
    }
    result["summary"] = payload_summary(result)
    return result


def decode_equipment_battle_modifier(fields: bytes, index: int) -> dict | None:
    if index >= len(fields) or not fields[index]:
        return None
    candidate_family_code = index + 0x0B
    value = signed8(fields[index])
    return {
        "offset": index,
        "key": f"f{index:02d}",
        "value": value,
        "valueHex": f"0x{fields[index]:02x}",
        "candidateAttackFamilyCode": candidate_family_code,
        "candidateAttackFamilyHex": f"0x{candidate_family_code:02x}",
        "candidateAttackFamilyLabel": attack_family_label(candidate_family_code),
        "summary": f"f{index:02d} {value:+d}",
    }


def decode_equipment_skill_ref(
    exe: bytes,
    sections: list[dict],
    target: dict,
    field_offset: int,
    label: str,
    index: int,
) -> dict:
    table = EQUIPMENT_TARGET_TABLES.get(target.get("characterMask"))
    record_va = (table or {}).get("baseVa", 0) + index * 8 if table else 0
    record_offset = va_to_offset(sections, record_va) if table else None
    pointer_va = 0
    meta = 0
    name = ""
    payload = None
    in_range = bool(table and record_va < table["endVa"])
    if in_range and record_offset is not None:
        pointer_va, meta = struct.unpack_from("<II", exe, record_offset)
        name = read_name(exe, sections, pointer_va) if pointer_va else ""
        payload = decode_action_payload(exe, sections, pointer_va) if pointer_va else None
    action_grid = grid_for_meta(meta) if in_range else {}
    return {
        "offset": field_offset,
        "key": f"f{field_offset:02d}",
        "label": label,
        "index": index,
        "recordVaHex": hex32(record_va) if record_va else None,
        "textVaHex": hex32(pointer_va) if pointer_va else None,
        "metaHex": hex32(meta) if in_range else None,
        "actionGrid": action_grid,
        "isDirectActionIcon": bool(
            action_grid.get("sheetKey") == "icon"
            and action_grid.get("inSheet")
            and action_grid.get("cellIndex") != 0
        ),
        "name": name or "(empty)",
        "payload": payload,
        "payloadSummary": payload_summary(payload),
        "status": "equipment-action-table-index" if name else "missing-or-empty-equipment-action-index",
    }


def read_fixed_blob(exe: bytes, sections: list[dict], va: int, size: int) -> bytes:
    offset = va_to_offset(sections, va)
    if offset is None:
        return b""
    return exe[offset : offset + size]


def detail_record_for(exe: bytes, sections: list[dict], spec: dict, text_va: int) -> dict | None:
    if not text_va:
        return None
    if spec["key"] == "equipment":
        blob = read_fixed_blob(exe, sections, text_va, 0x2E)
        if len(blob) != 0x2E:
            return None
        fields = blob[16:]
        nonzero = [
            {
                "offset": index,
                "key": f"f{index:02d}",
                "hex": f"0x{value:02x}",
                "u8": value,
                "s8": signed8(value),
            }
            for index, value in enumerate(fields)
            if value
        ]
        stat_rows = [
            {
                "offset": index,
                "key": f"f{index:02d}",
                "label": label,
                "value": signed8(fields[index]),
            }
            for index, label in EQUIPMENT_STAT_FIELDS
            if index < len(fields) and fields[index]
        ]
        stat_keys = {row["key"] for row in stat_rows}
        target = decode_equipment_target(fields[25]) if fields[25] else None
        skill_refs = [
            decode_equipment_skill_ref(exe, sections, target, index, label, fields[index])
            for index, label in EQUIPMENT_SKILL_FIELDS
            if target and index < len(fields) and fields[index]
        ]
        battle_modifiers = [
            modifier
            for index in EQUIPMENT_BATTLE_MODIFIER_OFFSETS
            if (modifier := decode_equipment_battle_modifier(fields, index))
        ]
        decoded_keys = (
            stat_keys
            | {"f25"}
            | {row["key"] for row in skill_refs}
            | {row["key"] for row in battle_modifiers}
        )
        extra_fields = [field for field in nonzero if field["key"] not in decoded_keys]
        stat_summary = " ".join(
            f"{row['label']} {row['value']:+d}" for row in stat_rows
        )
        target_summary = target["label"] if target else ""
        skill_summary = ", ".join(
            f"{row['label']}={row['name']}({row['payloadSummary']})"
            if row.get("payloadSummary") else f"{row['label']}={row['name']}"
            for row in skill_refs
        )
        battle_modifier_summary = ", ".join(row["summary"] for row in battle_modifiers)
        raw_extra_summary = " ".join(f"{field['key']}={field['s8']}" for field in extra_fields)
        summary = stat_summary or "능력치 증가 없음"
        if target_summary:
            summary = f"{summary} / {target_summary}"
        if skill_summary:
            summary = f"{summary} / {skill_summary}"
        if battle_modifier_summary:
            summary = f"{summary} / 전투 보정 {battle_modifier_summary}"
        if raw_extra_summary:
            summary = f"{summary} / raw {raw_extra_summary}"
        return {
            "kind": "equipment-detail-record",
            "status": "equipment-stats-decoded",
            "recordVaHex": hex32(text_va),
            "strideBytes": 0x2E,
            "nameBytes": 16,
            "fieldBytes": len(fields),
            "rawHex": fields.hex(" "),
            "statFields": stat_rows,
            "equipTarget": target,
            "equipmentSkillRefs": skill_refs,
            "battleModifiers": battle_modifiers,
            "familyModifiers": battle_modifiers,
            "nonZeroFields": nonzero,
            "extraFields": extra_fields,
            "summary": summary,
            "statSummary": stat_summary or "능력치 증가 없음",
            "equipTargetSummary": target_summary,
            "equipmentSkillSummary": skill_summary,
            "battleModifierSummary": battle_modifier_summary,
            "familyModifierSummary": battle_modifier_summary,
            "rawExtraSummary": raw_extra_summary,
            "classificationNote": "f00~f04 are decoded as attack/defense/technique/agility/luck; f05~f23 are signed battle modifier fields whose exact formula labels are not confirmed; f25 is character/slot; f26~f29 are per-character equipment action table indices.",
        }
    if spec["key"] == "items":
        blob = read_fixed_blob(exe, sections, text_va, 0x12)
        if len(blob) != 0x12:
            return None
        flag = struct.unpack_from("<H", blob, 16)[0]
        meaning = {
            0: "소모품/전투·메뉴 사용 아이템 후보",
            1: "중요품/스토리 아이템 후보",
        }.get(flag, "미분류 아이템 플래그")
        item_name = read_name(exe, sections, text_va)
        reference_effect = CONSUMABLE_ITEM_REFERENCE.get(item_name)
        reference_use = KEY_ITEM_REFERENCE.get(item_name)
        exe_effect = consumable_exe_effect_for(item_name)
        reference_label = ""
        reference_summary = ""
        reference_price_summary = ""
        if reference_effect:
            price = reference_effect.get("priceGold")
            price_summary = "상점 구매 불가" if price is None else f"{price}G"
            reference_label = "참고 가격"
            reference_price_summary = price_summary
            reference_summary = f"{reference_effect['effectSummary']}, {price_summary}"
        elif reference_use:
            reference_label = "참고 용도"
            reference_summary = reference_use["useSummary"]
        summary = f"{meaning} ({flag:#04x})"
        if exe_effect:
            summary = f"{summary} / EXE 효과 {exe_effect['effectSummary']}"
        if reference_summary:
            summary = f"{summary} / {reference_label} {reference_summary}"
        return {
            "kind": "item-detail-record",
            "status": (
                "item-flag-and-exe-effect-handler-extracted"
                if exe_effect else "item-flag-extracted"
            ),
            "recordVaHex": hex32(text_va),
            "strideBytes": 0x12,
            "nameBytes": 16,
            "flag": flag,
            "flagHex": f"0x{flag:04x}",
            "flagMeaning": meaning,
            "exeEffect": exe_effect,
            "exeEffectSummary": exe_effect["effectSummary"] if exe_effect else "",
            "exeEffectStatus": exe_effect["status"] if exe_effect else "",
            "referenceEffect": reference_effect,
            "referenceUse": reference_use,
            "referenceLabel": reference_label,
            "referenceSummary": reference_summary,
            "referencePriceSummary": reference_price_summary,
            "referenceEffectSummary": reference_summary,
            "summary": summary,
            "classificationNote": (
                "The 0x12-byte item record itself confirms the 16-bit item-kind flag. "
                "For the first six consumables, the runtime effect is confirmed through "
                "the effect dispatch table at 0x00546a38 and the first-six consumable "
                "row order/behavior. Prices and key item uses remain reference/event/shop data."
            ),
        }
    return None


def grid_for_meta(meta: int) -> dict:
    high = meta >> 16
    low = meta & 0xFFFF
    sheet = SHEETS.get(high)
    if not sheet:
        return {
            "sheetKey": None,
            "sheetClass": high,
            "sheetClassHex": f"0x{high:04x}",
            "cellIndex": low,
            "cellIndexHex": f"0x{low:02x}",
            "inSheet": False,
        }
    col = low % sheet["columns"]
    row = low // sheet["columns"]
    in_sheet = low < sheet["cellCount"]
    return {
        "sheetKey": sheet["key"],
        "sheetClass": high,
        "sheetClassHex": f"0x{high:04x}",
        "cns": sheet["cns"],
        "png": sheet["png"],
        "webPath": sheet["webPath"],
        "cellIndex": low,
        "cellIndexHex": f"0x{low:02x}",
        "cellIndex1Based": low + 1,
        "col": col,
        "row": row,
        "x": col * sheet["cellWidth"],
        "y": row * sheet["cellHeight"],
        "w": sheet["cellWidth"],
        "h": sheet["cellHeight"],
        "inSheet": in_sheet,
    }


def read_record(
    exe: bytes,
    sections: list[dict],
    spec: dict,
    index: int,
    description_tables: dict[str, dict],
) -> dict:
    record_va = spec["startVa"] + index * 8
    offset = va_to_offset(sections, record_va)
    if offset is None:
        raise ValueError(f"record VA outside file: {hex32(record_va)}")
    text_va, meta = struct.unpack_from("<II", exe, offset)
    grid = grid_for_meta(meta)
    name = read_name(exe, sections, text_va) if text_va else ""
    if not name:
        name = "(empty)"
    detail_record = detail_record_for(exe, sections, spec, text_va)
    description_rows = (description_tables.get(spec["key"]) or {}).get("usableRows") or []
    description_row = description_rows[index] if index < len(description_rows) else None
    status = spec["status"]
    if grid.get("sheetKey") == "icon" and grid.get("cellIndex") == 0 and spec["kind"] == "skill-name":
        status = "no-direct-icon-in-name-table"
    is_direct_grid_mapping = status in {"confirmed-item-grid", "direct-icon-grid"} and bool(grid.get("inSheet"))
    return {
        "tableKey": spec["key"],
        "tableTitle": spec["title"],
        "kind": spec["kind"],
        "index": index,
        "index1Based": index + 1,
        "recordVa": record_va,
        "recordVaHex": hex32(record_va),
        "textVa": text_va,
        "textVaHex": hex32(text_va) if text_va else "0x00000000",
        "name": name,
        "meta": meta,
        "metaHex": hex32(meta),
        "mappingStatus": status,
        "isDirectGridMapping": is_direct_grid_mapping,
        "grid": grid,
        "detailRecord": detail_record,
        "description": (description_row or {}).get("text") or "",
        "descriptionVaHex": (description_row or {}).get("pointerVaHex"),
        "descriptionStatus": (description_tables.get(spec["key"]) or {}).get("status"),
    }


def build_summary(exe: bytes) -> dict:
    sections = read_sections(exe)
    description_tables = build_description_tables(exe, sections)
    tables = []
    records = []
    for spec in TABLE_SPECS:
        table_records = [
            read_record(exe, sections, spec, index, description_tables)
            for index in range(spec["count"])
        ]
        tables.append({
            "key": spec["key"],
            "title": spec["title"],
            "kind": spec["kind"],
            "startVaHex": hex32(spec["startVa"]),
            "count": spec["count"],
            "status": spec["status"],
            "note": spec["note"],
            "records": table_records,
        })
        records.extend(table_records)

    skill_refs = skill_reference_records(exe, records)
    equipment_skill_bindings = equipment_skill_binding_records(records)
    confirmed_item_records = [
        row for row in records
        if row["grid"].get("sheetKey") == "item" and row["grid"].get("inSheet")
    ]
    direct_icon_records = [
        row for row in records
        if row["grid"].get("sheetKey") == "icon"
        and row["grid"].get("inSheet")
        and row["grid"].get("cellIndex") != 0
    ]
    no_direct_icon_records = [
        row for row in records
        if row["mappingStatus"] == "no-direct-icon-in-name-table"
    ]
    equipment_detail_records = [
        row for row in records
        if (row.get("detailRecord") or {}).get("kind") == "equipment-detail-record"
    ]
    item_flag_records = [
        row for row in records
        if (row.get("detailRecord") or {}).get("kind") == "item-detail-record"
    ]
    item_reference_effect_records = [
        row for row in item_flag_records
        if (row.get("detailRecord") or {}).get("referenceEffect")
    ]
    item_exe_effect_records = [
        row for row in item_flag_records
        if (row.get("detailRecord") or {}).get("exeEffect")
    ]
    item_reference_use_records = [
        row for row in item_flag_records
        if (row.get("detailRecord") or {}).get("referenceUse")
    ]
    item_reference_records = item_reference_effect_records + item_reference_use_records
    equipment_description_records = [
        row for row in records
        if row["tableKey"] == "equipment" and row.get("description")
    ]
    item_description_records = [
        row for row in records
        if row["tableKey"] == "items" and row.get("description")
    ]
    direct_grid_records = [row for row in records if row.get("isDirectGridMapping")]
    confirmed_skill_level_payloads = [
        row for row in skill_refs if row.get("exeLevelPayloadConfirmed")
    ]
    return {
        "scope": "Item/equipment/action/category EXE records mapped to UI CNS 32x32 grid cells.",
        "source": "Hwanse2.exe data records + icon.cns + item.cns rendered through web/engine/cns/renderer.js",
        "coordinateSystem": "0-based pixels in decoded CNS canvas; x=(cellIndex % columns)*32, y=(cellIndex // columns)*32.",
        "status": "item/equipment confirmed; action/category/equipment-source icons mapped; skill-name tables are treated as name/payload evidence, not as per-skill icon tables.",
        "sheets": list(SHEETS.values()),
        "tableCount": len(tables),
        "recordCount": len(records),
        "directGridRecordCount": len(direct_grid_records),
        "confirmedItemEquipmentCount": len(confirmed_item_records),
        "directIconRecordCount": len(direct_icon_records),
        "noDirectIconNameRecordCount": len(no_direct_icon_records),
        "defaultIconOrNoDirectIconCount": len(no_direct_icon_records),
        "equipmentDetailRecordCount": len(equipment_detail_records),
        "itemFlagRecordCount": len(item_flag_records),
        "itemReferenceCount": len(item_reference_records),
        "itemReferenceEffectCount": len(item_reference_effect_records),
        "itemReferenceConsumableCount": len(item_reference_effect_records),
        "itemExeEffectCount": len(item_exe_effect_records),
        "itemExeEffectConfirmedCount": len(item_exe_effect_records),
        "consumableEffectDispatchTableVaHex": hex32(CONSUMABLE_EFFECT_DISPATCH_TABLE_VA),
        "consumableEffectDispatchRefVaHexes": [
            hex32(value) for value in CONSUMABLE_EFFECT_DISPATCH_REFS
        ],
        "itemReferenceUseCount": len(item_reference_use_records),
        "skillReferenceCount": len(skill_refs),
        "skillReferenceCharacterCount": len({row["character"] for row in skill_refs}),
        "skillReferenceExeNameRecordCount": sum(1 for row in skill_refs if row["matchedRecordCount"]),
        "skillReferenceActionPayloadHitCount": sum(
            1 for row in skill_refs if row.get("actionPayloadHitCount")
        ),
        "skillReferenceExeLevelPayloadConfirmedCount": len(confirmed_skill_level_payloads),
        "skillReferenceExeLevelPayloadPartialCount": sum(
            1
            for row in skill_refs
            if row.get("exeLevelPayloadStatus") == "exe-level-payload-partial"
        ),
        "skillReferenceExeLevelPayloadMissingCount": sum(
            1
            for row in skill_refs
            if row.get("exeLevelPayloadStatus") == "exe-level-payload-missing"
        ),
        "skillReferenceExeTextHitCount": sum(1 for row in skill_refs if row["exeTextHitCount"]),
        "skillDisplayIconBoundCount": sum(
            1 for row in skill_refs if row.get("skillDisplayIconStatus") == "cns-bound"
        ),
        "skillDisplayIconCategoryUnboundCount": sum(
            1 for row in skill_refs
            if row.get("skillDisplayIconStatus") == "category-icon-unbound"
        ),
        "equipmentSkillBindingCount": len(equipment_skill_bindings),
        "equipmentSkillActionIconBoundCount": sum(
            1 for row in equipment_skill_bindings if row.get("skillActionIcon")
        ),
        "equipmentSkillSourceItemIconBoundCount": sum(
            1 for row in equipment_skill_bindings if row.get("sourceEquipmentIcon")
        ),
        "consumableItemCommonReference": CONSUMABLE_ITEM_COMMON_REFERENCE,
        "equipmentDescriptionCount": len(equipment_description_records),
        "itemDescriptionCount": len(item_description_records),
        "descriptionTextStatus": "exe-description-pointer-tables-bound-by-row-order",
        "descriptionTables": description_tables,
        "skillReferences": skill_refs,
        "equipmentSkillBindings": equipment_skill_bindings,
        "tables": tables,
        "notes": [
            "0x000600xx records map to item.cns. The low word xx is the 32x32 cell index.",
            "0x000500xx records map to icon.cns. Basic actions/commands use non-zero low-word indices.",
            "Skill display icons are derived from skill records toward CNS cells: direct action/command icon first, equipment-source item icon for equipment-granted skills, and category/common icons still need separate binding.",
            "Equipment-granted skills are listed separately with both the source equipment item.cns cell and the granted action icon.cns cell when present.",
            "Large skill-name tables mostly use 0x00050000, so they are name/payload evidence and not failed per-skill icon mappings.",
            "Equipment names point at 0x2e-byte detail records: 16 name bytes followed by 30 data bytes.",
            "Equipment data f00~f04 map to 공격력/방어력/기술력/순발력/운.",
            "Equipment data f05~f23 are signed battle modifier fields; their exact combat formula labels are intentionally left structural for now.",
            "Equipment data f25 maps to character and equipment slot: 1/2/4 are 아타호/린샹/스마슈 weapons; 0x81/0x82/0x84 are their armor slots.",
            "Equipment data f26~f29 map to equipment-granted command/action indices inside the equipped character action table: f26=기본기, f27=개인공격기, f28=전체공격기, f29=특수기.",
            "Item names point at 0x12-byte records: 16 name bytes followed by a 16-bit item-kind flag.",
            "Consumable recovery/status effects for the first six consumables are EXE-bound through runtime effect dispatch table 0x00546a38; the 0x12-byte item record itself still only stores the item-kind flag.",
            "Consumable prices remain user/wiki reference/event/shop data; no compact static price table has been promoted yet.",
            "Key item uses shown as reference data are user/wiki reference, not yet EXE-bound event trigger tables.",
            "Skill reference rows are user/wiki reference; evidenceStatus reports whether the skill name appears in current EXE name records, action payload records, or text bytes.",
            "Consumable reference common rule: max 10 per item, inventory shared across characters, usable in and out of battle.",
            "Equipment descriptions are bound through the 0x004e915c pointer table by row order after one default entry.",
            "Item descriptions are bound through the 0x004e91f8 pointer table by row order after one default entry.",
        ],
    }


def record_md(row: dict) -> str:
    grid = row["grid"]
    coord = (
        f"`{grid.get('cns')}` #{grid.get('cellIndex')} "
        f"({grid.get('x')},{grid.get('y')},{grid.get('w')},{grid.get('h')})"
        if row.get("isDirectGridMapping") and grid.get("inSheet")
        else "-"
    )
    detail = (row.get("detailRecord") or {}).get("summary") or "-"
    description = (row.get("description") or "").replace("\n", "<br>")
    return (
        f"| {row['index1Based']} | {row['name']} | `{row['recordVaHex']}` | "
        f"`{row['metaHex']}` | {coord} | {description or '-'} | {detail} | {row['mappingStatus']} |"
    )


def md_cell(value: object) -> str:
    text = "" if value is None else str(value)
    return text.replace("|", "\\|").replace("\n", "<br>")


def markdown(summary: dict) -> str:
    lines = [
        "# UI CNS Grid Mappings",
        "",
        summary["scope"],
        "",
        f"- Status: {summary['status']}",
        f"- Coordinate: {summary['coordinateSystem']}",
        f"- Item/equipment records: {summary['confirmedItemEquipmentCount']}",
        f"- Direct icon records: {summary['directIconRecordCount']}",
        f"- No-direct-icon name records: {summary['noDirectIconNameRecordCount']}",
        f"- Equipment detail records: {summary['equipmentDetailRecordCount']}",
        f"- Item flag records: {summary['itemFlagRecordCount']}",
        f"- Equipment descriptions: {summary['equipmentDescriptionCount']}",
        f"- Item descriptions: {summary['itemDescriptionCount']}",
        f"- Description text status: `{summary['descriptionTextStatus']}`",
        "",
        "## Sheets",
        "",
        "| class | cns | render source | grid | cells |",
        "| --- | --- | --- | --- | ---: |",
    ]
    for sheet in summary["sheets"]:
        lines.append(
            f"| `0x{next(key for key, value in SHEETS.items() if value['key'] == sheet['key']):04x}` | "
            f"`{sheet['cns']}` | `{sheet['webPath']}` | "
            f"{sheet['columns']}x{sheet['rows']} cells, {sheet['cellWidth']}x{sheet['cellHeight']} px | "
            f"{sheet['cellCount']} |"
        )
    for table in summary["tables"]:
        lines.extend([
            "",
            f"## {table['title']}",
            "",
            table["note"],
            "",
            "| # | name | record | meta | cns grid | in-game description | detail data | status |",
            "| ---: | --- | --- | --- | --- | --- | --- | --- |",
        ])
        lines.extend(record_md(row) for row in table["records"])
    lines.extend([
        "",
        "## 주인공 기술 참고/EXE payload 대조",
        "",
        "획득/설명/비고는 사용자/위키 참고 데이터이다. 숙련도별 MP와 공격수는 EXE action payload의 levelByte 0x01..0x04에서 확인되는 경우 별도 표시한다.",
        "",
        "| # | character | category | name | alias | reference MP | EXE level payload | acquisition | display icon | description | notes | evidence |",
        "| ---: | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary.get("skillReferences") or []:
        evidence = (
            f"{row['evidenceStatus']} "
            f"(records {row['matchedRecordCount']}, "
            f"payload hits {row.get('actionPayloadHitCount', 0)}, "
            f"text hits {row['exeTextHitCount']})"
        )
        lines.append(
            f"| {row['index1Based']} | {md_cell(row['character'])} | "
            f"{md_cell(row['category'])} | {md_cell(row['name'])} | "
            f"{md_cell(row['alias']) or '-'} | {md_cell(row['mpCost'])} | "
            f"{md_cell(row.get('exeLevelPayloadSummary'))} | "
            f"{md_cell(row['acquisition'])} | {md_cell(row.get('skillDisplayIconSummary'))} | "
            f"{md_cell(row['description'])} | "
            f"{md_cell(row['notes'])} | {md_cell(evidence)} |"
        )
    lines.extend([
        "",
        "## 장비 제공 기술 CNS 연결",
        "",
        "장비 레코드에서 확인되는 전용 기술과 표시 가능한 CNS 셀이다. 장비 아이콘은 item.cns, 액션 아이콘은 icon.cns에서 온다.",
        "",
        "| # | character | equipment | equipment icon | menu category | skill | action icon | MP | 타수 | 대상 | 계열 | 상태 | 기타 | status |",
        "| ---: | --- | --- | --- | --- | --- | --- | ---: | ---: | --- | --- | --- | --- | --- |",
    ])
    for row in summary.get("equipmentSkillBindings") or []:
        lines.append(
            f"| {row['index1Based']} | {md_cell(row['character'])} | "
            f"{md_cell(row['equipmentName'])} | {md_cell(grid_candidate_summary(row.get('sourceEquipmentIcon')))} | "
            f"{md_cell(row['skillSlotLabel'])} | {md_cell(row['skillName'])} | "
            f"{md_cell(grid_candidate_summary(row.get('skillActionIcon')))} | "
            f"{md_cell(row.get('mpCost'))} | {md_cell(row.get('attackCount') or '-')} | "
            f"{md_cell(row.get('targetSummary'))} | {md_cell(row.get('familySummary'))} | "
            f"{md_cell(row.get('statusSummary'))} | {md_cell(row.get('specialSummary'))} | "
            f"{md_cell(row['bindingStatus'])} |"
        )
    lines.extend(["", "## Notes", ""])
    lines.extend(f"- {note}" for note in summary["notes"])
    return "\n".join(lines) + "\n"


def html_page(summary: dict) -> str:
    sections = []
    for table in summary["tables"]:
        rows = []
        for row in table["records"]:
            grid = row["grid"]
            coord = (
                f"{html.escape(grid.get('cns', ''))} #{grid['cellIndex']} "
                f"x={grid['x']} y={grid['y']} w={grid['w']} h={grid['h']}"
                if row.get("isDirectGridMapping") and grid.get("inSheet")
                else "-"
            )
            detail = (row.get("detailRecord") or {}).get("summary") or "-"
            description = "<br>".join(
                html.escape(line) for line in (row.get("description") or "").splitlines()
            )
            preview = (
                "<canvas class=\"cell\" width=\"64\" height=\"64\" "
                f"data-cns=\"{html.escape(grid.get('sheetKey', ''))}\" "
                f"data-x=\"{grid['x']}\" data-y=\"{grid['y']}\" "
                f"data-w=\"{grid['w']}\" data-h=\"{grid['h']}\" "
                "data-scale=\"2\"></canvas>"
                if row.get("isDirectGridMapping") and grid.get("inSheet")
                else ""
            )
            rows.append(
                "<tr>"
                f"<td>{row['index1Based']}</td>"
                f"<td>{html.escape(row['name'])}</td>"
                f"<td>{preview}</td>"
                f"<td><code>{html.escape(row['metaHex'])}</code></td>"
                f"<td>{coord}</td>"
                f"<td>{description if description else '-'}</td>"
                f"<td>{html.escape(detail)}</td>"
                f"<td>{html.escape(row['mappingStatus'])}</td>"
                "</tr>"
            )
        sections.append(
            "<section>"
            f"<h2>{html.escape(table['title'])}</h2>"
            f"<p>{html.escape(table['note'])}</p>"
            "<div class=\"table-wrap\"><table>"
            "<thead><tr><th>#</th><th>name</th><th>cell</th><th>meta</th><th>grid</th><th>in-game description</th><th>detail data</th><th>status</th></tr></thead>"
            f"<tbody>{''.join(rows)}</tbody>"
            "</table></div>"
            "</section>"
        )
    skill_rows = []
    for row in summary.get("skillReferences") or []:
        evidence = (
            f"{row['evidenceStatus']} "
            f"(records {row['matchedRecordCount']}, "
            f"payload hits {row.get('actionPayloadHitCount', 0)}, "
            f"text hits {row['exeTextHitCount']})"
        )
        skill_rows.append(
            "<tr>"
            f"<td>{row['index1Based']}</td>"
            f"<td>{html.escape(row['character'])}</td>"
            f"<td>{html.escape(row['category'])}</td>"
            f"<td>{html.escape(row['name'])}</td>"
            f"<td>{html.escape(row['alias'] or '-')}</td>"
            f"<td>{html.escape(row['mpCost'])}</td>"
            f"<td>{html.escape(row.get('exeLevelPayloadSummary') or '-')}</td>"
            f"<td>{html.escape(row['acquisition'])}</td>"
            f"<td>{html.escape(row.get('skillDisplayIconSummary') or '-')}</td>"
            f"<td>{html.escape(row['description'])}</td>"
            f"<td>{html.escape(row['notes'])}</td>"
            f"<td>{html.escape(evidence)}</td>"
            "</tr>"
        )
    equipment_skill_rows = []
    for row in summary.get("equipmentSkillBindings") or []:
        equipment_skill_rows.append(
            "<tr>"
            f"<td>{row['index1Based']}</td>"
            f"<td>{html.escape(row.get('character') or '-')}</td>"
            f"<td>{html.escape(row.get('equipmentName') or '-')}</td>"
            f"<td>{html.escape(grid_candidate_summary(row.get('sourceEquipmentIcon')))}</td>"
            f"<td>{html.escape(row.get('skillSlotLabel') or '-')}</td>"
            f"<td>{html.escape(row.get('skillName') or '-')}</td>"
            f"<td>{html.escape(grid_candidate_summary(row.get('skillActionIcon')))}</td>"
            f"<td>{html.escape(str(row.get('mpCost') if row.get('mpCost') is not None else '-'))}</td>"
            f"<td>{html.escape(str(row.get('attackCount') or '-'))}</td>"
            f"<td>{html.escape(row.get('targetSummary') or '-')}</td>"
            f"<td>{html.escape(row.get('familySummary') or '-')}</td>"
            f"<td>{html.escape(row.get('statusSummary') or '-')}</td>"
            f"<td>{html.escape(row.get('specialSummary') or '-')}</td>"
            f"<td>{html.escape(row.get('bindingStatus') or '-')}</td>"
            "</tr>"
        )
    sections.append(
        "<section>"
        "<h2>주인공 기술 참고/EXE payload 대조</h2>"
        "<p>획득/설명/비고는 사용자/위키 참고 데이터이다. 숙련도별 MP와 공격수는 EXE action payload의 levelByte 0x01..0x04에서 확인되는 경우 별도 표시한다.</p>"
        "<div class=\"table-wrap\"><table>"
        "<thead><tr><th>#</th><th>character</th><th>category</th><th>name</th>"
        "<th>alias</th><th>reference MP</th><th>EXE level payload</th><th>acquisition</th><th>display icon</th><th>description</th>"
        "<th>notes</th><th>evidence</th></tr></thead>"
        f"<tbody>{''.join(skill_rows)}</tbody>"
        "</table></div>"
        "</section>"
    )
    sections.append(
        "<section>"
        "<h2>장비 제공 기술 CNS 연결</h2>"
        "<p>장비 레코드에서 확인되는 전용 기술과 표시 가능한 CNS 셀이다.</p>"
        "<div class=\"table-wrap\"><table>"
        "<thead><tr><th>#</th><th>character</th><th>equipment</th><th>equipment icon</th>"
        "<th>menu category</th><th>skill</th><th>action icon</th><th>MP</th><th>hits</th><th>target</th><th>family</th><th>status</th><th>extra</th><th>binding</th></tr></thead>"
        f"<tbody>{''.join(equipment_skill_rows)}</tbody>"
        "</table></div>"
        "</section>"
    )
    return "\n".join([
        "<!doctype html>",
        '<html lang="ko">',
        "<head>",
        '  <meta charset="utf-8" />',
        '  <meta name="viewport" content="width=device-width, initial-scale=1" />',
        "  <title>UI CNS Grid Mappings</title>",
        "  <style>",
        "    :root{color-scheme:dark;background:#0b0d0f;color:#eff3f5;font-family:system-ui,sans-serif}",
        "    body{margin:0;padding:18px;background:#0b0d0f;color:#eff3f5}",
        "    a{color:#6cb8ff} section{margin:0 0 18px;padding:12px;border:1px solid #313940;background:#15191d}",
        "    .metrics{display:flex;flex-wrap:wrap;gap:8px;margin:12px 0}.metric{border:1px solid #313940;padding:8px;background:#101316}",
        "    .table-wrap{overflow:auto} table{width:100%;min-width:920px;border-collapse:collapse;font-size:13px}",
        "    th,td{padding:7px 8px;border-bottom:1px solid #313940;text-align:left;vertical-align:middle} th{color:#aeb8bf}",
        "    code{color:#f3ce62}.cell{display:inline-block;border:1px solid #46545d;image-rendering:pixelated;background-color:#101316}",
        "  </style>",
        '  <script src="../web/engine/cns/renderer.js"></script>',
        "</head>",
        "<body>",
        "  <h1>UI CNS Grid Mappings</h1>",
        f"  <p>{html.escape(summary['scope'])}</p>",
        "  <div class=\"metrics\">",
        f"    <div class=\"metric\">item/equipment <b>{summary['confirmedItemEquipmentCount']}</b></div>",
        f"    <div class=\"metric\">direct icon <b>{summary['directIconRecordCount']}</b></div>",
        f"    <div class=\"metric\">skill icon bound <b>{summary['skillDisplayIconBoundCount']}</b></div>",
        f"    <div class=\"metric\">no direct icon <b>{summary['noDirectIconNameRecordCount']}</b></div>",
        f"    <div class=\"metric\">detail records <b>{summary['equipmentDetailRecordCount'] + summary['itemFlagRecordCount']}</b></div>",
        f"    <div class=\"metric\">descriptions <b>{summary['equipmentDescriptionCount'] + summary['itemDescriptionCount']}</b></div>",
        "  </div>",
        *sections,
        "  <script>",
        "    const sheetSource = { icon: 'icon', item: 'item' };",
        "    async function renderCells(){",
        "      for (const canvas of document.querySelectorAll('canvas.cell[data-cns]')) {",
        "        const source = sheetSource[canvas.dataset.cns];",
        "        if (!source) continue;",
        "        const x = Number(canvas.dataset.x || 0);",
        "        const y = Number(canvas.dataset.y || 0);",
        "        const w = Number(canvas.dataset.w || 32);",
        "        const h = Number(canvas.dataset.h || 32);",
        "        const scale = Number(canvas.dataset.scale || 2);",
        "        canvas.width = w * scale;",
        "        canvas.height = h * scale;",
        "        const ctx = canvas.getContext('2d');",
        "        ctx.imageSmoothingEnabled = false;",
        "        ctx.clearRect(0, 0, canvas.width, canvas.height);",
        "        try {",
        "          const image = await window.HWANSE_CNS_RENDERER.loadImageCanvas(source);",
        "          ctx.drawImage(image, x, y, w, h, 0, 0, canvas.width, canvas.height);",
        "        } catch (error) {",
        "          ctx.fillStyle = '#2a1111';",
        "          ctx.fillRect(0, 0, canvas.width, canvas.height);",
        "          ctx.fillStyle = '#fca5a5';",
        "          ctx.font = '10px monospace';",
        "          ctx.fillText('CNS?', 4, 14);",
        "        }",
        "      }",
        "    }",
        "    renderCells();",
        "  </script>",
        "</body>",
        "</html>",
        "",
    ])


def web_payload(summary: dict) -> dict:
    """Keep the browser payload lean; full evidence remains in JSON/MD/HTML."""
    def compact_candidate(candidate: dict | None) -> dict | None:
        if not candidate:
            return None
        return {
            key: candidate.get(key)
            for key in ("source", "sheetKey", "cellIndex", "equipmentName")
            if candidate.get(key) not in (None, "")
        }

    compact = {key: value for key, value in summary.items() if key != "descriptionTables"}
    compact["skillReferences"] = [
        {
            key: row.get(key)
            for key in (
                "index",
                "index1Based",
                "source",
                "character",
                "category",
                "name",
                "alias",
                "mpCost",
                "acquisition",
                "description",
                "matchedRecordCount",
                "actionPayloadHitCount",
                "exeLevelPayloadStatus",
                "exeLevelPayloadSummary",
                "exeLevelPayloadConfirmed",
                "exeLevelPayloads",
                "exeTextHitCount",
                "evidenceStatus",
                "skillDisplayIconStatus",
                "skillDisplayIconSummary",
                "displayIconCandidateCount",
                "displayIconCandidates",
            )
        }
        for row in summary.get("skillReferences") or []
    ]
    for row in compact["skillReferences"]:
        row["displayIconCandidates"] = [
            candidate for candidate in (
                compact_candidate(candidate)
                for candidate in row.get("displayIconCandidates") or []
            )
            if candidate
        ]
    compact["equipmentSkillBindings"] = [
        {
            "index1Based": row.get("index1Based"),
            "character": row.get("character"),
            "equipmentName": row.get("equipmentName"),
            "sourceEquipmentIcon": compact_candidate(row.get("sourceEquipmentIcon")),
            "skillSlotLabel": row.get("skillSlotLabel"),
            "skillName": row.get("skillName"),
            "skillActionIcon": compact_candidate(row.get("skillActionIcon")),
            "payloadSummary": row.get("payloadSummary"),
            "mpCost": row.get("mpCost"),
            "attackCount": row.get("attackCount"),
            "targetSummary": row.get("targetSummary"),
            "familySummary": row.get("familySummary"),
            "statusSummary": row.get("statusSummary"),
            "specialSummary": row.get("specialSummary"),
            "isAttackPayload": row.get("isAttackPayload"),
            "bindingStatus": row.get("bindingStatus"),
        }
        for row in summary.get("equipmentSkillBindings") or []
    ]
    compact_tables = []
    for table in summary["tables"]:
        compact_table = {key: value for key, value in table.items() if key != "records"}
        compact_records = []
        for row in table["records"]:
            compact_row = dict(row)
            detail = compact_row.get("detailRecord")
            if detail:
                compact_detail = {
                    key: detail.get(key)
                    for key in (
                        "kind",
                        "summary",
                        "flagMeaning",
                        "flagHex",
                        "exeEffectSummary",
                        "exeEffectStatus",
                        "exeEffect",
                        "referenceLabel",
                        "referenceSummary",
                        "referencePriceSummary",
                        "statSummary",
                        "equipTargetSummary",
                        "equipmentSkillSummary",
                        "battleModifierSummary",
                        "familyModifierSummary",
                        "rawExtraSummary",
                    )
                    if detail.get(key) not in (None, "")
                }
                compact_row["detailRecord"] = compact_detail
            compact_records.append(compact_row)
        compact_table["records"] = compact_records
        compact_tables.append(compact_table)
    compact["tables"] = compact_tables
    return compact


def write_outputs(summary: dict, out_dir: Path = OUT) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "ui_cns_grid_mappings.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    (out_dir / "ui_cns_grid_mappings.js").write_text(
        "window.HWANSE_UI_CNS_GRID_MAPPINGS = " + js_json(web_payload(summary)) + ";\n",
        encoding="utf-8",
    )
    (out_dir / "ui_cns_grid_mappings.md").write_text(markdown(summary), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(args.exe.read_bytes())
    write_outputs(summary, args.out_dir)


if __name__ == "__main__":
    main()
