#!/usr/bin/env python3
"""Build a review for player skill/equipment state slots.

The previous gate review grounded Event VM opcodes e2/e3.  This artifact goes
one step lower and records the actual actor-state arrays consumed by those
gates and by the equipment/skill add/remove helpers.
"""
from __future__ import annotations

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

from probe_exe_scene_tables import read_sections, va_to_offset


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

ACTOR_BASE_VA = 0x00457750
ACTOR_STRIDE = 0xD8
CURRENT_EQUIPMENT_OFFSET = 0x48
EQUIPMENT_SLOTS_OFFSET = 0x4A
SKILL_SLOTS_OFFSET = 0x56

ACTORS = {
    0: "아타호",
    1: "린샹",
    2: "스마슈",
}

EQUIPMENT_GROUPS = {
    0: "무기/술",
    1: "방어구",
}

SKILL_CATEGORIES = {
    0: "기본기",
    1: "개인공격기",
    2: "전체공격기",
    3: "특수기",
}

FUNCTIONS = [
    {
        "key": "skillPredicateDownOrExisting",
        "label": "기술 보유/하위 숙련도 lookup",
        "startVa": 0x00421B33,
        "endVa": 0x00421BDF,
        "role": "actor+0x56 category 슬롯에서 action id를 하향 스캔한다.",
    },
    {
        "key": "skillPredicateTier",
        "label": "기술 숙련도 gate lookup",
        "startVa": 0x00421BDF,
        "endVa": 0x00421D3D,
        "role": "e3 handler가 호출한다. action table record+0x10 tier marker를 따라 actor+0x56 슬롯을 검사한다.",
    },
    {
        "key": "equipmentOwnedPredicate",
        "label": "장비 보유 gate lookup",
        "startVa": 0x00421D3D,
        "endVa": 0x00421E31,
        "role": "e2 handler가 호출한다. detail+0x29 type으로 actor/group을 고른 뒤 actor+0x4a 슬롯을 검사한다.",
    },
    {
        "key": "skillInsertWriter",
        "label": "기술 추가 writer",
        "startVa": 0x00421EF7,
        "endVa": 0x00421F9C,
        "role": "중복 검사 후 actor+0x56 category의 첫 빈 슬롯에 action id를 쓴다.",
    },
    {
        "key": "skillSortHelper",
        "label": "기술 슬롯 pack/sort helper",
        "startVa": 0x00421F9C,
        "endVa": 0x00422093,
        "role": "기술 슬롯의 0 구멍을 뒤로 밀고 action id를 정렬/압축한다.",
    },
    {
        "key": "equipmentAddWriter",
        "label": "장비 추가 writer",
        "startVa": 0x0042234E,
        "endVa": 0x00422587,
        "role": "detail+0x29 type으로 actor/group을 고른 뒤 actor+0x4a group 슬롯에 item id를 쓴다.",
    },
    {
        "key": "equipmentSortHelper",
        "label": "장비 슬롯 pack/sort helper",
        "startVa": 0x00422587,
        "endVa": 0x0042267E,
        "role": "장비 보유 슬롯의 0 구멍을 뒤로 밀고 item id를 정렬/압축한다.",
    },
    {
        "key": "skillRemoveWriter",
        "label": "기술 제거 writer",
        "startVa": 0x0042267E,
        "endVa": 0x004226E9,
        "role": "actor+0x56 category 슬롯에서 skill id를 찾아 0으로 지운다.",
    },
    {
        "key": "equipmentRemoveWriter",
        "label": "장비 제거 writer",
        "startVa": 0x00422899,
        "endVa": 0x00422A81,
        "role": "actor+0x4a group 슬롯에서 item id를 찾아 0으로 지운다.",
    },
    {
        "key": "equipmentApplyAndSkillGrant",
        "label": "장비 장착/제공 기술 갱신",
        "startVa": 0x00422A81,
        "endVa": 0x00422B95,
        "role": "현재 장비 actor+0x48/+0x49를 바꾸고 기존 제공 기술 제거 뒤 detail+0x2a..0x2d 기술을 추가한다.",
    },
]

TABLES = [
    {
        "key": "equipmentOwnedJumpTable",
        "label": "장비 보유 predicate jump table",
        "va": 0x00421DF8,
        "length": 0x1C,
    },
    {
        "key": "equipmentOwnedTypeIndexTable",
        "label": "장비 보유 predicate type index table",
        "va": 0x00421E14,
        "length": 0x84,
    },
    {
        "key": "equipmentAddJumpTable",
        "label": "장비 추가 writer jump table",
        "va": 0x004223F7,
        "length": 0x1C,
    },
    {
        "key": "equipmentAddTypeIndexTable",
        "label": "장비 추가 writer type index table",
        "va": 0x00422413,
        "length": 0x84,
    },
    {
        "key": "equipmentRemoveJumpTable",
        "label": "장비 제거 writer jump table",
        "va": 0x00422942,
        "length": 0x1C,
    },
    {
        "key": "equipmentRemoveTypeIndexTable",
        "label": "장비 제거 writer type index table",
        "va": 0x0042295E,
        "length": 0x84,
    },
]

CALL_TARGETS = {
    0x00421B33: "skillPredicateDownOrExisting",
    0x00421BDF: "skillPredicateTier",
    0x00421D3D: "equipmentOwnedPredicate",
    0x00421EF7: "skillInsertWriter",
    0x0042267E: "skillRemoveWriter",
    0x00422899: "equipmentRemoveWriter",
    0x00422A81: "equipmentApplyAndSkillGrant",
}


def load_json(path: Path, default: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return default


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


def write_text(path: Path, text: str) -> None:
    path.write_text(text, encoding="utf-8")


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


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


def u8_rows(values: bytes) -> list[int]:
    return list(values)


def get_bytes(exe: bytes, sections: list[dict[str, Any]], va: int, length: int) -> bytes:
    off = va_to_offset(sections, va)
    if off is None:
        return b""
    return exe[off : off + length]


def ui_equipment_records(ui: dict[str, Any]) -> list[dict[str, Any]]:
    for table in ui.get("tables") or []:
        if table.get("key") == "equipment":
            return list(table.get("records") or [])
    return []


def equipment_name_map(equipment: list[dict[str, Any]]) -> dict[int, str]:
    return {
        int(row["index1Based"]): row.get("name") or f"#{row['index1Based']}"
        for row in equipment
        if row.get("index1Based") is not None
    }


def skill_name_map_from_gate(gate: dict[str, Any]) -> dict[tuple[int, int, int], str]:
    names: dict[tuple[int, int, int], str] = {}
    for row in (gate.get("skillMatrix") or {}).get("rows") or []:
        actor = row.get("actorIndex")
        category = row.get("categoryIndex")
        values = row.get("activeValues") or []
        refs = row.get("candidateNamesByReferenceOrder") or []
        if actor is None or category is None:
            continue
        for value, name in zip(values, refs, strict=False):
            if value:
                names[(int(actor), int(category), int(value))] = name
    return names


def scan_call_refs(exe: bytes, sections: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
    refs: dict[str, list[dict[str, Any]]] = {label: [] for label in CALL_TARGETS.values()}
    text_sections = [section for section in sections if section.get("name") == ".text"]
    for section in text_sections:
        raw = section["raw"]
        raw_size = section["raw_size"]
        base_va = section["va"]
        data = exe[raw : raw + raw_size]
        for index in range(0, max(0, len(data) - 5)):
            if data[index] != 0xE8:
                continue
            rel = struct.unpack_from("<i", data, index + 1)[0]
            call_va = base_va + index
            target = call_va + 5 + rel
            label = CALL_TARGETS.get(target)
            if not label:
                continue
            refs[label].append(
                {
                    "callVa": call_va,
                    "callVaHex": hx(call_va),
                    "targetVa": target,
                    "targetVaHex": hx(target),
                    "bytes": data[index : index + 5].hex(" "),
                }
            )
    return refs


def actor_state_rows(
    exe: bytes,
    sections: list[dict[str, Any]],
    equipment_names: dict[int, str],
    skill_names: dict[tuple[int, int, int], str],
) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for actor_index, actor_name in ACTORS.items():
        base = ACTOR_BASE_VA + actor_index * ACTOR_STRIDE
        raw = get_bytes(exe, sections, base, ACTOR_STRIDE)
        if not raw:
            continue
        current_weapon = raw[CURRENT_EQUIPMENT_OFFSET]
        current_armor = raw[CURRENT_EQUIPMENT_OFFSET + 1]
        equipment_groups = []
        for group in range(2):
            values = u8_rows(raw[EQUIPMENT_SLOTS_OFFSET + group * 6 : EQUIPMENT_SLOTS_OFFSET + group * 6 + 6])
            equipment_groups.append(
                {
                    "groupIndex": group,
                    "group": EQUIPMENT_GROUPS[group],
                    "offsetHex": f"+0x{EQUIPMENT_SLOTS_OFFSET + group * 6:02x}",
                    "values": values,
                    "names": [equipment_names.get(value, "") if value else "" for value in values],
                    "nonZeroCount": sum(1 for value in values if value),
                }
            )
        skill_categories = []
        for category in range(4):
            values = u8_rows(raw[SKILL_SLOTS_OFFSET + category * 6 : SKILL_SLOTS_OFFSET + category * 6 + 6])
            skill_categories.append(
                {
                    "categoryIndex": category,
                    "category": SKILL_CATEGORIES[category],
                    "offsetHex": f"+0x{SKILL_SLOTS_OFFSET + category * 6:02x}",
                    "values": values,
                    "names": [skill_names.get((actor_index, category, value), "") if value else "" for value in values],
                    "nonZeroCount": sum(1 for value in values if value),
                }
            )
        rows.append(
            {
                "actorIndex": actor_index,
                "actor": actor_name,
                "baseVa": base,
                "baseVaHex": hx(base),
                "stride": ACTOR_STRIDE,
                "currentEquipment": {
                    "offsetHex": f"+0x{CURRENT_EQUIPMENT_OFFSET:02x}",
                    "weaponOrLiquorId": current_weapon,
                    "weaponOrLiquorName": equipment_names.get(current_weapon, ""),
                    "armorId": current_armor,
                    "armorName": equipment_names.get(current_armor, ""),
                },
                "equipmentGroups": equipment_groups,
                "skillCategories": skill_categories,
            }
        )
    return rows


def type_mapping_rows(equipment: list[dict[str, Any]]) -> list[dict[str, Any]]:
    seen: dict[int, dict[str, Any]] = {}
    for row in equipment:
        detail = row.get("detailRecord") or {}
        equip_target = detail.get("equipTarget") or {}
        raw = equip_target.get("raw")
        if raw is None:
            continue
        raw = int(raw)
        if raw in seen:
            seen[raw]["examples"].append(row.get("name") or f"#{row.get('index1Based')}")
            continue
        seen[raw] = {
            "type": raw,
            "typeHex": f"0x{raw:02x}",
            "actorMask": raw & 0x7F,
            "isArmor": bool(raw & 0x80),
            "actor": equip_target.get("character") or "",
            "group": equip_target.get("slot") or "",
            "storageRule": "actorBase + 0x4a + group*6 + slot",
            "examples": [row.get("name") or f"#{row.get('index1Based')}"],
        }
    return sorted(seen.values(), key=lambda row: row["type"])


def table_rows(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows = []
    for table in TABLES:
        data = get_bytes(exe, sections, table["va"], table["length"])
        rows.append(
            {
                **table,
                "vaHex": hx(table["va"]),
                "rawHex": data.hex(" "),
            }
        )
    return rows


def function_rows(call_refs: dict[str, list[dict[str, Any]]]) -> list[dict[str, Any]]:
    rows = []
    for row in FUNCTIONS:
        rows.append(
            {
                **row,
                "startVaHex": hx(row["startVa"]),
                "endVaHex": hx(row["endVa"]),
                "size": row["endVa"] - row["startVa"],
                "callRefs": call_refs.get(row["key"], []),
                "callRefCount": len(call_refs.get(row["key"], [])),
            }
        )
    return rows


def build(args: argparse.Namespace) -> dict[str, Any]:
    exe = args.exe.read_bytes()
    sections = read_sections(exe)
    ui = load_json(args.ui_mappings, {})
    gate = load_json(args.gate_review, {})
    equipment = ui_equipment_records(ui)
    equipment_names = equipment_name_map(equipment)
    skill_names = skill_name_map_from_gate(gate)
    call_refs = scan_call_refs(exe, sections)
    actors = actor_state_rows(exe, sections, equipment_names, skill_names)
    functions = function_rows(call_refs)
    tables = table_rows(exe, sections)
    return {
        "scope": "skill-equipment-state-layout-review",
        "sourceArtifacts": {
            "exe": str(args.exe.relative_to(ROOT)),
            "uiCnsGridMappings": str(args.ui_mappings.relative_to(ROOT)),
            "skillEquipmentGateReview": str(args.gate_review.relative_to(ROOT)),
        },
        "summary": {
            "status": "skill-equipment-state-slot-layout-grounded",
            "actorBaseVaHex": hx(ACTOR_BASE_VA),
            "actorStride": ACTOR_STRIDE,
            "currentEquipmentOffsetHex": f"+0x{CURRENT_EQUIPMENT_OFFSET:02x}",
            "equipmentSlotsOffsetHex": f"+0x{EQUIPMENT_SLOTS_OFFSET:02x}",
            "skillSlotsOffsetHex": f"+0x{SKILL_SLOTS_OFFSET:02x}",
            "functionCount": len(functions),
            "typeMappingCount": len(type_mapping_rows(equipment)),
            "conclusion": (
                "장비와 기술은 개별 scenario flag가 아니라 actor 상태 구조체 내부의 6-slot 배열이다. "
                "e2/e3 gate, 장비 추가/삭제/장착, 기술 추가/삭제 helper가 같은 슬롯을 읽고 쓴다."
            ),
        },
        "actorLayout": {
            "baseVaHex": hx(ACTOR_BASE_VA),
            "stride": ACTOR_STRIDE,
            "currentEquipment": {
                "offsetHex": f"+0x{CURRENT_EQUIPMENT_OFFSET:02x}",
                "weaponOrLiquor": "byte actor+0x48",
                "armor": "byte actor+0x49",
            },
            "equipmentSlots": {
                "offsetHex": f"+0x{EQUIPMENT_SLOTS_OFFSET:02x}",
                "shape": "2 groups x 6 slots",
                "groups": EQUIPMENT_GROUPS,
            },
            "skillSlots": {
                "offsetHex": f"+0x{SKILL_SLOTS_OFFSET:02x}",
                "shape": "4 categories x 6 slots",
                "categories": SKILL_CATEGORIES,
            },
        },
        "functions": functions,
        "tables": tables,
        "equipmentTypeMap": type_mapping_rows(equipment),
        "actorStateRows": actors,
        "stateWriteSummary": [
            {
                "kind": "equipment-add",
                "functionVaHex": hx(0x0042234E),
                "writeTarget": "actorBase+0x4a+group*6+slot",
                "returnSemantics": "0 inserted, 1 duplicate, 2 no empty slot",
            },
            {
                "kind": "equipment-remove",
                "functionVaHex": hx(0x00422899),
                "writeTarget": "actorBase+0x4a+group*6+slot = 0",
                "returnSemantics": "0 removed, 1 not found",
            },
            {
                "kind": "equipment-apply",
                "functionVaHex": hx(0x00422A81),
                "writeTarget": "actorBase+0x48 weapon/liquor or actorBase+0x49 armor",
                "sideEffect": "old equipment-provided skills removed, new detail+0x2a..0x2d skills inserted",
            },
            {
                "kind": "skill-add",
                "functionVaHex": hx(0x00421EF7),
                "writeTarget": "actorBase+0x56+category*6+slot",
                "returnSemantics": "1 already known, 0 inserted",
            },
            {
                "kind": "skill-remove",
                "functionVaHex": hx(0x0042267E),
                "writeTarget": "actorBase+0x56+category*6+slot = 0",
                "returnSemantics": "matched slot cleared then packed/sorted",
            },
        ],
    }


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


def render_html(report: dict[str, Any]) -> str:
    summary = report["summary"]
    layout = report["actorLayout"]
    function_rows_html = [
        [
            h(row["label"]),
            f"<code>{h(row['startVaHex'])}..{h(row['endVaHex'])}</code>",
            h(row["role"]),
            h(row["callRefCount"]),
        ]
        for row in report["functions"]
    ]
    type_rows_html = [
        [
            f"<code>{h(row['typeHex'])}</code>",
            h(row["actor"]),
            h(row["group"]),
            h(", ".join(row["examples"][:4])),
        ]
        for row in report["equipmentTypeMap"]
    ]
    actor_rows_html = []
    for row in report["actorStateRows"]:
        equip_lines = []
        for group in row["equipmentGroups"]:
            pairs = [
                f"{value}:{name}" if value and name else (str(value) if value else "0")
                for value, name in zip(group["values"], group["names"], strict=False)
            ]
            equip_lines.append(f"{group['offsetHex']} {group['group']} [{', '.join(pairs)}]")
        skill_lines = []
        for category in row["skillCategories"]:
            pairs = [
                f"{value}:{name}" if value and name else (str(value) if value else "0")
                for value, name in zip(category["values"], category["names"], strict=False)
            ]
            skill_lines.append(f"{category['offsetHex']} {category['category']} [{', '.join(pairs)}]")
        actor_rows_html.append(
            [
                h(row["actor"]),
                f"<code>{h(row['baseVaHex'])}</code>",
                h(
                    f"+0x48={row['currentEquipment']['weaponOrLiquorId']}:{row['currentEquipment']['weaponOrLiquorName']} / "
                    f"+0x49={row['currentEquipment']['armorId']}:{row['currentEquipment']['armorName']}"
                ),
                "<br />".join(h(line) for line in equip_lines),
                "<br />".join(h(line) for line in skill_lines),
            ]
        )
    write_rows_html = [
        [
            h(row["kind"]),
            f"<code>{h(row['functionVaHex'])}</code>",
            h(row["writeTarget"]),
            h(row.get("returnSemantics") or row.get("sideEffect") or ""),
        ]
        for row in report["stateWriteSummary"]
    ]
    table_rows_html = [
        [
            h(row["label"]),
            f"<code>{h(row['vaHex'])}</code>",
            h(row["length"]),
            f"<code>{h(row['rawHex'][:140])}{' ...' if len(row['rawHex']) > 140 else ''}</code>",
        ]
        for row in report["tables"]
    ]

    return f"""<!doctype html>
<html lang="ko">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" href="../favicon.ico" />
    <title>기술/장비 상태 슬롯 리뷰</title>
    <style>
      :root {{ color-scheme: light; --bg:#f6f7f9; --fg:#17202a; --line:#d8dee6; --muted:#64748b; --head:#eef2f6; --good:#0f766e; }}
      * {{ box-sizing:border-box; }}
      body {{ margin:0; padding:24px; background:var(--bg); color:var(--fg); font:14px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
      h1, h2 {{ margin:0 0 12px; }}
      h2 {{ margin-top:24px; }}
      a {{ color:#155e75; }}
      .card {{ background:#fff; border:1px solid var(--line); border-radius:8px; padding:16px; margin:0 0 16px; box-shadow:0 1px 2px rgba(15,23,42,.04); }}
      .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(190px,1fr)); gap:10px; }}
      .metric {{ border:1px solid var(--line); border-radius:8px; padding:12px; background:#fafbfc; }}
      .metric strong {{ display:block; font-size:20px; }}
      .metric span, .muted {{ color:var(--muted); }}
      .tag {{ display:inline-flex; align-items:center; border-radius:999px; padding:2px 8px; background:#ccfbf1; color:var(--good); }}
      .table-wrap {{ overflow-x:auto; }}
      table {{ width:100%; border-collapse:collapse; background:#fff; }}
      th, td {{ border:1px solid var(--line); padding:7px 9px; vertical-align:top; text-align:left; }}
      th {{ background:var(--head); white-space:nowrap; }}
      code {{ font-family:ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size:12px; }}
      pre {{ white-space:pre-wrap; background:#0f172a; color:#dbeafe; padding:12px; border-radius:8px; overflow:auto; }}
    </style>
  </head>
  <body>
    <main>
      <h1>기술/장비 상태 슬롯 리뷰</h1>
      <section class="card">
        <p><span class="tag">HWANSE_SKILL_EQUIPMENT_STATE_LAYOUT_REVIEW_READY</span></p>
        <p>{h(summary["conclusion"])}</p>
        <div class="metrics">
          <div class="metric"><strong>{h(summary["actorBaseVaHex"])}</strong><span>actor state base</span></div>
          <div class="metric"><strong>{h(summary["actorStride"])}</strong><span>actor stride bytes</span></div>
          <div class="metric"><strong>{h(summary["equipmentSlotsOffsetHex"])}</strong><span>장비 보유 슬롯 offset</span></div>
          <div class="metric"><strong>{h(summary["skillSlotsOffsetHex"])}</strong><span>기술 슬롯 offset</span></div>
        </div>
      </section>

      <section class="card">
        <h2>Slot Layout</h2>
        <pre>{h(json.dumps(layout, ensure_ascii=False, indent=2))}</pre>
      </section>

      <section class="card">
        <h2>Actor Initial State</h2>
        <div class="table-wrap">
          {render_table(["actor", "base", "current equipment", "owned equipment slots", "skill slots"], actor_rows_html)}
        </div>
      </section>

      <section class="card">
        <h2>Readers/Writers</h2>
        <div class="table-wrap">
          {render_table(["function", "VA span", "role", "call refs"], function_rows_html)}
        </div>
      </section>

      <section class="card">
        <h2>Write Semantics</h2>
        <div class="table-wrap">
          {render_table(["kind", "function", "write target", "return/side effect"], write_rows_html)}
        </div>
      </section>

      <section class="card">
        <h2>Equipment Type Map</h2>
        <p class="muted">detail record +0x29 type byte. Low bits select actor, high bit 0x80 selects armor group.</p>
        <div class="table-wrap">
          {render_table(["type", "actor", "group", "examples"], type_rows_html)}
        </div>
      </section>

      <section class="card">
        <h2>Switch Tables</h2>
        <div class="table-wrap">
          {render_table(["table", "VA", "bytes", "raw"], table_rows_html)}
        </div>
      </section>
    </main>
    <script>
      window.HWANSE_LAST_SKILL_EQUIPMENT_STATE_LAYOUT_REVIEW = {{
        marker: "HWANSE_SKILL_EQUIPMENT_STATE_LAYOUT_REVIEW_READY",
        actorBaseVaHex: "{h(summary['actorBaseVaHex'])}",
        actorStride: {summary['actorStride']},
        equipmentSlotsOffsetHex: "{h(summary['equipmentSlotsOffsetHex'])}",
        skillSlotsOffsetHex: "{h(summary['skillSlotsOffsetHex'])}",
        slotArraysGrounded: true,
        scenarioBitModelRejectedForSkillEquipment: true
      }};
    </script>
  </body>
</html>
"""


def render_docs(report: dict[str, Any]) -> str:
    s = report["summary"]
    lines = [
        "# 기술/장비 상태 슬롯 리뷰",
        "",
        "## 결론",
        "",
        "장비와 기술은 개별 scenario flag가 아니라 actor 상태 구조체 내부 슬롯 배열이다.",
        "`e2/e3` gate, 장비 추가/삭제/장착, 기술 추가/삭제 helper가 같은 슬롯을 읽고 쓴다.",
        "",
        "## 상태 구조",
        "",
        f"- actor base: `{s['actorBaseVaHex']}`",
        f"- actor stride: `{s['actorStride']}` bytes (`0xd8`)",
        "- 현재 장비: `actor+0x48` 무기/술, `actor+0x49` 방어구",
        "- 장비 보유 슬롯: `actor+0x4a + group*6 + slot`, group 0 무기/술, group 1 방어구",
        "- 기술 슬롯: `actor+0x56 + category*6 + slot`, category 0 기본기, 1 개인공격기, 2 전체공격기, 3 특수기",
        "",
        "## Reader/Writer",
        "",
        "| 함수 | 범위 | 의미 |",
        "|---|---|---|",
    ]
    for row in report["functions"]:
        lines.append(f"| {row['label']} | `{row['startVaHex']}..{row['endVaHex']}` | {row['role']} |")
    lines.extend(
        [
            "",
            "## 장비 type byte",
            "",
            "`detail+0x29`의 하위 비트는 캐릭터, `0x80`은 방어구 그룹을 뜻한다.",
            "",
            "| type | actor | group | examples |",
            "|---|---|---|---|",
        ]
    )
    for row in report["equipmentTypeMap"]:
        lines.append(f"| `{row['typeHex']}` | {row['actor']} | {row['group']} | {', '.join(row['examples'][:4])} |")
    lines.extend(
        [
            "",
            "## 주의",
            "",
            "- 단 평가/퍼펙트 클리어가 보는 기술/장비 완료 조건은 이 슬롯 배열의 결과다.",
            "- scenario `0x31/0x32` bit와 혼동하면 안 된다.",
            "- 장비 장착 함수는 현재 장비 값을 바꾸면서 이전 장비 제공 기술을 제거하고 새 장비 제공 기술을 추가한다.",
        ]
    )
    return "\n".join(lines) + "\n"


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--ui-mappings", type=Path, default=OUT / "ui_cns_grid_mappings.json")
    parser.add_argument("--gate-review", type=Path, default=OUT / "skill_equipment_gate_review.json")
    parser.add_argument("--json-out", type=Path, default=OUT / "skill_equipment_state_layout_review.json")
    parser.add_argument("--web-out", type=Path, default=WEB / "skill_equipment_state_layout_review.html")
    parser.add_argument("--docs-out", type=Path, default=DOCS / "SKILL_EQUIPMENT_STATE_LAYOUT_REVIEW.md")
    args = parser.parse_args()

    report = build(args)
    write_json(args.json_out, report)
    write_text(args.web_out, render_html(report))
    write_text(args.docs_out, render_docs(report))
    print(
        f"wrote {args.json_out} / {args.web_out} / {args.docs_out} "
        f"(functions={report['summary']['functionCount']}, types={report['summary']['typeMappingCount']})"
    )


if __name__ == "__main__":
    main()
