#!/usr/bin/env python3
"""Build a combined normal HUD/menu preview page.

This page is intentionally a runtime-style integration surface rather than a
new EXE analysis report.  It combines the EXE-grounded window regions and menu
payloads that were already promoted by:

* menu_right_panel_ui_review.json: regions #3/#4/#2, top/detail/final-depth menu
* status_menu_ui_expression_review: region #6 status screen content
* hud_normal_static_hint_review: region #1 normal HUD lower-left panel
"""
from __future__ import annotations

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

from probe_exe_scene_tables import read_sections, va_to_offset


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

MENU_JSON = OUT / "menu_right_panel_ui_review.json"
STATUS_JSON = OUT / "status_menu_ui_expression_review.json"
HUD_JSON = OUT / "hud_normal_static_hint_review.json"

OUT_JSON = OUT / "hud_menu_preview.json"
WEB_HTML = WEB / "hud_menu_preview.html"

REGION2_NORMAL_BLOCK_VA = 0x004E807C
REGION2_LOCATION_TABLE_VA = 0x004EBC14


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


def cp949_text_atom(exe: bytes, sections: list[dict[str, Any]], va: int, limit: int = 96) -> str:
    offset = va_to_offset(sections, va)
    if offset is None:
        return ""
    raw = bytearray()
    for index in range(offset, min(len(exe), offset + limit)):
        byte = exe[index]
        if byte == 0:
            break
        if byte == 0x40 and index + 1 < len(exe) and 0 <= exe[index + 1] < 0x40:
            break
        raw.append(byte)
    return bytes(raw).decode("cp949", errors="replace")


def read_location_selector_table() -> dict[str, Any]:
    if not EXE.exists():
        return {
            "status": "missing-exe",
            "tableVaHex": f"0x{REGION2_LOCATION_TABLE_VA:08x}",
            "locations": [],
        }
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    offset = va_to_offset(sections, REGION2_LOCATION_TABLE_VA)
    if offset is None:
        return {
            "status": "missing-offset",
            "tableVaHex": f"0x{REGION2_LOCATION_TABLE_VA:08x}",
            "locations": [],
        }
    header = exe[offset : offset + 8]
    count = struct.unpack_from("<I", exe, offset + 4)[0]
    locations: list[dict[str, Any]] = []
    for index in range(count):
        pointer = struct.unpack_from("<I", exe, offset + 8 + index * 4)[0]
        locations.append(
            {
                "index": index,
                "textVaHex": f"0x{pointer:08x}",
                "text": cp949_text_atom(exe, sections, pointer),
            }
        )
    return {
        "status": "exe-grounded",
        "tableVaHex": f"0x{REGION2_LOCATION_TABLE_VA:08x}",
        "headerHex": header.hex(" "),
        "selectorHex": "0x01",
        "count": count,
        "locations": locations,
    }


def build_normal_hud_region2_info() -> dict[str, Any]:
    location_table = read_location_selector_table()
    locations = location_table.get("locations") or []
    default_location_index = 5 if len(locations) > 5 else 0
    default_location = (locations[default_location_index] or {}).get("text") if locations else "아타호의　거처"
    return {
        "status": "exe-grounded",
        "blockVaHex": f"0x{REGION2_NORMAL_BLOCK_VA:08x}",
        "blockFileOffsetHex": "0x000e607c",
        "originOpcode": {
            "streamVaHex": f"0x{REGION2_NORMAL_BLOCK_VA:08x}",
            "opcodeHex": "40 07 a0 01 60 01",
            "origin": {"x": 416, "y": 352},
        },
        "locationSelector": location_table,
        "locationDraw": {
            "opcodeVaHex": "0x004e80ac",
            "opcodeHex": "40 09 00 00 14 bc 4e 00",
            "x": 440,
            "y": 392,
            "defaultIndex": default_location_index,
            "defaultText": default_location,
        },
        "earnedMoneyLabel": {
            "text": "번　돈",
            "textVaHex": "0x004e80c8",
            "x": 440,
            "y": 432,
        },
        "goldValue": {
            "opcodeVaHex": "0x004e80d0",
            "opcodeHex": "40 2e 05 07 08 00 00 00",
            "numberCns": "num.cns",
            "row": "white",
            "onesDigitX": 584,
            "y": 432,
            "previewValue": 100,
        },
        "goldUnit": {
            "text": "Ｇ",
            "textVaHex": "0x004e80d8",
            "x": 600,
            "y": 432,
        },
        "note": "Region #2 draws current place and earned gold while the top menu is active. Detail description/final-choice content replaces it only after entering a submenu.",
    }


def region_contract(regions: list[dict[str, Any]]) -> list[dict[str, Any]]:
    """Describe the current HUD/menu screen-region contract.

    The page is a preview surface, so the important distinction is what each
    EXE-grounded region owns and which dynamic contents are still a rendering
    approximation.
    """
    by_index = {int(row.get("index", -1)): row for row in regions}

    def row(
        index: int,
        label: str,
        role: str,
        exe_grounded: list[str],
        preview_or_pending: list[str],
        status: str = "partial-exe-grounded",
    ) -> dict[str, Any]:
        region = by_index.get(index) or {}
        return {
            "regionIndex": index,
            "label": label,
            "rect": region.get("rect") or {},
            "templateIndex": region.get("templateIndex"),
            "resourceIdHex": region.get("resourceIdHex"),
            "role": role,
            "evidenceStatus": status,
            "exeGrounded": exe_grounded,
            "previewOrPending": preview_or_pending,
        }

    return [
        row(
            6,
            "#6 좌측 대형 상태창",
            "ESC/X 메뉴에서 맵 프레임을 대체하는 26x22 대형 window 영역.",
            [
                "region rect/template id는 EXE screen-region table 근거.",
                "캐릭터 초상화, 레벨/스탯/장비/문구 배치는 status_menu_ui_expression_review 승격 근거.",
                "동료 전환 화살표 source/표시 조건/배치는 handler 0x0040f78a 근거.",
            ],
            [
                "최종 폰트 rasterizer/kerning은 웹 폰트 기준 근사.",
                "시나리오별 상태 문구 선택 producer는 아직 분리 추적 대상.",
            ],
        ),
        row(
            3,
            "#3 우측 상단 메뉴",
            "메인 메뉴 아이콘과 현재 모드 라벨을 표시하는 14x6 window 영역.",
            [
                "region rect/template id는 EXE screen-region table 근거.",
                "메인 메뉴 아이콘 순서와 mode label 위치는 right-menu payload 근거.",
            ],
            [
                "상위 메뉴 pressed 상태의 정확한 consumer/root는 아직 추적 대상.",
            ],
        ),
        row(
            4,
            "#4 우측 중단 목록",
            "하위 메뉴 목록, 페이지, 기술 MP 타이틀/소모량을 표시하는 14x16 window 영역.",
            [
                "region rect/template id는 EXE screen-region table 근거.",
                "기술/장비/도구/모드 목록은 promoted menu/action/equipment payload 근거.",
                "기술 MP 값은 action row mpCost 구조화 필드 근거.",
            ],
            [
                "일부 최종-depth 설명 root는 계속 보강 가능.",
            ],
        ),
        row(
            1,
            "#1 하단 좌측 평상시 HUD",
            "활성 파티의 HP/MP/EXP 게이지와 수치를 표시하는 26x8 window 영역.",
            [
                "region rect/template id는 EXE screen-region table 근거.",
                "HP/MP/EXP row/column은 EXE record table 0x004870a8 및 consumers 근거.",
                "활성 display slot은 0x004576e8/0x004576e9/0x0059db30 근거.",
                "이름 source와 row 위치는 object+0xb0 VM handler 0x0041e93f 계열 근거.",
            ],
            [
                "시나리오 party flag producer는 별도 event/scene 분석 대상.",
                "최종 glyph rasterizer/kerning은 pending.",
            ],
        ),
        row(
            2,
            "#2 하단 우측 정보/설명",
            "상단 메뉴 상태에서는 현재 위치/번 돈, 하위 상태에서는 설명/선택지를 표시하는 14x8 window 영역.",
            [
                "region rect/template id는 EXE screen-region table 근거.",
                "상단 메뉴 기본 표시 위치명/골드는 EXE block 0x004e807c 및 location table 0x004ebc14 근거.",
                "하위 메뉴 설명/최종 선택창은 right-menu region2 output model 근거.",
            ],
            [
                "특정 메뉴 root에서 #2 내용을 여는 직접 opener는 추가 추적 대상.",
            ],
        ),
    ]


def evidence_split() -> dict[str, list[str]]:
    return {
        "EXE 확정": [
            "window.cns template catalog와 screen-region table.",
            "#1/#2/#3/#4/#6 region rect, template id, resource id.",
            "#1 HP/MP/EXP row record table과 활성 display slot model.",
            "#2 현재 위치/번 돈 기본 표시 block과 location selector table.",
            "#3/#4 우측 메뉴 구조, 페이지, MP cost, 수량 표시 일부.",
            "#6 상태창 주요 draw field와 actor-switch arrow.",
        ],
        "웹 프리뷰 편의": [
            "레벨/장비/현재 HP/MP/EXP 랜덤 버튼.",
            "시나리오별 파티 조합을 버튼으로 직접 바꾸는 기능.",
            "웹 폰트 기반 한글 렌더링과 일부 줄바꿈.",
        ],
        "추가 분석 대상": [
            "시나리오 party flag producer와 상태 문구 선택 root.",
            "상위 메뉴 pressed 상태의 직접 consumer.",
            "일부 region #2 final-depth 설명 opener.",
            "원작 glyph rasterizer/kerning의 최종 픽셀 일치.",
        ],
    }


def build_payload() -> dict[str, Any]:
    menu = read_json(MENU_JSON)
    status = read_json(STATUS_JSON)
    hud = read_json(HUD_JSON)
    actor_rows = ((status.get("actorLayoutCrossCheck") or {}).get("playerRows") or [])
    payload = {
        "version": 1,
        "kind": "hwanse-normal-hud-menu-preview",
        "sourceArtifacts": {
            "menuRightPanel": str(MENU_JSON.relative_to(ROOT)),
            "statusMenuExpression": str(STATUS_JSON.relative_to(ROOT)),
            "hudNormalStaticHint": str(HUD_JSON.relative_to(ROOT)),
        },
        "regions": menu.get("regions") or [],
        "templates": menu.get("templates") or [],
        "menuModel": menu.get("menuModel") or [],
        "rightMenuLayout": menu.get("rightMenuLayout") or {},
        "rightMenuPayload": menu.get("rightMenuPayload") or {},
        "consumableQuantityModel": menu.get("consumableQuantityModel") or {},
        "cursorModel": menu.get("cursorModel") or {},
        "region2OutputModel": menu.get("region2OutputModel") or {},
        "finalDepthSelectionModel": menu.get("finalDepthSelectionModel") or {},
        "actorRows": actor_rows,
        "portraitDraws": status.get("portraitDraws") or [],
        "textDraws": status.get("textDraws") or [],
        "statDraws": status.get("statDraws") or [],
        "equipmentDraws": status.get("equipmentDraws") or [],
        "equipmentIconRows": status.get("equipmentIconRows") or [],
        "equipmentCatalog": status.get("equipmentCatalog") or [],
        "levelGrowthRules": status.get("levelGrowthRules") or {},
        "statusCommentGroups": status.get("statusCommentGroups") or [],
        "statusCommentLayout": status.get("statusCommentLayout") or {},
        "normalHudStaticTrace": (hud.get("manual_static_trace") or {}),
        "normalHudStatusRecordTable": ((hud.get("exe_evidence") or {}).get("normal_hud_status_record_table") or {}),
        "normalHudActivePartySlotModel": ((hud.get("exe_evidence") or {}).get("normal_hud_active_party_slot_model") or {}),
        "normalHudActorNameText": ((hud.get("exe_evidence") or {}).get("normal_hud_actor_name_text") or {}),
        "normalHudRegion2Info": build_normal_hud_region2_info(),
        "regionContract": region_contract(menu.get("regions") or []),
        "evidenceSplit": evidence_split(),
        "normalHudPartyPreviewPresets": [
            {"key": "ataho", "label": "아타호", "actorSlots": [0], "exeMeaning": "active_count=1, order=[0]"},
            {"key": "ataho-rinshan", "label": "아타호+린샹", "actorSlots": [0, 1], "exeMeaning": "active_count=2, order=[0,1]"},
            {"key": "smashu", "label": "스마슈", "actorSlots": [2], "exeMeaning": "active_count=1, order=[2]"},
            {"key": "ataho-smashu", "label": "아타호+스마슈", "actorSlots": [0, 2], "exeMeaning": "active_count=2, order=[0,2]"},
            {"key": "full", "label": "아타호+린샹+스마슈", "actorSlots": [0, 1, 2], "exeMeaning": "active_count=3, order=[0,1,2]"},
        ],
        "normalHudEvidence": {
            "region1": next((r for r in (menu.get("regions") or []) if int(r.get("index", -1)) == 1), None),
            "windowTemplate": "region #1 uses window template #1 from EXE screen-region table",
            "dynamicNote": "region #1 dynamic HP/MP/EXP rows are backed by EXE record table 0x004870a8 and consumers 0x004209ee/0x00420b1a/0x00420ebc.",
            "activePartyNote": "region #1 rows are active display slots. 0x004576e8/0x004576e9/0x0059db30 map scenario party composition to actual actor rows.",
            "actorNameNote": "region #1 actor-name source and row position are EXE-grounded through object+0xb0 VM handler 0x0041e93f, set-pen handler 0x0041bd4f, and table 0x004ec280; final glyph rasterizer/kerning remains pending.",
        },
        "evidence": [
            "regions #1/#2/#3/#4/#6 and window.cns template ids are read from the EXE-grounded screen-region table.",
            "right menu top/detail/final-depth state uses the promoted right-menu payload and cursor model.",
            "left status content uses the promoted region #6 status expression payload.",
            "bottom #1 HUD party HP/MP/EXP gauges and numbers use the promoted EXE status record table; region #6 level/stat/equipment fields are intentionally not drawn there.",
            "bottom #1 HUD rows are active display slots backed by 0x004576e8/0x004576e9/0x0059db30, so actor identity is scenario-dependent.",
            "bottom #1 HUD actor-name source and row positions are EXE-grounded; final glyph rasterizer/kerning remains pending.",
            "bottom #2 top-menu state draws current location and earned gold through EXE block 0x004e807c and location selector table 0x004ebc14.",
            "region #6 actor-switch arrow source/visibility/placement is EXE-backed through handler 0x0040f78a stackArrows.",
            "skill detail MP values are read from promoted EXE action-row mpCost fields; current preview displays the first/current-rank cost.",
        ],
    }
    return payload


def build_html(payload: dict[str, Any]) -> str:
    payload_json = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>평상시 HUD 메뉴 통합 프리뷰</title>
  <script src="engine/cns/renderer.js"></script>
  <style>
    :root {{
      color-scheme: dark;
      --bg:#0b0d12;
      --panel:#151923;
      --line:#2b3447;
      --text:#e6ebf4;
      --muted:#9ca9bd;
      --accent:#f1d36b;
      --ok:#8bd17c;
    }}
    * {{ box-sizing:border-box; }}
    body {{
      margin:0;
      background:var(--bg);
      color:var(--text);
      font-family:system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }}
    header, main {{ width:min(1180px, calc(100vw - 24px)); margin:0 auto; }}
    header {{ padding:18px 0 10px; }}
    h1 {{ margin:0 0 8px; font-size:22px; }}
    p {{ margin:0; color:var(--muted); line-height:1.5; }}
    .toolbar {{
      display:flex;
      flex-wrap:wrap;
      gap:8px;
      align-items:center;
      margin:12px 0;
    }}
    button, select, input {{
      background:#202636;
      color:var(--text);
      border:1px solid var(--line);
      border-radius:6px;
      padding:7px 10px;
      font:inherit;
    }}
    button.is-active {{ border-color:var(--accent); color:#fff3ae; background:#32301f; }}
    button:disabled {{ opacity:.42; cursor:not-allowed; }}
    .chip {{
      border:1px solid var(--line);
      border-radius:999px;
      padding:6px 10px;
      color:var(--muted);
      font-size:12px;
    }}
    .layout {{ display:grid; grid-template-columns:minmax(0, 680px) minmax(300px, 1fr); gap:14px; align-items:start; }}
    .panel {{ background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:12px; }}
    canvas {{
      display:block;
      width:100%;
      max-width:640px;
      height:auto;
      image-rendering:pixelated;
      background:#000;
      border:1px solid #38435a;
    }}
    .help {{ display:grid; gap:8px; font-size:13px; color:var(--muted); }}
    .help strong {{ color:var(--text); }}
    .contract-grid {{ display:grid; gap:8px; }}
    .contract-card {{
      border:1px solid var(--line);
      border-radius:7px;
      padding:9px;
      background:#10141d;
    }}
    .contract-card h2 {{
      margin:0 0 5px;
      font-size:14px;
      color:#fff3ae;
    }}
    .contract-card p {{ font-size:12px; }}
    .contract-meta {{ display:flex; flex-wrap:wrap; gap:5px; margin:6px 0; }}
    .mini-chip {{
      border:1px solid #33405a;
      border-radius:999px;
      padding:2px 6px;
      color:#b9c5d8;
      font-size:11px;
    }}
    details {{
      border:1px solid var(--line);
      border-radius:7px;
      padding:8px 10px;
      background:#10141d;
    }}
    details summary {{ cursor:pointer; color:#fff3ae; }}
    details ul {{ margin:8px 0 0; padding-left:18px; }}
    details li {{ margin:4px 0; }}
    .dpad {{ display:grid; grid-template-columns:repeat(3,56px); grid-template-rows:repeat(3,42px); gap:6px; align-items:center; justify-items:center; }}
    .dpad button {{ width:56px; height:42px; padding:0; }}
    .wide {{ min-width:118px; }}
    .state {{ white-space:pre-wrap; font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; color:#c8d2e1; }}
    @media (max-width: 920px) {{
      .layout {{ grid-template-columns:1fr; }}
      header, main {{ width:min(100vw - 12px, 720px); }}
    }}
  </style>
</head>
<body>
  <header>
    <h1>평상시 HUD 메뉴 통합 프리뷰</h1>
    <p>#1/#2/#3/#4/#6 window template을 한 화면에 조립하고, 우측 메뉴 계층과 좌측 상태창을 같은 상태로 움직입니다.</p>
    <div class="toolbar">
      <a class="chip" href="index.html">index</a>
      <a class="chip" href="../out/menu_right_panel_ui_review.json">right panel JSON</a>
      <a class="chip" href="status_menu_ui_expression_review.html">left status 근거</a>
      <span id="stateChip" class="chip">loading</span>
    </div>
  </header>
  <main class="layout">
    <section class="panel">
      <canvas id="hudCanvas" width="640" height="480"></canvas>
      <div class="toolbar" id="menuButtons"></div>
      <div class="toolbar" id="partyButtons"></div>
      <div class="toolbar" id="actorButtons"></div>
      <div class="toolbar" id="equipmentButtons"></div>
      <div class="toolbar">
        <button id="resetButton" type="button">초기화</button>
        <button id="randomButton" type="button">레벨/장비/현재값 랜덤</button>
        <label class="chip"><input id="debugToggle" type="checkbox" /> region 표시</label>
      </div>
    </section>
    <aside class="panel help">
      <div><strong>키보드</strong></div>
      <div>상단 메뉴: ←/→ 메뉴 이동, ↑/↓ 캐릭터 전환, Enter 하위 메뉴 진입</div>
      <div>하위 메뉴: ↑/↓ 항목 이동, ←/→ 페이지 이동, Enter 선택, Esc/X 뒤로</div>
      <div>최종 선택창: ↑/↓ 선택, Enter 적용, Esc/X 닫기</div>
      <div><strong>#1 행</strong> HP/MP/EXP는 EXE record table 기반이고, 행의 실제 배우는 active display slot table(<code>0x004576e8/0x004576e9/0x0059db30</code>)을 따릅니다.</div>
      <div><strong>#1 이름</strong> 이름 source와 행 위치는 EXE 근거입니다. x=24, y=384/416/448, 행간 32px이며 최종 glyph rasterizer만 보류입니다.</div>
      <div><strong>#6 화살표</strong> source/표시 조건/좌표는 EXE handler <code>0x0040f78a</code>의 stackArrows direct blit 근거입니다.</div>
      <div><strong>기술 MP</strong> #4 제목 우측 MP 라벨과 행 우측 소모량을 표시합니다. 비용은 action row <code>mpCost</code> 구조화 필드에서 읽습니다.</div>
      <div><strong>#2 기본 표시</strong> 하위 메뉴 진입 전에는 EXE block <code>0x004e807c</code>에 따라 현재 위치명과 번 돈을 표시합니다.</div>
      <details open>
        <summary>리전 통합 계약</summary>
        <div id="regionContract" class="contract-grid"></div>
      </details>
      <details>
        <summary>근거/프리뷰/미확정 분리</summary>
        <div id="evidenceSplit" class="contract-grid"></div>
      </details>
      <div class="dpad" aria-label="mobile controls">
        <span></span><button data-key="ArrowUp">↑</button><span></span>
        <button data-key="ArrowLeft">←</button><button data-key="Enter">Enter</button><button data-key="ArrowRight">→</button>
        <button data-key="Escape">Esc</button><button data-key="ArrowDown">↓</button><button data-key="KeyX">X</button>
      </div>
      <div><strong>현재 상태</strong></div>
      <div id="stateDump" class="state"></div>
    </aside>
  </main>
  <script>
  const payload = {payload_json};
  const canvas = document.getElementById("hudCanvas");
  const ctx = canvas.getContext("2d");
  ctx.imageSmoothingEnabled = false;

  const regionById = new Map(payload.regions.map((row) => [Number(row.index), row]));
  const templateById = new Map(payload.templates.map((row) => [Number(row.index), row]));
  const equipmentById = new Map((payload.equipmentCatalog || []).map((row) => [Number(row.id), row]));
  const equipmentIconById = new Map((payload.equipmentIconRows || []).map((row) => [Number(row.equipmentId), row]));
  const actors = payload.actorRows || [];
  const actorNameBySlot = actors.map((row) => String(row.name || ""));

  let selectedMenu = 0;
  let selectedPage = 0;
  let selectedDetail = 0;
  let selectedActor = 0;
  let submenuActive = false;
  let finalDepthActive = false;
  let finalChoice = 0;
  let showDebugRegions = false;
  let sheetCanvases = {{}};
  const actorStates = actors.map((row) => makeActorState(row));
  const selectedModeByActor = Object.fromEntries(actors.map((row) => [String(row.name || ""), "보통"]));
  const partyPresets = payload.normalHudPartyPreviewPresets || [
    {{ key: "full", label: "아타호+린샹+스마슈", actorSlots: [0, 1, 2] }},
  ];
  let selectedPartyPreset = partyPresets.find((row) => row.key === "full") || partyPresets[0] || {{ actorSlots: [0] }};
  let previewLocationIndex = Number(payload.normalHudRegion2Info?.locationDraw?.defaultIndex ?? 5);
  let previewGold = Number(payload.normalHudRegion2Info?.goldValue?.previewValue ?? 100);

  function activePartySlots() {{
    const slots = (selectedPartyPreset?.actorSlots || [0]).map((value) => Number(value)).filter((slot) => actors[slot]);
    return slots.length ? slots : [0].filter((slot) => actors[slot]);
  }}

  function rectText(rect) {{
    if (!rect) return "-";
    return `${{Number(rect.x || 0)}},${{Number(rect.y || 0)}} ${{Number(rect.w || 0)}}x${{Number(rect.h || 0)}}`;
  }}

  function listHtml(rows) {{
    return `<ul>${{(rows || []).map((row) => `<li>${{row}}</li>`).join("")}}</ul>`;
  }}

  function renderEvidenceSummary() {{
    const contractHost = document.getElementById("regionContract");
    if (contractHost) {{
      contractHost.innerHTML = (payload.regionContract || []).map((row) => `
        <section class="contract-card">
          <h2>${{row.label}}</h2>
          <p>${{row.role}}</p>
          <div class="contract-meta">
            <span class="mini-chip">rect ${{rectText(row.rect)}}</span>
            <span class="mini-chip">template #${{row.templateIndex}}</span>
            <span class="mini-chip">${{row.resourceIdHex || ""}}</span>
            <span class="mini-chip">${{row.evidenceStatus || ""}}</span>
          </div>
          <p><strong>EXE 근거</strong></p>
          ${{listHtml(row.exeGrounded)}}
          <p><strong>프리뷰/보류</strong></p>
          ${{listHtml(row.previewOrPending)}}
        </section>
      `).join("");
    }}
    const splitHost = document.getElementById("evidenceSplit");
    if (splitHost) {{
      splitHost.innerHTML = Object.entries(payload.evidenceSplit || {{}}).map(([label, rows]) => `
        <section class="contract-card">
          <h2>${{label}}</h2>
          ${{listHtml(rows)}}
        </section>
      `).join("");
    }}
  }}

  function activePartyActorNames() {{
    return activePartySlots().map((slot) => actorNameBySlot[slot] || `slot${{slot}}`);
  }}

  function ensureSelectedActorInParty() {{
    const slots = activePartySlots();
    if (!slots.includes(selectedActor)) selectedActor = slots[0] || 0;
  }}

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

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

  function clampMeter(value, max, fallback = max) {{
    const cap = Math.max(0, Math.floor(Number(max) || 0));
    return clamp(Math.floor(Number(value ?? fallback) || 0), 0, cap);
  }}

  function setFullVitals(state, row) {{
    state.currentHp = clampMeter(state.raw.maxHp, state.raw.maxHp);
    state.currentMp = clampMeter(state.raw.maxMp, state.raw.maxMp);
    state.exp = clamp(Math.floor(Number(row?.exp ?? state.exp ?? 0) || 0), 0, 99);
  }}

  function rollPreviewVitals(state) {{
    const maxHp = Math.max(1, Math.floor(Number(state.raw.maxHp) || 1));
    const maxMp = Math.max(0, Math.floor(Number(state.raw.maxMp) || 0));
    state.currentHp = maxHp > 1 ? randIntInclusive(1, maxHp - 1) : maxHp;
    state.currentMp = maxMp > 0 ? randIntInclusive(0, maxMp) : 0;
    state.exp = randIntInclusive(0, 99);
  }}

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

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

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

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

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

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

  function actorPreview(slot = selectedActor, equipmentOverride = null) {{
    const base = actors[slot] || actors[0] || {{}};
    const state = actorStates[slot] || makeActorState(base);
    const weaponId = equipmentOverride?.slot === "weapon" ? Number(equipmentOverride.id) : Number(state.weaponId);
    const armorId = equipmentOverride?.slot === "armor" ? Number(equipmentOverride.id) : Number(state.armorId);
    const weapon = equipmentById.get(weaponId) || {{}};
    const armor = equipmentById.get(armorId) || {{}};
    const total = {{ ...state.raw }};
    for (const source of [weapon.bonus || {{}}, armor.bonus || {{}}]) {{
      for (const key of ["attack", "defense", "technique", "agility", "luck"]) total[key] += Number(source[key] || 0);
    }}
    return {{
      ...base,
      level: state.level,
      hp: clampMeter(state.currentHp, state.raw.maxHp, state.raw.maxHp),
      maxHp: state.raw.maxHp,
      mp: clampMeter(state.currentMp, state.raw.maxMp, state.raw.maxMp),
      maxMp: state.raw.maxMp,
      exp: clamp(Math.floor(Number(state.exp || 0)), 0, 99),
      weaponId: Number(weapon.id || weaponId),
      weaponName: weapon.name || base.weaponName,
      armorId: Number(armor.id || armorId),
      armorName: armor.name || base.armorName,
      actorAttack: total.attack,
      actorDefense: total.defense,
      actorTechnique: total.technique,
      actorAgility: total.agility,
      actorLuck: total.luck,
    }};
  }}

  function selectedEquipmentCandidate() {{
    const model = currentMenuModel();
    if (!submenuActive || model.label !== "장비") return null;
    const entry = selectedDetailEntry(model);
    if (!entry) return null;
    const label = entryLabel(entry);
    const pageLabel = currentPage(model)?.label || "";
    const expectedSlot = pageLabel === "무기" ? "weapon" : pageLabel === "방어구" ? "armor" : "";
    return (payload.equipmentCatalog || []).find((row) =>
      row.name === label &&
      Number(row.actorSlot) === Number(selectedActor) &&
      (!expectedSlot || row.slot === expectedSlot)
    ) || null;
  }}

  function equipmentPreviewActor() {{
    const candidate = selectedEquipmentCandidate();
    if (!candidate) return actorPreview();
    return actorPreview(selectedActor, {{ slot: candidate.slot, id: Number(candidate.id) }});
  }}

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

  function measureTextWidth(value, font = "16px Gulim, 'Malgun Gothic', sans-serif") {{
    ctx.save();
    ctx.font = font;
    const width = ctx.measureText(String(value || "")).width;
    ctx.restore();
    return width;
  }}

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

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

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

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

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

  function drawEquipmentIcon(equipmentId, x, y) {{
    const icon = equipmentIconById.get(Number(equipmentId));
    if (!icon || !sheetCanvases.item) return false;
    ctx.drawImage(sheetCanvases.item, icon.x, icon.y, icon.w, icon.h, x, y, 32, 32);
    return true;
  }}

  function entryLabel(entry) {{ return typeof entry === "string" ? entry : String(entry?.label || ""); }}
  function entrySummary(entry) {{ return typeof entry === "string" ? "" : String(entry?.summary || ""); }}
  function entrySource(entry) {{ return typeof entry === "string" ? "" : String(entry?.source || ""); }}
  function hasGridIcon(entry) {{ return Boolean(entry && typeof entry === "object" && entry.icon); }}
  function entryQuantityDisplay(entry) {{
    if (!entry || typeof entry !== "object" || entry.quantityKind !== "consumable-count") return "";
    if (entry.quantityDisplay !== undefined) return String(entry.quantityDisplay);
    if (entry.quantityPreview !== undefined) return `×${{Number(entry.quantityPreview || 0)}}`;
    return "";
  }}

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

  function currentMenuModel() {{ return payload.menuModel[selectedMenu] || payload.menuModel[0] || {{}}; }}
  function currentPages(model = currentMenuModel()) {{ return Array.isArray(model.pages) ? model.pages : []; }}
  function currentPage(model = currentMenuModel()) {{
    const pages = currentPages(model);
    return pages.length ? pages[clamp(selectedPage, 0, pages.length - 1)] : null;
  }}
  function currentActorName() {{ return actorNameBySlot[selectedActor] || "아타호"; }}

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

  function currentWeaponName(actor = currentActorName()) {{
    return String(actorPreview(actorNameBySlot.indexOf(actor)).weaponName || "");
  }}

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

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

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

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

  function selectedDetailEntry(model = currentMenuModel()) {{ return currentDetailItems(model)[selectedDetail] || null; }}
  function selectedFinalDepth(model = currentMenuModel()) {{
    const entry = selectedDetailEntry(model);
    return entry && typeof entry === "object" && entry.finalDepth ? entry.finalDepth : null;
  }}
  function finalDepthChoices(model = currentMenuModel()) {{
    return (selectedFinalDepth(model)?.choices || []).map((choice) => String(choice || "")).filter(Boolean);
  }}
  function resetFinalDepth() {{ finalDepthActive = false; finalChoice = 0; }}
  function openFinalDepth(model = currentMenuModel()) {{
    const choices = finalDepthChoices(model);
    if (!choices.length) return false;
    finalDepthActive = true;
    finalChoice = clamp(Number(selectedFinalDepth(model).defaultChoiceIndex || 0), 0, choices.length - 1);
    return true;
  }}

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

  function setSelectedActor(slot) {{
    selectedActor = clamp(slot, 0, Math.max(0, actors.length - 1));
    selectedDetail = 0;
    resetFinalDepth();
  }}

  function cycleActiveActor(delta) {{
    const slots = activePartySlots();
    if (!slots.length) return;
    const currentIndex = slots.includes(selectedActor) ? slots.indexOf(selectedActor) : 0;
    const nextIndex = (currentIndex + delta + slots.length) % slots.length;
    setSelectedActor(slots[nextIndex]);
  }}

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

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

  function drawTopMenuIcons() {{
    (payload.rightMenuPayload?.normalTopIconRows || []).forEach((row) => {{
      drawIconCell(sheetCanvases.icon, row.iconId, Number(row.previewX || 0), Number(row.previewY || 0));
    }});
  }}

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

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

  function drawSkillMpHeader() {{
    const header = payload.rightMenuLayout?.skillMpHeader || {{}};
    const source = header.sourceRect || {{ x: 32, y: 32, w: 32, h: 16 }};
    drawStatusRect(source, Number(header.x ?? 600), Number(header.y ?? 104));
  }}

  function skillMpDisplay(entry) {{
    if (!entry) return "";
    if (entry.currentMpCost !== undefined && entry.currentMpCost !== null) return String(entry.currentMpCost);
    const costs = entry.mpCosts || [];
    if (costs.length) return String(costs[0]);
    return "";
  }}

  function drawSkillMpCost(entry, y) {{
    const mp = skillMpDisplay(entry);
    if (!mp) return;
    const region = regionById.get(4)?.rect || {{ x: 416, y: 96, w: 224, h: 256 }};
    const right = Number(region.x || 416) + Number(region.w || 224) - 24;
    drawHudNumber(mp, right, y + 8, Math.min(2, Math.max(1, String(mp).length)));
  }}

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

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

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

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

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

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

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

  function locationName(index = previewLocationIndex) {{
    const rows = payload.normalHudRegion2Info?.locationSelector?.locations || [];
    const row = rows.find((entry) => Number(entry.index) === Number(index)) || rows[Number(index)] || {{}};
    return String(row.text || payload.normalHudRegion2Info?.locationDraw?.defaultText || "아타호의　거처");
  }}

  function drawLargeHudNumber(value, onesDigitX, y, digits = 3) {{
    const numberCanvas = sheetCanvases.largeNumbers;
    if (!numberCanvas) {{
      drawHudNumber(value, onesDigitX, y + 4, digits);
      return;
    }}
    const cell = 16;
    const textValue = String(Math.max(0, Math.min(999999, Math.floor(Number(value || 0))))).padStart(Number(digits || 3), " ");
    Array.from(textValue).forEach((char, index) => {{
      if (char < "0" || char > "9") return;
      const digit = Number(char);
      const dx = onesDigitX - (textValue.length - 1) * cell + index * cell;
      ctx.drawImage(numberCanvas, digit * cell, 0, cell, cell, dx, y, cell, cell);
    }});
  }}

  function drawNumberRendererValue(value, x, y, widthHint = 3, renderer = null) {{
    const mode = Number(renderer?.mode ?? 4);
    const useLarge = renderer?.cns === "num.cns";
    const sheet = useLarge ? sheetCanvases.largeNumbers : sheetCanvases.smallNumbers;
    if (!sheet) {{
      text(value, x, y, {{ color: "#fff", font: "16px Gulim, 'Malgun Gothic', sans-serif" }});
      return;
    }}
    const cell = useLarge ? 16 : 8;
    const defaultRow = useLarge ? (mode >= 4 ? mode - 4 : mode) : (mode >= 4 ? mode - 4 : mode);
    const row = clamp(Number(renderer?.row ?? defaultRow), 0, 3);
    const digits = Math.max(1, Number(widthHint || 1));
    const maxValue = Math.pow(10, Math.min(digits, 6)) - 1;
    const textValue = String(clamp(Math.floor(Number(value || 0)), 0, maxValue)).padStart(digits, " ");
    Array.from(textValue).forEach((char, index) => {{
      if (char < "0" || char > "9") return;
      const digit = Number(char);
      ctx.drawImage(sheet, digit * cell, row * cell, cell, cell, x + index * cell, y, cell, cell);
    }});
  }}

  function numberRendererForDelta(renderer, delta) {{
    const base = renderer || {{ mode: 4, cns: "num.cns", row: 0 }};
    if (!delta) return base;
    const copy = {{ ...base }};
    if (delta > 0) {{
      copy.mode = copy.cns === "num.cns" ? 5 : 1;
      copy.row = 1;
      copy.rowMeaning = "green";
    }} else {{
      copy.mode = copy.cns === "num.cns" ? 7 : 3;
      copy.row = 3;
      copy.rowMeaning = "red";
    }}
    return copy;
  }}

  function drawNormalHudInfoPanel() {{
    const info = payload.normalHudRegion2Info || {{}};
    const region = regionById.get(2)?.rect || {{ x: 416, y: 352, w: 224, h: 128 }};
    const locationDraw = info.locationDraw || {{}};
    const label = info.earnedMoneyLabel || {{}};
    const gold = info.goldValue || {{}};
    const unit = info.goldUnit || {{}};
    text(locationName(), Number(locationDraw.x ?? region.x + 24), Number(locationDraw.y ?? region.y + 40), {{
      color: "#fff",
      font: "15px Gulim, 'Malgun Gothic', sans-serif",
    }});
    text(String(label.text || "번　돈"), Number(label.x ?? region.x + 24), Number(label.y ?? region.y + 80), {{
      color: "#fff",
      font: "15px Gulim, 'Malgun Gothic', sans-serif",
    }});
    drawLargeHudNumber(previewGold, Number(gold.onesDigitX ?? region.x + 168), Number(gold.y ?? region.y + 80), 3);
    text(String(unit.text || "Ｇ"), Number(unit.x ?? region.x + 184), Number(unit.y ?? region.y + 80), {{
      color: "#fff",
      font: "15px Gulim, 'Malgun Gothic', sans-serif",
    }});
  }}

  function drawInfoPanel() {{
    const model = currentMenuModel();
    const region = regionById.get(2).rect;
    if (finalDepthActive) {{
      const finalDepth = selectedFinalDepth(model) || {{}};
      const choices = finalDepthChoices(model);
      text(String(finalDepth.prompt || "확인/선택창"), region.x + 18, region.y + 24, {{ color: "#fff", font: "15px Gulim, 'Malgun Gothic', sans-serif" }});
      choices.forEach((choice, index) => {{
        const y = region.y + 56 + index * 24;
        if (index === finalChoice) drawStatusRect((payload.cursorModel?.subCursor || {{}}).source, region.x + 18, y);
        text(choice, region.x + 42, y + 2, {{ color: "#fff", font: "15px Gulim, 'Malgun Gothic', sans-serif" }});
      }});
      return;
    }}
    if (!submenuActive) {{
      drawNormalHudInfoPanel();
      return;
    }}
    const page = currentPage(model);
    const selected = currentDetailItems(model)[selectedDetail] || "";
    const selectedLabel = entryLabel(selected);
    const description = descriptionEntryFor(selected);
    const descriptionLines = (description?.fragments || []).map(panelLineText).filter(Boolean);
    if (descriptionLines.length) {{
      descriptionLines.slice(0, 4).forEach((line, index) => {{
        text(line, region.x + 18, region.y + 28 + index * 20, {{ color: "#fff", font: "14px Gulim, 'Malgun Gothic', sans-serif" }});
      }});
      const rank = rankLabelForEntry(selected);
      if (rank) {{
        ctx.save();
        ctx.font = "14px Gulim, 'Malgun Gothic', sans-serif";
        const w = ctx.measureText(rank).width;
        ctx.restore();
        text(rank, region.x + region.w - 18 - w, region.y + region.h - 24, {{ color: "#fff", font: "14px Gulim, 'Malgun Gothic', sans-serif" }});
      }}
      return;
    }}
    text(selectedLabel || model.label, region.x + 18, region.y + 28, {{ color: "#fff", font: "15px Gulim, 'Malgun Gothic', sans-serif" }});
    const lines = [];
    const summary = entrySummary(selected);
    if (summary) lines.push(summary);
    if (selected?.children?.length) {{
      lines.push(`다음 목록: ${{selected.children.slice(0, 6).map((row) => entryLabel(row)).join(", ")}}${{selected.children.length > 6 ? " ..." : ""}}`);
    }}
    if (page) lines.push(`${{model.label}} 하위 페이지: ${{entryLabel(page)}}`);
    const source = entrySource(selected);
    if (source) lines.push(source);
    if (model.label === "기술") lines.push(`현재 무기: ${{currentWeaponName()}}`);
    if (model.actorSensitive) lines.push(`현재 캐릭터: ${{currentActorName()}}`);
    const font = "12px Gulim, 'Malgun Gothic', sans-serif";
    const rendered = [];
    lines.forEach((line) => wrapCanvasText(line, Number(region.w || 224) - 36, font).forEach((wrapped) => rendered.push(wrapped)));
    rendered.slice(0, 4).forEach((line, index) => text(line, region.x + 18, region.y + 68 + index * 15, {{ color: "#fff", font }}));
  }}

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

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

  function statusTextAdvance(value) {{
    // Region #6 status script uses fixed-width game text cells before 40 2e.
    // Do not derive the next numeric cursor from browser font metrics.
    return Array.from(String(value || "")).length * 16;
  }}

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

  function drawLeftStatusPanel() {{
    const actor = actorPreview();
    const previewActor = equipmentPreviewActor();
    const faceOrderByActorSlot = [0, 2, 1];
    const faceId = faceOrderByActorSlot[selectedActor] ?? selectedActor;
    text(actor.name || "", 16, 16, {{ color: "#fff", font: "16px Gulim, 'Malgun Gothic', sans-serif" }});
    if (sheetCanvases.face) {{
      ctx.drawImage(sheetCanvases.face, (faceId % 8) * 80, Math.floor(faceId / 8) * 80, 80, 80, 56, 40, 80, 80);
    }}
    (payload.textDraws || []).forEach((row) => {{
      const normalized = String(row.normalized || "").replace(/　/g, "");
      if (normalized) text(row.text, row.x, row.y, {{ color: "#fff", font: "16px Gulim, 'Malgun Gothic', sans-serif" }});
    }});
    (payload.equipmentDraws || []).forEach((row) => {{
      const isWeapon = Number(row.slot) === 0;
      const id = isWeapon ? actor.weaponId : actor.armorId;
      const label = isWeapon ? actor.weaponName : actor.armorName;
      const x = Number(row.cursorX ?? (isWeapon ? 32 : 32));
      const y = Number(row.cursorY ?? (isWeapon ? 152 : 216));
      drawEquipmentIcon(id, x, y);
      text(label || "-", x + 40, y + 8, {{ color: "#fff", font: "16px Gulim, 'Malgun Gothic', sans-serif" }});
    }});
    const statByContext = new Map();
    (payload.statDraws || []).forEach((row) => {{
      const key = String(row.labelContext || "");
      if (!statByContext.has(key)) statByContext.set(key, []);
      statByContext.get(key).push(row);
    }});
    (payload.textDraws || []).forEach((label) => {{
      const context = String(label.normalized || "").replace(/　/g, "");
      const rows = statByContext.get(context) || [];
      if (!rows.length) return;
      const values = rows.map((row) => statValue(previewActor, Number(row.fieldOffset)));
      const deltas = rows.map((row) => statValue(previewActor, Number(row.fieldOffset)) - statValue(actor, Number(row.fieldOffset)));
      const baseX = Number(label.x || 0) + statusTextAdvance(label.text);
      const baseY = Number(label.y || 0);
      if (values.length === 2) {{
        const left = rows[0] || {{}};
        const right = rows[1] || {{}};
        const leftWidth = Number(left.widthHint || 4);
        drawNumberRendererValue(values[0], baseX, baseY, leftWidth, numberRendererForDelta(left.numberRenderer, deltas[0]));
        const slashX = baseX + leftWidth * numberRendererCell(left.numberRenderer);
        text("／", slashX, baseY, {{ color: "#fff", font: "16px Gulim, 'Malgun Gothic', sans-serif" }});
        drawNumberRendererValue(values[1], slashX + 16, baseY, Number(right.widthHint || 3), numberRendererForDelta(right.numberRenderer, deltas[1]));
      }} else {{
        const row = rows[0] || {{}};
        drawNumberRendererValue(values[0], baseX, baseY, Number(row.widthHint || 4), numberRendererForDelta(row.numberRenderer, deltas[0]));
      }}
    }});
    drawStatusComment(actor);
    drawActorSwitchArrows();
  }}

  function drawActorSwitchArrows() {{
    const slots = activePartySlots();
    if (slots.length <= 1) return;
    const activeIndex = slots.indexOf(selectedActor);
    const region = regionById.get(6)?.rect || {{ x: 0, y: 0, w: 416, h: 352 }};
    const arrows = payload.cursorModel?.stackArrows || {{}};
    const placement = arrows.placement || {{}};
    const upSource = (arrows.leftPair?.sourceRects || [])[0];
    const downSource = (arrows.rightPair?.sourceRects || [])[0];
    const upDest = placement.leftDest || {{ x: Number(region.x || 0) + Math.floor((Number(region.w || 416) - 32) / 2), y: Number(region.y || 0) }};
    const downDest = placement.rightDest || {{ x: Number(region.x || 0) + Math.floor((Number(region.w || 416) - 32) / 2), y: Number(region.y || 0) + Number(region.h || 352) - 16 }};
    if (activeIndex > 0 && upSource) drawStatusRect(upSource, Number(upDest.x || 0), Number(upDest.y || 0));
    if (activeIndex >= 0 && activeIndex < slots.length - 1 && downSource) drawStatusRect(downSource, Number(downDest.x || 0), Number(downDest.y || 0));
  }}

  function currentCommentGroup() {{
    const groups = payload.statusCommentGroups || [];
    return groups.find((group) => group.tableIdHex === "0x77") || groups[0] || null;
  }}

  function drawStatusComment(actor) {{
    const layout = payload.statusCommentLayout || {{}};
    const rect = layout.textRect || [48, 272, 320, 48];
    const group = currentCommentGroup();
    const table = (group?.actorTables || [])[selectedActor] || null;
    const entry = (table?.entries || [])[0] || null;
    const lines = entry?.normalizedLines || ["오늘은 정말 날씨가 좋군", "정말 쇼핑하기 좋은 날씨야"];
    const lineHeight = lines.length >= 3 ? 15 : 17;
    lines.slice(0, 3).forEach((line, index) => {{
      text(line, rect[0], rect[1] + index * lineHeight, {{ color: "#fff", font: "14px Gulim, 'Malgun Gothic', sans-serif" }});
    }});
  }}

  function drawBottomHudPanel() {{
    const region = regionById.get(1)?.rect || {{ x: 0, y: 352, w: 416, h: 128 }};
    const records = (payload.normalHudStatusRecordTable?.records || [])
      .filter((row) => Number(row.actor_slot) >= 0 && Number(row.actor_slot) <= 2 && Number(row.stat_type) >= 0 && Number(row.stat_type) <= 2);
    const columns = new Map([
      [0, {{ header: {{ x: 0, y: 32, w: 32, h: 16 }}, value: (actor) => actor.hp, max: (actor) => actor.maxHp }}],
      [1, {{ header: {{ x: 32, y: 32, w: 32, h: 16 }}, value: (actor) => actor.mp, max: (actor) => actor.maxMp }}],
      [2, {{ header: {{ x: 64, y: 32, w: 32, h: 16 }}, value: (actor) => actor.exp || 0, max: () => 100 }}],
    ]);
    [0, 1, 2].forEach((type) => {{
      const row = records.find((record) => Number(record.actor_slot) === 0 && Number(record.stat_type) === type);
      const column = columns.get(type);
      if (row && column) drawStatusRect(column.header, Number(row.x || 0), region.y + 8);
    }});
    const nameEvidence = payload.normalHudActorNameText || {{}};
    const nameRows = nameEvidence.position_stream?.screen_rows || [];
    activePartySlots().forEach((actorSlot, displaySlot) => {{
      const actor = actorPreview(actorSlot);
      const slotRecords = records.filter((record) => Number(record.actor_slot) === displaySlot);
      const rowY = Number(slotRecords[0]?.y || (392 + displaySlot * 32));
      const nameRow = nameRows.find((row) => Number(row.display_slot) === displaySlot) || {{}};
      const nameX = Number(nameRow.screen_x ?? (region.x + 24));
      const nameY = Number(nameRow.screen_y ?? (rowY - 8));
      text(actor.name || "", nameX, nameY, {{
        color: "#fff",
        font: "13px Gulim, 'Malgun Gothic', sans-serif",
      }});
      if (showDebugRegions) {{
        text(`slot ${{displaySlot}}←actor ${{actorSlot}}`, nameX + 56, nameY + 1, {{
          color: "#8bd17c",
          font: "10px system-ui, sans-serif",
        }});
      }}
      slotRecords.forEach((record) => {{
        const column = columns.get(Number(record.stat_type));
        if (!column) return;
        const current = Math.max(0, Number(column.value(actor) || 0));
        const max = Math.max(1, Number(column.max(actor) || 1));
        drawStatusRect({{ x: 0, y: 24, w: 88, h: 8 }}, Number(record.x || 0), Number(record.y || 0) - 8);
        drawStatusRect({{ x: 0, y: 16, w: 88, h: 8 }}, Number(record.x || 0), Number(record.y || 0));
        const fill = Math.max(0, Math.min(84, Math.floor(current * 84 / max)));
        if (fill > 0 && sheetCanvases.status) {{
          ctx.drawImage(sheetCanvases.status, 2, 0, fill, 8, Number(record.x || 0) + 2, Number(record.y || 0), fill, 8);
        }}
        const currentDest = record.number_current_dest || {{ x: Number(record.x || 0) + 0x20, y: Number(record.y || 0) - 8 }};
        const maxDest = record.number_max_dest || {{ x: Number(record.x || 0) + 0x50, y: Number(record.y || 0) - 8 }};
        drawHudNumber(current, Number(currentDest.x || 0), Number(currentDest.y || 0), 3);
        drawHudNumber(max, Number(maxDest.x || 0), Number(maxDest.y || 0), 3);
      }});
    }});
  }}

  function drawHudNumber(value, onesDigitX, y, digits = 3) {{
    const numberCanvas = sheetCanvases.smallNumbers;
    if (!numberCanvas) return;
    const cell = 8;
    const textValue = String(Math.max(0, Math.min(999, Math.floor(Number(value || 0))))).padStart(Number(digits || 3), " ");
    Array.from(textValue).forEach((char, index) => {{
      if (char < "0" || char > "9") return;
      const digit = Number(char);
      const dx = onesDigitX - (textValue.length - 1) * cell + index * cell;
      ctx.drawImage(numberCanvas, digit * cell, 0, cell, cell, dx, y, cell, cell);
    }});
  }}

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

  async function render() {{
    const [windowCanvas, iconCanvas, itemCanvas, statusCanvas, faceCanvas, smallNumbersCanvas, largeNumbersCanvas] = await Promise.all([
      window.HWANSE_CNS_RENDERER.loadImageCanvas("window.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("icon.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("item.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("status.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("face_01.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("01234567.cns"),
      window.HWANSE_CNS_RENDERER.loadImageCanvas("num.cns"),
    ]);
    sheetCanvases = {{ window: windowCanvas, icon: iconCanvas, item: itemCanvas, status: statusCanvas, face: faceCanvas, smallNumbers: smallNumbersCanvas, largeNumbers: largeNumbersCanvas }};
    ctx.clearRect(0, 0, 640, 480);
    ctx.fillStyle = "#050505";
    ctx.fillRect(0, 0, 640, 480);
    [6, 3, 4, 1, 2].forEach((id) => drawRegion(windowCanvas, id));
    drawLeftStatusPanel();
    drawCurrentModeLabel();
    drawTopMenuIcons();
    drawDetailMenu();
    drawBottomHudPanel();
    drawInfoPanel();
    drawMenuCursors();
    drawDebugRegions();
    syncControls();
    updateStateDump();
  }}

  function applyDetailSelection() {{
    const model = currentMenuModel();
    const entry = selectedDetailEntry(model);
    if (!entry) return;
    if (model.label === "모드") {{
      selectedModeByActor[currentActorName()] = entryLabel(entry);
      return;
    }}
    if (model.label === "장비") {{
      const actor = actorPreview();
      const state = actorStates[selectedActor];
      const item = (payload.equipmentCatalog || []).find((row) => row.name === entryLabel(entry));
      if (item) {{
        if (item.slot === "weapon") state.weaponId = Number(item.id);
        if (item.slot === "armor") state.armorId = Number(item.id);
      }}
      return;
    }}
    openFinalDepth(model);
  }}

  function handleCommand(command) {{
    const model = currentMenuModel();
    if (command === "escape") {{
      if (finalDepthActive) resetFinalDepth();
      else if (submenuActive) {{ submenuActive = false; selectedDetail = 0; }}
      render();
      return;
    }}
    if (finalDepthActive) {{
      const choices = finalDepthChoices(model);
      if (command === "up") finalChoice = clamp(finalChoice - 1, 0, Math.max(0, choices.length - 1));
      if (command === "down") finalChoice = clamp(finalChoice + 1, 0, Math.max(0, choices.length - 1));
      if (command === "enter") resetFinalDepth();
      render();
      return;
    }}
    if (!submenuActive) {{
      if (command === "left") setSelectedMenu(selectedMenu - 1);
      if (command === "right") setSelectedMenu(selectedMenu + 1);
      if (command === "up") cycleActiveActor(-1);
      if (command === "down") cycleActiveActor(1);
      if (command === "enter") submenuActive = true;
      render();
      return;
    }}
    const pages = currentPages(model);
    const items = currentDetailItems(model);
    if (command === "up") selectedDetail = clamp(selectedDetail - 1, 0, Math.max(0, items.length - 1));
    if (command === "down") selectedDetail = clamp(selectedDetail + 1, 0, Math.max(0, items.length - 1));
    if (command === "left" && pages.length > 1) {{ selectedPage = clamp(selectedPage - 1, 0, pages.length - 1); selectedDetail = 0; }}
    if (command === "right" && pages.length > 1) {{ selectedPage = clamp(selectedPage + 1, 0, pages.length - 1); selectedDetail = 0; }}
    if (command === "enter") applyDetailSelection();
    render();
  }}

  function keyToCommand(event) {{
    if (event.code === "ArrowLeft") return "left";
    if (event.code === "ArrowRight") return "right";
    if (event.code === "ArrowUp") return "up";
    if (event.code === "ArrowDown") return "down";
    if (event.code === "Enter" || event.code === "Space" || event.code === "KeyZ") return "enter";
    if (event.code === "Escape" || event.code === "KeyX" || event.code === "Numpad0") return "escape";
    return "";
  }}

  function syncControls() {{
    const menuHost = document.getElementById("menuButtons");
    menuHost.innerHTML = "";
    payload.menuModel.forEach((row, index) => {{
      const button = document.createElement("button");
      button.type = "button";
      button.textContent = row.label;
      button.className = index === selectedMenu ? "is-active" : "";
      button.addEventListener("click", () => {{ setSelectedMenu(index); submenuActive = false; render(); }});
      menuHost.appendChild(button);
    }});
    const status = document.createElement("span");
    status.className = "chip";
    status.textContent = submenuActive ? "하위 메뉴" : "상단 메뉴";
    menuHost.appendChild(status);

    const partyHost = document.getElementById("partyButtons");
    partyHost.innerHTML = "";
    partyPresets.forEach((row) => {{
      const button = document.createElement("button");
      button.type = "button";
      button.textContent = row.label;
      button.className = row.key === selectedPartyPreset.key ? "is-active" : "";
      button.addEventListener("click", () => {{
        selectedPartyPreset = row;
        ensureSelectedActorInParty();
        selectedDetail = 0;
        resetFinalDepth();
        render();
      }});
      partyHost.appendChild(button);
    }});
    const partyNote = document.createElement("span");
    partyNote.className = "chip";
    partyNote.textContent = `#1 표시: ${{activePartyActorNames().join(" / ")}}`;
    partyHost.appendChild(partyNote);

    const actorHost = document.getElementById("actorButtons");
    actorHost.innerHTML = "";
    const activeSlots = activePartySlots();
    actors.forEach((row, index) => {{
      const button = document.createElement("button");
      button.type = "button";
      button.textContent = activeSlots.includes(index) ? row.name : `${{row.name}} · 비표시`;
      button.className = index === selectedActor ? "is-active" : "";
      button.disabled = !activeSlots.includes(index);
      button.addEventListener("click", () => {{ setSelectedActor(index); render(); }});
      actorHost.appendChild(button);
    }});
    const mode = document.createElement("span");
    mode.className = "chip";
    mode.textContent = `모드: ${{currentMode()}}`;
    actorHost.appendChild(mode);

    const equipHost = document.getElementById("equipmentButtons");
    equipHost.innerHTML = "";
    const actor = actorPreview();
    const label = document.createElement("span");
    label.className = "chip";
    label.textContent = `장비: ${{actor.weaponName}} / ${{actor.armorName}}`;
    equipHost.appendChild(label);
  }}

  function updateStateDump() {{
    const model = currentMenuModel();
    const selected = selectedDetailEntry(model);
    document.getElementById("stateChip").textContent = `${{selectedPartyPreset.label}} · ${{currentActorName()}} · ${{model.label}} · ${{submenuActive ? "submenu" : "top"}}${{finalDepthActive ? " · final" : ""}}`;
    document.getElementById("stateDump").textContent = JSON.stringify({{
      party: selectedPartyPreset.label,
      activeParty: activePartyActorNames(),
      actor: currentActorName(),
      mode: currentMode(),
      menu: model.label,
      page: currentPage(model)?.label || null,
      detail: entryLabel(selected),
      submenuActive,
      finalDepthActive,
      finalChoice,
      level: actorStates[selectedActor]?.level,
      hp: `${{actorPreview().hp}}/${{actorPreview().maxHp}}`,
      mp: `${{actorPreview().mp}}/${{actorPreview().maxMp}}`,
      exp: actorPreview().exp,
      weapon: actorPreview().weaponName,
      armor: actorPreview().armorName,
      region2: submenuActive ? "submenu-detail/description" : `normal-hud ${{locationName()}} / ${{previewGold}}Ｇ`,
      normalHudActiveSlots: payload.normalHudActivePartySlotModel?.status || "pending",
      normalHudActorNames: payload.normalHudActorNameText?.status || "pending",
      region6ActorSwitchArrows: `${{payload.cursorModel?.stackArrows?.evidenceLevel || "pending"}} / placement ${{payload.cursorModel?.stackArrows?.placement?.leftDest ? "decoded" : "pending"}}`,
    }}, null, 2);
  }}

  function randomizeBuild() {{
    actors.forEach((row, index) => {{
      const state = actorStates[index];
      state.level = randIntInclusive(1, 99);
      state.raw = rollRawStats(row, state.level);
      const weapons = itemOptions(row.slot, "weapon");
      const armors = itemOptions(row.slot, "armor");
      if (weapons.length) state.weaponId = Number(weapons[randIntInclusive(0, weapons.length - 1)].id);
      if (armors.length) state.armorId = Number(armors[randIntInclusive(0, armors.length - 1)].id);
      rollPreviewVitals(state);
    }});
    render();
  }}

  document.addEventListener("keydown", (event) => {{
    const command = keyToCommand(event);
    if (!command) return;
    event.preventDefault();
    handleCommand(command);
  }});
  document.querySelectorAll("[data-key]").forEach((button) => {{
    button.addEventListener("click", () => {{
      const code = button.dataset.key;
      const command = keyToCommand({{ code }});
      if (command) handleCommand(command);
    }});
  }});
  document.getElementById("debugToggle").addEventListener("change", (event) => {{
    showDebugRegions = Boolean(event.target.checked);
    render();
  }});
  document.getElementById("resetButton").addEventListener("click", () => {{
    selectedMenu = 0; selectedPage = 0; selectedDetail = 0; selectedPartyPreset = partyPresets.find((row) => row.key === "full") || partyPresets[0] || selectedPartyPreset; selectedActor = activePartySlots()[0] || 0; submenuActive = false; resetFinalDepth();
    render();
  }});
  document.getElementById("randomButton").addEventListener("click", randomizeBuild);

  renderEvidenceSummary();
  render().catch((error) => {{
    console.error(error);
    document.getElementById("stateChip").textContent = `error: ${{error.message}}`;
  }});
  </script>
</body>
</html>
"""


def main() -> None:
    payload = build_payload()
    OUT_JSON.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
    html = build_html(payload)
    WEB_HTML.write_text(html, encoding="utf-8")
    print(f"wrote {OUT_JSON}")
    print(f"wrote {WEB_HTML}")


if __name__ == "__main__":
    main()
