#!/usr/bin/env python3
"""Statically walk a narrow slice of the battle display VM scripts.

This does not try to emulate the whole battle engine.  It decodes the bytecode
stream that chooses frames/sounds/effect waits for known player action families.
The first promoted families are the three opening-observed actions and their
nearby skill-level variants.
"""
from __future__ import annotations

import html
import json
import struct
from dataclasses import dataclass
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]
EXE = ROOT / "Hwanse2.exe"
OUT = ROOT / "out"
MAPPING_JSON = OUT / "battle_action_mapping.json"


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


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


def u16(raw: bytes, offset: int) -> int | None:
    if offset + 2 > len(raw):
        return None
    return struct.unpack_from("<H", raw, offset)[0]


def u32(raw: bytes, offset: int) -> int | None:
    if offset + 4 > len(raw):
        return None
    return struct.unpack_from("<I", raw, offset)[0]


def s32(value: int | None) -> int | None:
    if value is None:
        return None
    return struct.unpack("<i", struct.pack("<I", value & 0xFFFFFFFF))[0]


def fixed16(value: int | None) -> str:
    signed = s32(value)
    if signed is None:
        return ""
    return f"{signed / 65536:g}px"


OPERATION_NAMES = {
    0x0: "set",
    0x1: "add",
    0x2: "sub",
    0x3: "mul",
    0x4: "div",
    0x5: "mod",
    0x6: "and",
    0x7: "or",
    0x8: "xor",
    0x9: "not",
    0xA: "neg",
    0xB: "shl",
    0xC: "shr",
}

DISPLAY_FIELD_MEANINGS = {
    0x1C: "x position",
    0x20: "y position",
    0x24: "z/depth position",
    0x28: "packed sprite/frame selector",
    0x3C: "repeat-loop target cursor",
    0x40: "VM script cursor",
    0x58: "child/effect/resource handle",
    0x60: "repeat-loop counter",
    0x62: "display/actor flag byte",
    0x64: "resource/context pointer",
    0x68: "x acceleration",
    0x6C: "y acceleration",
    0x70: "z acceleration",
    0x74: "x velocity/delta",
    0x78: "y velocity/delta",
    0x7C: "z velocity/delta",
    0x80: "target/base x",
    0x84: "target/base y",
    0x88: "target/base z",
    0x8C: "x angle",
    0x8E: "y angle",
    0x90: "z angle",
    0x92: "x amplitude/radius",
    0x94: "y amplitude/radius",
    0x96: "z amplitude/radius",
    0xA0: "parent display object backlink",
    0xA4: "child display object link",
    0xA8: "parent battle actor pointer",
    0xF2: "battle actor slot id",
}

ACTOR_FIELD_MEANINGS = {
    0x59: "battle action/skill id",
    0x5A: "local display slot / sprite action variant",
    0x60: "battle action phase",
    0x61: "target slot",
    0x62: "result/presentation flags",
    0x63: "hit count",
    0x67: "targeting mode",
    0x68: "result pending flag",
    0x6B: "damage/result family",
    0x6C: "status result byte",
    0x6D: "raw result byte",
    0x6E: "damage/result value",
    0x70: "attack coefficient 0",
    0x72: "attack coefficient 1",
    0x74: "attack coefficient 2",
    0x80: "attack stat coefficient 0",
    0x82: "attack stat coefficient 1",
    0x84: "attack stat coefficient 2",
    0x88: "display object pointer",
}

CONDITION_NAMES = {
    0x0: "!=",
    0x1: "==",
    0x2: ">",
    0x3: "<",
    0x4: ">=",
    0x5: "<=",
    0x6: "bit-test",
    0x7: "or-nonzero",
    0x8: "xor-nonzero",
    0x9: "nonzero",
}


def field_meaning(container: str, selector: int | None) -> str:
    if selector is None:
        return ""
    if container == "parentActor":
        return ACTOR_FIELD_MEANINGS.get(selector, "")
    if container in {"display", "child/display"}:
        return DISPLAY_FIELD_MEANINGS.get(selector, "")
    return ""


def pointer_to_static(value: int | None) -> int | None:
    if value is None:
        return None
    if 0x00120000 <= value < 0x00290000:
        return value - 0x00120000 + 0x00400000
    return value


def read_immediate_width(raw: bytes, offset: int, width: int) -> int | None:
    if width == 1:
        return raw[offset] if len(raw) > offset else None
    if width == 2:
        return u16(raw, offset)
    if width == 4:
        return u32(raw, offset)
    return None


def conditional_source_label(group: int, selector: int | None, width: int, side: str) -> str:
    width_name = {1: "byte", 2: "word", 4: "dword"}.get(width, f"{width}-byte")
    if selector is None:
        return f"{side}:unknown"
    if group in {0x10, 0x40}:
        return f"{side}:globalTemp[+0x{selector:02x}]"
    if group in {0x20, 0x80}:
        return f"{side}:display.{width_name}[+0x{selector:02x}]"
    if group in {0x30, 0xC0}:
        return f"{side}:parentActor.{width_name}[+0x{selector:02x}]"
    return f"{side}:imm.{width_name}" if group == 0x00 else f"{side}:group{hex8(group)}[+0x{selector:02x}]"


def byte_source_label(group: int, selector: int | None, side: str) -> tuple[str, str, str]:
    """Return label, container, and known field meaning for opcode 0x10 operands."""
    if selector is None:
        return f"{side}:unknown", "", ""
    if group == 0x00:
        return f"{side}:imm.byte={hex8(selector)}", "immediate", ""
    if group == 0x10:
        return f"{side}:globalTemp[+0x{selector:02x}]", "globalTemp", ""
    if group == 0x20:
        return f"{side}:display.byte[+0x{selector:02x}]", "display", field_meaning("display", selector)
    if group == 0x30:
        return f"{side}:parentActor.byte[+0x{selector:02x}]", "parentActor", field_meaning("parentActor", selector)
    return f"{side}:group{hex8(group)}[+0x{selector:02x}]", "", ""


def byte_dest_label(group: int, selector: int | None) -> tuple[str, str, str]:
    """Return label, container, and known field meaning for opcode 0x10 destination."""
    if selector is None:
        return "dest:unknown", "", ""
    if group == 0x40:
        return f"dest:globalTemp[+0x{selector:02x}]", "globalTemp", ""
    if group == 0x80:
        return f"dest:display.byte[+0x{selector:02x}]", "display", field_meaning("display", selector)
    if group == 0xC0:
        return f"dest:parentActor.byte[+0x{selector:02x}]", "parentActor", field_meaning("parentActor", selector)
    return f"dest:no-write-group{hex8(group)}[+0x{selector:02x}]", "none", ""


def field_container_from_mode(mode: int, *, child: bool = False) -> str:
    group = mode & 0xC0
    if group == 0xC0:
        return "parentActor"
    if group == 0x80:
        return "child/display" if child else "display"
    if group == 0x40:
        return "globalTemp"
    return "none"


def resolve_static_branch(
    op: int,
    mode: int,
    left_selector: int | None,
    right_value: int | None,
    skill_id: int,
    side_flags: int,
) -> tuple[bool | None, str | None]:
    cmp_op = mode & 0x0F
    left_group = mode & 0xC0
    right_group = mode & 0x30
    if op == 0x13 and left_group == 0xC0 and left_selector == 0x59 and right_group == 0x00 and right_value is not None:
        if cmp_op == 0x00:
            return skill_id != right_value, f"actor.skillId != {hex8(right_value)}"
        if cmp_op == 0x01:
            return skill_id == right_value, f"actor.skillId == {hex8(right_value)}"
        if cmp_op == 0x02:
            return skill_id > right_value, f"actor.skillId > {hex8(right_value)}"
        if cmp_op == 0x03:
            return skill_id < right_value, f"actor.skillId < {hex8(right_value)}"
        if cmp_op == 0x04:
            return skill_id >= right_value, f"actor.skillId >= {hex8(right_value)}"
        if cmp_op == 0x05:
            return skill_id <= right_value, f"actor.skillId <= {hex8(right_value)}"
    if op == 0x13 and left_group == 0xC0 and left_selector == 0x62 and right_group == 0x00 and right_value is not None:
        if cmp_op == 0x06:
            return (side_flags & right_value) != 0, f"(actor.sideFlags & {hex8(right_value)}) != 0"
    return None, None


def decode_conditional_branch(raw: bytes, op: int, width: int, skill_id: int, side_flags: int) -> dict[str, Any]:
    mode = raw[1] if len(raw) > 1 else 0
    left_selector = raw[2] if len(raw) > 2 else None
    right_group = mode & 0x30
    left_group = mode & 0xC0
    right_selector: int | None = None
    right_value: int | None = None
    if right_group == 0x00:
        if width == 1:
            right_selector = raw[3] if len(raw) > 3 else None
            right_value = right_selector
            target_offset = 4
        else:
            right_value = read_immediate_width(raw, 4, width)
            target_offset = 8
    else:
        right_selector = raw[3] if len(raw) > 3 else None
        target_offset = 4
    target = pointer_to_static(u32(raw, target_offset))
    cmp_op = mode & 0x0F
    cmp_name = CONDITION_NAMES.get(cmp_op, f"cmp{cmp_op:x}")
    left_label = conditional_source_label(left_group, left_selector, width, "left")
    if right_group == 0x00:
        if right_value is None:
            right_label = f"right:imm.{width}-byte=-"
        elif width == 1:
            right_label = f"right:imm.byte={hex8(right_value)}"
        elif width == 2:
            right_label = f"right:imm.word=0x{right_value:04x}"
        else:
            right_label = f"right:imm.dword={hex32(right_value)}"
    else:
        right_label = conditional_source_label(right_group, right_selector, width, "right")
    taken, resolved_expr = resolve_static_branch(op, mode, left_selector, right_value, skill_id, side_flags)
    expr = resolved_expr or f"{left_label} {cmp_name} {right_label}"
    width_name = {1: "byte", 2: "word", 4: "dword"}[width]
    right_immediate_hex = ""
    if right_group == 0x00:
        if width == 1:
            right_immediate_hex = hex8(right_value)
        elif width == 2:
            right_immediate_hex = f"0x{right_value:04x}" if right_value is not None else ""
        else:
            right_immediate_hex = hex32(right_value)
    row: dict[str, Any] = {
        "category": "branch",
        "mode": mode,
        "modeHex": hex8(mode),
        "comparison": cmp_name,
        "conditionOp": cmp_op,
        "conditionOpHex": hex8(cmp_op),
        "operandWidth": width,
        "leftSelector": left_selector,
        "leftSelectorHex": hex8(left_selector),
        "leftSourceGroupHex": hex8(left_group),
        "leftSource": left_label,
        "rightSelector": right_selector,
        "rightSelectorHex": hex8(right_selector),
        "rightSourceGroupHex": hex8(right_group),
        "rightSource": right_label,
        "rightImmediate": right_value if right_group == 0x00 else None,
        "rightImmediateHex": right_immediate_hex,
        "branchTargetOffset": target_offset,
        "branchTargetVa": target,
        "branchTargetVaHex": hex32(target),
        "targetVa": target,
        "targetVaHex": hex32(target),
        "summary": f"{width_name} conditional branch unresolved if {expr} -> {hex32(target)}",
        "nextMode": "branch-unresolved",
    }
    if taken is not None:
        row.update(
            summary=f"{width_name} conditional branch {'take' if taken else 'skip'} if {expr} -> {hex32(target)}",
            nextMode="jump" if taken else "fallthrough",
            targetVa=target if taken else None,
            targetVaHex=hex32(target) if taken else "",
            conditionResolved=True,
            branchTaken=taken,
        )
    return row


@dataclass(frozen=True)
class EntrySpec:
    owner_key: str
    owner_name: str
    family_name: str
    sprite_hex: str
    start_va: int
    skill_ids: tuple[int, ...]
    confidence: str
    note: str
    prefer_start_va: bool = False


ENTRY_SPECS = [
    EntrySpec(
        "ataho",
        "아타호",
        "호격권",
        "0x11",
        0x004D6540,
        (0x0A, 0x0B, 0x0C, 0x0D),
        "static-branch-family",
        "actor.skillId branch가 0x0a..0x0d 숙련도 차이를 가르고 idle/end marker에서 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "ataho",
        "아타호",
        "맹호비상각",
        "0x11",
        0x004D6640,
        (0x0E, 0x0F, 0x10, 0x11),
        "static-branch-family",
        "actor.skillId branch와 target-relative movement block이 확인되고 idle/end marker에서 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "ataho",
        "아타호",
        "폭전축/퍽전축",
        "0x11",
        0x004D66D4,
        (0x12, 0x13, 0x14, 0x15),
        "static-branch-family",
        "0x14는 한국판 기술명 오타 퍽전축으로 분리되지만 같은 branch family 안의 숙련도 변형이다.",
        True,
    ),
    EntrySpec(
        "ataho",
        "아타호",
        "맹호스페셜",
        "0x11",
        0x004D67E0,
        (0x16, 0x17, 0x18, 0x19),
        "static-branch-family",
        "actor.skillId branch가 0x16..0x19 숙련도 차이를 가르고 같은 branch block 안에서 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "ataho",
        "아타호",
        "비기·맹호광파참",
        "0x11",
        0x004D6A14,
        (0x1A, 0x1B, 0x1C, 0x1D),
        "static-branch-family",
        "actor.skillId branch가 레벨별 hit/write 반복 수를 가르고 idle/end marker에서 정상 종료된다. 0x004d6a10은 선행 branch-tail byte라 0x004d6a14부터 걷는다.",
        True,
    ),
    EntrySpec(
        "ataho",
        "아타호",
        "취호권 개인공격기",
        "0x11",
        0x004D6BB8,
        (0x1E, 0x1F, 0x20, 0x21, 0x22),
        "static-repeat-loop-family",
        "무기 술 계열 개인공격기 shared branch family. 0x004d6bb8의 WLK 23 음주 시전음/선행 frame block부터 시작해야 하며, 0x06 repeat-loop control이 포함되고 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "ataho",
        "아타호",
        "맹호난무",
        "0x11",
        0x004D6DF4,
        (0x23, 0x24, 0x25, 0x26),
        "static-repeat-loop-family",
        "맹호난무 숙련도 family. 0x004d6df4의 WLK 18 시전음/선행 frame부터 시작해야 하며, 일부 레벨은 0x06 repeat-loop control을 포함하고 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "ataho",
        "아타호",
        "맹호의 울부짖음",
        "0x11",
        0x004D70A8,
        (0x27, 0x28, 0x29, 0x2A),
        "static-branch-family",
        "맹호의 울부짖음 숙련도 family. frame/write 및 WLK id 00 호출 후 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "ataho",
        "아타호",
        "호포권",
        "0x11",
        0x004D7140,
        (0x2B, 0x2C, 0x2D, 0x2E),
        "static-branch-family",
        "호포권 숙련도 family. 레벨별 WLK 호출 분기가 보이고 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "ataho",
        "아타호",
        "맹호룬룬권",
        "0x11",
        0x004D7240,
        (0x2F, 0x30, 0x31, 0x32),
        "static-branch-family",
        "맹호룬룬권 숙련도 family. 0x004d7240의 WLK 32 시전음과 선행 무릎차기 frame block부터 시작해야 하며, 레벨이 올라갈수록 actor frame 조합과 WLK 호출 수가 늘어난다.",
        True,
    ),
    EntrySpec(
        "rinshan",
        "린샹",
        "안면백조권",
        "0x14",
        0x004DB224,
        (0x09, 0x0A, 0x0B, 0x0C),
        "display-table-phase-family",
        "Rinshan display phase table이 기준이다. phase 0x13은 0x004db1cc, phase 0x14..0x16은 0x004db224를 가리키며, 0x004db2d8은 후반 branch fragment라 앞쪽 1/2타 frame cluster를 떨어뜨린다.",
        False,
    ),
    EntrySpec(
        "rinshan",
        "린샹",
        "선렬각",
        "0x14",
        0x004DB564,
        (0x0D, 0x0E, 0x0F, 0x10),
        "static-branch-family",
        "actor.skillId branch가 0x0d..0x10 숙련도 차이를 가르고 같은 branch block 안에서 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "rinshan",
        "린샹",
        "열화폭염권",
        "0x14",
        0x004DB784,
        (0x11, 0x12, 0x13, 0x14),
        "static-branch-family",
        "레벨이 올라갈수록 frame 23 반복 횟수가 늘어나는 정적 branch family로 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "rinshan",
        "린샹",
        "암각·영상승룡파",
        "0x14",
        0x004DB828,
        (0x15, 0x16, 0x17, 0x18),
        "static-repeat-loop-family",
        "암각·영상승룡파 숙련도 family. 0x06 repeat-loop control이 포함되며 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "rinshan",
        "린샹",
        "고양이달래기",
        "0x14",
        0x004DBAD0,
        (0x1C, 0x1D, 0x1E, 0x1F),
        "static-branch-family",
        "고양이달래기 숙련도 family. frame 24/25 및 WLK id 35 호출 후 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "rinshan",
        "린샹",
        "유미쌍조",
        "0x14",
        0x004DBB54,
        (0x20, 0x21, 0x22, 0x23),
        "static-branch-family",
        "actor.skillId branch가 frame 반복 수를 가르고 idle/end marker에서 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "rinshan",
        "린샹",
        "대폭진",
        "0x14",
        0x004DBDE4,
        (0x24, 0x25, 0x26, 0x27),
        "static-branch-family",
        "대폭진 branch family. frame/write 및 WLK id 29 호출이 보이고 idle/end marker에서 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "rinshan",
        "린샹",
        "암각·영상뢰화",
        "0x14",
        0x004DBF64,
        (0x28, 0x29, 0x2A, 0x2B),
        "static-branch-family",
        "0x28..0x2b 레벨별 branch family. 0x004dbf64의 WLK 18 선행 시전음/프레임부터 시작해야 하며, 신기 레벨에서 여러 기본 동작 frame을 조합한 뒤 후반 공통 sequence로 이어진다.",
        True,
    ),
    EntrySpec(
        "rinshan",
        "린샹",
        "회복/방어 특수기",
        "0x14",
        0x004DC41C,
        (0x31, 0x32, 0x33, 0x34),
        "static-shared-special-family",
        "기공회복/기공독치료/기공대회복/수경이 공유하는 특수기 표시 block. 정상 종료되지만 세부 효과 처리는 별도 payload 쪽에 있다.",
        True,
    ),
    EntrySpec(
        "smashu",
        "스마슈",
        "대타격",
        "0x17",
        0x00524874,
        (0x05, 0x06, 0x07, 0x08),
        "static-branch-family",
        "대타격 숙련도 family. branch 이후 동일 frame family로 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "smashu",
        "스마슈",
        "쾌진격",
        "0x17",
        0x005249CC,
        (0x09, 0x0A, 0x0B, 0x0C),
        "static-branch-family",
        "actor.skillId branch가 0x09..0x0c 숙련도 차이를 가르고 같은 branch block 안에서 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "smashu",
        "스마슈",
        "비검·목단미인",
        "0x17",
        0x00524A6C,
        (0x0D, 0x0E, 0x0F, 0x10),
        "static-repeat-loop-family",
        "비검·목단미인 숙련도 family. 0x06 repeat-loop와 0x4b display-list cleanup을 포함하며 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "smashu",
        "스마슈",
        "백인일섬",
        "0x17",
        0x0052501C,
        (0x14, 0x15, 0x16, 0x17),
        "static-branch-family",
        "백인일섬 숙련도 family. frame 10..13 block과 WLK id 20 호출 후 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "smashu",
        "스마슈",
        "인법·분신술",
        "0x17",
        0x005250E8,
        (0x18, 0x19, 0x1A, 0x1B),
        "static-child-display-family",
        "분신/자식 display field write(0x11/0x14)로 분신 객체를 구성한 뒤 공격 frame block으로 이어지는 family. 정상 종료된다.",
        True,
    ),
    EntrySpec(
        "smashu",
        "스마슈",
        "비검·시공단",
        "0x17",
        0x00525544,
        (0x1C, 0x1D, 0x1E, 0x1F),
        "static-repeat-loop-family",
        "비검·시공단 숙련도 family. 0x00525544의 WLK 17 시전음부터 시작해야 하며, 0x06 repeat-loop와 0x4b helper가 포함되고 정상 종료된다.",
        True,
    ),
]


REGIONS = {
    "ataho": (0x004D5F00, 0x004D7400),
    "rinshan": (0x004DAD00, 0x004DC600),
    "smashu": (0x00524400, 0x00525800),
}

DISPLAY_TABLE_BASES = {
    "ataho": 0x004D5DD0,
    "rinshan": 0x004DAD40,
    "smashu": 0x00524474,
}

OWNER_SPRITES = {
    "ataho": "0x11",
    "rinshan": "0x14",
    "smashu": "0x17",
}


KNOWN_OPCODE_LENGTHS = {
    0x01: "4 bytes, observed as idle/end marker before self-loop jump",
    0x03: "8 bytes, unconditional script cursor jump",
    0x06: "8 bytes, grounded repeat-loop control; word +2 is repeat count and dword +4 is loop target, stored through object +0x60/+0x3c",
    0x07: "8 bytes, spawn/link a child display VM object; byte1 is child type and dword at +4 is child script pointer",
    0x08: "8 bytes candidate generic control",
    0x0C: "8 bytes candidate local variable stream",
    0x0D: "8 bytes candidate conditional stream block",
    0x10: "4 bytes placement expression/helper observed after 0xbc direct placement",
    0x11: "4 or 8 bytes display/child actor field write candidate; immediate modes use 8 bytes",
    0x12: "4 or 8 bytes; immediate source uses 8 bytes",
    0x13: "8 bytes byte-width conditional branch",
    0x14: "8 or 12 bytes word-width conditional branch",
    0x15: "8 or 12 bytes dword-width conditional branch",
    0x1E: "4 bytes, simple x/y acceleration-toward-target movement update consumer",
    0x1F: "4 bytes, link current display object to child object from +0x58 through +0xa4/+0xa0",
    0x21: "8 bytes sprite/frame write",
    0x24: "4 bytes effect/helper call candidate",
    0x25: "4 bytes, effect-control submode dispatch; 0xf1 flushes/updates global effect slots through 0x0042976b",
    0x2C: "4 bytes, axis-flag acceleration-toward-target movement update; byte1 bits 0x01/0x02/0x04 select x/y/z",
    0x2D: "4 bytes, axis-flag trigonometric motion update; byte1 low bits select axes and high bits select additive vs base-relative mode",
    0x2E: "12 bytes, compute angle from dword dx/dy operands and store to selected angle fields +0x8c/+0x8e/+0x90",
    0x38: "8 bytes palette transform / fade helper",
    0x39: "8 bytes palette transform helper variant",
    0x3A: "4 bytes palette helper",
    0x3B: "4 bytes palette backup range",
    0x3C: "4 bytes palette restore range",
    0x3D: "4 bytes palette copy range",
    0x3E: "4 bytes palette/global wait gate",
    0x3F: "8 bytes palette/global pointer bind",
    0x42: "4 bytes, bind display object +0xa8 to parent battle actor pointer via actor table 0x0059db30",
    0x4B: "4 bytes, clear/free display objects in a list slot by kind/priority mask",
    0x60: "4 bytes, display resource/list reset; calls 0x00430130",
    0x61: "4 bytes, rebuild/refresh display resource list; calls 0x0043022d",
    0x62: "4 bytes, add/register descriptor actor/resource id byte1; calls 0x00431fe8",
    0x63: "4 bytes, remove/unregister descriptor actor/resource id byte1; calls 0x00432541",
    0x64: "8 bytes, place/update active descriptor display object; byte1 mode, word x/y, byte6 target id, byte7 state selector",
    0xAD: "8 bytes battle actor flag set/clear",
    0xBC: "4 bytes; byte1=0 direct placement, byte1>0 interpolated child motion, byte2=position selector, byte3=motion step divisor",
    0xBD: "4 bytes child/display cleanup/helper",
    0xBF: "4 bytes busy wait gate",
    0xC0: "4 bytes, bounded axis-flag movement update; byte1 bits 0x01/0x02/0x04 select x/y/z and clamps against display boundaries",
    0xC1: "8 bytes actor flag wait gate",
    0xC2: "4 bytes DirectSound/WLK effect",
}


DISPLAY_VM_RUNNER_EVIDENCE = {
    "runnerVaHex": "0x00402321",
    "dispatchTableVaHex": "0x00440538",
    "cursorField": "display object +0x40",
    "stopFlagGlobal": "0x0055a1b8",
    "summary": "0x00402321 resets 0x55a1b8, reads the opcode byte from display object +0x40, dispatches through 0x00440538[opcode], and repeats until a handler sets the stop flag.",
    "handlers": [
        {
            "handlerVaHex": "0x0040239f",
            "role": "default/skip",
            "summary": "advance +0x40 by 4 when the opcode has no special handler.",
        },
        {
            "handlerVaHex": "0x004023b1",
            "role": "free/stop",
            "summary": "free the display object and set 0x55a1b8=1.",
        },
        {
            "handlerVaHex": "0x004023ee",
            "role": "wait/timer",
            "summary": "consume display object +0x5e as a wait/timer gate.",
        },
    ],
}


DISPLAY_MOTION_CONSUMERS = [
    {
        "opcodeHex": "0x1e",
        "handlerVaHex": "0x004043c5",
        "entryVaHex": "0x004405b0",
        "length": 4,
        "role": "simple x/y acceleration movement consumer",
        "fields": "+0x80/+0x84 target, +0x68/+0x6c acceleration, +0x74/+0x78 velocity, +0x1c/+0x20 position",
        "summary": "Compares x/y position against target, adjusts velocity by acceleration sign, then adds velocity to position.",
    },
    {
        "opcodeHex": "0x2c",
        "handlerVaHex": "0x004058fb",
        "entryVaHex": "0x004405e8",
        "length": 4,
        "role": "axis-flag acceleration movement consumer",
        "fields": "byte1 bits 0x01 x, 0x02 y, 0x04 z; +0x80/+0x84/+0x88 target; +0x68/+0x6c/+0x70 acceleration; +0x74/+0x78/+0x7c velocity; +0x1c/+0x20/+0x24 position",
        "summary": "Same target-seeking acceleration as 0x1e, but per-axis selectable and includes z/height.",
    },
    {
        "opcodeHex": "0x2d",
        "handlerVaHex": "0x00405a09",
        "entryVaHex": "0x004405ec",
        "length": 4,
        "role": "trigonometric oscillation/orbit movement consumer",
        "fields": "byte1 low bits select axes; byte1 high bits mode 0 additive, mode 8 base-relative; +0x8c/+0x8e/+0x90 angle; +0x92/+0x94/+0x96 amplitude; +0x80/+0x84/+0x88 base",
        "summary": "Uses 0x428070/0x42819b sine/cosine-like helpers to offset or set x/y/z.",
    },
    {
        "opcodeHex": "0x2e",
        "handlerVaHex": "0x00405bde",
        "entryVaHex": "0x004405f0",
        "length": 12,
        "role": "angle field producer",
        "fields": "dword operands at script +4/+8 feed 0x4282bb; byte1 bits select +0x8c/+0x8e/+0x90 destination",
        "summary": "Computes an angle from two 32-bit operands and writes it into one or more trigonometric motion angle fields.",
    },
    {
        "opcodeHex": "0xbc",
        "handlerVaHex": "0x0040e216",
        "entryVaHex": "0x00440828",
        "length": 4,
        "role": "direct placement / child linear motion producer",
        "fields": "byte1 mode, byte2 position selector, byte3 step divisor; writes +0x80/+0x84 target and +0x74/+0x78 step on child motion objects",
        "summary": "Grounded selector formulas are listed separately in movementOpcode0xbc.",
    },
    {
        "opcodeHex": "0xc0",
        "handlerVaHex": "0x0040ec00",
        "entryVaHex": "0x00440838",
        "length": 4,
        "role": "bounded axis-flag movement consumer",
        "fields": "byte1 bits 0x01 x, 0x02 y, 0x04 z; same target/velocity fields as 0x2c plus boundary helpers 0x416c77/0x4173db",
        "summary": "Moves selected axes using velocity/acceleration and clamps or bounces against calculated display bounds.",
    },
]


DISPLAY_RESOURCE_OPCODE_FLOW = [
    {
        "opcodeHex": "0x60",
        "handlerVaHex": "0x00407c93",
        "calleeVaHex": "0x00430130",
        "length": 4,
        "role": "reset display resource/list state",
        "summary": "Clears 0x574531/0x574543/0x4576e8/0x574533, sets default visible slots 0x574538={0x11,0x12,0x13}, initializes order bytes 0x574540 and clears cached tile/state rows 0x574550..0x574554.",
    },
    {
        "opcodeHex": "0x61",
        "handlerVaHex": "0x00407caa",
        "calleeVaHex": "0x0043022d",
        "length": 4,
        "role": "rebuild/refresh active display resources",
        "summary": "Walks active resource slots, copies descriptor state through 0x402360, frees stale objects through 0x435c9d, and creates display VM objects with runner 0x402321 from descriptor table entries.",
    },
    {
        "opcodeHex": "0x62",
        "handlerVaHex": "0x00407cc1",
        "calleeVaHex": "0x00431fe8",
        "length": 4,
        "role": "register actor/resource id",
        "summary": "Uses script byte1 as a resource/actor id. If absent from the active resource list, it appends it and creates display VM objects from the 0x442d95 descriptor pointer table.",
    },
    {
        "opcodeHex": "0x63",
        "handlerVaHex": "0x00407ce5",
        "calleeVaHex": "0x00432541",
        "length": 4,
        "role": "unregister actor/resource id",
        "summary": "Uses script byte1 to remove matching active resource entries, free their display objects, compact the active list, and rebuild remaining display VM objects.",
    },
    {
        "opcodeHex": "0x64",
        "handlerVaHex": "0x00407d09",
        "calleeVaHex": "",
        "length": 8,
        "role": "place/update active resource object",
        "summary": "Matches active display object +0x16 against script byte6 (or 0xff wildcard), calls 0x424cd6 mode 2, writes tile position +0xe8/+0xea and fixed-point x/y/z from script words +2/+4, then maps byte7 to object +0x68 state selector.",
    },
]


BC_SELECTOR_FORMULAS = [
    {
        "selector": 0,
        "selectorHex": "0x00",
        "targetRangePolicy": "self slot only",
        "kind1Formula": "self.baseX(+0x8c), self.baseY(+0x90)",
        "kind2Formula": "self.baseX(+0x8c), self.baseY(+0x90)",
        "meaning": "current battle actor base position; used as return/reset placement.",
        "handlerEvidence": "0x0040e259 range=self +0xf2..+1; 0x0040e37e/0x0040e639 read current display +0x8c/+0x90.",
    },
    {
        "selector": 1,
        "selectorHex": "0x01",
        "targetRangePolicy": "resolved target range from 0x00433c99/0x00433dc0",
        "kind1Formula": "target.base + ((target.e6-self.e6)<<18, (target.e7-self.e7)<<19)",
        "kind2Formula": "target.base + ((target.e6-self.e6)<<18, (target.e7-self.e7)<<19)",
        "meaning": "target-relative center/size-adjusted placement.",
        "handlerEvidence": "0x0040e39b/0x0040e656 compute target display size delta.",
    },
    {
        "selector": 2,
        "selectorHex": "0x02",
        "targetRangePolicy": "resolved target range from 0x00433c99/0x00433dc0",
        "kind1Formula": "target.baseX + target.e6*8px, target.baseY + (target.e7-self.e7)*8px",
        "kind2Formula": "target.baseX - self.e6*8px, target.baseY + (target.e7-self.e7)*8px",
        "meaning": "one target-side edge placement; side depends on actor kind.",
        "handlerEvidence": "0x0040e3fc/0x0040e6b7 use target/current display width offsets.",
    },
    {
        "selector": 3,
        "selectorHex": "0x03",
        "targetRangePolicy": "resolved target range from 0x00433c99/0x00433dc0",
        "kind1Formula": "target.baseX - self.e6*8px, target.baseY + (target.e7-self.e7)*8px",
        "kind2Formula": "target.baseX + target.e6*8px, target.baseY + (target.e7-self.e7)*8px",
        "meaning": "opposite target-side edge placement.",
        "handlerEvidence": "0x0040e450/0x0040e706 mirror selector 2.",
    },
    {
        "selector": 4,
        "selectorHex": "0x04",
        "targetRangePolicy": "resolved target range from 0x00433c99/0x00433dc0",
        "kind1Formula": "fixedX=184px, target-relative Y",
        "kind2Formula": "fixedX=456px, target-relative Y",
        "meaning": "side-fixed screen X with target-relative Y.",
        "handlerEvidence": "0x0040e49f fixed 0x00b80000; 0x0040e75a fixed 0x01c80000.",
    },
    {
        "selector": 5,
        "selectorHex": "0x05",
        "targetRangePolicy": "special/current range; no target scan skip",
        "kind1Formula": "X=((0x4a-self.e6)*4+0x18), Y=((0x2a-self.e7)*4+0x08)",
        "kind2Formula": "X=((0x4a-self.e6)*4+0x18), Y=((0x2a-self.e7)*4+0x08)",
        "meaning": "screen-size/display-size based fixed placement.",
        "handlerEvidence": "0x0040e4d9/0x0040e794 use constants 0x4a, 0x2a, 0x18, 0x08.",
    },
    {
        "selector": 6,
        "selectorHex": "0x06",
        "targetRangePolicy": "parent display object based",
        "kind1Formula": "parent.baseX + (parent.e6-4)*4px, parent.baseY + (parent.e7*8-40)px",
        "kind2Formula": "parent.baseX + (parent.e6-4)*4px, parent.baseY + (parent.e7*8-40)px",
        "meaning": "child motion/display object attached to parent with -4 X correction.",
        "handlerEvidence": "0x0040e51a/0x0040e7d5 read child parent +0xa0 then parent +0xf2/+0xe6/+0xe7.",
    },
    {
        "selector": 7,
        "selectorHex": "0x07",
        "targetRangePolicy": "parent display object based",
        "kind1Formula": "parent.baseX + (parent.e6-2)*4px, parent.baseY + (parent.e7*8-40)px",
        "kind2Formula": "parent.baseX + (parent.e6-2)*4px, parent.baseY + (parent.e7*8-40)px",
        "meaning": "child motion/display object attached to parent with -2 X correction.",
        "handlerEvidence": "0x0040e581/0x0040e83c read child parent +0xa0 then parent +0xf2/+0xe6/+0xe7.",
    },
]


BC_SELECTOR_BY_ID = {row["selector"]: row for row in BC_SELECTOR_FORMULAS}


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


def display_init_list_length(raw: bytes) -> int:
    """Return dynamic length for opcode 0x08 display-object init lists.

    Handler 0x00402590 skips the 4-byte opcode header, then reads typed
    field-write records until an 0xff terminator record.  Records are 4 bytes
    for byte/word writes and 8 bytes for dword writes.
    """
    if len(raw) < 4:
        return 0
    cursor = 4
    while cursor < len(raw):
        record_type = raw[cursor]
        if record_type == 0xFF:
            return cursor + 4 if cursor + 4 <= len(raw) else 0
        if record_type in {0x01, 0x02}:
            cursor += 4
            continue
        if record_type == 0x03:
            cursor += 8
            continue
        return 0
    return 0


def instruction_length(raw: bytes) -> int:
    if not raw:
        return 0
    op = raw[0]
    if op == 0x08:
        return display_init_list_length(raw)
    if op == 0x03:
        return 8
    if op == 0x06:
        return 8
    if op == 0x07:
        return 8
    if op == 0x0A:
        count = raw[2] if len(raw) > 2 else 0
        return 4 + count * 4 if len(raw) >= 4 + count * 4 else 0
    if op in {0x0C, 0x0D, 0x13, 0x21, 0x38, 0x39, 0x3F, 0xAD, 0xC1}:
        return 8
    if op == 0x11:
        mode = raw[1] if len(raw) > 1 else 0
        return 4 if (mode & 0x20) else 8
    if op == 0x12:
        mode = raw[1] if len(raw) > 1 else 0
        src_group = mode & 0x30
        return 4 if src_group else 8
    if op in {0x14, 0x15}:
        mode = raw[1] if len(raw) > 1 else 0
        return 12 if (mode & 0x30) == 0 else 8
    if op == 0x2E:
        return 12
    if op == 0x64:
        return 8
    if op == 0x20:
        return 8
    if op in {0x01, 0x10, 0x1E, 0x1F, 0x24, 0x25, 0x2B, 0x2C, 0x2D, 0x3A, 0x3B, 0x3C, 0x3D, 0x3E, 0x42, 0x4B, 0x60, 0x61, 0x62, 0x63, 0xBC, 0xBD, 0xBF, 0xC0, 0xC2}:
        return 4
    return 0


def decode_display_init_records(raw: bytes) -> list[dict[str, Any]]:
    records: list[dict[str, Any]] = []
    cursor = 4
    index = 0
    while cursor < len(raw):
        record_type = raw[cursor]
        if record_type == 0xFF:
            records.append(
                {
                    "index": index,
                    "offset": cursor,
                    "recordType": record_type,
                    "recordTypeHex": hex8(record_type),
                    "kind": "terminator",
                    "summary": "init-list terminator",
                }
            )
            break
        field = raw[cursor + 1] if cursor + 1 < len(raw) else None
        row: dict[str, Any] = {
            "index": index,
            "offset": cursor,
            "recordType": record_type,
            "recordTypeHex": hex8(record_type),
            "fieldOffset": field,
            "fieldOffsetHex": hex8(field),
        }
        if record_type == 0x01 and cursor + 4 <= len(raw):
            value = raw[cursor + 2]
            row.update(kind="byte-write", value=value, valueHex=hex8(value), summary=f"byte +0x{field:02x} = {hex8(value)}")
            cursor += 4
        elif record_type == 0x02 and cursor + 4 <= len(raw):
            value = u16(raw, cursor + 2)
            row.update(kind="word-write", value=value, valueHex=f"0x{value:04x}" if value is not None else "", summary=f"word +0x{field:02x} = {f'0x{value:04x}' if value is not None else '-'}")
            cursor += 4
        elif record_type == 0x03 and cursor + 8 <= len(raw):
            value = u32(raw, cursor + 4)
            row.update(kind="dword-write", value=value, valueHex=hex32(value), summary=f"dword +0x{field:02x} = {hex32(value)}")
            if field == 0x28 and value is not None:
                sprite = (value >> 16) & 0xFFFF
                frame = value & 0xFFFF
                row.update(
                    kind="sprite-frame-init",
                    sprite=sprite,
                    spriteHex=hex8(sprite),
                    frame=frame,
                    summary=f"init sprite/frame +0x28 = sprite {hex8(sprite)}, frame {frame}",
                )
            cursor += 8
        else:
            row.update(kind="malformed", summary=f"malformed init record at +0x{cursor:x}")
            records.append(row)
            break
        records.append(row)
        index += 1
    return records


def decode_instruction(raw: bytes, skill_id: int, side_flags: int = 0) -> dict[str, Any]:
    op = raw[0] if raw else None
    length = instruction_length(raw)
    row: dict[str, Any] = {
        "opcode": hex8(op),
        "length": length,
        "category": "unknown",
        "summary": raw[: max(length, 1)].hex(" "),
        "nextMode": "fallthrough",
        "targetVa": None,
        "targetVaHex": "",
    }
    if op is None:
        return row
    if op == 0x01:
        row.update(category="idle-end", summary="idle/end marker; stop static walk before self-loop")
    elif op == 0x03:
        target = pointer_to_static(u32(raw, 4))
        row.update(category="jump", summary=f"jump {hex32(target)}", nextMode="jump", targetVa=target, targetVaHex=hex32(target))
    elif op == 0x06:
        count = u16(raw, 2)
        target = pointer_to_static(u32(raw, 4))
        row.update(
            category="repeat-loop",
            summary=f"repeat-loop count={count}, target={hex32(target)}; handler stores target in object +0x3c and counter in +0x60",
            handlerVaHex="0x004024cc",
            repeatCount=count,
            loopTargetFieldHex="0x3c",
            repeatCounterFieldHex="0x60",
            targetVa=target,
            targetVaHex=hex32(target),
        )
    elif op == 0x07:
        target = pointer_to_static(u32(raw, 4))
        row.update(
            category="spawn-child-vm",
            summary=f"spawn child display VM type={raw[1]} runner=0x00402321 target={hex32(target)} into +0x58",
            childObjectType=raw[1],
            targetVa=target,
            targetVaHex=hex32(target),
        )
    elif op == 0x08:
        init_records = decode_display_init_records(raw[:length])
        sprite_frames = [
            {"spriteHex": record.get("spriteHex"), "frame": record.get("frame"), "fieldOffsetHex": record.get("fieldOffsetHex")}
            for record in init_records
            if record.get("kind") == "sprite-frame-init"
        ]
        summary = f"display object init-list records={max(0, len(init_records) - 1)}"
        if sprite_frames:
            summary += "; " + ", ".join(f"sprite {item['spriteHex']} frame {item['frame']}" for item in sprite_frames)
        row.update(
            category="display-init-list",
            summary=summary,
            handlerVaHex="0x00402590",
            initRecords=init_records,
            initSpriteFrames=sprite_frames,
        )
    elif op == 0x0A:
        field = raw[1]
        count = raw[2]
        targets = [pointer_to_static(u32(raw, 4 + index * 4)) for index in range(count)]
        row.update(
            category="branch-table",
            summary=f"branch table by display byte +0x{field:02x}, count={count}, targets={', '.join(hex32(target) for target in targets)}",
            handlerVaHex="0x0040288a",
            selectorField=field,
            selectorFieldHex=hex8(field),
            branchCount=count,
            branchTargetsVa=targets,
            branchTargetsVaHex=[hex32(target) for target in targets],
            nextMode="branch-unresolved",
        )
    elif op == 0x12:
        mode = raw[1]
        dest = raw[2]
        source = raw[3]
        imm = u32(raw, 4)
        imm_signed = s32(imm)
        op_kind = mode & 0x0F
        op_name = OPERATION_NAMES.get(op_kind, f"op{op_kind:x}")
        immediate = (mode & 0x30) == 0
        if (mode & 0x30) == 0:
            detail = f"{op_name} display/actor field +0x{dest:02x}, imm={hex32(imm)} ({fixed16(imm)})"
        else:
            detail = f"{op_name} display/actor field +0x{dest:02x}, source +0x{source:02x}, mode={hex8(mode)}"
        row.update(
            category="write",
            summary=detail,
            semanticCategory="dword-field-op",
            handlerVaHex="0x0040308c",
            mode=mode,
            modeHex=hex8(mode),
            opKind=op_kind,
            opName=op_name,
            dest=dest,
            destHex=hex8(dest),
            destContainer=field_container_from_mode(mode),
            destMeaning=field_meaning(field_container_from_mode(mode), dest),
            source=source,
            sourceHex=hex8(source),
            sourceMeaning=field_meaning(field_container_from_mode(mode), source),
            immediate=immediate,
        )
        if immediate:
            row.update(
                imm=imm,
                immHex=hex32(imm),
                immSigned=imm_signed,
                immFixed=(imm_signed / 65536) if imm_signed is not None else None,
            )
            if dest == 0x28 and imm is not None:
                frame = imm & 0xFFFF
                sprite = (imm >> 16) & 0xFFFF
                row.update(
                    category="frame",
                    summary=f"direct sprite/frame selector write sprite={hex8(sprite)}, frame={frame}, field +0x28, mode={hex8(mode)}",
                    sprite=sprite,
                    spriteHex=hex8(sprite),
                    frame=frame,
                    gate=1,
                    directFrameSelector=True,
                )
    elif op == 0x10:
        mode = raw[1]
        dest = raw[2]
        source = raw[3]
        op_kind = mode & 0x0F
        op_name = OPERATION_NAMES.get(op_kind, f"op{op_kind:x}")
        source_group = mode & 0x30
        dest_group = mode & 0xC0
        source_label, source_container, source_meaning = byte_source_label(source_group, source, "source")
        dest_label, dest_container, dest_meaning = byte_dest_label(dest_group, dest)
        if op_name == "set":
            expr = f"{dest_label} = {source_label}"
        else:
            expr = f"{dest_label} {op_name}= {source_label}"
        row.update(
            category="placement-expr",
            semanticCategory="byte-field-op",
            summary=f"byte field op {expr}",
            handlerVaHex="0x00402d2e",
            operationHelperVaHex="0x00402f6f",
            mode=mode,
            modeHex=hex8(mode),
            opKind=op_kind,
            opName=op_name,
            dest=dest,
            destHex=hex8(dest),
            destSourceGroupHex=hex8(dest_group),
            destContainer=dest_container,
            destMeaning=dest_meaning,
            source=source,
            sourceHex=hex8(source),
            sourceGroupHex=hex8(source_group),
            sourceContainer=source_container,
            sourceMeaning=source_meaning,
            sourceLabel=source_label,
            destLabel=dest_label,
            grounded=True,
            note="Handler 0x00402d2e reads byte source by mode&0x30, reads byte destination by mode&0xc0, applies 0x402f6f low-nibble operation, then writes byte/dword depending on destination group. group 0x00 has no write target and is not valid for promoted scripts.",
        )
    elif op == 0x11:
        mode = raw[1]
        dest = raw[2]
        source = raw[3]
        imm = u32(raw, 4)
        immediate = length == 8
        if length == 8:
            detail = f"display/child field +0x{dest:02x}, imm={hex32(imm)} ({fixed16(imm)}), mode={hex8(mode)}"
        else:
            detail = f"display/child field +0x{dest:02x}, source +0x{source:02x}, mode={hex8(mode)}"
        row.update(
            category="child-write",
            summary=detail,
            semanticCategory="child/display-field-op",
            handlerVaHex="0x0040308c",
            mode=mode,
            modeHex=hex8(mode),
            dest=dest,
            destHex=hex8(dest),
            destContainer=field_container_from_mode(mode, child=True),
            destMeaning=field_meaning(field_container_from_mode(mode, child=True), dest),
            source=source,
            sourceHex=hex8(source),
            sourceMeaning=field_meaning(field_container_from_mode(mode, child=True), source),
            immediate=immediate,
        )
        if immediate:
            row.update(
                imm=imm,
                immHex=hex32(imm),
                immSigned=s32(imm),
                immFixed=(s32(imm) / 65536) if s32(imm) is not None else None,
            )
    elif op == 0x13:
        row.update(decode_conditional_branch(raw, op, 1, skill_id, side_flags))
    elif op == 0x14:
        row.update(decode_conditional_branch(raw, op, 2, skill_id, side_flags))
    elif op == 0x15:
        row.update(decode_conditional_branch(raw, op, 4, skill_id, side_flags))
    elif op == 0x38:
        mode = raw[1]
        start = raw[2]
        count = raw[3]
        arg = u32(raw, 4)
        if mode == 0:
            detail = f"palette mode0 repeat count={count}, arg={hex32(arg)}"
        elif mode == 1:
            detail = f"palette set/flash mode1 range={hex8(start)} count={hex8(count)} arg={hex32(arg)}"
        elif mode == 2:
            detail = f"palette fade/step mode2 range={hex8(start)} count={hex8(count)} steps={raw[4]}"
        else:
            detail = f"palette transform mode={hex8(mode)} range={hex8(start)} count={hex8(count)} arg={hex32(arg)}"
        row.update(
            category="palette-transform",
            summary=detail,
            mode=mode,
            modeHex=hex8(mode),
            paletteStart=start,
            paletteStartHex=hex8(start),
            paletteCount=count,
            paletteCountHex=hex8(count),
            arg=arg,
            argHex=hex32(arg),
        )
    elif op == 0x39:
        mode = raw[1]
        start = raw[2]
        count = raw[3]
        arg = u32(raw, 4)
        row.update(
            category="palette-transform-variant",
            summary=f"palette variant mode={hex8(mode)} range={hex8(start)} count={hex8(count)} arg={hex32(arg)}",
            mode=mode,
            modeHex=hex8(mode),
            paletteStart=start,
            paletteStartHex=hex8(start),
            paletteCount=count,
            paletteCountHex=hex8(count),
            arg=arg,
            argHex=hex32(arg),
        )
    elif op == 0x3A:
        row.update(category="palette-triplet-write", summary=f"palette triplet/write args={raw[:4].hex(' ')}")
    elif op == 0x3B:
        start = raw[1]
        count = raw[2]
        row.update(
            category="palette-backup",
            summary=f"backup palette range start={hex8(start)} count={hex8(count)}",
            paletteStart=start,
            paletteStartHex=hex8(start),
            paletteCount=count,
            paletteCountHex=hex8(count),
        )
    elif op == 0x3C:
        start = raw[1]
        count = raw[2]
        row.update(
            category="palette-restore",
            summary=f"restore palette range start={hex8(start)} count={hex8(count)}",
            paletteStart=start,
            paletteStartHex=hex8(start),
            paletteCount=count,
            paletteCountHex=hex8(count),
        )
    elif op == 0x3D:
        src = raw[1]
        dest = raw[2]
        count = raw[3]
        row.update(
            category="palette-copy",
            summary=f"copy palette range src={hex8(src)} dest={hex8(dest)} count={hex8(count)}",
            paletteSource=src,
            paletteSourceHex=hex8(src),
            paletteDest=dest,
            paletteDestHex=hex8(dest),
            paletteCount=count,
            paletteCountHex=hex8(count),
        )
    elif op == 0x3E:
        row.update(category="palette/global-wait", summary="wait until global palette/display flag 0x59e314 clears")
    elif op == 0x3F:
        target = pointer_to_static(u32(raw, 4))
        row.update(
            category="global-palette-context",
            summary=f"set global/palette context pointer candidate {hex32(target)}",
            targetVa=target,
            targetVaHex=hex32(target),
        )
    elif op == 0x1F:
        row.update(
            category="link-child-parent",
            summary="link current display object to child/display object in +0x58: current +0xa4 = child, child +0xa0 = current",
            handlerVaHex="0x00404457",
            linkSourceFieldHex="0x58",
            parentLinkFieldHex="0xa4",
            childBacklinkFieldHex="0xa0",
            grounded=True,
        )
    elif op == 0x20:
        context = u32(raw, 4)
        row.update(
            category="resource/context-bind",
            summary=f"resource/context bind opcode 0x20 context={hex32(context)}; handler writes object +0x64, sets +0x62=1 and flag 0x04000000",
            handlerVaHex="0x0040448a",
            context=context,
            contextHex=hex32(context),
        )
    elif op == 0x1E:
        row.update(
            category="movement-update",
            summary="simple x/y acceleration-toward-target update: target +0x80/+0x84, acceleration +0x68/+0x6c, velocity +0x74/+0x78, position +0x1c/+0x20",
            handlerVaHex="0x004043c5",
            motionKind="simple-xy-accelerate",
        )
    elif op == 0x21:
        mode = raw[1]
        gate = u16(raw, 2)
        packed = u32(raw, 4) or 0
        frame = packed & 0xFFFF
        sprite = (packed >> 16) & 0xFFFF
        category = "frame" if mode in {0, 1} else "sprite"
        row.update(category=category, summary=f"set sprite={hex8(sprite)}, frame={frame}, gate={gate}, mode={mode}", sprite=sprite, spriteHex=hex8(sprite), frame=frame, gate=gate, mode=mode)
    elif op == 0x24:
        arg0 = raw[1]
        arg1 = raw[2]
        arg2 = raw[3]
        wlk_no = arg2 if arg0 == 0x01 and arg1 == 0x00 else None
        if wlk_no is None:
            row.update(
                category="effect/helper",
                semanticCategory="effect-handle-or-helper",
                summary=f"effect/helper opcode 0x24 args={raw[1:4].hex(' ')}",
                handlerVaHex="0x004045a0",
                effectArgsHex=raw[1:4].hex(" "),
                mode=arg0,
                modeHex=hex8(arg0),
                calleeVaHex="",
            )
        else:
            row.update(
                category="effect-sound",
                semanticCategory="effect/cast-wlk-cue",
                summary=f"effect/cast cue mode=0x01 WLK id {wlk_no:02d}; handler resolves through 0x0042af73 and stores handle in object +0x58/global 0x0059dd6c",
                handlerVaHex="0x004045a0",
                calleeVaHex="0x0042af73",
                effectArgsHex=raw[1:4].hex(" "),
                mode=arg0,
                modeHex=hex8(arg0),
                wlkNo=wlk_no,
                wlkFileIndex=arg2,
                grounded=True,
            )
    elif op == 0x25:
        mode = raw[1]
        operand = u16(raw, 2)
        submodes = {
            0x00: ("call-0x42d820", "0x0042d820", "effect/control helper call"),
            0x01: ("effect-handle-op-0x42c7f4", "0x0042c7f4", "effect handle operation; operand policy supports immediate, object +0x58, and global handle"),
            0x80: ("call-0x42d0cc", "0x0042d0cc", "effect/control helper call"),
            0x81: ("effect-handle-op-0x42ae12", "0x0042ae12", "effect handle operation; operand policy supports immediate, object +0x58, and global handle"),
            0xF1: (
                "global-effect-slot-flush-update",
                "0x0042976b",
                "flush/update global effect slots 0x58d618..0x58d622; used after 비기·맹호유성각 helper 79 child-effect swarm",
            ),
        }
        label, callee, detail = submodes.get(mode, (f"unknown-submode-{hex8(mode)}", "", "unhandled/default submode"))
        row.update(
            category="effect-control-call",
            summary=f"effect-control submode={hex8(mode)} operand={f'0x{operand:04x}' if operand is not None else '-'}; {detail}",
            handlerVaHex="0x004047c1",
            mode=mode,
            modeHex=hex8(mode),
            submodeLabel=label,
            operand=operand,
            operandHex=f"0x{operand:04x}" if operand is not None else "",
            calleeVaHex=callee,
            semanticCategory="effect-slot-control",
            exactVisualRole="global-effect-slot-flush/update" if mode == 0xF1 else "",
            grounded=True,
        )
    elif op == 0x2C:
        flags = raw[1]
        axes = [name for bit, name in ((0x01, "x"), (0x02, "y"), (0x04, "z")) if flags & bit]
        row.update(
            category="movement-update",
            summary=f"axis acceleration-toward-target update axes={','.join(axes) or '-'} flags={hex8(flags)}; target +0x80/+0x84/+0x88, acceleration +0x68/+0x6c/+0x70, velocity +0x74/+0x78/+0x7c",
            handlerVaHex="0x004058fb",
            motionKind="axis-accelerate",
            axisFlags=flags,
            axes=axes,
        )
    elif op == 0x2D:
        flags = raw[1]
        axes = [name for bit, name in ((0x01, "x"), (0x02, "y"), (0x04, "z")) if flags & bit]
        mode = flags & 0xF8
        mode_label = "additive" if mode == 0 else "base-relative" if mode == 0x08 else f"mode-{hex8(mode)}"
        row.update(
            category="trig-motion",
            summary=f"trigonometric motion update axes={','.join(axes) or '-'} mode={mode_label}; angle fields +0x8c/+0x8e/+0x90, amplitude +0x92/+0x94/+0x96",
            handlerVaHex="0x00405a09",
            motionKind="trig",
            axisFlags=flags,
            axes=axes,
            trigMode=mode,
            trigModeHex=hex8(mode),
            trigModeLabel=mode_label,
        )
    elif op == 0x2E:
        flags = raw[1]
        axes = [name for bit, name in ((0x01, "x-angle +0x8c"), (0x02, "y-angle +0x8e"), (0x04, "z-angle +0x90")) if flags & bit]
        arg_a = u32(raw, 4)
        arg_b = u32(raw, 8)
        row.update(
            category="angle-producer",
            summary=f"compute angle via 0x4282bb({hex32(arg_a)}, {hex32(arg_b)}) and write {', '.join(axes) or '-'}",
            handlerVaHex="0x00405bde",
            axisFlags=flags,
            axes=axes,
            argA=arg_a,
            argAHex=hex32(arg_a),
            argB=arg_b,
            argBHex=hex32(arg_b),
        )
    elif op == 0x2B:
        resource_id = u16(raw, 2)
        row.update(
            category="effect-resource-handle",
            summary=f"resolve effect/resource handle id={f'0x{resource_id:04x}' if resource_id is not None else '-'} through 0x427730 into display object +0x58",
            handlerVaHex="0x004056de",
            calleeVaHex="0x00427730",
            resourceId=resource_id,
            resourceIdHex=f"0x{resource_id:04x}" if resource_id is not None else "",
        )
    elif op == 0x42:
        mode = raw[1]
        actor_slot = raw[2] if len(raw) > 2 else None
        if mode == 0:
            summary = "bind display object +0xa8 from this display object's actor slot +0xf2 via battle actor table 0x0059db30"
        elif mode == 1:
            summary = f"bind display object +0xa8 from explicit actor slot {hex8(actor_slot)} via battle actor table 0x0059db30"
        else:
            summary = f"parent actor bind opcode 0x42 unknown mode={hex8(mode)}"
        row.update(
            category="parent-actor-bind",
            summary=summary,
            handlerVaHex="0x0040644f",
            mode=mode,
            modeHex=hex8(mode),
            actorSlot=actor_slot if mode == 1 else None,
            actorSlotHex=hex8(actor_slot) if mode == 1 else "",
            displayParentActorFieldHex="0xa8",
            displayActorSlotFieldHex="0xf2",
            actorTableVaHex="0x0059db30",
            grounded=mode in {0, 1},
        )
    elif op == 0x4B:
        list_index = raw[1]
        mask = u16(raw, 2)
        row.update(
            category="clear-display-list",
            summary=f"clear/free display list slot={list_index} mask={f'0x{mask:04x}' if mask is not None else '-'}",
            listIndex=list_index,
            mask=mask,
            maskHex=f"0x{mask:04x}" if mask is not None else "",
        )
    elif op == 0x60:
        row.update(
            category="resource-list-reset",
            summary="reset display resource/list state via 0x00430130; clears active slots and seeds default display ids 0x11/0x12/0x13",
            handlerVaHex="0x00407c93",
            calleeVaHex="0x00430130",
        )
    elif op == 0x61:
        row.update(
            category="resource-list-rebuild",
            summary="rebuild/refresh active display resources via 0x0043022d; creates display VM objects with runner 0x402321 from descriptor table entries",
            handlerVaHex="0x00407caa",
            calleeVaHex="0x0043022d",
        )
    elif op == 0x62:
        resource_id = raw[1]
        row.update(
            category="resource-register",
            summary=f"register/add descriptor resource id {hex8(resource_id)} via 0x00431fe8",
            handlerVaHex="0x00407cc1",
            calleeVaHex="0x00431fe8",
            resourceId=resource_id,
            resourceIdHex=hex8(resource_id),
        )
    elif op == 0x63:
        resource_id = raw[1]
        row.update(
            category="resource-unregister",
            summary=f"unregister/remove descriptor resource id {hex8(resource_id)} via 0x00432541",
            handlerVaHex="0x00407ce5",
            calleeVaHex="0x00432541",
            resourceId=resource_id,
            resourceIdHex=hex8(resource_id),
        )
    elif op == 0x64:
        mode = raw[1]
        x_word = u16(raw, 2)
        y_word = u16(raw, 4)
        target_id = raw[6]
        state_selector = raw[7]
        x_applies = mode in {1, 3, 4}
        y_applies = mode in {2, 3, 4}
        row.update(
            category="resource-placement",
            summary=f"place/update active resource mode={mode}, x={x_word}, y={y_word}, targetId={hex8(target_id)}, stateSelector={hex8(state_selector)}; x applies={x_applies}, y applies={y_applies}",
            handlerVaHex="0x00407d09",
            mode=mode,
            modeHex=hex8(mode),
            xWord=x_word,
            yWord=y_word,
            targetResourceId=target_id,
            targetResourceIdHex=hex8(target_id),
            stateSelector=state_selector,
            stateSelectorHex=hex8(state_selector),
            xApplies=x_applies,
            yApplies=y_applies,
        )
    elif op == 0xAD:
        mode = raw[1]
        mask = u32(raw, 4)
        row.update(category="actor-flags", summary=f"actor flag mode={mode}, mask={hex32(mask)}", mode=mode, mask=mask, maskHex=hex32(mask))
    elif op == 0xBC:
        mode = raw[1]
        selector = raw[2]
        divisor = raw[3]
        selector_row = BC_SELECTOR_BY_ID.get(selector, {})
        motion_kind = "direct-placement" if mode == 0 else "interpolated-child-motion"
        selector_meaning = selector_row.get("meaning", "unknown selector")
        target_policy = selector_row.get("targetRangePolicy", "unknown target range")
        formula_kind1 = selector_row.get("kind1Formula", "")
        formula_kind2 = selector_row.get("kind2Formula", "")
        if mode == 0:
            summary = f"direct placement selector={selector}: {selector_meaning}"
        else:
            summary = f"interpolated child motion mode={mode}, selector={selector}, stepDivisor={divisor}: {selector_meaning}"
        row.update(
            category="movement",
            summary=summary,
            movementMode=mode,
            motionMode=mode,
            motionKind=motion_kind,
            selector=selector,
            selectorHex=hex8(selector),
            divisor=divisor,
            stepDivisor=divisor,
            targetRangePolicy=target_policy,
            selectorMeaning=selector_meaning,
            kind1Formula=formula_kind1,
            kind2Formula=formula_kind2,
            handlerEvidence=selector_row.get("handlerEvidence", ""),
        )
    elif op == 0xC0:
        flags = raw[1]
        axes = [name for bit, name in ((0x01, "x"), (0x02, "y"), (0x04, "z")) if flags & bit]
        row.update(
            category="movement-update",
            summary=f"bounded axis movement update axes={','.join(axes) or '-'} flags={hex8(flags)}; clamps/bounces through 0x416c77/0x4173db display boundary helpers",
            handlerVaHex="0x0040ec00",
            motionKind="bounded-axis-accelerate",
            axisFlags=flags,
            axes=axes,
        )
    elif op == 0xBD:
        helper_id = u16(raw, 1) if len(raw) > 2 else None
        row.update(
            category="cleanup/helper",
            semanticCategory="helper-call",
            summary=f"helper-call id={helper_id}; handler calls 0x00411730, which dispatches by script[1] through table 0x00454c10",
            handlerVaHex="0x0040eab3",
            dispatcherVaHex="0x00411730",
            dispatchTableVaHex="0x00454c10",
            helperId=helper_id,
            helperIdHex=hex32(helper_id) if helper_id is not None else "",
            grounded=True,
        )
    elif op == 0xBF:
        row.update(
            category="wait",
            semanticCategory="display-state-barrier",
            summary="display/actor busy barrier; waits for active actor/display flags to clear before advancing",
            handlerVaHex="0x0040ecdb",
            grounded=True,
        )
    elif op == 0xC1:
        mode = raw[1]
        mask = u32(raw, 4)
        row.update(
            category="wait",
            semanticCategory="actor-flag-barrier",
            summary=f"actor flag barrier mode={mode}, mask={hex32(mask)}",
            handlerVaHex="0x0040edb5",
            mode=mode,
            modeHex=hex8(mode),
            mask=mask,
            maskHex=hex32(mask),
            grounded=True,
        )
    elif op == 0xC2:
        mode = raw[1]
        normal = raw[2]
        alt = raw[3]
        chosen = alt if side_flags & 0x10 else normal
        row.update(
            category="sound",
            summary=f"WLK id {chosen:02d} (normal id {normal:02d}, alt id {alt:02d}, mode={mode})",
            wlkNo=chosen,
            wlkFileIndex=chosen,
            normalWlkNo=normal,
            normalWlkFileIndex=normal,
            altWlkNo=alt,
            altWlkFileIndex=alt,
            mode=mode,
        )
    return row


def walk_script(data: bytes, sections: list[dict[str, Any]], start_va: int, skill_id: int, side_flags: int = 0, max_steps: int = 260) -> dict[str, Any]:
    va = start_va
    visited: set[int] = set()
    rows: list[dict[str, Any]] = []
    stop_reason = "max-steps"
    for _ in range(max_steps):
        if va in visited:
            stop_reason = f"loop at {hex32(va)}"
            break
        visited.add(va)
        raw = read_bytes(data, sections, va, 128)
        if not raw:
            stop_reason = f"unreadable {hex32(va)}"
            break
        decoded = decode_instruction(raw, skill_id, side_flags)
        length = decoded.get("length") or 0
        decoded.update(va=va, vaHex=hex32(va), bytes=raw[: max(length, 1)].hex(" "))
        rows.append(decoded)
        if length <= 0:
            stop_reason = f"unknown opcode {decoded['opcode']} at {hex32(va)}"
            break
        if decoded["category"] == "idle-end":
            stop_reason = f"idle/end marker at {hex32(va)}"
            break
        if decoded["nextMode"] == "jump":
            target = decoded.get("targetVa")
            if not isinstance(target, int):
                stop_reason = f"bad jump at {hex32(va)}"
                break
            va = target
            continue
        if decoded["nextMode"] == "branch-unresolved":
            # For now keep the linear path and expose unresolved branch in rows.
            va += length
            continue
        va += length
    frames = [
        {"vaHex": row["vaHex"], "spriteHex": row.get("spriteHex"), "frame": row.get("frame"), "gate": row.get("gate")}
        for row in rows if row.get("category") == "frame"
    ]
    sounds = [
        {
            "vaHex": row["vaHex"],
            "wlkNo": row.get("wlkNo"),
            "wlkFileIndex": row.get("wlkFileIndex"),
            "normalWlkNo": row.get("normalWlkNo"),
            "normalWlkFileIndex": row.get("normalWlkFileIndex"),
            "altWlkNo": row.get("altWlkNo"),
            "altWlkFileIndex": row.get("altWlkFileIndex"),
            "mode": row.get("mode"),
            "summary": row.get("summary"),
        }
        for row in rows if row.get("category") == "sound"
    ]
    effect_sounds = [
        {"vaHex": row["vaHex"], "wlkNo": row.get("wlkNo"), "wlkFileIndex": row.get("wlkFileIndex"), "effectArgsHex": row.get("effectArgsHex"), "summary": row.get("summary")}
        for row in rows if row.get("category") == "effect-sound"
    ]
    branches = [
        {
            "vaHex": row["vaHex"],
            "summary": row.get("summary"),
            "branchTaken": row.get("branchTaken"),
            "branchTargetVaHex": row.get("branchTargetVaHex"),
        }
        for row in rows if row.get("category") == "branch"
    ]
    repeat_loops = [
        {
            "vaHex": row["vaHex"],
            "repeatCount": row.get("repeatCount"),
            "targetVaHex": row.get("targetVaHex"),
            "summary": row.get("summary"),
        }
        for row in rows if row.get("category") == "repeat-loop"
    ]
    movements = [
        {
            "vaHex": row["vaHex"],
            "movementMode": row.get("movementMode"),
            "selector": row.get("selector"),
            "divisor": row.get("divisor"),
            "summary": row.get("summary"),
        }
        for row in rows if row.get("category") == "movement"
    ]
    return {
        "startVa": start_va,
        "startVaHex": hex32(start_va),
        "skillId": skill_id,
        "skillIdHex": hex8(skill_id),
        "stopReason": stop_reason,
        "instructionCount": len(rows),
        "frames": frames,
        "frameSequence": [row["frame"] for row in frames],
        "sounds": sounds,
        "effectSounds": effect_sounds,
        "branches": branches,
        "repeatLoops": repeat_loops,
        "movements": movements,
        "rows": rows,
    }


def scan_skill_branches(data: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    results: list[dict[str, Any]] = []
    for owner_key, (start_va, end_va) in REGIONS.items():
        start_off = va_to_offset(sections, start_va)
        end_off = va_to_offset(sections, end_va)
        if start_off is None or end_off is None:
            continue
        region = data[start_off:end_off]
        for index in range(0, max(0, len(region) - 8)):
            if region[index] != 0x13:
                continue
            mode = region[index + 1]
            if region[index + 2] != 0x59 or (mode & 0xC0) != 0xC0:
                continue
            skill_id = region[index + 3]
            target = struct.unpack_from("<I", region, index + 4)[0]
            if not (0x00400000 <= target <= 0x00600000):
                continue
            results.append({
                "ownerKey": owner_key,
                "branchVa": start_va + index,
                "branchVaHex": hex32(start_va + index),
                "modeHex": hex8(mode),
                "skillId": skill_id,
                "skillIdHex": hex8(skill_id),
                "targetVa": target,
                "targetVaHex": hex32(target),
                "op": {
                    0x00: "!=",
                    0x01: "==",
                    0x02: ">",
                    0x03: "<",
                    0x04: ">=",
                    0x05: "<=",
                    0x06: "&",
                }.get(mode & 0x0F, f"op{mode & 0x0F:x}"),
            })
    return results


def mapping_by_owner_skill(mapping: dict[str, Any]) -> dict[tuple[str, int], dict[str, Any]]:
    return {
        (row["ownerKey"], row["skillId"]): row
        for row in mapping.get("playerRows", [])
    }


def read_c_string(data: bytes, sections: list[dict[str, Any]], va: int, max_len: int = 64) -> str:
    offset = va_to_offset(sections, va)
    if offset is None:
        return ""
    end = offset
    limit = min(len(data), offset + max_len)
    while end < limit and data[end] != 0:
        end += 1
    chunk = data[offset:end]
    try:
        return chunk.decode("ascii")
    except UnicodeDecodeError:
        return ""


def display_resource_descriptor_flow(data: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    table_va = 0x00442D95
    sample_index = 0x2A
    table_offset = va_to_offset(sections, table_va)
    entries: list[dict[str, Any]] = []
    if table_offset is not None:
        for index in range(0, 0x40):
            value = u32(data, table_offset + index * 4)
            if not value or not (0x00400000 <= value < 0x00600000):
                continue
            entries.append(
                {
                    "index": index,
                    "indexHex": hex8(index),
                    "entryVaHex": hex32(table_va + index * 4),
                    "descriptorVa": value,
                    "descriptorVaHex": hex32(value),
                }
            )
    sample = next((row for row in entries if row["index"] == sample_index), entries[0] if entries else {})
    sample_va = sample.get("descriptorVa") if isinstance(sample.get("descriptorVa"), int) else None
    dwords: list[str] = []
    cns_name = ""
    if sample_va is not None:
        sample_off = va_to_offset(sections, sample_va)
        if sample_off is not None:
            dwords = [hex32(u32(data, sample_off + i * 4)) for i in range(5)]
            for probe in range(sample_off, min(sample_off + 96, len(data))):
                if 0x20 <= data[probe] <= 0x7E:
                    text = read_c_string(data, sections, sample_va + (probe - sample_off), 64)
                    if text.endswith(".cns"):
                        cns_name = text
                        break
    return {
        "dispatchPointerTableVaHex": "0x00442d95",
        "relatedPointerTableVaHex": "0x00442da1",
        "activeResourceCountGlobal": "0x004576e8",
        "activeResourceIdsGlobal": "0x004576e9",
        "activeDescriptorObjectTable": "0x0059db30",
        "displayObjectTable": "0x0059dd70",
        "runnerVaHex": "0x00402321",
        "summary": "0x62/0x63/0x61 routines index 0x00442d95 with active resource ids, create display VM objects through 0x435b5b(runner=0x402321), copy descriptor state through 0x402360, and then set the object +0x40 cursor from descriptor +0x08.",
        "sample": {
            "indexHex": sample.get("indexHex", ""),
            "entryVaHex": sample.get("entryVaHex", ""),
            "descriptorVaHex": sample.get("descriptorVaHex", ""),
            "firstDwords": dwords,
            "cnsName": cns_name,
            "note": "The descriptor begins with pointers/fields and an embedded CNS filename, so it is not just a flat frame sequence.",
        },
        "entries": entries[:24],
        "entryCountInFirst0x40": len(entries),
    }


def display_table_start(data: bytes, sections: list[dict[str, Any]], owner_key: str, phase: int | None) -> tuple[int | None, int | None]:
    base = DISPLAY_TABLE_BASES.get(owner_key)
    if base is None or phase is None:
        return None, None
    table_va = base + phase * 4
    offset = va_to_offset(sections, table_va)
    if offset is None:
        return table_va, None
    return table_va, u32(data, offset)


def entry_start_pre_effect_audit(
    data: bytes,
    sections: list[dict[str, Any]],
    starts: list[dict[str, Any]],
    lookbehind: int = 128,
) -> dict[str, Any]:
    rows: list[dict[str, Any]] = []
    seen: set[tuple[str, int, int]] = set()
    for item in starts:
        start_va = item.get("startVa")
        skill_id = item.get("skillId")
        if not isinstance(start_va, int) or not isinstance(skill_id, int):
            continue
        key = (str(item.get("ownerKey") or ""), skill_id, start_va)
        if key in seen:
            continue
        seen.add(key)
        for va in range(start_va - lookbehind, start_va, 4):
            raw = read_bytes(data, sections, va, 4)
            if len(raw) < 4 or raw[:3] != b"\x24\x01\x00":
                continue
            walk = walk_script(data, sections, va, skill_id, max_steps=100)
            reaches_start = any(row.get("va") == start_va for row in walk.get("rows", []))
            rows.append(
                {
                    "ownerKey": item.get("ownerKey") or "",
                    "ownerName": item.get("ownerName") or "",
                    "familyName": item.get("familyName") or "",
                    "skillIdHex": item.get("skillIdHex") or hex8(skill_id),
                    "skillName": item.get("skillName") or "",
                    "entryStartVaHex": hex32(start_va),
                    "preEffectVaHex": hex32(va),
                    "preEffectWlkNo": raw[3],
                    "preEffectWlkLabel": f"WLK id {raw[3]:02d}",
                    "walkFromPreEffectReachesEntryStart": reaches_start,
                    "walkFromPreEffectStopReason": walk.get("stopReason"),
                    "classification": "start-may-be-too-late" if reaches_start else "previous-block-or-unrelated",
                }
            )
    clear = [row for row in rows if row["walkFromPreEffectReachesEntryStart"]]
    return {
        "lookbehindBytes": lookbehind,
        "method": "For each decoded skill start, scan 4-byte aligned bytes before start for 0x24 01 00 <wlk>. Walk from that pre-effect opcode using the same skill id; if the walk reaches the chosen start, the start is likely too late.",
        "rowCount": len(rows),
        "clearCandidateCount": len(clear),
        "status": "pass" if not clear else "has-start-candidates",
        "rows": rows,
    }


def choose_display_start(
    data: bytes,
    sections: list[dict[str, Any]],
    owner_key: str,
    phase: int | None,
    fallback_start_va: int | None = None,
    prefer_fallback: bool = False,
) -> tuple[int | None, int | None, int | None, str]:
    table_va, table_start_va = display_table_start(data, sections, owner_key, phase)
    if prefer_fallback and isinstance(fallback_start_va, int):
        return fallback_start_va, table_va, table_start_va, "entry-spec-family-start"
    if isinstance(table_start_va, int):
        return table_start_va, table_va, table_start_va, "display-table-phase-pointer"
    if isinstance(fallback_start_va, int):
        return fallback_start_va, table_va, table_start_va, "entry-spec-fallback"
    return None, table_va, table_start_va, "display-table-phase-pointer-missing"


DISPLAY_START_OVERRIDES = {
    # The Rinshan display phase table has duplicate early entries:
    #   phase 0x0b/0x0c -> 0x004db064 (claw frames)
    # but the following EXE-local frame scripts are the actual visible
    # high/middle/low kick scripts.  The player action payload/name table is
    # still authoritative for the action id; this override only selects the
    # actor display VM start used by the browser timeline.
    ("rinshan", 0x02): {
        "startVa": 0x004DB0B8,
        "confidence": "display-start-corrected-basic-action",
        "note": "하이킥은 btl_rs EXE rect #10/#11 high-kick script at 0x004db0b8; phase-table 0x0c duplicates 손톱공격.",
    },
    ("rinshan", 0x03): {
        "startVa": 0x004DB0FC,
        "confidence": "display-start-corrected-basic-action",
        "note": "미들킥은 btl_rs EXE rect #12/#13 side-kick script at 0x004db0fc.",
    },
    ("rinshan", 0x04): {
        "startVa": 0x004DB140,
        "confidence": "display-start-corrected-basic-action",
        "note": "로 킥은 btl_rs EXE rect #14/#15 low-kick script at 0x004db140.",
    },
    ("rinshan", 0x05): {
        "startVa": 0x004DAF28,
        "confidence": "display-start-corrected-support-action",
        "note": "도발은 자기 상태 변경 payload(targetScope=1, family 0x2e)라 skillId+0x0a 공격 phase 0x0f를 타면 로 킥이 나온다. actor+0x60 direct status phase 0x06 및 btl_rs #51..#54 도발 자세 script 0x004daf28을 표시 시작점으로 쓴다.",
    },
    ("rinshan", 0x06): {
        "startVa": 0x004DB1CC,
        "confidence": "display-start-corrected-weapon-basic-family",
        "note": "꼬챙이 꿰기 1번째 장비 변형은 phase table 0x10 -> 0x004db194를 그대로 쓰면 도발 tail과 같은 #4/#5/#4 보조 fragment만 나온다. 같은 이름/장비 기본기 family인 0x07/0x08이 phase 0x11/0x12에서 공유하는 0x004db1cc가 btl_rs #20..#23, helper 110, result WLK를 가진 실제 공격 본문이다.",
    },
    ("rinshan", 0x09): {
        "startVa": 0x004DB224,
        "confidence": "display-start-corrected-skill-family",
        "note": "안면백조권 1단계는 phase table 0x13 -> 0x004db1cc를 그대로 쓰면 꼬챙이 꿰기형 btl_rs #20..#23 script가 나온다. EXE branch family 0x004db224는 skillId 0x09..0x0c를 모두 분기하며 0x09도 3타 claw cluster를 생성하므로 이 family start를 쓴다.",
    },
}

DISPLAY_START_INVALIDATIONS = {
}


def build() -> dict[str, Any]:
    data = EXE.read_bytes()
    sections = read_sections(data)
    mapping = json.loads(MAPPING_JSON.read_text(encoding="utf-8"))
    by_skill = mapping_by_owner_skill(mapping)
    decoded_rows: list[dict[str, Any]] = []
    covered_keys: set[tuple[str, int]] = set()
    for spec in ENTRY_SPECS:
        for skill_id in spec.skill_ids:
            covered_keys.add((spec.owner_key, skill_id))
            mapped = by_skill.get((spec.owner_key, skill_id), {})
            phase = mapped.get("phase")
            start_va, table_va, table_start_va, start_source = choose_display_start(
                data,
                sections,
                spec.owner_key,
                phase,
                spec.start_va,
                prefer_fallback=spec.prefer_start_va,
            )
            override = DISPLAY_START_OVERRIDES.get((spec.owner_key, skill_id))
            invalidation = DISPLAY_START_INVALIDATIONS.get((spec.owner_key, skill_id))
            confidence = spec.confidence
            note = spec.note
            if override:
                start_va = override["startVa"]
                start_source = "display-start-override"
                confidence = override.get("confidence", confidence)
                note = override.get("note", note)
            walk = walk_script(data, sections, start_va, skill_id) if isinstance(start_va, int) else {
                "startVa": None,
                "startVaHex": "",
                "stopReason": "missing display table pointer and entry spec fallback",
                "instructionCount": 0,
                "frames": [],
                "frameSequence": [],
                "sounds": [],
                "effectSounds": [],
                "branches": [],
                "repeatLoops": [],
                "movements": [],
                "rows": [],
            }
            decoded_rows.append({
                "ownerKey": spec.owner_key,
                "ownerName": spec.owner_name,
                "familyName": spec.family_name,
                "skillId": skill_id,
                "skillIdHex": hex8(skill_id),
                "skillName": mapped.get("name") or "",
                "phaseHex": mapped.get("phaseHex") or "",
                "payloadVaHex": mapped.get("payloadVaHex") or "",
                "mpCost": mapped.get("mpCost"),
                "effectCount": mapped.get("effectCount"),
                "spriteHex": spec.sprite_hex,
                "entryStartVaHex": hex32(start_va),
                "entrySpecStartVaHex": hex32(spec.start_va),
                "displayTableBaseVaHex": hex32(DISPLAY_TABLE_BASES.get(spec.owner_key)),
                "displayTableEntryVaHex": hex32(table_va),
                "displayTableStartVaHex": hex32(table_start_va),
                "startSource": start_source,
                "confidence": confidence,
                "note": note,
                "displayStartOverride": bool(override),
                "displayStartOverrideNote": (override or {}).get("note", ""),
                "displayStartInvalidated": bool(invalidation),
                "displayStartInvalidationNote": (invalidation or {}).get("note", ""),
                **walk,
            })
    display_pointer_rows: list[dict[str, Any]] = []
    for mapped in mapping.get("playerRows", []):
        owner_key = mapped.get("ownerKey")
        skill_id = mapped.get("skillId")
        phase = mapped.get("phase")
        start_va, table_va, table_start_va, start_source = choose_display_start(data, sections, owner_key, phase)
        override = DISPLAY_START_OVERRIDES.get((owner_key, skill_id))
        invalidation = DISPLAY_START_INVALIDATIONS.get((owner_key, skill_id))
        if override:
            start_va = override["startVa"]
            start_source = "display-start-override"
        pointer_row = {
            "ownerKey": owner_key,
            "ownerName": mapped.get("ownerName") or "",
            "skillId": skill_id,
            "skillIdHex": mapped.get("skillIdHex") or hex8(skill_id),
            "skillName": mapped.get("name") or "",
            "phase": phase,
            "phaseHex": mapped.get("phaseHex") or hex8(phase),
            "displayTableBaseVaHex": hex32(DISPLAY_TABLE_BASES.get(owner_key)),
            "displayTableEntryVaHex": hex32(table_va),
            "displayStartVaHex": hex32(table_start_va),
            "chosenStartVaHex": hex32(start_va),
            "startSource": start_source,
            "displayStartOverride": bool(override),
            "displayStartOverrideNote": (override or {}).get("note", ""),
            "displayStartInvalidated": bool(invalidation),
            "displayStartInvalidationNote": (invalidation or {}).get("note", ""),
            "coveredByEntrySpec": (owner_key, skill_id) in covered_keys,
        }
        display_pointer_rows.append(pointer_row)
        if pointer_row["coveredByEntrySpec"]:
            continue
        if not isinstance(start_va, int):
            decoded_rows.append({
                "ownerKey": owner_key,
                "ownerName": mapped.get("ownerName") or "",
                "familyName": "display-table fallback",
                "skillId": skill_id,
                "skillIdHex": mapped.get("skillIdHex") or hex8(skill_id),
                "skillName": mapped.get("name") or "",
                "phaseHex": mapped.get("phaseHex") or "",
                "payloadVaHex": mapped.get("payloadVaHex") or "",
                "mpCost": mapped.get("mpCost"),
                "effectCount": mapped.get("effectCount"),
                "spriteHex": OWNER_SPRITES.get(owner_key, ""),
                "entryStartVaHex": "",
                "entrySpecStartVaHex": "",
                "displayTableBaseVaHex": hex32(DISPLAY_TABLE_BASES.get(owner_key)),
                "displayTableEntryVaHex": hex32(table_va),
                "displayTableStartVaHex": hex32(table_start_va),
                "startSource": start_source,
                "confidence": "display-table-phase-pointer-missing",
                "note": "phase-indexed display VM pointer table entry could not be read.",
                "startVa": None,
                "startVaHex": "",
                "stopReason": "missing display table pointer",
                "instructionCount": 0,
                "frames": [],
                "frameSequence": [],
                "sounds": [],
                "effectSounds": [],
                "branches": [],
                "repeatLoops": [],
                "movements": [],
                "rows": [],
            })
            continue
        walk = walk_script(data, sections, start_va, skill_id)
        decoded_rows.append({
            "ownerKey": owner_key,
            "ownerName": mapped.get("ownerName") or "",
            "familyName": "display-table fallback",
            "skillId": skill_id,
            "skillIdHex": mapped.get("skillIdHex") or hex8(skill_id),
            "skillName": mapped.get("name") or "",
            "phaseHex": mapped.get("phaseHex") or "",
            "payloadVaHex": mapped.get("payloadVaHex") or "",
            "mpCost": mapped.get("mpCost"),
            "effectCount": mapped.get("effectCount"),
            "spriteHex": OWNER_SPRITES.get(owner_key, ""),
            "entryStartVaHex": hex32(start_va),
            "entrySpecStartVaHex": "",
            "displayTableBaseVaHex": hex32(DISPLAY_TABLE_BASES.get(owner_key)),
            "displayTableEntryVaHex": hex32(table_va),
            "displayTableStartVaHex": hex32(table_start_va),
            "startSource": start_source,
            "confidence": (override or invalidation or {}).get("confidence", "display-table-phase-pointer"),
            "note": (override or invalidation or {}).get(
                "note",
                "Fallback/payload-only row promoted by the per-actor display VM pointer table indexed by phase = skillId + 0x0a.",
            ),
            "displayStartInvalidated": bool(invalidation),
            **walk,
        })
    pre_effect_audit = entry_start_pre_effect_audit(data, sections, decoded_rows)
    return {
        "version": 1,
        "kind": "hwanse-battle-display-vm-static-decode",
        "source": "Hwanse2.exe",
        "status": "static-vm-walk-for-promoted-and-phase-table-player-actions",
        "opcodeLengthRules": [{"opcode": hex8(op), "rule": rule} for op, rule in sorted(KNOWN_OPCODE_LENGTHS.items())],
        "displayTableBases": [
            {
                "ownerKey": owner_key,
                "baseVaHex": hex32(base),
                "indexRule": "table[phase], phase = skillId + 0x0a",
                "spriteHex": OWNER_SPRITES.get(owner_key, ""),
            }
            for owner_key, base in DISPLAY_TABLE_BASES.items()
        ],
        "entrySpecs": [
            {
                "ownerKey": spec.owner_key,
                "ownerName": spec.owner_name,
                "familyName": spec.family_name,
                "spriteHex": spec.sprite_hex,
                "startVaHex": hex32(spec.start_va),
                "skillIds": [hex8(value) for value in spec.skill_ids],
                "confidence": spec.confidence,
                "note": spec.note,
            }
            for spec in ENTRY_SPECS
        ],
        "entryStartPreEffectAudit": pre_effect_audit,
        "displayPointerRows": display_pointer_rows,
        "displayVmRunner": DISPLAY_VM_RUNNER_EVIDENCE,
        "displayMotionOpcodeConsumers": DISPLAY_MOTION_CONSUMERS,
        "displayResourceOpcodeFlow": DISPLAY_RESOURCE_OPCODE_FLOW,
        "displayResourceDescriptorFlow": display_resource_descriptor_flow(data, sections),
        "movementOpcode0xbc": {
            "handlerVaHex": "0x0040e216",
            "dispatchTableVaHex": "0x00440538",
            "opcodeTableEntryVaHex": "0x00440828",
            "handlerPointerVaHex": "0x0040e216",
            "scriptByte1": "0 means write current display object +0x1c/+0x20 directly; nonzero creates a child display VM object and writes motion deltas.",
            "scriptByte2": "position selector. Selectors 1..4 resolve a target slot range through 0x00433c99/0x00433dc0 before applying formulas.",
            "scriptByte3": "motion step divisor/count. The handler divides deltaX/deltaY by this byte and stores the child motion step.",
            "targetRangeHandlers": [
                {
                    "handlerVaHex": "0x00433c99",
                    "role": "target range start",
                    "note": "uses current battle-order selector 0x59e300/0x59e2b0, actor kind +0x04, target mode +0x67 low nibble, and falls back to actor +0x61.",
                },
                {
                    "handlerVaHex": "0x00433dc0",
                    "role": "target range end",
                    "note": "same context as 0x00433c99, usually start+1 or team/range end depending on target mode.",
                },
            ],
            "selectorFormulas": BC_SELECTOR_FORMULAS,
        },
        "decodedRows": decoded_rows,
        "skillBranchScanRows": scan_skill_branches(data, sections),
        "notes": [
            "This is static bytecode walking, not runtime sampling.",
            f"Entry start pre-effect audit status: {pre_effect_audit['status']} ({pre_effect_audit['clearCandidateCount']} clear candidates after current corrections).",
            "Promoted rows are the player skill families whose VM stream reaches an idle/end marker with the currently decoded opcode set.",
            "Fallback/payload-only player rows are now looked up through per-actor display VM pointer tables indexed by phase = skillId + 0x0a.",
            "0xbc handler is statically grounded at 0x0040e216. byte1=0 performs direct +0x1c/+0x20 placement, byte1>0 allocates a child display VM object and stores target +0x80/+0x84 plus per-step delta +0x74/+0x78.",
            "0x08 is a variable-length display-object init list. It writes byte/word/dword fields until an 0xff terminator; dword +0x28 is a packed sprite/frame selector.",
            "0x0a is a branch table. It reads a byte from the display object at script byte1 offset, clamps it to script byte2 count, and jumps to one of the following dword pointers.",
            "0x20 binds a resource/context pointer into object +0x64, sets +0x62=1, and marks the display object with flag 0x04000000.",
            "0x2b resolves an effect/resource handle by calling 0x427730 with the script word operand and stores the returned handle in display object +0x58.",
            "0xbc byte2 is a position selector. selector 0 returns to the actor base position, 1..4 are target-relative placements, 5 is a screen-size fixed placement, and 6..7 attach child objects to the parent display object.",
            "0xbc byte3 is a motion step divisor/count. The handler divides target-current delta by byte3 for child motion; it is not an arbitrary speed label.",
            "0x1e/0x2c/0xc0 consume the target/velocity fields that prior setup opcodes write. 0xc0 is the bounded variant and calls display-boundary helpers.",
            "0x2d/0x2e form a separate trigonometric motion pair: 0x2e computes angle fields, 0x2d applies sine/cosine-like offsets.",
            "0x60..0x64 are descriptor/resource-list opcodes. They create and update display VM objects from the 0x00442d95 descriptor pointer table; they are not simple frame opcodes.",
            "0x06 is grounded as a repeat-loop instruction at handler 0x004024cc. It stores the loop target in display object +0x3c and the repeat counter in +0x60; downstream timeline builders expand the loop body where needed.",
            "0x4b is decoded as a 4-byte display-list cleanup: it walks a list slot and frees objects whose kind/priority mask matches the operand.",
            "0x1f is grounded as child/parent display-object linkage: current +0xa4 receives the object in current +0x58, and that linked object +0xa0 points back to current.",
            "0x42 is grounded as parent battle actor binding: it writes display object +0xa8 from battle actor table 0x0059db30, using either current object +0xf2 or an explicit script actor slot.",
            "0x25 is grounded as a submode dispatch. The observed 0xf1 form in 비기·맹호유성각 calls 0x0042976b, which scans the 16-entry global effect slot table at 0x58d618..0x58d622, clears active flags, and invokes each slot object's vtable update/free callbacks.",
            "The frame sequence here is based on 0x21 frame-write instructions. Runtime samples can show the previous frame at a sound/damage cursor, so the runtime trace and static write stream are offset by one cursor step in places.",
            "Speed-scaling skills are visible in movement/gate fields: 아타호 맹호비상각 uses gate/divisor 32,24,16,8 by skill level, and 스마슈 쾌진격 uses movement divisor 20,16,12,8.",
            "아타호 비기·맹호유성각 is not a normal direct-hit actor motion. The main actor VM starts WLK/effect control and spawns child VM 0x004d7490, matching the observed full-screen flying/meteor-style effect.",
        ],
    }


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# Battle Display VM Static Decode",
        "",
        f"- status: `{report['status']}`",
        "- scope: opening-observed player skill families first",
        "",
        "## Promoted Families",
        "",
        "| actor | family | start | skill ids | confidence |",
        "| --- | --- | --- | --- | --- |",
    ]
    for spec in report["entrySpecs"]:
        lines.append(f"| {spec['ownerName']} | {spec['familyName']} | `{spec['startVaHex']}` | {', '.join('`'+x+'`' for x in spec['skillIds'])} | `{spec['confidence']}` |")
    lines += [
        "",
        "## 0xbc Movement Opcode",
        "",
        f"- handler: `{report['movementOpcode0xbc']['handlerVaHex']}`",
        f"- byte1: {report['movementOpcode0xbc']['scriptByte1']}",
        f"- byte2: {report['movementOpcode0xbc']['scriptByte2']}",
        f"- byte3: {report['movementOpcode0xbc']['scriptByte3']}",
        "",
        "| selector | target range | kind 1 formula | kind 2 formula | meaning | evidence |",
        "| ---: | --- | --- | --- | --- | --- |",
    ]
    for selector_row in report["movementOpcode0xbc"]["selectorFormulas"]:
        lines.append(
            "| "
            + " | ".join(
                [
                    f"`{selector_row['selectorHex']}`",
                    selector_row["targetRangePolicy"],
                    selector_row["kind1Formula"],
                    selector_row["kind2Formula"],
                    selector_row["meaning"],
                    selector_row["handlerEvidence"],
                ]
            )
            + " |"
        )
    lines += [
        "",
        "## Display VM Runner",
        "",
        f"- runner: `{report['displayVmRunner']['runnerVaHex']}`",
        f"- dispatch table: `{report['displayVmRunner']['dispatchTableVaHex']}`",
        f"- cursor field: `{report['displayVmRunner']['cursorField']}`",
        f"- summary: {report['displayVmRunner']['summary']}",
        "",
        "## Motion Opcode Consumers",
        "",
        "| opcode | handler | length | role | fields | summary |",
        "| --- | --- | ---: | --- | --- | --- |",
    ]
    for row in report["displayMotionOpcodeConsumers"]:
        lines.append(f"| `{row['opcodeHex']}` | `{row['handlerVaHex']}` | {row['length']} | {row['role']} | {row['fields']} | {row['summary']} |")
    lines += [
        "",
        "## Resource/Descriptor Opcode Flow",
        "",
        "| opcode | handler | callee | length | role | summary |",
        "| --- | --- | --- | ---: | --- | --- |",
    ]
    for row in report["displayResourceOpcodeFlow"]:
        lines.append(f"| `{row['opcodeHex']}` | `{row['handlerVaHex']}` | `{row.get('calleeVaHex') or '-'}` | {row['length']} | {row['role']} | {row['summary']} |")
    descriptor = report["displayResourceDescriptorFlow"]
    sample = descriptor["sample"]
    lines += [
        "",
        "## Descriptor Table Sample",
        "",
        f"- descriptor pointer table: `{descriptor['dispatchPointerTableVaHex']}`",
        f"- active resource count: `{descriptor['activeResourceCountGlobal']}`",
        f"- sample: index `{sample['indexHex']}`, entry `{sample['entryVaHex']}`, descriptor `{sample['descriptorVaHex']}`, CNS `{sample.get('cnsName') or '-'}`",
        f"- first dwords: {', '.join('`'+x+'`' for x in sample.get('firstDwords', []))}",
        f"- note: {sample.get('note') or ''}",
    ]
    lines += [
        "",
        "## Decoded Frame/Sound Summary",
        "",
        "| actor | skill | skill id | phase | start | frames | 0x24 WLK | 0xc2 WLK | movement | repeat | stop |",
        "| --- | --- | ---: | ---: | --- | --- | --- | --- | --- | --- | --- |",
    ]
    for row in report["decodedRows"]:
        frames = ", ".join(str(value) for value in row["frameSequence"])
        effect_sounds = ", ".join(f"WLK id {int(sound['wlkNo']):02d}" for sound in row["effectSounds"])
        sounds = ", ".join(f"WLK id {int(sound['wlkNo']):02d}" for sound in row["sounds"])
        movements = ", ".join(
            f"{movement.get('motionKind') or 'movement'}/s{movement.get('selector')}/d{movement.get('stepDivisor', movement.get('divisor'))}"
            for movement in row.get("movements", [])
        )
        repeats = ", ".join(f"{loop['repeatCount']}@{loop['targetVaHex']}" for loop in row.get("repeatLoops", []))
        lines.append(f"| {row['ownerName']} | {row['skillName']} | `{row['skillIdHex']}` | `{row['phaseHex']}` | `{row['entryStartVaHex']}` | {frames} | {effect_sounds} | {sounds} | {movements} | {repeats} | {row['stopReason']} |")
    lines += ["", "## Notes", ""]
    lines.extend(f"- {note}" for note in report["notes"])
    lines.append("")
    return "\n".join(lines)


def html_page(report: dict[str, Any]) -> str:
    def esc(value: Any) -> str:
        return html.escape(str(value if value is not None else ""))

    def wlk_label(value: Any) -> str:
        return f"WLK id {int(value):02d}"

    movement = report["movementOpcode0xbc"]
    formula_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['selectorHex'])}</code></td>"
        f"<td>{esc(row['targetRangePolicy'])}</td>"
        f"<td>{esc(row['kind1Formula'])}</td>"
        f"<td>{esc(row['kind2Formula'])}</td>"
        f"<td>{esc(row['meaning'])}</td>"
        f"<td>{esc(row['handlerEvidence'])}</td>"
        "</tr>"
        for row in movement["selectorFormulas"]
    )
    runner = report["displayVmRunner"]
    runner_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['handlerVaHex'])}</code></td>"
        f"<td>{esc(row['role'])}</td>"
        f"<td>{esc(row['summary'])}</td>"
        "</tr>"
        for row in runner.get("handlers", [])
    )
    motion_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['opcodeHex'])}</code></td>"
        f"<td><code>{esc(row['handlerVaHex'])}</code></td>"
        f"<td>{esc(row['length'])}</td>"
        f"<td>{esc(row['role'])}</td>"
        f"<td>{esc(row['fields'])}</td>"
        f"<td>{esc(row['summary'])}</td>"
        "</tr>"
        for row in report["displayMotionOpcodeConsumers"]
    )
    resource_rows = "".join(
        "<tr>"
        f"<td><code>{esc(row['opcodeHex'])}</code></td>"
        f"<td><code>{esc(row['handlerVaHex'])}</code></td>"
        f"<td><code>{esc(row.get('calleeVaHex') or '-')}</code></td>"
        f"<td>{esc(row['length'])}</td>"
        f"<td>{esc(row['role'])}</td>"
        f"<td>{esc(row['summary'])}</td>"
        "</tr>"
        for row in report["displayResourceOpcodeFlow"]
    )
    descriptor = report["displayResourceDescriptorFlow"]
    sample = descriptor["sample"]
    summary_rows = "".join(
        (
        "<tr>"
        f"<td>{esc(row['ownerName'])}</td>"
        f"<td>{esc(row['skillName'])}</td>"
        f"<td><code>{esc(row['skillIdHex'])}</code></td>"
        f"<td><code>{esc(row['phaseHex'])}</code></td>"
        f"<td><code>{esc(row['entryStartVaHex'])}</code></td>"
        f"<td>{esc(', '.join(str(value) for value in row['frameSequence']))}</td>"
        f"<td>{esc(', '.join(wlk_label(sound['wlkNo']) for sound in row['effectSounds']))}</td>"
        f"<td>{esc(', '.join(wlk_label(sound['wlkNo']) for sound in row['sounds']))}</td>"
        f"<td>{esc(', '.join(str(movement.get('motionKind') or 'movement') + '/s' + str(movement.get('selector')) + '/d' + str(movement.get('stepDivisor', movement.get('divisor'))) for movement in row.get('movements', [])))}</td>"
        f"<td>{esc(', '.join(str(loop.get('repeatCount')) + '@' + str(loop.get('targetVaHex')) for loop in row.get('repeatLoops', [])))}</td>"
        f"<td>{esc(row['stopReason'])}</td>"
        "</tr>"
        )
        for row in report["decodedRows"]
    )
    branch_rows = "".join(
        "<tr>"
        f"<td>{esc(row['ownerKey'])}</td>"
        f"<td><code>{esc(row['branchVaHex'])}</code></td>"
        f"<td>{esc(row['op'])}</td>"
        f"<td><code>{esc(row['skillIdHex'])}</code></td>"
        f"<td><code>{esc(row['targetVaHex'])}</code></td>"
        "</tr>"
        for row in report["skillBranchScanRows"]
    )
    detail_blocks = []
    for row in report["decodedRows"]:
        instr_rows = "".join(
            "<tr>"
            f"<td><code>{esc(instr['vaHex'])}</code></td>"
            f"<td><code>{esc(instr['opcode'])}</code></td>"
            f"<td>{esc(instr['category'])}</td>"
            f"<td>{esc(instr['length'])}</td>"
            f"<td>{esc(instr['summary'])}</td>"
            "</tr>"
            for instr in row["rows"]
        )
        detail_blocks.append(
            f"<details><summary>{esc(row['ownerName'])} · {esc(row['skillName'])} · {esc(row['skillIdHex'])}</summary>"
            f"<table><thead><tr><th>VA</th><th>op</th><th>kind</th><th>len</th><th>summary</th></tr></thead><tbody>{instr_rows}</tbody></table>"
            "</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>Battle Display VM Static Decode</title>
  <style>
    body {{ margin: 20px; background: #101114; color: #f1f3f5; font-family: system-ui, sans-serif; }}
    a {{ color: #9ecbff; }} code {{ color: #ffd37a; }}
    table {{ width: 100%; border-collapse: collapse; margin: 14px 0 24px; }}
    th, td {{ border: 1px solid #30343d; padding: 6px 8px; font-size: 12px; vertical-align: top; }}
    th {{ background: #1a1d24; color: #bac2cf; position: sticky; top: 0; }}
    details {{ margin: 12px 0; border: 1px solid #30343d; border-radius: 8px; padding: 8px; background: #151821; }}
    summary {{ cursor: pointer; color: #ffd37a; font-weight: 700; }}
  </style>
</head>
<body>
  <h1>Battle Display VM Static Decode</h1>
  <p><a href="../web/index.html">홈</a> · <a href="../web/battle_simulator.html">전투 기술 실행</a> · <a href="battle_display_vm_static_decode.json">JSON</a> · <a href="battle_display_vm_static_decode.md">MD</a></p>
  <p>Status: <code>{esc(report['status'])}</code></p>
  <h2>0xbc Movement Opcode</h2>
  <p>handler <code>{esc(movement['handlerVaHex'])}</code>. {esc(movement['scriptByte1'])} {esc(movement['scriptByte2'])} {esc(movement['scriptByte3'])}</p>
  <table><thead><tr><th>selector</th><th>target range</th><th>kind 1 formula</th><th>kind 2 formula</th><th>meaning</th><th>evidence</th></tr></thead><tbody>{formula_rows}</tbody></table>
  <h2>Display VM Runner</h2>
  <p>runner <code>{esc(runner['runnerVaHex'])}</code>, dispatch table <code>{esc(runner['dispatchTableVaHex'])}</code>, cursor <code>{esc(runner['cursorField'])}</code>.</p>
  <p>{esc(runner['summary'])}</p>
  <table><thead><tr><th>handler</th><th>role</th><th>summary</th></tr></thead><tbody>{runner_rows}</tbody></table>
  <h2>Motion Opcode Consumers</h2>
  <table><thead><tr><th>op</th><th>handler</th><th>len</th><th>role</th><th>fields</th><th>summary</th></tr></thead><tbody>{motion_rows}</tbody></table>
  <h2>Resource/Descriptor Opcode Flow</h2>
  <table><thead><tr><th>op</th><th>handler</th><th>callee</th><th>len</th><th>role</th><th>summary</th></tr></thead><tbody>{resource_rows}</tbody></table>
  <h2>Descriptor Table Sample</h2>
  <p>table <code>{esc(descriptor['dispatchPointerTableVaHex'])}</code>, active count <code>{esc(descriptor['activeResourceCountGlobal'])}</code>, active object table <code>{esc(descriptor['activeDescriptorObjectTable'])}</code>.</p>
  <p>sample index <code>{esc(sample['indexHex'])}</code>, entry <code>{esc(sample['entryVaHex'])}</code>, descriptor <code>{esc(sample['descriptorVaHex'])}</code>, CNS <code>{esc(sample.get('cnsName') or '-')}</code>.</p>
  <p>first dwords: {esc(', '.join(sample.get('firstDwords', [])))}</p>
  <h2>Decoded Frame/Sound Summary</h2>
  <table><thead><tr><th>actor</th><th>skill</th><th>skill id</th><th>phase</th><th>start</th><th>frames</th><th>0x24 WLK</th><th>0xc2 WLK</th><th>movement</th><th>repeat</th><th>stop</th></tr></thead><tbody>{summary_rows}</tbody></table>
  <h2>Instruction Details</h2>
  {''.join(detail_blocks)}
  <h2>Skill Branch Scan Evidence</h2>
  <table><thead><tr><th>owner</th><th>branch VA</th><th>op</th><th>skill id</th><th>target</th></tr></thead><tbody>{branch_rows}</tbody></table>
</body>
</html>
"""


def main() -> None:
    report = build()
    OUT.mkdir(parents=True, exist_ok=True)
    (OUT / "battle_display_vm_static_decode.json").write_text(json.dumps(report, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
    (OUT / "battle_display_vm_static_decode.md").write_text(markdown(report), encoding="utf-8")
    (OUT / "battle_display_vm_static_decode.html").write_text(html_page(report), encoding="utf-8")
    print(f"wrote {OUT / 'battle_display_vm_static_decode.json'}")
    print(f"wrote {OUT / 'battle_display_vm_static_decode.md'}")
    print(f"wrote {OUT / 'battle_display_vm_static_decode.html'}")


if __name__ == "__main__":
    main()
