#!/usr/bin/env python3
"""Build a focused proof report for Ataho's 맹호비상각 dash/dust behavior.

맹호비상각 is intentionally different from the helper-driven effects such as
호격권 or 폭전축.  The promoted player action rows do not call 0xbd helpers.
Instead, the visible speed change and the higher-level dust cue are encoded in
the 0xbc movement opcode: mode/divisor changes with proficiency.
"""

from __future__ import annotations

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

from build_battle_display_vm_static_decode import EXE, read_bytes, read_sections, walk_script


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


def load_json(name: str) -> dict[str, Any]:
    return json.loads((OUT / name).read_text(encoding="utf-8"))


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


def compact(values: list[Any] | tuple[Any, ...], limit: int = 12) -> str:
    items = [str(value) for value in values if value not in (None, "")]
    if not items:
        return "-"
    if len(items) > limit:
        items = items[:limit] + [f"... +{len(items) - limit}"]
    return ", ".join(items)


def sprite_frame_label(frame: dict[str, Any]) -> str:
    return f"{frame.get('spriteHex')}:{frame.get('frame')}"


def load_rect_scan() -> dict[str, Any]:
    path = OUT / "cns_frame_rect_exe_scan.json"
    return json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}


def effect_rect_frames(frame_numbers: list[int]) -> list[dict[str, Any]]:
    scan = load_rect_scan()
    btl_efc = next((row for row in scan.get("rows") or [] if row.get("asset") == "btl_efc"), {})
    table = btl_efc.get("bestTable") or {}
    rects = table.get("rects") or []
    out: list[dict[str, Any]] = []
    for frame in frame_numbers:
        rect = rects[frame] if 0 <= frame < len(rects) else None
        out.append(
            {
                "asset": "btl_efc",
                "cns": "btl_efc.cns",
                "spriteHex": "0x1a",
                "frame": frame,
                "rect": rect,
                "rectTableVaHex": table.get("tableStartVaHex"),
                "rectTableFrameCount": table.get("frameCount"),
            }
        )
    return out


def words_at(va: int, count: int) -> list[int]:
    data = EXE.read_bytes()
    sections = read_sections(data)
    raw = read_bytes(data, sections, va, count * 2)
    if len(raw) != count * 2:
        return []
    return list(struct.unpack_from(f"<{count}H", raw, 0))


def rng_trig_evidence() -> dict[str, Any]:
    quarter_wave_start = words_at(0x004CC300, 12)
    quarter_wave_tail = words_at(0x004CC300 + (1024 - 8) * 2, 8)
    return {
        "randomHelper": {
            "helperVaHex": "0x00427730",
            "seedGlobalVaHex": "0x004aaafc",
            "lcgMultiplierHex": "0x41c64e6d",
            "lcgIncrementHex": "0x00003039",
            "returnFormula": "range16 == 0 ? 0 : (((seed * 0x41c64e6d + 0x3039) >> 16) & 0xffff) % range16",
            "hishokakuUses": [
                {
                    "branchVaHex": "0x004b5844/0x004b5a18",
                    "rangeHex": "0x2000",
                    "targetField": "child +0x58",
                    "meaning": "dust scatter phase seed before the angle remap",
                },
                {
                    "branchVaHex": "0x004b5868/0x004b5a3c",
                    "rangeHex": "0x0040",
                    "targetField": "child +0x58, then +0x20 into amplitude",
                    "meaning": "dust scatter radius/amplitude, effective integer range 0x20..0x5f",
                },
            ],
        },
        "trigMotionOpcode": {
            "opcode": "0x2d",
            "handlerVaHex": "0x00405a09",
            "relativeGroup": "flags & 0xf8 == 0x00",
            "absoluteGroup": "flags & 0xf8 == 0x08",
            "relativeFormula": [
                "if flags&0x01: display.+0x1c += trigX(+0x8c) * signed(+0x92)",
                "if flags&0x02: display.+0x20 -= trigY(+0x8e) * signed(+0x94)",
                "if flags&0x04: display.+0x24 += trigY(+0x90) * signed(+0x96)",
            ],
            "absoluteFormula": [
                "if flags&0x01: display.+0x1c = +0x80 + trigX(+0x8c) * signed(+0x92)",
                "if flags&0x02: display.+0x20 = +0x84 - trigY(+0x8e) * signed(+0x94)",
                "if flags&0x04: display.+0x24 = +0x88 + trigY(+0x90) * signed(+0x96)",
            ],
            "scaleNote": "trig helper returns fixed16; 0x2d stores fixed16 dword positions. The hishokaku branch divides temporary x/y by 16 after 0x2d.",
        },
        "trigHelpers": {
            "trigXHelperVaHex": "0x00428070",
            "trigYHelperVaHex": "0x0042819b",
            "phasePreprocess": "phase16 += 8; tableIndex = phase16 >> 4; quadrant = tableIndex & 0x0c00",
            "cleanQuarterWaveTableVaHex": "0x004cc300",
            "cleanQuarterWaveTableEntries": 1024,
            "cleanQuarterWaveStartSample": quarter_wave_start,
            "cleanQuarterWaveTailSample": quarter_wave_tail,
            "tableCaveat": (
                "0x004cc300 is the clean 0..65535 quarter-wave table. Adjacent addresses referenced by "
                "some disassembly branches overlap non-table script/data, so exact full quadrant table "
                "mapping remains review-only; hishokaku dust only needs the helper call semantics and "
                "the static 0x2d multiply/add flow."
            ),
        },
        "hishokakuScatterFormula": {
            "status": "static-exe-grounded-symbolic",
            "angleFormula": [
                "rAngle = rand16_mod(0x2000)",
                "angleTemp = rAngle - 0x1c00",
                "if child.+0x90 >= 0x8000: angleTemp += 0x1800",
                "child.+0x8c = child.+0x90 + angleTemp",
                "child.+0x8e = child.+0x8c",
            ],
            "amplitudeFormula": [
                "rAmp = rand16_mod(0x0040)",
                "child.+0x92 = rAmp + 0x20",
                "child.+0x94 = child.+0x92",
            ],
            "offsetFormula": [
                "tempX = trigX(child.+0x8c) * child.+0x92",
                "tempY = -trigY(child.+0x8e) * child.+0x94",
                "scatterX = tempX / 16",
                "scatterY = tempY / 16",
            ],
            "amplitudeIntegerRange": [0x20, 0x5F],
            "exactSampleCaveat": "The formula is grounded. Exact runtime pixels still depend on current seed and child.+0x90 phase.",
        },
    }


def actor_gate_labels(row: dict[str, Any]) -> list[str]:
    out: list[str] = []
    for item in row.get("expandedFrameGateSequence") or row.get("actorExpandedFrameGateSequence") or []:
        out.append(f"t{item.get('tick')}:#{item.get('frame')}@{item.get('gate')}")
    return out


def row_movements(row: dict[str, Any]) -> list[dict[str, Any]]:
    return [
        {
            "tick": event.get("tick"),
            "vaHex": event.get("vaHex"),
            "opcode": event.get("opcode"),
            "movementMode": event.get("movementMode"),
            "selector": event.get("selector"),
            "stepDivisor": event.get("divisor"),
            "summary": event.get("summary"),
        }
        for event in row.get("timelineEvents") or []
        if event.get("kind") == "movement"
    ]


def motion_row(movements: list[dict[str, Any]]) -> dict[str, Any] | None:
    for movement in movements:
        if int(movement.get("movementMode") or 0) > 0 and int(movement.get("selector") or 0) == 1:
            return movement
    return None


def reset_row(movements: list[dict[str, Any]]) -> dict[str, Any] | None:
    for movement in movements:
        if int(movement.get("movementMode") or 0) == 0 and int(movement.get("selector") or -1) == 0:
            return movement
    return None


def dust_class(mode: int, level: int) -> tuple[str, str, bool, int]:
    if mode <= 1:
        return (
            "none",
            "1~2단은 0xbc selector=1 이동만 있고 별도 먼지 mode로 승격되지 않는다.",
            False,
            0,
        )
    if mode == 2:
        return (
            "child-motion-dust",
            "3단은 0xbc movementMode=2로 바뀌며 motion child descriptor branch가 0x004b53c0 child effect를 생성한다. branch 초입의 +0x92/+0x96, mod 2 gate 때문에 preview는 이동 업데이트 2틱마다 1개 burst로 둔다.",
            True,
            1,
        )
    return (
        "child-motion-dust-fast",
        "4단은 movementMode=3 및 divisor=8이다. 3단과 같은 0x004b53c0 child effect 계열을 더 빠른 dash branch에서 생성하고, repeat-loop count=2로 업데이트마다 3개 burst를 둔다.",
        True,
        3,
    )


def dust_segments(start_tick: int, gate: int, mode: int) -> list[dict[str, Any]]:
    if mode <= 1:
        return []
    cadence = 2 if mode == 2 else 1
    burst_count = 1 if mode == 2 else 3
    life = 4
    segments: list[dict[str, Any]] = []
    particle_index = 0
    for update_tick in range(0, max(1, gate), cadence):
        progress = update_tick / max(1, gate)
        for burst_index in range(burst_count):
            # Legacy fallback only. The primary preview uses effectFrameStream.
            x = round(-70 + 140 * progress + (burst_index - (burst_count - 1) / 2) * 8)
            y = 20 + ((burst_index % 3) - 1) * 5
            segments.append(
                {
                    "tick": start_tick + update_tick,
                    "gate": life,
                    "offsetX": x,
                    "offsetY": y,
                    "particleIndex": particle_index,
                    "burstIndex": burst_index,
                    "movementUpdateTick": update_tick,
                    "movementUpdateCadence": cadence,
                    "dustMode": mode,
                    "source": "0xbc movement-mode legacy fallback projection",
                }
            )
            particle_index += 1
    return segments


def branch_spawn_evidence(mode: int) -> dict[str, Any]:
    if mode <= 1:
        return {"spawnCount": 0, "repeatCount": 0, "burstCount": 0, "rows": []}
    data = EXE.read_bytes()
    sections = read_sections(data)
    branch_va = 0x004B57E4 if mode == 2 else 0x004B5970
    walk = walk_script(data, sections, branch_va, 0x10, max_steps=120)
    rows = [
        {
            "vaHex": row.get("vaHex"),
            "category": row.get("category"),
            "targetVaHex": row.get("targetVaHex"),
            "repeatCount": row.get("repeatCount"),
            "summary": row.get("summary"),
        }
        for row in walk.get("rows") or []
        if row.get("category") in {"spawn-child-vm", "repeat-loop"}
    ]
    spawn_count = sum(1 for row in rows if row.get("category") == "spawn-child-vm" and row.get("targetVaHex") == "0x004b53c0")
    repeat_count = sum(int(row.get("repeatCount") or 0) for row in rows if row.get("category") == "repeat-loop" and row.get("targetVaHex"))
    return {
        "branchVaHex": f"0x{branch_va:08x}",
        "spawnCount": spawn_count,
        "repeatCount": repeat_count,
        "burstCount": spawn_count + repeat_count,
        "rows": rows,
    }


def branch_transform_evidence(mode: int) -> dict[str, Any]:
    if mode <= 1:
        return {
            "status": "not-applicable",
            "summary": "mode 1 uses the simple 0x004b5778 branch, so no child dust transform is emitted.",
            "rows": [],
        }
    branch_va = 0x004B57E4 if mode == 2 else 0x004B5970
    if mode == 2:
        path_step = "uses parent +0x74/+0x78 path offset after one scatter calculation"
        repeat = "no repeat-loop; preview uses one child burst on each accepted movement update"
        key_rows = [
            ("0x004b57e4..0x004b57f0", "+0x92 = +0x96 then opKind=5/mod imm=2; branch to simple movement if gate test fails"),
            ("0x004b57fc", "+0x80 = actor +0x1c"),
            ("0x004b5800..0x004b5814", "+0x80 += (placement(e6) - 4) * 4px"),
            ("0x004b5818", "+0x84 = actor +0x20"),
            ("0x004b581c..0x004b5830", "+0x84 += (placement(e7) - 2) * 8px"),
            ("0x004b5834", "spawn child VM 0x004b53c0"),
            ("0x004b5844..0x004b5880", "0x2b random angle/amplitude fields for child scatter"),
            ("0x004b5894..0x004b58e0", "0x2d x/y trig scatter, then divide x/y by 16"),
            ("0x004b58f0..0x004b5908", "add +0x74/+0x78 path offset; copy child initial x/y and scatter velocity fields; set child +0x14=0x0110"),
            ("0x004b5910..0x004b5954", "remove size correction and restore parent coordinates"),
        ]
    else:
        path_step = "copies parent +0x74/+0x78 into +0x68/+0x6c and divides both by 2 before spawning"
        repeat = "0x004b5ae4 repeat-loop count=2; original spawn + two repeats = three child bursts per movement update"
        key_rows = [
            ("0x004b5970", "+0x80 = actor +0x1c"),
            ("0x004b5974..0x004b5988", "+0x80 += (placement(e6) - 4) * 4px"),
            ("0x004b598c", "+0x84 = actor +0x20"),
            ("0x004b5990..0x004b59a4", "+0x84 += (placement(e7) - 2) * 8px"),
            ("0x004b59a8..0x004b59f0", "+0x68/+0x6c = (+0x74/+0x78) / 2"),
            ("0x004b5a08", "spawn child VM 0x004b53c0"),
            ("0x004b5a18..0x004b5a54", "0x2b random angle/amplitude fields for child scatter"),
            ("0x004b5a68..0x004b5ab4", "0x2d x/y trig scatter, then divide x/y by 16"),
            ("0x004b5ac4..0x004b5adc", "add cumulative half-step path offset; copy child initial x/y and scatter velocity fields; set child +0x14=0x0110"),
            ("0x004b5ae4", "repeat-loop count=2 back to 0x004b5a08"),
            ("0x004b5aec..0x004b5b30", "remove size correction and restore parent coordinates"),
        ]
    return {
        "status": "symbolic-exe-grounded",
        "branchVaHex": f"0x{branch_va:08x}",
        "summary": (
            "child dust placement is EXE-grounded as a formula: size-corrected actor position "
            "+ path step offset + randomized trig scatter. Exact sampled RNG/trig pixel values are "
            "not fixed in this static report."
        ),
        "coordinateFormula": [
            "baseX = actor.x + (placement(e6) - 4) * 4px",
            "baseY = actor.y + (placement(e7) - 2) * 8px",
            "mode2 accepted update: childBase = base + parentPathStep",
            "mode3 accepted update burst N: childBase = base + parentPathStep/2 * (N+1), N=0..2",
            "rAngle = rand16_mod(0x2000); angleTemp = rAngle - 0x1c00; if +0x90 >= 0x8000 then angleTemp += 0x1800",
            "angle = +0x90 + angleTemp; amplitude = rand16_mod(0x0040) + 0x20",
            "scatterX = trigX(angle) * amplitude / 16; scatterY = -trigY(angle) * amplitude / 16",
            "0x12 e0 copies child +0x1c/+0x20 = childBase and child +0x74/+0x78 = scatter; child 0x1e then applies local motion/lifetime",
        ],
        "pathStep": path_step,
        "repeat": repeat,
        "rows": [{"vaHex": va, "meaning": meaning} for va, meaning in key_rows],
    }


def child_effect_frames(start_tick: int, mode: int, burst_count: int = 1, dash_gate: int = 1) -> list[dict[str, Any]]:
    if mode <= 1:
        return []
    context_frames = frame_stream_rows(
        walk_script(EXE.read_bytes(), read_sections(EXE.read_bytes()), 0x004B543C, 0x10, max_steps=50)
    )
    frames: list[dict[str, Any]] = []
    cadence = 2 if mode == 2 else 1
    per_update_bursts = 1 if mode == 2 else max(1, burst_count)
    update_ticks = list(range(0, max(1, dash_gate), cadence))
    for update_index, update_tick in enumerate(update_ticks):
        for burst_index in range(per_update_bursts):
            offset = 0
            for item in context_frames:
                gate = int(item.get("gate") or 1)
                relative_tick = update_tick + offset
                path_step_multiplier = 1.0 if mode == 2 else (burst_index + 1) / 2
                frames.append(
                    {
                        "actorTick": start_tick + relative_tick,
                        "relativeTick": relative_tick,
                        "spawnRelativeTick": update_tick,
                        "gate": gate,
                        "sourceGate": gate,
                        "asset": item.get("asset") or "btl_efc",
                        "cns": "btl_efc.cns",
                        "spriteHex": item.get("spriteHex") or "0x1a",
                        "frame": item.get("frame"),
                        "rect": item.get("rect"),
                        "sourceVaHex": item.get("vaHex"),
                        "source": "0xbc movement child update -> child VM context 0x004b543c",
                        "effectAnimationClass": "hishokaku-dash-dust-framescript",
                        "animationClass": "hishokaku-dash-dust-framescript",
                        "visualBehaviorClass": "child-motion-dust-framescript",
                        "label": f"update {update_index + 1} burst {burst_index + 1} btl_efc#{item.get('frame')}@{gate}",
                        "burstIndex": burst_index,
                        "burstInUpdate": burst_index,
                        "movementUpdateIndex": update_index,
                        "movementUpdateTick": update_tick,
                        "movementUpdateCadence": cadence,
                        "pathProgress": update_tick / max(1, dash_gate),
                        "pathStepMultiplier": path_step_multiplier,
                        "visibleCandidate": True,
                        "hiddenCandidate": False,
                        "visibilityBasis": "0x004b53c0 opcode 0x20 binds context 0x004b543c; context emits btl_efc frames 60..64 gate 4",
                        "transformScope": {
                            "scope": "movement child update tick",
                            "status": "symbolic-exe-grounded",
                            "coordinateSummary": (
                                "base=(actor movement position + size correction), mode2 pathStep=+0x74/+0x78, "
                                "mode3 pathStep=(+0x74/+0x78)/2 repeated 3 times, scatter copied to child +0x74/+0x78"
                            ),
                        },
                        "offsetStatus": "formula-projected",
                        "previewBasis": "movement update cadence and burst count are EXE-grounded; exact RNG/trig samples remain preview-only",
                        "offsetX": 0,
                        "offsetY": 0,
                        "motionX": 0,
                        "motionY": 0,
                    }
                )
                offset += gate
    return sorted(
        frames,
        key=lambda frame: (
            int(frame.get("actorTick") or 0),
            int(frame.get("movementUpdateIndex") or 0),
            int(frame.get("burstIndex") or 0),
            int(frame.get("frame") or 0),
        ),
    )


def first_rows(walk: dict[str, Any], limit: int = 18) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for row in (walk.get("rows") or [])[:limit]:
        rows.append(
            {
                "vaHex": row.get("vaHex"),
                "opcode": row.get("opcode"),
                "category": row.get("category"),
                "summary": row.get("summary"),
                "branchTargetsVaHex": row.get("branchTargetsVaHex"),
                "initSpriteFrames": row.get("initSpriteFrames"),
                "resourceIdHex": row.get("resourceIdHex"),
                "contextHex": row.get("contextHex"),
                "targetVaHex": row.get("targetVaHex"),
            }
        )
    return rows


def frame_stream_rows(walk: dict[str, Any]) -> list[dict[str, Any]]:
    frames: list[dict[str, Any]] = []
    for row in walk.get("rows") or []:
        if row.get("category") != "frame":
            continue
        frame = row.get("frame")
        rect_info = effect_rect_frames([int(frame)])[0] if isinstance(frame, int) else {}
        frames.append(
            {
                "vaHex": row.get("vaHex"),
                "spriteHex": row.get("spriteHex"),
                "asset": "btl_efc" if str(row.get("spriteHex")).lower() == "0x1a" else "",
                "frame": frame,
                "gate": row.get("gate"),
                "rect": rect_info.get("rect"),
            }
        )
    return frames


def descriptor_evidence() -> dict[str, Any]:
    data = EXE.read_bytes()
    sections = read_sections(data)
    motion_root = walk_script(data, sections, 0x004B573C, 0x10, max_steps=80)
    mode2 = walk_script(data, sections, 0x004B57E4, 0x10, max_steps=90)
    mode3 = walk_script(data, sections, 0x004B5970, 0x10, max_steps=90)
    spawned = walk_script(data, sections, 0x004B53C0, 0x10, max_steps=50)
    spawned_context = walk_script(data, sections, 0x004B543C, 0x10, max_steps=50)
    mode4 = walk_script(data, sections, 0x004B5B4C, 0x10, max_steps=90)
    spawned_alt = walk_script(data, sections, 0x004B5138, 0x10, max_steps=50)
    spawned_alt_context = walk_script(data, sections, 0x004B517C, 0x10, max_steps=50)
    child_rows = spawned.get("rows") or []
    child_motion_rows = [
        {
            "vaHex": row.get("vaHex"),
            "opcode": row.get("opcode"),
            "category": row.get("category"),
            "summary": row.get("summary"),
            "destHex": row.get("destHex"),
            "sourceHex": row.get("sourceHex"),
            "targetVaHex": row.get("targetVaHex"),
            "rightImmediateHex": row.get("rightImmediateHex"),
            "immHex": row.get("immHex"),
        }
        for row in child_rows
        if row.get("vaHex") in {
            "0x004b5400",
            "0x004b5404",
            "0x004b5408",
            "0x004b540c",
            "0x004b5418",
            "0x004b5420",
            "0x004b5428",
        }
    ]

    branch_table = next((row for row in motion_root.get("rows") or [] if row.get("category") == "branch-table"), {})
    def spawned_targets(walk: dict[str, Any]) -> list[str]:
        return [
            str(row.get("targetVaHex"))
            for row in walk.get("rows") or []
            if row.get("category") == "spawn-child-vm"
        ]
    def resource_ids(walk: dict[str, Any]) -> list[str]:
        return [
            str(row.get("resourceIdHex"))
            for row in walk.get("rows") or []
            if row.get("category") == "effect-resource-handle"
        ]
    return {
        "rootDescriptorVaHex": "0x004b573c",
        "rootStopReason": motion_root.get("stopReason"),
        "branchTable": {
            "vaHex": branch_table.get("vaHex"),
            "selectorFieldHex": branch_table.get("selectorFieldHex"),
            "branchTargetsVaHex": branch_table.get("branchTargetsVaHex"),
            "summary": branch_table.get("summary"),
        },
        "modeBranchMap": [
            {"mode": 0, "branchTargetVaHex": "0x004b5778", "role": "simple/no extra effect"},
            {"mode": 1, "branchTargetVaHex": "0x004b5778", "role": "simple/no extra effect"},
            {"mode": 2, "branchTargetVaHex": "0x004b57e4", "role": "spawns 0x004b53c0 child effect"},
            {"mode": 3, "branchTargetVaHex": "0x004b5970", "role": "spawns 0x004b53c0 child effect after halving motion deltas"},
            {"mode": 4, "branchTargetVaHex": "0x004b5b4c", "role": "spawns alternate 0x004b5138 child effect; not used by 맹호비상각"},
            {"mode": 5, "branchTargetVaHex": "0x004b5d14", "role": "multi-child/loop branch; not used by 맹호비상각"},
        ],
        "mode2Branch": {
            "startVaHex": "0x004b57e4",
            "spawnTargetsVaHex": spawned_targets(mode2),
            "resourceIdsHex": resource_ids(mode2),
            "rows": first_rows(mode2, 24),
        },
        "mode3Branch": {
            "startVaHex": "0x004b5970",
            "spawnTargetsVaHex": spawned_targets(mode3),
            "resourceIdsHex": resource_ids(mode3),
            "rows": first_rows(mode3, 24),
        },
        "sharedChildEffect": {
            "startVaHex": "0x004b53c0",
            "stopReason": spawned.get("stopReason"),
            "initSpriteFrames": [
                frame
                for row in spawned.get("rows") or []
                for frame in (row.get("initSpriteFrames") or [])
            ],
            "contextVaHex": "0x004b543c",
            "contextStopReason": spawned_context.get("stopReason"),
            "contextFrameStream": frame_stream_rows(spawned_context),
            "rows": first_rows(spawned, 14),
            "childMotionEvidence": {
                "scriptVaHex": "0x004b53c0",
                "movementOpcodeVaHex": "0x004b5400",
                "movementKind": "0x1e simple x/y acceleration-toward-target update",
                "contextVaHex": "0x004b543c",
                "thresholdHex": "0x00080000",
                "thresholdPixels": 8,
                "summary": (
                    "After binding context 0x004b543c, the child does not stay static. "
                    "Opcode 0x1e updates target/acceleration/velocity/position fields, then "
                    "+0x88 accumulates +0x80. When +0x88 reaches 0x00080000 (8px fixed16), "
                    "the script subtracts that threshold, writes child +e7=1, and loops. "
                    "This proves the dust frame stream has child-local motion/lifetime behavior; "
                    "the exact runtime RNG seed and full integration state are still preview-only."
                ),
                "rows": child_motion_rows,
            },
            "interpretation": (
                "mode 2/3 branches both spawn this child. Its init list sets sprite 0x1a frame 60 "
                "and size fields e6=4/e7=3. Opcode 0x20 binds context 0x004b543c, whose frameScript "
                "emits sprite 0x1a frames 60..64 with gate 4. The child then runs 0x1e movement-update, "
                "so previewing every context frame at one fixed burst coordinate under-represents the real dust."
            ),
        },
        "alternateNonHishokakuChild": {
            "mode4BranchStartVaHex": "0x004b5b4c",
            "mode4SpawnTargetsVaHex": spawned_targets(mode4),
            "startVaHex": "0x004b5138",
            "initSpriteFrames": [
                frame
                for row in spawned_alt.get("rows") or []
                for frame in (row.get("initSpriteFrames") or [])
            ],
            "contextVaHex": "0x004b517c",
            "contextFrameStream": frame_stream_rows(spawned_alt_context),
            "interpretation": "This is an adjacent mode 4 child effect, not selected by 맹호비상각's mode 1/2/3 rows.",
        },
    }


def build() -> dict[str, Any]:
    complete = load_json("battle_skill_complete_pattern_review.json")
    display_decode = load_json("battle_display_vm_static_decode.json")

    source_rows = [
        row
        for row in complete.get("rows") or []
        if row.get("ownerName") == "아타호" and row.get("skillName") == "맹호비상각"
    ]
    source_rows.sort(key=lambda row: int(str(row.get("skillIdHex") or "0").replace("0x", ""), 16))

    rows: list[dict[str, Any]] = []
    for index, row in enumerate(source_rows, start=1):
        movements = row_movements(row)
        motion = motion_row(movements) or {}
        reset = reset_row(movements) or {}
        mode = int(motion.get("movementMode") or 0)
        divisor = int(motion.get("stepDivisor") or 0)
        start_tick = int(motion.get("tick") or 0)
        actor_gate = 0
        for frame in row.get("expandedFrameGateSequence") or []:
            if frame.get("frame") == 7:
                actor_gate = int(frame.get("gate") or 0)
                break
        cls, interpretation, has_dust, particle_count = dust_class(mode, index)
        spawn_evidence = branch_spawn_evidence(mode)
        transform_evidence = branch_transform_evidence(mode)
        burst_count = int(spawn_evidence.get("burstCount") or 0)
        preview_caveat = (
            "1/2단은 0xbc simple branch라 child effect frameScript가 없다."
            if mode <= 1
            else (
                "3/4단 child effect는 0x004b53c0 + context 0x004b543c에서 btl_efc#60..64@4로 해석된다. "
                "좌표는 size correction + path step + RNG/trig scatter 식까지 정적 확인됐고, "
                "정확한 난수 샘플 픽셀은 preview-only다."
            )
        )
        rows.append(
            {
                "level": row.get("levelOrFixed") or index,
                "skillIdHex": row.get("skillIdHex"),
                "mpCost": row.get("mpCost"),
                "actorFrames": actor_gate_labels(row),
                "effectWlkNos": row.get("effectWlkNos") or [],
                "resultWlkNos": row.get("resultWlkNos") or [],
                "helperIds": row.get("helperIds") or [],
                "helperFacts": row.get("helperFacts") or [],
                "movementOpcode": motion,
                "returnOpcode": reset,
                "movementMode": mode,
                "stepDivisor": divisor,
                "actorDashFrame": 7,
                "dashGate": actor_gate,
                "dashStartTick": start_tick,
                "previewStartX": -78,
                "previewMotionX": 156,
                "dustClass": cls,
                "dustFromMovementMode": has_dust,
                "dustParticleCount": particle_count,
                "dustSegments": dust_segments(start_tick, actor_gate or divisor or 1, mode),
                "effectBurstEvidence": spawn_evidence,
                "effectBurstCount": burst_count,
                "effectTransformEvidence": transform_evidence,
                "effectFrameStream": child_effect_frames(start_tick, mode, burst_count, actor_gate or divisor or 1),
                "interpretation": interpretation,
                "evidence": [
                    "skill family frame sequence is #0 -> #7 -> #0",
                    "0xbc selector=1 is target-relative approach motion",
                    f"0xbc movementMode={mode}, stepDivisor={divisor}",
                    "no 0xbd helperIds/helperFacts in promoted skill rows; mode 2/3 effects come from 0xbc child descriptor instead",
                    f"0xbc branch spawn/repeat evidence: burstCount={burst_count}",
                    "selector=0 direct placement returns actor to base position after hit/wait",
                ],
                "previewCaveat": preview_caveat,
            }
        )

    movement_opcode = display_decode.get("movementOpcode0xbc") or {}
    child_evidence = descriptor_evidence()
    return {
        "version": 1,
        "kind": "hwanse-battle-hishokaku-motion-effect-review",
        "status": "static-exe-proof-for-hishokaku-dash-and-0xbc-child-effect",
        "source": [
            "out/battle_skill_complete_pattern_review.json",
            "out/battle_display_vm_static_decode.json",
        ],
        "summary": {
            "levels": len(rows),
            "levelsWithHelper": sum(1 for row in rows if row["helperIds"]),
            "levelsWithDustProjection": sum(1 for row in rows if row["dustFromMovementMode"]),
            "levelsWithGroundedEffectFrameStream": sum(1 for row in rows if row["effectFrameStream"]),
            "effectBurstCounts": [row["effectBurstCount"] for row in rows],
            "movementModes": [row["movementMode"] for row in rows],
            "stepDivisors": [row["stepDivisor"] for row in rows],
            "childEffectDescriptorGrounded": True,
            "childEffectFramesGrounded": True,
            "childEffectTransformFormulaGrounded": True,
            "childEffectTransformExactSamplesGrounded": False,
        },
        "rngTrigEvidence": rng_trig_evidence(),
        "movementOpcode0xbc": {
            "handlerVaHex": movement_opcode.get("handlerVaHex"),
            "scriptByte1": movement_opcode.get("scriptByte1"),
            "scriptByte2": movement_opcode.get("scriptByte2"),
            "scriptByte3": movement_opcode.get("scriptByte3"),
        },
        "interpretationNotes": [
            "맹호비상각 1~4단은 actor #7 dash frame과 0xbc movement opcode로 구성된다.",
            "숙련도가 오를수록 #7 gate와 0xbc divisor가 32,24,16,8로 줄어든다. 이것이 시전 속도 증가의 EXE 근거다.",
            "1~2단은 movementMode=1로 0x004b5778 simple branch를 탄다. 3단은 mode=2로 0x004b57e4, 4단은 mode=3으로 0x004b5970 branch를 탄다.",
            "mode 2/3 branch는 모두 child display VM 0x004b53c0을 생성한다. 이 child init-list는 sprite 0x1a frame 60을 설정한다.",
            "0x004b53c0의 opcode 0x20은 context 0x004b543c를 묶고, 이 context는 sprite 0x1a의 frame 60,61,62,63,64를 gate 4로 출력한다.",
            "movement child script는 dash gate 동안 갱신되는 구조다. preview는 3단을 2틱 cadence의 1개 burst, 4단을 매 update 3개 burst로 펼친다.",
            "4단 mode=3 branch에는 0x004b5ae4 repeat-loop count=2가 있어 원래 spawn 1회에 2회 추가 반복, 총 3 burst per update로 표시한다.",
            "mode 2/3의 좌표는 base=(actor movement position + size correction), pathStep=(+0x74/+0x78 또는 그 half-step), scatter=(0x2b RNG + 0x2d trig)/16 식으로 계산된다.",
            "0x12 e0 copy rows에 따라 scatter는 child 초기 x/y가 아니라 child +0x74/+0x78 local motion 성분으로 넘겨진다.",
            "0x2b는 이 문맥에서 리소스 ID가 아니라 rand16_mod(range) 결과를 display +0x58에 쓰는 RNG opcode다.",
            "0x2d는 phase(+0x8c/+0x8e/+0x90)와 amplitude(+0x92/+0x94/+0x96)를 사용해 fixed16 삼각 이동을 x/y/z에 적용한다.",
            "sprite 0x1a는 기존 result/helper 분석과 CNS rect table에서 btl_efc.cns로 연결된다. 따라서 3단부터 흙먼지가 생긴다는 관찰은 btl_efc#60..64 이펙트 스트림과 일치한다.",
            "아직 남은 부분은 실제 런타임 난수/삼각함수 샘플을 적용한 픽셀 단위 위치 동일성이다.",
        ],
        "childDescriptorEvidence": child_evidence,
        "rows": rows,
    }


def markdown(report: dict[str, Any]) -> str:
    lines = [
        "# 맹호비상각 이동/먼지 해석",
        "",
        f"status: `{report['status']}`",
        "",
        "## 결론",
        "",
        "- 1단/2단: `0xbc mode=1 selector=1` target approach. `0x004b5778` simple branch라 별도 child effect 없음.",
        "- 3단: `0xbc mode=2 selector=1 divisor=16`. `0x004b57e4` branch에서 `0x004b53c0` child effect 생성.",
        "- 4단: `0xbc mode=3 selector=1 divisor=8`. `0x004b5970` branch에서 같은 `0x004b53c0` child effect 생성.",
        "- `0x004b53c0` init-list는 sprite `0x1a`, frame `60`, size `e6=4/e7=3`을 설정한다.",
        "- `0x004b53c0`의 opcode `0x20`은 context `0x004b543c`를 묶고, 이 context는 `btl_efc#60..64@4`를 출력한다.",
        "- child 좌표식은 EXE에서 확인됐다: `actor + size correction + path step + RNG/trig scatter`.",
        "- 남은 미확정은 난수/삼각함수 샘플까지 포함한 픽셀 단위 런타임 동일성이다.",
        "",
        "## 레벨별 증거",
        "",
        "| Lv | skill | actor | 0xbc motion | return | WLK | effect bursts | dust |",
        "|---:|---|---|---|---|---|---:|---|",
    ]
    for row in report["rows"]:
        motion = row["movementOpcode"]
        ret = row["returnOpcode"]
        lines.append(
            "| "
            + " | ".join(
                [
                    str(row["level"]),
                    f"`{row['skillIdHex']}`",
                    "`" + compact(row["actorFrames"]) + "`",
                    f"`{motion.get('vaHex', '-')}` mode={row['movementMode']} selector={motion.get('selector', '-')} div={row['stepDivisor']}",
                    f"`{ret.get('vaHex', '-')}` selector={ret.get('selector', '-')}",
                    f"effect `{compact(row['effectWlkNos'])}` / result `{compact(row['resultWlkNos'])}`",
                    str(row.get("effectBurstCount") or 0),
                    f"{row['dustClass']} ({row['dustParticleCount']})",
                ]
            )
            + " |"
        )
    lines += [
        "",
        "## 0xbc 핸들러 근거",
        "",
        f"- handler: `{report['movementOpcode0xbc'].get('handlerVaHex')}`",
        f"- byte1: {report['movementOpcode0xbc'].get('scriptByte1')}",
        f"- byte2: {report['movementOpcode0xbc'].get('scriptByte2')}",
        f"- byte3: {report['movementOpcode0xbc'].get('scriptByte3')}",
        "",
        "## child descriptor 근거",
        "",
    ]
    evidence = report["childDescriptorEvidence"]
    branch = evidence["branchTable"]
    lines += [
        f"- root descriptor: `{evidence['rootDescriptorVaHex']}`",
        f"- branch table: `{branch.get('vaHex')}` selector field `{branch.get('selectorFieldHex')}`",
        f"- targets: `{compact(branch.get('branchTargetsVaHex') or [], 16)}`",
        "",
        "| mode | branch | role |",
        "|---:|---|---|",
    ]
    for item in evidence["modeBranchMap"]:
        lines.append(f"| {item['mode']} | `{item['branchTargetVaHex']}` | {item['role']} |")
    child = evidence["sharedChildEffect"]
    context_labels = [
        f"{frame.get('asset')}#{frame.get('frame')}@{frame.get('gate')}"
        for frame in child.get("contextFrameStream") or []
    ]
    lines += [
        "",
        f"- shared child effect: `{child['startVaHex']}`",
        f"- child init sprite frames: `{compact([sprite_frame_label(frame) for frame in child.get('initSpriteFrames') or []])}`",
        f"- child context: `{child.get('contextVaHex')}`",
        f"- child context frames: `{compact(context_labels, 16)}`",
        f"- child interpretation: {child['interpretation']}",
    ]
    motion = child.get("childMotionEvidence") or {}
    if motion:
        lines += [
            f"- child motion: `{motion.get('movementOpcodeVaHex')}` {motion.get('movementKind')}",
            f"- child motion threshold: `{motion.get('thresholdHex')}` = {motion.get('thresholdPixels')}px",
            f"- child motion summary: {motion.get('summary')}",
            "",
            "| child motion VA | summary |",
            "|---|---|",
        ]
        for item in motion.get("rows") or []:
            lines.append(f"| `{item.get('vaHex')}` | {item.get('summary')} |")
    lines += [
        "",
        "## transform 근거",
        "",
    ]
    for row in report["rows"]:
        transform = row.get("effectTransformEvidence") or {}
        if transform.get("status") == "not-applicable":
            continue
        lines += [
            f"### Lv {row['level']} `{row['skillIdHex']}`",
            "",
            f"- status: `{transform.get('status')}`",
            f"- branch: `{transform.get('branchVaHex')}`",
            f"- path: {transform.get('pathStep')}",
            f"- repeat: {transform.get('repeat')}",
            "- formula:",
        ]
        for formula in transform.get("coordinateFormula") or []:
            lines.append(f"  - `{formula}`")
        lines += ["", "| VA | meaning |", "|---|---|"]
        for item in transform.get("rows") or []:
            lines.append(f"| `{item.get('vaHex')}` | {item.get('meaning')} |")
        lines.append("")
    rng = report.get("rngTrigEvidence") or {}
    random_helper = rng.get("randomHelper") or {}
    trig_opcode = rng.get("trigMotionOpcode") or {}
    trig_helpers = rng.get("trigHelpers") or {}
    scatter = rng.get("hishokakuScatterFormula") or {}
    lines += [
        "## 0x2b RNG / 0x2d trig 근거",
        "",
        f"- RNG helper: `{random_helper.get('helperVaHex')}` seed `{random_helper.get('seedGlobalVaHex')}`",
        f"- RNG formula: `{random_helper.get('returnFormula')}`",
        f"- 0x2d handler: `{trig_opcode.get('handlerVaHex')}`",
        f"- trig helpers: X `{trig_helpers.get('trigXHelperVaHex')}`, Y `{trig_helpers.get('trigYHelperVaHex')}`",
        f"- phase preprocess: `{trig_helpers.get('phasePreprocess')}`",
        f"- clean quarter-wave table `{trig_helpers.get('cleanQuarterWaveTableVaHex')}` start `{compact(trig_helpers.get('cleanQuarterWaveStartSample') or [])}` tail `{compact(trig_helpers.get('cleanQuarterWaveTailSample') or [])}`",
        f"- table caveat: {trig_helpers.get('tableCaveat')}",
        "",
        "### 맹호비상각 scatter 식",
        "",
    ]
    for formula in scatter.get("angleFormula") or []:
        lines.append(f"- `{formula}`")
    for formula in scatter.get("amplitudeFormula") or []:
        lines.append(f"- `{formula}`")
    for formula in scatter.get("offsetFormula") or []:
        lines.append(f"- `{formula}`")
    lines += [
        f"- amplitude integer range: `{compact(scatter.get('amplitudeIntegerRange') or [])}`",
        f"- caveat: {scatter.get('exactSampleCaveat')}",
        "",
    ]
    lines += [
        "## 주의",
        "",
        "- `btl_efc#60..64@4` 프레임 스트림과 transform 식은 EXE에서 확인됐다.",
        "- 브라우저 타임라인의 반복 burst 간격/좌표는 난수 샘플을 고정하지 않은 preview-only 표시다.",
    ]
    return "\n".join(lines) + "\n"


def html_page(report: dict[str, Any]) -> str:
    rows = []
    for row in report["rows"]:
        motion = row["movementOpcode"]
        transform = row.get("effectTransformEvidence") or {}
        transform_status = transform.get("status") or "-"
        rows.append(
            "<tr>"
            f"<td>{esc(row['level'])}</td>"
            f"<td><code>{esc(row['skillIdHex'])}</code></td>"
            f"<td>{esc(compact(row['actorFrames']))}</td>"
            f"<td><code>{esc(motion.get('vaHex', '-'))}</code> mode {esc(row['movementMode'])} / selector {esc(motion.get('selector', '-'))} / div {esc(row['stepDivisor'])}</td>"
            f"<td>{esc(compact(row['effectWlkNos']))} / {esc(compact(row['resultWlkNos']))}</td>"
            f"<td>{esc(row.get('effectBurstCount') or 0)}</td>"
            f"<td>{esc(row['dustClass'])}<br><small>{esc(transform_status)}</small></td>"
            f"<td>{esc(row['interpretation'])}</td>"
            "</tr>"
        )
    evidence = report["childDescriptorEvidence"]
    branch = evidence["branchTable"]
    mode_rows = []
    for item in evidence["modeBranchMap"]:
        mode_rows.append(
            "<tr>"
            f"<td>{esc(item['mode'])}</td>"
            f"<td><code>{esc(item['branchTargetVaHex'])}</code></td>"
            f"<td>{esc(item['role'])}</td>"
            "</tr>"
        )
    child = evidence["sharedChildEffect"]
    child_frames = compact([f"{frame.get('spriteHex')}:{frame.get('frame')}" for frame in child.get("initSpriteFrames") or []])
    context_frames = compact(
        [
            f"{frame.get('asset')}#{frame.get('frame')}@{frame.get('gate')}"
            for frame in child.get("contextFrameStream") or []
        ],
        16,
    )
    child_motion = child.get("childMotionEvidence") or {}
    child_motion_rows = "".join(
        "<tr>"
        f"<td><code>{esc(item.get('vaHex'))}</code></td>"
        f"<td>{esc(item.get('summary'))}</td>"
        "</tr>"
        for item in child_motion.get("rows") or []
    )
    child_motion_section = ""
    if child_motion:
        child_motion_section = (
            "<h3>child-local motion</h3>"
            f"<p><code>{esc(child_motion.get('movementOpcodeVaHex'))}</code> {esc(child_motion.get('movementKind'))}; "
            f"threshold <code>{esc(child_motion.get('thresholdHex'))}</code> = {esc(child_motion.get('thresholdPixels'))}px</p>"
            f"<p>{esc(child_motion.get('summary'))}</p>"
            "<table><thead><tr><th>VA</th><th>summary</th></tr></thead>"
            f"<tbody>{child_motion_rows}</tbody></table>"
        )
    transform_sections = []
    for row in report["rows"]:
        transform = row.get("effectTransformEvidence") or {}
        if transform.get("status") == "not-applicable":
            continue
        formula = "".join(f"<li><code>{esc(item)}</code></li>" for item in transform.get("coordinateFormula") or [])
        transform_rows = "".join(
            "<tr>"
            f"<td><code>{esc(item.get('vaHex'))}</code></td>"
            f"<td>{esc(item.get('meaning'))}</td>"
            "</tr>"
            for item in transform.get("rows") or []
        )
        transform_sections.append(
            "<section class=\"panel\">"
            f"<h2>Lv {esc(row['level'])} transform</h2>"
            f"<p><strong>status:</strong> <code>{esc(transform.get('status'))}</code>, branch <code>{esc(transform.get('branchVaHex'))}</code></p>"
            f"<p>{esc(transform.get('summary'))}</p>"
            f"<p><strong>path:</strong> {esc(transform.get('pathStep'))}</p>"
            f"<p><strong>repeat:</strong> {esc(transform.get('repeat'))}</p>"
            f"<ul>{formula}</ul>"
            "<table><thead><tr><th>VA</th><th>meaning</th></tr></thead>"
            f"<tbody>{transform_rows}</tbody></table>"
            "</section>"
        )
    rng = report.get("rngTrigEvidence") or {}
    random_helper = rng.get("randomHelper") or {}
    trig_opcode = rng.get("trigMotionOpcode") or {}
    trig_helpers = rng.get("trigHelpers") or {}
    scatter = rng.get("hishokakuScatterFormula") or {}
    random_uses = "".join(
        "<li>"
        f"<code>{esc(item.get('branchVaHex'))}</code> range <code>{esc(item.get('rangeHex'))}</code> "
        f"-> {esc(item.get('targetField'))}: {esc(item.get('meaning'))}"
        "</li>"
        for item in random_helper.get("hishokakuUses") or []
    )
    trig_rel = "".join(f"<li><code>{esc(item)}</code></li>" for item in trig_opcode.get("relativeFormula") or [])
    scatter_formula = "".join(
        f"<li><code>{esc(item)}</code></li>"
        for group in ("angleFormula", "amplitudeFormula", "offsetFormula")
        for item in (scatter.get(group) or [])
    )
    rng_trig_section = (
        "<section class=\"panel\">"
        "<h2>0x2b RNG / 0x2d trig</h2>"
        f"<p>RNG helper <code>{esc(random_helper.get('helperVaHex'))}</code>, seed <code>{esc(random_helper.get('seedGlobalVaHex'))}</code></p>"
        f"<p><code>{esc(random_helper.get('returnFormula'))}</code></p>"
        f"<ul>{random_uses}</ul>"
        f"<p>0x2d handler <code>{esc(trig_opcode.get('handlerVaHex'))}</code></p>"
        f"<ul>{trig_rel}</ul>"
        f"<p>trig X <code>{esc(trig_helpers.get('trigXHelperVaHex'))}</code>, Y <code>{esc(trig_helpers.get('trigYHelperVaHex'))}</code>; {esc(trig_helpers.get('phasePreprocess'))}</p>"
        f"<p>quarter-wave table <code>{esc(trig_helpers.get('cleanQuarterWaveTableVaHex'))}</code>: start <code>{esc(compact(trig_helpers.get('cleanQuarterWaveStartSample') or []))}</code>, tail <code>{esc(compact(trig_helpers.get('cleanQuarterWaveTailSample') or []))}</code></p>"
        f"<p>{esc(trig_helpers.get('tableCaveat'))}</p>"
        "<h3>scatter formula</h3>"
        f"<ul>{scatter_formula}</ul>"
        f"<p>amplitude range <code>{esc(compact(scatter.get('amplitudeIntegerRange') or []))}</code>. {esc(scatter.get('exactSampleCaveat'))}</p>"
        "</section>"
    )
    return f"""<!doctype html>
<html lang="ko">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>맹호비상각 이동/먼지 해석</title>
  <style>
    body {{ margin: 0; background: #f5f7fb; color: #20242b; font-family: system-ui, sans-serif; }}
    main {{ width: min(1180px, calc(100vw - 24px)); margin: 0 auto; padding: 18px 0 32px; }}
    h1 {{ margin: 0 0 8px; font-size: 25px; }}
    .panel {{ background: #fff; border: 1px solid #d8dee8; border-radius: 8px; padding: 14px; margin: 12px 0; }}
    table {{ width: 100%; border-collapse: collapse; background: #fff; }}
    th, td {{ border: 1px solid #d8dee8; padding: 8px; vertical-align: top; text-align: left; }}
    th {{ background: #eef2f7; }}
    code {{ background: #f8fafc; border: 1px solid #e0e4ec; border-radius: 4px; padding: 1px 4px; }}
    li {{ margin: 5px 0; }}
  </style>
</head>
<body>
<main>
  <h1>맹호비상각 이동/먼지 해석</h1>
  <div class="panel">
    <p><strong>status:</strong> <code>{esc(report['status'])}</code></p>
    <ul>
      {"".join(f"<li>{esc(note)}</li>" for note in report["interpretationNotes"])}
    </ul>
  </div>
  <table>
    <thead><tr><th>Lv</th><th>skill</th><th>actor</th><th>0xbc motion</th><th>WLK</th><th>bursts</th><th>dust</th><th>해석</th></tr></thead>
    <tbody>{"".join(rows)}</tbody>
  </table>
  <div class="panel">
    <h2>0xbc 핸들러</h2>
    <p>handler <code>{esc(report['movementOpcode0xbc'].get('handlerVaHex'))}</code></p>
    <p>{esc(report['movementOpcode0xbc'].get('scriptByte1'))}</p>
    <p>{esc(report['movementOpcode0xbc'].get('scriptByte2'))}</p>
    <p>{esc(report['movementOpcode0xbc'].get('scriptByte3'))}</p>
  </div>
  <div class="panel">
    <h2>child descriptor</h2>
    <p>root <code>{esc(evidence['rootDescriptorVaHex'])}</code>, branch table <code>{esc(branch.get('vaHex'))}</code>, selector field <code>{esc(branch.get('selectorFieldHex'))}</code></p>
    <p>targets <code>{esc(compact(branch.get('branchTargetsVaHex') or [], 16))}</code></p>
    <table>
      <thead><tr><th>mode</th><th>branch</th><th>role</th></tr></thead>
      <tbody>{"".join(mode_rows)}</tbody>
    </table>
    <p>shared child effect <code>{esc(child['startVaHex'])}</code>, init sprite/frame <code>{esc(child_frames)}</code></p>
    <p>context <code>{esc(child.get('contextVaHex'))}</code>, frames <code>{esc(context_frames)}</code></p>
    <p>{esc(child['interpretation'])}</p>
    {child_motion_section}
  </div>
  {"".join(transform_sections)}
  {rng_trig_section}
</main>
</body>
</html>
"""


def main() -> None:
    report = build()
    (OUT / "battle_hishokaku_effect_review.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    print("wrote out/battle_hishokaku_effect_review.json")


if __name__ == "__main__":
    main()
