#!/usr/bin/env python3
"""Build a review for battle mode/status transitions.

The previous coefficient report established that actor +0x2a indexes the
mode/status table at 0x0048bcaa.  This report follows the writer side:
player stance cycling, hit result status application, temporary status
recovery, and character-specific mode progressions.
"""
from __future__ import annotations

import html
import json
import re
import subprocess
from pathlib import Path
from typing import Any


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


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


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


def disassemble(start_va: int, stop_va: int) -> str:
    try:
        result = subprocess.run(
            [
                "objdump",
                "-Mintel",
                "-D",
                "-b",
                "pei-i386",
                f"--start-address=0x{start_va:08x}",
                f"--stop-address=0x{stop_va:08x}",
                str(EXE),
            ],
            check=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            text=True,
        )
    except (OSError, subprocess.CalledProcessError) as exc:
        return f"; disassembly unavailable: {exc}"
    return "\n".join(line for line in result.stdout.splitlines() if re.match(r"\s*[0-9a-f]{6,8}:", line))


def compact_lines(disasm: str, needles: list[str], *, context: int = 0) -> list[str]:
    lines = disasm.splitlines()
    selected: list[tuple[int, str]] = []
    for index, line in enumerate(lines):
        if any(needle in line for needle in needles):
            for i in range(max(0, index - context), min(len(lines), index + context + 1)):
                selected.append((i, lines[i]))
    seen: set[int] = set()
    result: list[str] = []
    for index, line in selected:
        if index not in seen:
            seen.add(index)
            result.append(line)
    return result


STATUS_IDS = [
    {"id": 1, "name": "보통", "group": "player stance"},
    {"id": 2, "name": "돌격", "group": "player stance"},
    {"id": 3, "name": "방어", "group": "player stance"},
    {"id": 4, "name": "선제", "group": "player stance"},
    {"id": 5, "name": "반격", "group": "player stance"},
    {"id": 6, "name": "얼근히 취함", "group": "아타호 술취함"},
    {"id": 7, "name": "만취함", "group": "아타호 술취함"},
    {"id": 8, "name": "정신없이 취함", "group": "아타호 술취함"},
    {"id": 9, "name": "대호", "group": "아타호 술취함"},
    {"id": 10, "name": "곤드레만드레", "group": "아타호 술취함"},
    {"id": 11, "name": "흥분", "group": "스마슈 눈요기"},
    {"id": 12, "name": "분노", "group": "스마슈 눈요기"},
    {"id": 13, "name": "폭발직전", "group": "스마슈 눈요기"},
    {"id": 14, "name": "대폭발", "group": "스마슈 눈요기"},
    {"id": 15, "name": "푸쉬", "group": "스마슈 눈요기"},
    {"id": 16, "name": "위압적인 자세", "group": "린샹 도발"},
    {"id": 17, "name": "노발충천", "group": "린샹 도발"},
    {"id": 18, "name": "무뚝뚝한 표정", "group": "린샹 도발"},
    {"id": 19, "name": "여왕님", "group": "린샹 도발"},
    {"id": 20, "name": "피곤함", "group": "린샹 도발"},
    {"id": 21, "name": "졸림", "group": "상태이상/행동불가"},
    {"id": 22, "name": "행동정지", "group": "상태이상/행동불가"},
    {"id": 23, "name": "넘어짐", "group": "상태이상/행동불가"},
    {"id": 24, "name": "휙 날아감", "group": "상태이상/행동불가"},
    {"id": 25, "name": "독", "group": "상태이상/행동불가"},
    {"id": 26, "name": "마비", "group": "상태이상/행동불가"},
    {"id": 27, "name": "기절", "group": "상태이상/행동불가"},
]


STATUS_PRODUCER_GROUPS = [
    {
        "idRange": "1..5",
        "group": "전투 자세",
        "ids": "보통, 돌격, 방어, 선제, 반격",
        "producer": "플레이어 PgUp/PgDn 자세 변경 루틴",
        "relationToEffect": "+0x6c 상태 효과값과 무관하게 actor +0x2a를 직접 갱신한다.",
    },
    {
        "idRange": "6..10",
        "group": "아타호 술취함",
        "ids": "얼근히 취함, 만취함, 정신없이 취함, 대호, 곤드레만드레",
        "producer": "술 마시기/취호권 전용 루틴 0x00434a4a 및 부작용 검사 0x0043512e",
        "relationToEffect": "+0x6c 없이 actor +0x2a를 직접 갱신한다. 곤드레만드레는 부작용 경로에서 졸림으로 전환될 수 있다.",
    },
    {
        "idRange": "11..15",
        "group": "스마슈 눈요기",
        "ids": "흥분, 분노, 폭발직전, 대폭발, 푸쉬",
        "producer": "눈요기 전용 루틴 0x00434cbb",
        "relationToEffect": "+0x6c 없이 actor +0x2a를 직접 갱신한다.",
    },
    {
        "idRange": "16..20",
        "group": "린샹 도발",
        "ids": "위압적인 자세, 노발충천, 무뚝뚝한 표정, 여왕님, 피곤함",
        "producer": "도발 전용 루틴 0x00434d8d",
        "relationToEffect": "+0x6c 없이 actor +0x2a를 직접 갱신한다.",
    },
    {
        "idRange": "21..27",
        "group": "일반 상태이상/행동불가",
        "ids": "졸림, 행동정지, 넘어짐, 휙 날아감, 독, 마비, 기절",
        "producer": "공격 부가효과 +0x6c 변환 루틴 0x00434e84, HP 0 처리, 일부 캐릭터 고유 부작용",
        "relationToEffect": "+0x6c는 이 그룹 중 21..26만 생산한다. 기절 27은 HP 0 경로, 졸림 21은 아타호 술취함 부작용 경로에서도 생산된다.",
    },
]


ACTOR_FIELDS = [
    {
        "offset": "+0x58",
        "name": "action target mode",
        "status": "고신뢰",
        "meaning": "타겟 selector 초기화 방식. 0이면 payload hit-unit byte[4]를 +0x67로 복사하고, 1이면 +0x67=0x09, 2이면 +0x67=0x00으로 강제한다.",
    },
    {
        "offset": "+0x59",
        "name": "current skill/action id",
        "status": "확정",
        "meaning": "현재 시전 중인 skill/action id. 플레이어는 character skill table, 적/공용은 shared action table의 8-byte record를 고르는 인덱스다.",
    },
    {
        "offset": "+0x61",
        "name": "selected target actor index",
        "status": "확정",
        "meaning": "단일 대상 인덱스. action setup에서 selected target latch 0x0059e347이 복사되고, +0x67 selector가 전체 범위가 아니거나 0x09/0x0a 선택 대상 계열일 때 이 값을 기본 target range로 쓴다.",
    },
    {
        "offset": "+0x2a",
        "name": "mode/status id",
        "status": "확정",
        "meaning": "0x0048bcaa 자세/상태 테이블 index. 자세, 아타호 술취함, 스마슈 눈요기, 린샹 도발, 일반 상태이상이 모두 합쳐진 unified id다.",
    },
    {
        "offset": "+0x18",
        "name": "주량",
        "status": "확정",
        "meaning": "아타호 술취함 부작용을 낮추는 영속 누적값. 0x434a4a에서 주량 경험치 +0x1a가 100을 넘을 때 최대 100까지 1씩 증가하고, 0x43522b의 곤드레만드레 판정 random(100)+주량(+0x18) < 취기(+0x65)에 쓰인다.",
    },
    {
        "offset": "+0x1a",
        "name": "주량 경험치",
        "status": "확정",
        "meaning": "정식 UI 명칭이 없는 숨은 성장 게이지. 술/취호권 계열 result의 취기 증가량이 누적되고, 100을 넘으면 주량 +0x18을 1 올린 뒤 0으로 리셋된다. 전투 종료 정리에서 +0x18과 함께 player row로 복사되므로 전투를 넘어 유지된다.",
    },
    {
        "offset": "+0x62 bit 0x01",
        "name": "one-turn action/display latch",
        "status": "확정",
        "meaning": "넘어짐/휙 날아감/행동정지와 마비 해제 직후에 세워지는 1턴 latch. 다음 battle-order cleanup 0x40d95b에서 +0x2a를 +0x66에서 복원하고 +0x64/+0x66을 비운 뒤 bit 0x01만 지운다. UI 상태명은 이 bit가 아니라 +0x2a 상태 id가 결정한다.",
    },
    {
        "offset": "+0x62 bit 0x02",
        "name": "timed command/action lock",
        "status": "확정",
        "meaning": "마비/졸림/푸쉬/곤드레만드레처럼 timer +0x64로 유지되는 상태에서 세워지는 command/action lock. 0x43512e가 timer/random recovery를 검사한 뒤 +0x2a를 +0x66에서 복원하고 bit 0x02만 해제한다. UI 상태명은 이 bit가 아니라 +0x2a 상태 id가 결정한다.",
    },
    {
        "offset": "+0x62 bit 0x40",
        "name": "HP zero / knockout",
        "status": "확정",
        "meaning": "HP damage apply 후 current HP가 0이면 세워지고, 0x434e84가 +0x2a를 27 기절로 전환한다.",
    },
    {
        "offset": "+0x64",
        "name": "temporary status timer",
        "status": "확정",
        "meaning": "마비/졸림/독/푸쉬 같은 timed 상태의 남은 tick. 랜덤으로 2..6 근처 값이 들어간다.",
    },
    {
        "offset": "+0x65",
        "name": "취기",
        "status": "확정",
        "meaning": "아타호 술취함 전투 중 누적치. 0x434a4a가 기술/무기 계열 result로 증가시키고 0xff에서 포화한다. 0/0x21/0x42/0x64 임계값으로 +0x2a를 6/7/8/9 술취함 상태로 승급하며, 곤드레만드레 판정에서는 random(100)+주량(+0x18) < 취기(+0x65)로 소비된다. 전투 종료 정리에서 player row로 복사되는 값은 +0x18/+0x1a이고 +0x65는 복사되지 않으므로 전투별 임시값으로 본다.",
    },
    {
        "offset": "+0x66",
        "name": "saved prior mode/status",
        "status": "확정",
        "meaning": "상태이상/캐릭터 고유 모드 진입 전에 기존 +0x2a를 저장하고, 회복 시 +0x2a로 복원한다.",
    },
    {
        "offset": "+0x67",
        "name": "target selector/range code",
        "status": "확정",
        "meaning": "런타임 target scope selector. payload hit-unit byte[4]에서 오거나 +0x58 mode로 강제되며, 0x433c99/0x433dc0이 low nibble로 대상 시작/끝을 계산한다.",
    },
    {
        "offset": "+0x68",
        "name": "status display dirty flag",
        "status": "고신뢰",
        "meaning": "상태/자세가 바뀔 때 1로 세워진다. 기절은 적 actor에서는 0, player actor에서는 1로 처리된다.",
    },
    {
        "offset": "+0x69",
        "name": "drunk hazard pending flag",
        "status": "확정",
        "meaning": "술취함 상태 갱신 후 1로 세워지는 1회성 pending flag. 0x43522b가 random(100)+주량(+0x18) < 취기(+0x65)이면 +0x2a=10 곤드레만드레와 +0x62 bit 0x02 lock을 세우고, 검사 후 항상 0으로 지운다. 화면에는 직접 표시되지 않고 +0x2a 상태 변화만 보인다.",
    },
    {
        "offset": "+0x6c",
        "name": "status effect/result id",
        "status": "확정",
        "meaning": "payload status byte가 들어가는 상태 효과 id. 상태 성공 gate를 통과하면 0x434e84가 1..6을 실제 상태로 매핑하고, 실패하면 0으로 지운다.",
    },
]


STATUS_EFFECTS = [
    {
        "effectValue": 1,
        "statusId": 23,
        "statusName": "넘어짐",
        "timer": "0",
        "flag": "+0x62 bit 0x01",
        "evidence": "0x43504c set +0x2a=0x17",
    },
    {
        "effectValue": 2,
        "statusId": 24,
        "statusName": "휙 날아감",
        "timer": "0",
        "flag": "+0x62 bit 0x01",
        "evidence": "0x435077 set +0x2a=0x18",
    },
    {
        "effectValue": 3,
        "statusId": 22,
        "statusName": "행동정지",
        "timer": "0",
        "flag": "+0x62 bit 0x01",
        "evidence": "0x4350a2 set +0x2a=0x16",
    },
    {
        "effectValue": 4,
        "statusId": 25,
        "statusName": "독",
        "timer": "current + rand(3)+4",
        "flag": "timer only",
        "evidence": "0x4350cd set +0x2a=0x19",
    },
    {
        "effectValue": 5,
        "statusId": 26,
        "statusName": "마비",
        "timer": "rand(3)+4",
        "flag": "+0x62 bit 0x02",
        "evidence": "0x434f8a set +0x2a=0x1a",
    },
    {
        "effectValue": 6,
        "statusId": 21,
        "statusName": "졸림",
        "timer": "current + rand(3)+4",
        "flag": "+0x62 bit 0x02",
        "evidence": "0x434fc4 set +0x2a=0x15",
    },
]


TARGET_SCOPE_ROWS = [
    {
        "value": "0x00",
        "scope": "default selected target",
        "source": "runtime forced by +0x58 == 2",
        "meaning": "+0x67 helper에서는 특수 전체 범위가 아니므로 +0x61 단일 target index로 fallback한다.",
    },
    {
        "value": "0x01",
        "scope": "self/setup/current actor",
        "source": "payload byte[4]",
        "meaning": "마시기, 술/장비 기술의 준비 hit-unit처럼 자기 자신 또는 시전 준비 효과에 쓰인다.",
    },
    {
        "value": "0x05",
        "scope": "all allies",
        "source": "payload byte[4]",
        "meaning": "기공대회복처럼 아군 전체 범위. actor +0x04가 어느 편인지에 따라 0..playerCount 또는 3..enemyEnd 범위로 바뀐다.",
    },
    {
        "value": "0x06",
        "scope": "all enemies",
        "source": "payload byte[4]",
        "meaning": "전체공격기 범위. actor +0x04가 어느 편인지에 따라 반대편 actor range를 반환한다.",
    },
    {
        "value": "0x09",
        "scope": "one ally / selected ally",
        "source": "payload byte[4] or runtime forced by +0x58 == 1",
        "meaning": "기공회복/기공독치료처럼 선택한 아군 하나. range helper에서는 +0x61 단일 target index로 fallback한다.",
    },
    {
        "value": "0x0a",
        "scope": "one enemy / selected enemy",
        "source": "payload byte[4]",
        "meaning": "일반 단일 공격기. range helper에서는 +0x61 단일 target index로 fallback한다.",
    },
]


TARGET_SCOPE_FLOW = [
    {
        "va": 0x0040FA6D,
        "name": "selected-target gate opcode 0x5c",
        "summary": "handler-table opcode 0x5c가 현재 actor/skill의 payload target scope byte[4]를 0x433649로 미리 읽어 bit 0x08을 검사한다. 선택 대상 계열이면 stream +4의 nested target-selection script로 들어가고, 아니면 현재 actor index 0x0059e33e를 0x0059e347 selected target latch에 저장한다.",
        "result": "0x5c/0x0040fa6d는 UI 본체가 아니라 선택 필요 여부 gate이자 기본 selected target latch 생산자다. 0x0040fb1c/0x0040fb21이 0x0059e33e -> 0x0059e347을 직접 수행하는 유일한 direct writer다.",
    },
    {
        "va": 0x0040F1F4,
        "name": "selection/register opcode 0x57",
        "summary": "stream byte[1]의 entry type으로 class를 나눈 뒤 class 0은 0x421ef7, class 1은 0x422093, class 2는 0x42234e를 호출한다. 반환값은 현재 script object +0x58에 저장된다.",
        "result": "선택/소지/기술 후보 등록 또는 검증 opcode로 승격. 최종 전투 대상 latch를 직접 쓰지는 않는다.",
    },
    {
        "va": 0x0040F30D,
        "name": "selection/remove opcode 0x58",
        "summary": "0x57과 같은 entry class 해석을 사용하지만 paired removal helper를 호출한다. class 0은 0x42267e, class 1은 0x4226e9, class 2는 0x422899로 이어진다.",
        "result": "선택/소지/기술 후보 제거 opcode로 승격",
    },
    {
        "va": 0x0040F426,
        "name": "selection/check branch opcode 0x59",
        "summary": "entry class별 helper로 후보 존재/상태를 확인하고 조건이 맞으면 stream +4 nested pointer로 분기하며, 아니면 stream +8로 진행한다.",
        "result": "선택 후보 존재 여부에 따른 branch opcode로 승격",
    },
    {
        "va": 0x0040FC41,
        "name": "selection-list setup opcode 0x5e",
        "summary": "stream +8을 후보 entry 배열로 0x0059e2a8에 저장하고, stream +4 pointer table을 0x0059db50에 저장한다. stream byte[1]을 후보 수 0x0059e2d8로 두고, 현재 stream은 8 + count*4만큼 넘긴다. 이후 0x0059e337을 세우고 공통 선택 루프 0x004dc51c로 점프한다.",
        "result": "단일 대상 선택 UI의 후보 목록 setup opcode로 승격",
    },
    {
        "va": 0x0040FCEE,
        "name": "selection-page/window opcode 0x60",
        "summary": "현재 선택 cursor 0x0059e34a로 0x0059e2a8 후보 entry를 읽고, entry type별로 0x0059e339/0x0059e33a/0x0059e33b를 계산한다. 이 값들은 선택 목록의 시작/표시 범위/스크롤 보정 계열로 보이며 전투 damage/result 값은 아니다.",
        "result": "단일 대상 선택 UI의 표시 범위/커서 보정 opcode로 승격",
    },
    {
        "va": 0x0041FBAA,
        "name": "interactive cursor-entry confirm helper",
        "summary": "0x0059e34a cursor로 0x0059e2a8의 현재 후보 entry를 읽고, entry id(byte[1])를 script object +0x58에 저장한다. entry class에 따라 0x0059e33f를 0/1/2로 세운 뒤 object +0xb0을 4만큼 진행한다.",
        "result": "선택 UI에서 사용자가 고른 항목값과 항목 종류를 생산하는 confirm/helper 경로로 승격. 단 이 지점은 0x0059e347 selected target latch를 직접 쓰지는 않는다.",
    },
    {
        "va": 0x0041E72D,
        "name": "selection result reader by 0x0059e33f",
        "summary": "0x0059e33f dispatch로 actor/action/item/skill-family 후보 테이블을 읽어 object +0x58에 반영한다. case 4/5는 cursor 0x0059e34a 자체를 object +0x58에 쓴다.",
        "result": "선택 결과 표시/조회 helper로 승격. 최종 target latch가 아니라 후보 결과 조회 계층이다.",
    },
    {
        "va": 0x0040FED4,
        "name": "selection-entry resolver opcode 0x61",
        "summary": "0x0059e34a cursor로 선택된 4-byte entry를 0x0059e2a8에서 읽고 entry type을 dispatch한다. class 0은 actor/action table 0x457750과 0x421ef7, class 1은 0x422093, class 2는 0x42234e를 호출한 뒤 결과를 script object +0x58에 저장한다.",
        "result": "선택 cursor entry를 검증/등록 결과로 변환하는 resolver opcode로 승격. object +0x58을 쓰지만 0x0059e347 selected target latch를 직접 쓰는 경로는 아니다.",
    },
    {
        "va": 0x0041004F,
        "name": "selection pointer-table activation opcode 0x63",
        "summary": "0x0059db50 pointer table에서 stream byte[1] index 항목을 읽어 script object +0xb0에 저장하고, object routine 0x41b66d를 호출한다. 0x5e가 세팅한 pointer table을 실제 선택 후속 동작으로 소비하는 경로다.",
        "result": "선택 UI 후속 script/object pointer 소비 경로",
    },
    {
        "va": 0x0040F57C,
        "name": "manual/menu action setup",
        "summary": "action case 0에서 actor +0x59 skill id를 채운 뒤 0x433649로 initial target scope를 +0x67에 쓰고, 0x0059e347 selected target latch를 actor +0x61로 복사한다. 이어 0x435538이 같은 latch actor를 인자로 받아 target-side setup을 수행한다.",
        "result": "0x0059e347 selected target latch 소비 경로 확정. 실제 기술 실행 actor에는 actor +0x61 단일 대상 index로 들어간다.",
    },
    {
        "va": 0x0040C513,
        "name": "selection-mode target apply",
        "summary": "0x0059e34d가 1인 selection mode에서 현재 actor +0x67 selector를 검사하고 actor +0x61을 세팅한다. mode 1은 0x0059e348 + 3을 actor +0x61에 직접 쓰고, mode 2는 selected target latch 0x0059e347을 actor +0x61에 쓴다.",
        "result": "selection mode에서 단일 대상 actor index가 실제 actor +0x61로 적용되는 소비 경로 확정. 0x0059e347 direct producer는 닫혔고, 0x0059e348은 공용 opcode24 mode1/source 계열로 격리한다.",
    },
    {
        "va": 0x00433649,
        "name": "payload target scope byte reader",
        "summary": "actor +0x04/+0x05/+0x59로 player/shared action payload를 찾고, hitIndex를 count에 맞춰 clamp한 뒤 payload + 0x16 + hitIndex*8 + 4 값을 반환한다.",
        "result": "hit-unit byte[4] 직접 소비 지점 확정",
    },
    {
        "va": 0x00433545,
        "name": "initial target selector writer",
        "summary": "actor +0x58이 0이면 0x433649(..., 0xff) 결과를 +0x67에 쓰고, 1이면 0x09, 2이면 0x00을 쓴다.",
        "result": "액션 시작 전 selector 초기화",
    },
    {
        "va": 0x004335C6,
        "name": "per-hit target selector writer",
        "summary": "global hitIndex 0x0059e2a4를 사용해 현재 hit-unit의 byte[4]를 다시 +0x67에 반영한다. +0x58 1/2 override는 동일하다.",
        "result": "다중 hit-unit 기술에서 hit마다 target scope 갱신",
    },
    {
        "va": 0x00433402,
        "name": "selected target index resolver",
        "summary": "+0x67 selector를 보고 +0x61 단일 target index를 보정한다. 0x09/0x0a 같은 선택 대상 계열은 결국 +0x61 경로로 소비된다.",
        "result": "+0x61 selected target index 소비 경로 확정",
    },
    {
        "va": 0x00433C99,
        "name": "target range start helper",
        "summary": "현재 actor의 +0x67 low nibble이 0x01/0x05/0x06이면 각각 현재 actor/아군 전체/적 전체 범위 시작점을 반환하고, 그 외에는 +0x61을 반환한다.",
        "result": "target loop start 계산",
    },
    {
        "va": 0x00433DC0,
        "name": "target range end helper",
        "summary": "0x433c99와 같은 selector를 사용해 exclusive end index를 반환한다. 0x01은 현재 actor+1, 0x05/0x06은 편별 actor range 끝이다.",
        "result": "target loop end 계산",
    },
    {
        "va": 0x00435538,
        "name": "target-scope hit-unit target loop",
        "summary": "actor +0x67을 다시 검사해 0x01/0x09는 +0x61 단일 actor, 0x05는 아군 전체 range로 풀고, 각 target actor에 hit-unit family helper table 0x00546970을 호출한다.",
        "result": "+0x61이 실제 hit-unit target iteration에 들어가는 소비 경로 확정",
    },
]


TRANSITIONS = [
    {
        "name": "플레이어 자세 변경",
        "va": 0x0040FB85,
        "routine": "PgUp/PgDn handler",
        "summary": "현재 선택 actor의 +0x2a를 1..5 범위에서 감소/증가시킨다. 1보다 작아지면 5, 5보다 커지면 1로 wrap.",
        "result": "보통/돌격/방어/선제/반격 순환 확정",
    },
    {
        "name": "명중 후 상태 적용",
        "va": 0x00434E84,
        "routine": "hit result status applier",
        "summary": "HP zero면 기절(27)을 우선 적용하고, 아니면 +0x6c 상태 효과값 1..6을 상태 id 21..26/23..25로 변환한다.",
        "result": "+0x6c 상태 효과 매핑 확정",
    },
    {
        "name": "상태 지속/회복",
        "va": 0x0043512E,
        "routine": "temporary status recovery tick",
        "summary": "+0x64 timer를 감소시키고 luck-side +0x26 기반 random gate를 통과하면 +0x66의 이전 +0x2a를 복원한다.",
        "result": "+0x64 timer, +0x66 saved prior status 확정",
    },
    {
        "name": "아타호 술취함",
        "va": 0x00434A4A,
        "routine": "drunk state accumulator",
        "summary": "기술/무기 계열 result가 취기(+0x65)를 누적하고 임계값 0/0x21/0x42/0x64를 넘으면 6/7/8/9 상태로 승급한다. 같은 증가량은 주량 경험치(+0x1a)에도 누적되며, 100을 넘으면 주량(+0x18)이 최대 100까지 1 오른다.",
        "result": "취기, 주량 경험치, 주량과 얼근히 취함/만취함/정신없이 취함/대호 진행 확정",
    },
    {
        "name": "곤드레만드레/졸림 전환",
        "va": 0x0043512E,
        "routine": "drunk hazard and sleep conversion",
        "summary": "+0x69가 세워진 뒤 random(100)+주량(+0x18) < 취기(+0x65)이면 곤드레만드레(10). 곤드레만드레 상태는 random으로 졸림(21)으로 바뀔 수 있다.",
        "result": "+0x69 pending flag와 술취함 부작용 경로 확정",
    },
    {
        "name": "스마슈 눈요기",
        "va": 0x00434CBB,
        "routine": "Smash eye-candy mode progression",
        "summary": "기존 상태를 +0x66에 저장하고 11..14를 진행시킨다. 14 대폭발 이후에는 15 푸쉬로 전환하고 timer를 건다.",
        "result": "흥분/분노/폭발직전/대폭발/푸쉬 진행 확정",
    },
    {
        "name": "린샹 도발",
        "va": 0x00434D8D,
        "routine": "Rinshan taunt random mode",
        "summary": "random(4)+0x10으로 16..19 모드 중 하나를 고른다. 현재와 같은 모드가 다시 걸리면 20 피곤함으로 전환하고 timer를 건다.",
        "result": "도발 4상태 + 피곤함 경로 확정",
    },
    {
        "name": "대상 범위 계산",
        "va": 0x00433C99,
        "routine": "target range start/end helpers",
        "summary": "+0x67 low nibble을 기준으로 자신/선택 대상/아군 전체/적 전체 범위의 시작과 끝 actor index를 계산한다.",
        "result": "+0x67은 mode table tail이 아니라 action target selector로 분리",
    },
    {
        "name": "타겟 selector payload 소비",
        "va": 0x00433649,
        "routine": "hit-unit byte[4] reader / +0x67 writers",
        "summary": "0x433649가 payload hit-unit byte[4]를 읽고, 0x433545/0x4335c6이 이를 actor +0x67에 쓴다.",
        "result": "byte[4] target scope 소비 경로 확정",
    },
]


FUNCTION_SPECS = [
    {
        "va": 0x0040FB85,
        "end": 0x0040FC30,
        "label": "player stance cycle handler",
        "needles": ["[eax+0x2a]", "c6 40 2a 05", "c6 40 2a 01", "inc", "dec"],
    },
    {
        "va": 0x00434E84,
        "end": 0x0043512E,
        "label": "hit result status applier",
        "needles": ["[eax+0x62]", "[eax+0x6c]", "[eax+0x66]", "[eax+0x64]", "[eax+0x68]", "c6 40 2a 1b", "c6 40 2a 1a", "c6 40 2a 15", "c6 40 2a 17", "c6 40 2a 18", "c6 40 2a 16", "c6 40 2a 19"],
    },
    {
        "va": 0x0043512E,
        "end": 0x00435295,
        "label": "temporary status recovery and drunk hazard",
        "needles": ["[eax+0x64]", "[eax+0x66]", "[eax+0x2a]", "[eax+0x69]", "[eax+0x65]", "c6 40 2a 15", "c6 40 2a 0a"],
    },
    {
        "va": 0x00434A4A,
        "end": 0x00434CBA,
        "label": "Ataho drunk state progression",
        "needles": ["[eax+0x6b]", "[eax+0x65]", "[eax+0x66]", "[eax+0x2a]", "c6 40 2a 06", "c6 40 2a 07", "c6 40 2a 08", "c6 40 2a 09", "[eax+0x69]"],
    },
    {
        "va": 0x00434CBB,
        "end": 0x00434D8D,
        "label": "Smash eye-candy state progression",
        "needles": ["[eax+0x66]", "[eax+0x2a]", "c6 40 2a 0f", "c6 40 2a 0b", "[eax+0x64]", "[eax+0x62]", "[eax+0x68]"],
    },
    {
        "va": 0x00434D8D,
        "end": 0x00434E29,
        "label": "Rinshan taunt state progression",
        "needles": ["[eax+0x66]", "[eax+0x2a]", "83 c0 10", "c6 40 2a 14", "[eax+0x64]", "[eax+0x62]", "[eax+0x68]"],
    },
    {
        "va": 0x00433C99,
        "end": 0x00433E95,
        "label": "target range helpers",
        "needles": ["[eax+0x67]", "[eax+0x61]", "0x4576e8", "0x59db28", "0x59e2b0"],
    },
    {
        "va": 0x00435538,
        "end": 0x00435711,
        "label": "target-scope hit-unit target loop",
        "needles": ["[eax+0x67]", "[eax+0x61]", "0x546970", "0x59db30", "[eax+0x59]", "[eax+0x12]", "[ecx+eax*8+0x1b]"],
    },
    {
        "va": 0x00433649,
        "end": 0x004336F1,
        "label": "payload hit-unit byte[4] target scope reader",
        "needles": ["0x4d2488", "0x4d2494", "[eax+0x12]", "[ecx+eax*8+0x1a]"],
    },
    {
        "va": 0x00433545,
        "end": 0x00433645,
        "label": "target selector +0x67 writers",
        "needles": ["[eax+0x58]", "[ecx+0x67]", "[eax+0x67]", "c6 40 67 09", "c6 40 67 00", "0x433649"],
    },
    {
        "va": 0x00433402,
        "end": 0x00433545,
        "label": "selected target index +0x61 resolver",
        "needles": ["[eax+0x67]", "[ecx+0x61]", "[eax+0x61]", "0x59db28", "0x4576e8"],
    },
    {
        "va": 0x0040FA6D,
        "end": 0x0040FB32,
        "label": "selected-target branch gate and latch producer",
        "needles": ["0x433649", "test", "0x8", "0x59e347", "0x59e33e", "[ecx+0x40]"],
    },
    {
        "va": 0x0040F1F4,
        "end": 0x0040F30D,
        "label": "selection/register opcode 0x57",
        "needles": ["0x421ef7", "0x422093", "0x42234e", "[eax+0x58]", "[ecx+0x40]"],
    },
    {
        "va": 0x0040F30D,
        "end": 0x0040F426,
        "label": "selection/remove opcode 0x58",
        "needles": ["0x42267e", "0x4226e9", "0x422899", "[eax+0x58]", "[ecx+0x40]"],
    },
    {
        "va": 0x0040F426,
        "end": 0x0040F57C,
        "label": "selection/check branch opcode 0x59",
        "needles": ["0x421b33", "0x422093", "0x42234e", "[eax+0x40]", "[ecx+0x40]"],
    },
    {
        "va": 0x0040FC41,
        "end": 0x0040FCBE,
        "label": "selection-list setup opcode 0x5e",
        "needles": ["0x59e2a8", "0x59db50", "0x59e2d8", "0x59e337", "0x4dc51c", "[ecx+0x40]"],
    },
    {
        "va": 0x0040FCEE,
        "end": 0x0040FED4,
        "label": "selection-page/window opcode 0x60",
        "needles": ["0x59e34a", "0x59e2a8", "0x59e339", "0x59e33a", "0x59e33b", "0x4576e0"],
    },
    {
        "va": 0x0040FED4,
        "end": 0x0041004F,
        "label": "selection-entry resolver opcode 0x61",
        "needles": ["0x59e34a", "0x59e2a8", "0x421ef7", "0x422093", "0x42234e", "[eax+0x58]"],
    },
    {
        "va": 0x0041FBAA,
        "end": 0x0041FC8A,
        "label": "interactive cursor-entry confirm helper",
        "needles": ["0x59e34a", "0x59e2a8", "[eax+0x58]", "0x59e33f", "[ecx+0xb0]"],
    },
    {
        "va": 0x0041E72D,
        "end": 0x0041E862,
        "label": "selection result reader by 0x59e33f",
        "needles": ["0x59e33f", "0x59e34a", "[eax+0x58]", "0x4577a6", "0x45779a", "0x4576ec", "0x4576f8"],
    },
    {
        "va": 0x0041004F,
        "end": 0x0041009D,
        "label": "selection pointer-table activation opcode 0x63",
        "needles": ["0x59db50", "[ecx+0xb0]", "0x41b66d", "[eax+0x40]"],
    },
    {
        "va": 0x0040F5F0,
        "end": 0x0040F6A0,
        "label": "manual/menu action setup uses payload scope and selected target latch",
        "needles": ["[ecx+0x59]", "0x4336f1", "0x43376e", "0x433932", "0x433649", "[ecx+0x67]", "0x59e347", "[ecx+0x61]", "0x435538"],
    },
    {
        "va": 0x0040C513,
        "end": 0x0040C6CB,
        "label": "selection-mode target apply into actor +0x61",
        "needles": ["0x59e34d", "0x59e347", "0x59e348", "[ecx+0x61]", "[eax+0x67]"],
    },
    {
        "va": 0x00411BE0,
        "end": 0x00411C35,
        "label": "status/mode name display",
        "needles": ["[eax+0x2a]", "0x48bcaa", "0x41b480"],
    },
]


def build() -> dict[str, Any]:
    function_rows = []
    for spec in FUNCTION_SPECS:
        disasm = disassemble(spec["va"], spec["end"])
        function_rows.append(
            {
                **spec,
                "vaHex": hex32(spec["va"]),
                "endHex": hex32(spec["end"]),
                "keyLines": compact_lines(disasm, spec["needles"], context=0),
            }
        )

    return {
        "sourceExe": str(EXE.relative_to(ROOT)),
        "summary": [
            "actor +0x2a는 전투 자세와 상태이상을 모두 담는 확정 필드다.",
            "1..5는 플레이어가 전투 중 PgUp/PgDn으로 순환시키는 자세다.",
            "6..20은 공격 부가효과가 아니라 아타호 술취함, 스마슈 눈요기, 린샹 도발 전용 루틴이 직접 쓰는 캐릭터 고유 상태다.",
            "21..27은 일반 상태이상/행동불가 계열이다. 이 중 21..26은 +0x6c 공격 부가효과에서 올 수 있고, 27 기절은 HP 0 경로에서 온다.",
            "payload가 넣은 +0x6c 상태 효과값은 성공 gate를 거쳐 0x434e84에서 실제 상태 id로 변환된다.",
            "+0x6c는 actor +0x2a 전체를 뜻하지 않는다. 공격 hit-unit의 부가 상태 효과값 1..6만 담는 별도 result byte다.",
            "+0x66은 상태 진입 전 +0x2a 백업, +0x64는 timed 상태 timer, +0x68은 상태 표시 갱신 flag로 확정됐다.",
            "+0x67은 mode/status table +0x16/+0x17이 아니라 액션 대상 범위 selector로 확정됐다.",
            "hit-unit byte[4]는 0x433649가 payload에서 읽고 0x433545/0x4335c6이 actor +0x67에 쓰는 target scope id다.",
            "0x0059e347은 selected target latch다. 0x5c/0x0040fa6d가 선택 필요 여부를 가르고, 0x40f57c action setup이 이 latch를 actor +0x61에 복사한다.",
            "0x0059e347의 direct producer는 0x0040fb1c/0x0040fb21 하나다. 이 경로는 0x0059e33e current actor/object index를 e347에 복사하는 기본 대상 경로이며, target-scope bit 0x08 선택 UI 경로에서는 e347을 직접 쓰지 않는다.",
            "0x57/0x58/0x59는 선택 후보의 등록/제거/존재 분기 opcode다. helper 0x421ef7/0x422093/0x42234e와 paired removal helper를 통해 actor/action, item, skill-family 후보 목록을 갱신한다.",
            "0x5e/0x0040fc41, 0x60/0x0040fcee, 0x61/0x0040fed4는 0x5c가 넘기는 nested target-selection script의 핵심 UI opcode다. 각각 후보 목록 setup, 표시 범위/커서 보정, 선택 entry resolver로 분리된다. 단 0x61은 object +0x58 결과를 쓰며 0x0059e347 latch를 직접 쓰지는 않는다.",
            "0x41fbaa interactive confirm helper는 cursor entry id를 object +0x58에, entry class를 0x0059e33f에 남긴다. 0x41e72d는 0x0059e33f 기준으로 선택 결과를 다시 object +0x58로 읽는 표시/조회 helper다.",
            "0x40c513 selection-mode target apply는 0x0059e34d==1일 때 actor +0x67 selector를 보고 actor +0x61을 세팅한다. mode 1은 공용 opcode24 source인 0x0059e348+3, mode 2는 전투 selected-target latch인 0x0059e347을 source로 쓴다.",
            "+0x61은 단일 대상 actor index이며 0x433402/0x433c99/0x433dc0와 0x435538 target loop에서 선택 대상 fallback으로 쓰인다.",
            "mode/status table의 +0x16/+0x17 꼬리 byte는 전투 계산 계수가 아니다. 0x48bcaa code path와 record direct-ref scan상 계산 소비자는 없으므로 전투 런타임 미확정이 아니라 비소비 정적 분류 메타데이터로 남긴다.",
            "actor +0x65/+0x69는 화면 표시값이 아니라 아타호 술취함 전용 내부 제어값이다. +0x65는 전투 중 취기, +0x69는 곤드레만드레 hazard check pending flag이며 화면 상태명은 +0x2a 상태 id가 담당한다.",
            "actor +0x18/+0x1a는 아타호 술 관련 영속값이다. +0x1a 주량 경험치가 100을 넘으면 +0x18 주량이 최대 100까지 증가하고, 곤드레만드레 판정 random(100)+주량 < 취기에서 주량이 높을수록 부작용 확률이 낮아진다.",
            "전투 종료 정리 루틴은 +0x18/+0x1a를 player row로 복사하지만 +0x65는 복사하지 않는다. 따라서 +0x65 취기는 전투별 임시값, +0x18 주량과 +0x1a 주량 경험치는 전투를 넘어 유지되는 값으로 분리한다.",
            "critical WLK id 14 결과음과 palette flash/fade child script는 result presentation 쪽에서 확정됐다. 상태 전이 루틴의 남은 항목으로 보지 않는다.",
        ],
        "statusIds": STATUS_IDS,
        "statusProducerGroups": STATUS_PRODUCER_GROUPS,
        "actorFields": ACTOR_FIELDS,
        "statusEffects": STATUS_EFFECTS,
        "targetScopes": TARGET_SCOPE_ROWS,
        "targetScopeFlow": [
            {**row, "vaHex": hex32(row["va"])}
            for row in TARGET_SCOPE_FLOW
        ],
        "transitions": TRANSITIONS,
        "functionRows": function_rows,
        "openQuestions": [],
    }


def render_table(headers: list[str], rows: list[dict[str, Any]]) -> str:
    head = "".join(f"<th>{esc(header)}</th>" for header in headers)
    body = []
    for row in rows:
        cells = []
        for header in headers:
            value = row.get(header, "")
            cells.append(f"<td>{esc(value)}</td>")
        body.append("<tr>" + "".join(cells) + "</tr>")
    return f"<table><thead><tr>{head}</tr></thead><tbody>{''.join(body)}</tbody></table>"


def render_html(data: dict[str, Any]) -> str:
    summary = "".join(f"<li>{esc(item)}</li>" for item in data["summary"])
    open_questions = "".join(f"<li>{esc(item)}</li>" for item in data["openQuestions"])
    functions = []
    for row in data["functionRows"]:
        functions.append(
            "\n".join(
                [
                    "<details>",
                    f"  <summary>{esc(row['vaHex'])} {esc(row['label'])}</summary>",
                    f"  <pre>{esc(chr(10).join(row['keyLines']) or '(no key lines)')}</pre>",
                    "</details>",
                ]
            )
        )
    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;
        --muted: #607080;
        --line: #d8dee6;
        --head: #eef2f6;
        --link: #185abc;
      }}
      * {{ box-sizing: border-box; }}
      body {{
        margin: 0;
        background: var(--bg);
        color: var(--fg);
        font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
        line-height: 1.45;
      }}
      main {{ max-width: 1440px; margin: 0 auto; padding: 18px; }}
      header {{ display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; margin-bottom: 14px; }}
      h1 {{ margin: 0 0 6px; font-size: 24px; }}
      h2 {{ margin: 22px 0 8px; font-size: 18px; }}
      a {{ color: var(--link); text-decoration: none; font-weight: 650; }}
      a:hover {{ text-decoration: underline; }}
      nav {{ display: flex; flex-wrap: wrap; gap: 8px; justify-content: flex-end; }}
      .sub {{ color: var(--muted); }}
      .panel {{
        background: white;
        border: 1px solid var(--line);
        border-radius: 8px;
        padding: 14px;
        margin: 12px 0;
      }}
      .wide {{ overflow: auto; border: 1px solid var(--line); }}
      table {{
        width: 100%;
        border-collapse: collapse;
        background: white;
      }}
      th, td {{
        border-bottom: 1px solid var(--line);
        padding: 8px 10px;
        text-align: left;
        vertical-align: top;
        font-size: 13px;
      }}
      th {{ background: var(--head); position: sticky; top: 0; z-index: 1; }}
      details {{ background: white; border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; margin: 8px 0; }}
      summary {{ cursor: pointer; font-weight: 700; }}
      pre {{
        white-space: pre-wrap;
        overflow: auto;
        background: #111827;
        color: #e5e7eb;
        border-radius: 6px;
        padding: 10px;
        font-size: 12px;
      }}
    </style>
  </head>
  <body>
    <main>
      <header>
        <div>
          <h1>전투 상태/모드 전환 검토</h1>
          <p class="sub">actor +0x2a writer paths · status effect +0x6c · target selector +0x67</p>
        </div>
        <nav>
          <a href="../web/index.html">홈</a>
          <a href="battle_mode_coefficient_review.html">자세/상태 계수</a>
          <a href="battle_stat_flag_status_semantics_review.html">스탯/플래그</a>
          <a href="battle_damage_formula_trace_review.html">계산 추적</a>
        </nav>
      </header>

      <section class="panel">
        <h2>결론</h2>
        <ul>{summary}</ul>
      </section>

      <h2>상태 ID</h2>
      <div class="wide">{render_table(["id", "name", "group"], data["statusIds"])}</div>

      <h2>상태 ID 생산 경로</h2>
      <div class="wide">{render_table(["idRange", "group", "ids", "producer", "relationToEffect"], data["statusProducerGroups"])}</div>

      <h2>Actor 필드</h2>
      <div class="wide">{render_table(["offset", "name", "status", "meaning"], data["actorFields"])}</div>

      <h2>상태 효과값 +0x6c 매핑</h2>
      <div class="wide">{render_table(["effectValue", "statusId", "statusName", "timer", "flag", "evidence"], data["statusEffects"])}</div>

      <h2>Target Scope byte[4] / actor +0x67</h2>
      <div class="wide">{render_table(["value", "scope", "source", "meaning"], data["targetScopes"])}</div>

      <h2>타겟 selector 흐름</h2>
      <div class="wide">{render_table(["vaHex", "name", "summary", "result"], data["targetScopeFlow"])}</div>

      <h2>전환 루틴</h2>
      <div class="wide">{render_table(["name", "routine", "summary", "result"], data["transitions"])}</div>

      <section class="panel">
        <h2>남은 부분</h2>
        <ul>{open_questions}</ul>
      </section>

      <h2>근거 함수</h2>
      {''.join(functions)}
    </main>
  </body>
</html>
"""


def render_md(data: dict[str, Any]) -> str:
    lines = [
        "# 전투 상태/모드 전환 검토",
        "",
        "## 결론",
        "",
    ]
    lines.extend(f"- {item}" for item in data["summary"])
    lines.extend(["", "## 상태 ID 생산 경로", ""])
    lines.append("| id range | group | ids | producer | +0x6c relation |")
    lines.append("|---|---|---|---|---|")
    for row in data["statusProducerGroups"]:
        lines.append(
            f"| {row['idRange']} | {row['group']} | {row['ids']} | {row['producer']} | {row['relationToEffect']} |"
        )
    lines.extend(["", "## 상태 효과값 +0x6c 매핑", ""])
    lines.append("| effect value | status id | status | timer | flag |")
    lines.append("|---:|---:|---|---|---|")
    for row in data["statusEffects"]:
        lines.append(f"| {row['effectValue']} | {row['statusId']} | {row['statusName']} | {row['timer']} | {row['flag']} |")
    lines.extend(["", "## Target Scope byte[4] / actor +0x67", ""])
    lines.append("| value | scope | source |")
    lines.append("|---|---|---|")
    for row in data["targetScopes"]:
        lines.append(f"| {row['value']} | {row['scope']} | {row['source']} |")
    lines.extend(["", "## 타겟 selector 흐름", ""])
    for row in data["targetScopeFlow"]:
        lines.append(f"- `{row['vaHex']}` {row['name']}: {row['summary']}")
    lines.extend(["", "## 전환 루틴", ""])
    for row in data["transitions"]:
        lines.append(f"- `{hex32(row['va'])}` {row['name']}: {row['summary']} ({row['result']})")
    lines.extend(["", "## 남은 부분", ""])
    if data["openQuestions"]:
        lines.extend(f"- {item}" for item in data["openQuestions"])
    else:
        lines.append("- 없음")
    lines.append("")
    return "\n".join(lines)


def write_outputs(data: dict[str, Any]) -> None:
    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / "battle_status_transition_review.json").write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    (OUT / "battle_status_transition_review.html").write_text(render_html(data), encoding="utf-8")
    (OUT / "battle_status_transition_review.md").write_text(render_md(data), encoding="utf-8")


def main() -> None:
    write_outputs(build())


if __name__ == "__main__":
    main()
