#!/usr/bin/env python3
"""Build a review for the shared active descriptor stack behind menu windows.

This is a follow-up to menu_cancel_window_consumer_review.  The earlier review
grounds the ESC/X/Num0 cancel bit and the top-window region candidates.  This
one records the descriptor-stack bridge that the menu/controller opcodes use.

Important naming constraint: 0x457750/0x59db30 are not treated as a menu-only
table.  The same active descriptor machinery is reused by battle/display/save
flows, so this report calls it a shared active descriptor stack.
"""
from __future__ import annotations

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

from probe_exe_scene_tables import offset_to_va, read_sections, va_to_offset
from summarize_object_payload_442c75_callers import decode_stream


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

JSON_OUT = OUT / "menu_descriptor_stack_review.json"
HTML_OUT = WEB / "menu_descriptor_stack_review.html"
WEB_HTML_OUT = WEB / "menu_descriptor_stack_review.html"

VM_HANDLER_TABLE_VA = 0x00440538

ACTIVE_DESCRIPTOR_COUNT_VA = 0x004576E8
ACTIVE_DESCRIPTOR_ID_ORDER_VA = 0x004576E9
ACTIVE_DESCRIPTOR_ROOT_TABLE_VA = 0x00442D95
ACTIVE_DESCRIPTOR_SLOT_TABLE_VA = 0x00457750
ACTIVE_DESCRIPTOR_SLOT_STRIDE = 0xD8
ACTIVE_DESCRIPTOR_SLOT_POINTERS_VA = 0x0059DB30
ACTIVE_DESCRIPTOR_OBJECT_POINTERS_VA = 0x0059DD70
ACTIVE_DESCRIPTOR_OBJECT_INDEX_TABLE_VA = 0x00574538
ACTIVE_DESCRIPTOR_SAVED_STATE_VA = 0x00574550
ACTIVE_DESCRIPTOR_MAX_STACK = 3

ADD_DESCRIPTOR_ROUTINE_VA = 0x00431FE8
REBUILD_DESCRIPTOR_ROUTINE_VA = 0x00432323
REMOVE_DESCRIPTOR_ROUTINE_VA = 0x00432541
VM_OBJECT_CREATE_ROUTINE_VA = 0x00435B5B
VM_RUN_SCRIPT_ROUTINE_VA = 0x00402360
VM_OBJECT_DESTROY_ROUTINE_VA = 0x00435C9D
REGION_DRAW_FUNCTION_VA = 0x0041B579
REGION_RECT_TABLE_VA = 0x004548B0
REGION_RESOURCE_TABLE_VA = 0x00454B60

TOP_MENU_OBJECT_SEQUENCE_VA = 0x004DDC6C
TOP_MENU_INTERMEDIATE_RECORD_VA = 0x0047E6A0
TOP_MENU_INTERMEDIATE_SCRIPT_PTR_VA = 0x0047E6B0
TOP_MENU_PARENT_CONTEXT_SCRIPT_VA = 0x0047E624
TOP_MENU_PARENT_DYNAMIC_SELECT_VA = 0x0047E664
TOP_MENU_PARENT_DYNAMIC_CALL_VA = 0x0047E66C

SELECTED_OPCODES = {
    0x07: "create child object; stream+4 becomes child object+0x40 script pointer",
    0x28: "store/load child object through runtime object pointer array 0x59dd70",
    0x45: "draw window/region id from command byte",
    0x62: "add/materialize active descriptor id from stream+1",
    0x63: "remove/rebuild active descriptor id from stream+1",
    0x81: "select VM continuation pointer from a dword table into 0x59de30",
    0x82: "call/jump to selected VM continuation pointer 0x59de30 with return stack",
    0x86: "set runtime object-slot state flag through 0x59dd70[stream+2]",
    0x80: "bind stack slot to slot table row 0x457750 + id*0xd8",
    0x99: "rebuild stack pointer array from active id order",
    0x9F: "cancel/back controller over active descriptor cursor",
}

STRICT_INVENTORY_STACK_OPCODES = {0x60, 0x61, 0x62, 0x63, 0x80, 0x99, 0x9F}
STRICT_INVENTORY_INPUTLIKE_OPCODES = {0x1D, 0x40, 0x88, 0x9F}


def hx(value: int | None, width: int = 8) -> str:
    return "-" if value is None else f"0x{value:0{width}x}"


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


def code(value: Any) -> str:
    return f"<code>{h(value)}</code>"


def u32(exe: bytes, sections: list[dict[str, Any]], va: int) -> int | None:
    off = va_to_offset(sections, va)
    if off is None or off + 4 > len(exe):
        return None
    return struct.unpack_from("<I", exe, off)[0]


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


def byte_hex(data: bytes, max_len: int = 32) -> str:
    clipped = data[:max_len]
    text = " ".join(f"{b:02x}" for b in clipped)
    return text + (" ..." if len(data) > max_len else "")


def ascii_token_around(exe: bytes, sections: list[dict[str, Any]], va: int, radius: int = 48) -> str:
    off = va_to_offset(sections, va)
    if off is None:
        return ""
    start = off
    lower_bound = max(0, off - radius)
    while start > lower_bound and 0x20 <= exe[start - 1] <= 0x7E:
        start -= 1
    end = off
    upper_bound = min(len(exe), off + radius)
    while end < upper_bound and 0x20 <= exe[end] <= 0x7E:
        end += 1
    if end <= start:
        return ""
    try:
        return exe[start:end].decode("ascii")
    except UnicodeDecodeError:
        return ""


def is_asset_ascii_false_opcode(exe: bytes, sections: list[dict[str, Any]], va: int) -> str:
    token = ascii_token_around(exe, sections, va)
    if not token:
        return ""
    markers = (
        ".cns",
        ".mlk",
        ".wlk",
        ".wav",
        "btl_",
        "cara_",
        "map_",
        "face_",
        "logo_",
        "title",
        "compile",
        "middata",
    )
    lowered = token.lower()
    if any(marker in lowered for marker in markers):
        return token
    return ""


def monotonic_u16_table_around(exe: bytes, sections: list[dict[str, Any]], va: int) -> list[int]:
    off = va_to_offset(sections, va)
    if off is None:
        return []
    for start_delta in (0, -2, -4):
        start = off + start_delta
        if start < 0 or start + 24 > len(exe):
            continue
        values = [struct.unpack_from("<H", exe, start + index * 2)[0] for index in range(12)]
        diffs = [b - a for a, b in zip(values, values[1:])]
        smooth_steps = [diff for diff in diffs if 0x20 <= diff <= 0x100]
        if len(smooth_steps) >= 8 and values[-1] > values[0]:
            return values
    return []


def ascii_strings_from(data: bytes, limit: int = 8) -> list[str]:
    strings: list[str] = []
    for part in data.split(b"\x00"):
        if len(strings) >= limit:
            break
        if len(part) < 4:
            continue
        if all(32 <= byte < 127 for byte in part):
            strings.append(part.decode("ascii", errors="replace"))
    return strings


def text_sections(sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return [section for section in sections if section.get("name") == ".text"]


def script_data_sections(sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    return [section for section in sections if section.get("name") in {".data", ".rdata"}]


def section_name_for(sections: list[dict[str, Any]], va: int) -> str | None:
    for section in sections:
        start = int(section["va"])
        end = start + int(section["raw_size"])
        if start <= va < end:
            return str(section["name"])
    return None


def is_file_backed(sections: list[dict[str, Any]], va: int | None) -> bool:
    return va is not None and va_to_offset(sections, va) is not None


def find_call_sites(exe: bytes, sections: list[dict[str, Any]], target_va: int) -> list[int]:
    hits: list[int] = []
    for section in text_sections(sections):
        start = section["raw"]
        end = start + section["raw_size"]
        for off in range(start, max(start, end - 5)):
            if exe[off] != 0xE8:
                continue
            rel = struct.unpack_from("<i", exe, off + 1)[0]
            call_va = offset_to_va(sections, off)
            if call_va is not None and call_va + 5 + rel == target_va:
                hits.append(call_va)
    return hits


def find_dword_refs(exe: bytes, sections: list[dict[str, Any]], value: int) -> list[int]:
    needle = struct.pack("<I", value)
    hits: list[int] = []
    for section in text_sections(sections):
        start = section["raw"]
        end = start + section["raw_size"]
        pos = exe.find(needle, start, end)
        while pos != -1:
            va = offset_to_va(sections, pos)
            if va is not None:
                hits.append(va)
            pos = exe.find(needle, pos + 1, end)
    return hits


def find_dword_refs_in_sections(
    exe: bytes,
    sections_to_scan: list[dict[str, Any]],
    value: int,
) -> list[int]:
    needle = struct.pack("<I", value)
    refs: list[int] = []
    for section in sections_to_scan:
        start = section["raw"]
        end = start + section["raw_size"]
        pos = exe.find(needle, start, end)
        while pos != -1:
            refs.append(int(section["va"]) + (pos - start))
            pos = exe.find(needle, pos + 1, end)
    return refs


def find_byte_pattern_in_sections(
    exe: bytes,
    sections_to_scan: list[dict[str, Any]],
    pattern: bytes,
) -> list[int]:
    hits: list[int] = []
    for section in sections_to_scan:
        start = section["raw"]
        end = start + section["raw_size"]
        pos = exe.find(pattern, start, end)
        while pos != -1:
            hits.append(int(section["va"]) + (pos - start))
            pos = exe.find(pattern, pos + 1, end)
    return hits


def region_binding(exe: bytes, sections: list[dict[str, Any]], index: int) -> dict[str, Any] | None:
    rect_off = va_to_offset(sections, REGION_RECT_TABLE_VA + index * 16)
    if rect_off is None:
        return None
    x0, y0, x1, y1 = struct.unpack_from("<4I", exe, rect_off)
    resource = u32(exe, sections, REGION_RESOURCE_TABLE_VA + index * 4)
    return {
        "index": index,
        "rect": [x0, y0, x1, y1],
        "rectText": f"{x0},{y0} {x1 - x0}x{y1 - y0}",
        "resourceIdHex": hx(resource),
        "templateIndex": resource & 0xFFFF,
        "resourceGroupHex": hx(resource >> 16, 4),
    }


def decode_object_initializer(exe: bytes, sections: list[dict[str, Any]], va: int) -> dict[str, Any] | None:
    off = va_to_offset(sections, va)
    if off is None or off + 4 > len(exe):
        return None
    if exe[off : off + 4] != b"\x08\x00\x00\x00":
        return None
    cursor = va + 4
    writes: list[dict[str, Any]] = []
    for _ in range(64):
        sub_off = va_to_offset(sections, cursor)
        if sub_off is None or sub_off + 4 > len(exe):
            return None
        subcmd = exe[sub_off]
        if subcmd == 0xFF:
            return {"vaHex": hx(va), "length": cursor + 4 - va, "writes": writes}
        field = exe[sub_off + 1]
        if subcmd == 1:
            value = exe[sub_off + 2]
            width = 1
            cursor += 4
        elif subcmd == 2:
            value = struct.unpack_from("<H", exe, sub_off + 2)[0]
            width = 2
            cursor += 4
        elif subcmd == 3:
            value = struct.unpack_from("<I", exe, sub_off + 4)[0]
            width = 4
            cursor += 8
        else:
            return None
        writes.append(
            {
                "fieldOffset": field,
                "field": f"+0x{field:02x}",
                "width": width,
                "value": value,
                "valueHex": hx(value, 2 if width == 1 else 4 if width == 2 else 8),
            }
        )
    return None


def init_write_value(init: dict[str, Any], field_offset: int) -> int | None:
    for write in init.get("writes") or []:
        if write.get("fieldOffset") == field_offset:
            value = write.get("value")
            return value if isinstance(value, int) else None
    return None


def summarize_initializer_region(
    exe: bytes,
    sections: list[dict[str, Any]],
    init_va: int,
) -> dict[str, Any] | None:
    init = decode_object_initializer(exe, sections, init_va)
    if not init:
        return None
    packed_region = init_write_value(init, 0x28)
    x_raw = init_write_value(init, 0x1C)
    y_raw = init_write_value(init, 0x20)
    region_index = packed_region & 0xFFFF if packed_region is not None else None
    x = x_raw >> 16 if x_raw is not None else None
    y = y_raw >> 16 if y_raw is not None else None
    region = region_binding(exe, sections, region_index) if region_index is not None else None
    return {
        "initializerVaHex": hx(init_va),
        "regionIndex": region_index,
        "packedRegionHex": hx(packed_region),
        "regionGroupHex": hx(packed_region >> 16, 4) if packed_region is not None else "-",
        "x": x,
        "y": y,
        "xRawHex": hx(x_raw),
        "yRawHex": hx(y_raw),
        "region": region,
    }


def decode_top_menu_object_sequence(exe: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    commands: list[dict[str, Any]] = []
    cursor = TOP_MENU_OBJECT_SEQUENCE_VA
    end = TOP_MENU_OBJECT_SEQUENCE_VA + 0x90
    while cursor < end:
        off = va_to_offset(sections, cursor)
        if off is None:
            break
        opcode = exe[off]
        length = 4
        role = "unclassified"
        detail: dict[str, Any] = {}
        if opcode == 0x07:
            length = 8
            child_script = struct.unpack_from("<I", exe, off + 4)[0]
            role = "create child object"
            detail = {
                "childScriptVaHex": hx(child_script),
                "childScriptRefCount": len(find_dword_refs_in_sections(exe, script_data_sections(sections), child_script)),
                "initializerRegion": summarize_initializer_region(exe, sections, child_script),
            }
        elif opcode == 0x28:
            mode = exe[off + 1]
            slot = exe[off + 2]
            role = "store child object to runtime slot" if mode == 0 else "load runtime slot into context"
            detail = {"mode": mode, "slot": slot, "runtimeSlotExpression": f"0x59dd70[{slot}]"}
        elif opcode == 0x2F:
            length = 8
            payload_va = struct.unpack_from("<I", exe, off + 4)[0]
            role = "attach/display payload pointer"
            detail = {"payloadVaHex": hx(payload_va)}
        elif opcode == 0x12:
            length = 8
            role = "dword field arithmetic/write"
            detail = {
                "fieldGroupHex": hx(exe[off + 1], 2),
                "fieldOffsetHex": hx(struct.unpack_from("<H", exe, off + 2)[0], 4),
                "literalHex": hx(struct.unpack_from("<I", exe, off + 4)[0]),
            }
        elif opcode == 0x86:
            role = "set object slot state flag"
            detail = {
                "mode": exe[off + 1],
                "slot": exe[off + 2],
                "runtimeSlotExpression": f"0x59dd70[{exe[off + 2]}]",
            }
        elif opcode in {0x40, 0x10, 0x8A, 0xD5, 0x87, 0xB0, 0x01, 0x14}:
            role = {
                0x40: "input/controller mask or state bind",
                0x10: "field/control write",
                0x8A: "object/menu selector update",
                0xD5: "dispatch/helper command",
                0x87: "runtime object slot field cursor/draw helper",
                0xB0: "state/math helper",
                0x01: "small write/helper",
                0x14: "condition/helper",
            }[opcode]
            if opcode == 0x87:
                length = 8
                selector_byte = exe[off + 2]
                mode_byte = exe[off + 3]
                base_x = struct.unpack_from("<H", exe, off + 4)[0]
                base_y = struct.unpack_from("<H", exe, off + 6)[0]
                detail = {
                    "mode": exe[off + 1],
                    "selectorOffsetHex": hx(selector_byte, 2),
                    "cursorMode": mode_byte,
                    "baseX": base_x,
                    "baseY": base_y,
                    "handlerVaHex": "0x0040af7e",
                    "drawHelperVaHex": "0x00421406",
                    "sourceExpression": f"object+0xa8[0x{selector_byte:02x}]",
                }
            elif opcode in {0x01, 0x14}:
                length = 8
        else:
            role = "stop: unknown opcode for focused top-menu decoder"
            length = 4
        raw = exe[off : off + length]
        commands.append(
            {
                "va": cursor,
                "vaHex": hx(cursor),
                "opcode": opcode,
                "opcodeHex": hx(opcode, 2),
                "rawHex": raw.hex(" "),
                "length": length,
                "role": role,
                "detail": detail,
            }
        )
        if role.startswith("stop:"):
            break
        cursor += length

    pairs: list[dict[str, Any]] = []
    for index, command in enumerate(commands[:-1]):
        if command["opcode"] != 0x07:
            continue
        next_command = commands[index + 1]
        if next_command["opcode"] != 0x28 or next_command["detail"].get("mode") != 0:
            continue
        init = command["detail"].get("initializerRegion") or {}
        pairs.append(
            {
                "createCommandVaHex": command["vaHex"],
                "storeCommandVaHex": next_command["vaHex"],
                "childScriptVaHex": command["detail"].get("childScriptVaHex"),
                "runtimeSlot": next_command["detail"].get("slot"),
                "regionIndex": init.get("regionIndex"),
                "position": [init.get("x"), init.get("y")],
                "regionRectText": ((init.get("region") or {}).get("rectText") if init else None),
                "templateIndex": ((init.get("region") or {}).get("templateIndex") if init else None),
            }
        )

    direct_refs = find_dword_refs_in_sections(
        exe,
        [section for section in sections if section.get("name") in {".text", ".data", ".rdata"}],
        TOP_MENU_OBJECT_SEQUENCE_VA,
    )
    intermediate_refs = find_dword_refs_in_sections(
        exe,
        [section for section in sections if section.get("name") in {".text", ".data", ".rdata"}],
        TOP_MENU_INTERMEDIATE_RECORD_VA,
    )
    parent_refs = find_dword_refs_in_sections(
        exe,
        [section for section in sections if section.get("name") in {".text", ".data", ".rdata"}],
        TOP_MENU_PARENT_CONTEXT_SCRIPT_VA,
    )
    parent_select_off = va_to_offset(sections, TOP_MENU_PARENT_DYNAMIC_SELECT_VA)
    parent_call_off = va_to_offset(sections, TOP_MENU_PARENT_DYNAMIC_CALL_VA)
    dynamic_table_va = None
    dynamic_selected_va = None
    if parent_select_off is not None:
        dynamic_table_va = struct.unpack_from("<I", exe, parent_select_off + 4)[0]
        table_off = va_to_offset(sections, dynamic_table_va)
        if table_off is not None:
            dynamic_index = exe[parent_select_off + 1]
            dynamic_selected_va = struct.unpack_from("<I", exe, table_off + dynamic_index * 4)[0]
    dynamic_table_refs = (
        find_dword_refs_in_sections(
            exe,
            [section for section in sections if section.get("name") in {".text", ".data", ".rdata"}],
            dynamic_table_va,
        )
        if dynamic_table_va is not None
        else []
    )
    table_bytes = read_bytes(exe, sections, dynamic_table_va or 0, 0x80)
    dynamic_selected_decode = (
        decode_stream(exe, sections, dynamic_selected_va, max_commands=80, max_bytes=0x240)
        if dynamic_selected_va is not None and is_file_backed(sections, dynamic_selected_va)
        else None
    )
    return {
        "sequenceStartVaHex": hx(TOP_MENU_OBJECT_SEQUENCE_VA),
        "sequenceDirectRefsHex": [hx(ref) for ref in direct_refs],
        "intermediateRecordVaHex": hx(TOP_MENU_INTERMEDIATE_RECORD_VA),
        "intermediateScriptPointerVaHex": hx(TOP_MENU_INTERMEDIATE_SCRIPT_PTR_VA),
        "intermediateRecordDirectRefsHex": [hx(ref) for ref in intermediate_refs],
        "intermediateRecordBytes": byte_hex(read_bytes(exe, sections, TOP_MENU_INTERMEDIATE_RECORD_VA, 0x40), 0x40),
        "parentContext": {
            "scriptVaHex": hx(TOP_MENU_PARENT_CONTEXT_SCRIPT_VA),
            "scriptDirectRefsHex": [hx(ref) for ref in parent_refs],
            "dynamicSelectVaHex": hx(TOP_MENU_PARENT_DYNAMIC_SELECT_VA),
            "dynamicCallVaHex": hx(TOP_MENU_PARENT_DYNAMIC_CALL_VA),
            "dynamicTableVaHex": hx(dynamic_table_va),
            "dynamicTableRefsHex": [hx(ref) for ref in dynamic_table_refs],
            "dynamicSelectedScriptVaHex": hx(dynamic_selected_va),
            "dynamicTableBytes": byte_hex(table_bytes, 0x80),
            "dynamicTableAsciiStrings": ascii_strings_from(table_bytes),
            "status": "context overlaps title/intro resource flow; not promoted as field ESC opener",
            "dynamicSelectedDecode": dynamic_selected_decode,
        },
        "commands": commands,
        "createStorePairs": pairs,
        "groundedTopMenuSlots": [
            row
            for row in pairs
            if row["runtimeSlot"] in {7, 8, 9} and row["regionIndex"] in {6, 3, 4}
        ],
        "status": "object construction grounded; upstream trigger of intermediate record still pending",
    }


def build_context_region_initializer_rows(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for section in script_data_sections(sections):
        start = section["raw"]
        end = start + section["raw_size"]
        pos = exe.find(b"\x08\x00\x00\x00", start, end)
        while pos != -1:
            va = int(section["va"]) + (pos - start)
            init = decode_object_initializer(exe, sections, va)
            packed_region = init_write_value(init or {}, 0x28) if init else None
            if packed_region is not None and (packed_region & 0xFFFF) in {3, 4, 6}:
                region_index = packed_region & 0xFFFF
                x_raw = init_write_value(init or {}, 0x1C)
                y_raw = init_write_value(init or {}, 0x20)
                x = (x_raw >> 16) if x_raw is not None else None
                y = (y_raw >> 16) if y_raw is not None else None
                refs = find_dword_refs_in_sections(
                    exe,
                    [section for section in sections if section.get("name") in {".data", ".rdata", ".text"}],
                    va,
                )
                region = region_binding(exe, sections, region_index)
                rows.append(
                    {
                        "initializerVaHex": hx(va),
                        "packedRegionHex": hx(packed_region),
                        "regionIndex": region_index,
                        "regionGroupHex": hx(packed_region >> 16, 4),
                        "xRawHex": hx(x_raw),
                        "yRawHex": hx(y_raw),
                        "x": x,
                        "y": y,
                        "region": region,
                        "pointerRefCount": len(refs),
                        "pointerRefsHex": [hx(ref) for ref in refs[:16]],
                        "topMenuCandidate": (packed_region >> 16) == 0x0003 and region_index in {3, 4, 6},
                    }
                )
            pos = exe.find(b"\x08\x00\x00\x00", pos + 1, end)
    rows.sort(key=lambda row: (not row["topMenuCandidate"], row["regionIndex"], row["initializerVaHex"]))
    return rows


def read_handler_rows(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for opcode, role in SELECTED_OPCODES.items():
        handler = u32(exe, sections, VM_HANDLER_TABLE_VA + opcode * 4)
        rows.append(
            {
                "opcode": opcode,
                "opcodeHex": hx(opcode, 2),
                "handlerVa": handler,
                "handlerVaHex": hx(handler),
                "role": role,
                "handlerBytes": byte_hex(read_bytes(exe, sections, handler or 0, 28)),
            }
        )
    return rows


def read_descriptor_roots(exe: bytes, sections: list[dict[str, Any]], count: int = 96) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for index in range(count):
        root = u32(exe, sections, ACTIVE_DESCRIPTOR_ROOT_TABLE_VA + index * 4)
        script0 = u32(exe, sections, root) if is_file_backed(sections, root) else None
        script4 = u32(exe, sections, (root or 0) + 4) if is_file_backed(sections, root) else None
        script8 = u32(exe, sections, (root or 0) + 8) if is_file_backed(sections, root) else None
        rows.append(
            {
                "id": index,
                "rootVa": root,
                "rootVaHex": hx(root),
                "fileBacked": is_file_backed(sections, root),
                "script0Va": script0,
                "script0VaHex": hx(script0),
                "script4Va": script4,
                "script4VaHex": hx(script4),
                "script8Va": script8,
                "script8VaHex": hx(script8),
                "rootBytes": byte_hex(read_bytes(exe, sections, root or 0, 24)),
            }
        )
    return rows


def pointer_targets_from_command(command: dict[str, Any]) -> list[int]:
    targets: list[int] = []
    for key in ("targetVa", "payloadVa"):
        value = command.get(key)
        if isinstance(value, int):
            targets.append(value)
    for item in command.get("pointerOperands") or []:
        value = item.get("value")
        if isinstance(value, int):
            targets.append(value)
    return targets


def decode_descriptor_closure(
    exe: bytes,
    sections: list[dict[str, Any]],
    descriptor: dict[str, Any],
    max_nodes: int = 48,
) -> dict[str, Any]:
    roots = [
        descriptor.get("script0Va"),
        descriptor.get("script4Va"),
        descriptor.get("script8Va"),
    ]
    queue = [value for value in roots if is_file_backed(sections, value)]
    seen: set[int] = set()
    op_counts: dict[str, int] = {}
    region_ops: list[dict[str, Any]] = []
    command_samples: list[dict[str, Any]] = []
    ascii_false_opcode_samples: list[dict[str, Any]] = []
    numeric_false_opcode_samples: list[dict[str, Any]] = []
    node_count = 0
    command_count = 0

    while queue and node_count < max_nodes:
        start = queue.pop(0)
        if start in seen or not is_file_backed(sections, start):
            continue
        if section_name_for(sections, start) == ".text":
            continue
        seen.add(start)
        node_count += 1
        try:
            decoded = decode_stream(exe, sections, start, max_commands=64, max_bytes=0x280)
        except Exception:
            continue
        for command in decoded.get("commands", []):
            opcode = command.get("opcode")
            if not isinstance(opcode, int):
                continue
            command_va_hex = command.get("vaHex")
            command_va = int(command_va_hex, 16) if isinstance(command_va_hex, str) and command_va_hex.startswith("0x") else None
            ascii_token = is_asset_ascii_false_opcode(exe, sections, command_va) if command_va is not None else ""
            if ascii_token:
                if len(ascii_false_opcode_samples) < 12:
                    ascii_false_opcode_samples.append(
                        {
                            "vaHex": command_va_hex,
                            "opcodeHex": f"0x{opcode:02x}",
                            "rawHex": command.get("rawHex", ""),
                            "asciiToken": ascii_token,
                        }
                    )
                continue
            numeric_table = monotonic_u16_table_around(exe, sections, command_va) if command_va is not None else []
            if numeric_table:
                if len(numeric_false_opcode_samples) < 12:
                    numeric_false_opcode_samples.append(
                        {
                            "vaHex": command_va_hex,
                            "opcodeHex": f"0x{opcode:02x}",
                            "rawHex": command.get("rawHex", ""),
                            "u16ValuesHex": [f"0x{value:04x}" for value in numeric_table[:8]],
                        }
                    )
                continue
            opcode_hex = f"0x{opcode:02x}"
            op_counts[opcode_hex] = op_counts.get(opcode_hex, 0) + 1
            command_count += 1
            if len(command_samples) < 10 and opcode in {0x40, 0x44, 0x45, 0x62, 0x63, 0x80, 0x99, 0x9F}:
                command_samples.append(
                    {
                        "vaHex": command.get("vaHex"),
                        "opcodeHex": opcode_hex,
                        "summary": command.get("summary", ""),
                        "rawHex": command.get("rawHex", ""),
                    }
                )
            raw = bytes.fromhex(command.get("rawHex", ""))
            if opcode == 0x44 and len(raw) >= 2:
                region_ops.append(
                    {
                        "commandVaHex": command.get("vaHex"),
                        "opcodeHex": opcode_hex,
                        "role": "conditional draw region 0",
                        "regionIndex": 0,
                        "streamByte1": raw[1],
                        "rawHex": command.get("rawHex", ""),
                    }
                )
            elif opcode == 0x45 and len(raw) >= 2:
                region_ops.append(
                    {
                        "commandVaHex": command.get("vaHex"),
                        "opcodeHex": opcode_hex,
                        "role": "draw region id from stream+1",
                        "regionIndex": raw[1],
                        "streamByte1": raw[1],
                        "rawHex": command.get("rawHex", ""),
                    }
                )
            for target in pointer_targets_from_command(command):
                if target not in seen and is_file_backed(sections, target) and section_name_for(sections, target) != ".text":
                    queue.append(target)

    top_menu_hits = [row for row in region_ops if row["regionIndex"] in {3, 4, 6}]
    return {
        "id": descriptor["id"],
        "rootVaHex": descriptor["rootVaHex"],
        "nodeCount": node_count,
        "commandCount": command_count,
        "opcodeCounts": dict(sorted(op_counts.items())),
        "regionOps": region_ops,
        "topMenuRegionOps": top_menu_hits,
        "commandSamples": command_samples,
        "asciiFalseOpcodeSamples": ascii_false_opcode_samples,
        "numericFalseOpcodeSamples": numeric_false_opcode_samples,
    }


def build_descriptor_closure_rows(
    exe: bytes,
    sections: list[dict[str, Any]],
    descriptors: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    rows = [decode_descriptor_closure(exe, sections, descriptor) for descriptor in descriptors]
    return [
        row
        for row in rows
        if row["regionOps"]
        or any(key in row["opcodeCounts"] for key in ("0x62", "0x63", "0x80", "0x99", "0x9f"))
        or row["asciiFalseOpcodeSamples"]
        or row["numericFalseOpcodeSamples"]
    ]


def build_region_draw_call_rows(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    role_by_call = {
        0x00406514: "opcode 0x44: conditional region 0 draw after 0x421509 refresh helper",
        0x0040653A: "opcode 0x45: draw stream+1 region id",
        0x00414FC9: "fixed region #39 draw, then text from skill/action table",
        0x004150D2: "fixed region #40 draw, then text from item/skill table",
        0x0041BB59: "generic object bridge: draw region id from object/context +0x28",
    }
    fixed_region_by_call = {0x00406514: 0, 0x00414FC9: 0x27, 0x004150D2: 0x28}
    rows = []
    for call in find_call_sites(exe, sections, REGION_DRAW_FUNCTION_VA):
        region_index = fixed_region_by_call.get(call)
        rows.append(
            {
                "callVaHex": hx(call),
                "role": role_by_call.get(call, "unclassified region draw call"),
                "fixedRegionIndex": region_index,
                "fixedRegion": region_binding(exe, sections, region_index) if region_index is not None else None,
            }
        )
    return rows


def build_top_region_probe(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows = []
    for index in (6, 3, 4):
        pattern = bytes([0x45, index])
        hits = find_byte_pattern_in_sections(exe, script_data_sections(sections), pattern)
        rows.append(
            {
                "regionIndex": index,
                "pattern": pattern.hex(" "),
                "scriptDataHitCount": len(hits),
                "sampleHitsHex": [hx(hit) for hit in hits[:16]],
                "region": region_binding(exe, sections, index),
            }
        )
    return rows


def read_initial_slot_rows(exe: bytes, sections: list[dict[str, Any]], count: int = 16) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for index in range(count):
        va = ACTIVE_DESCRIPTOR_SLOT_TABLE_VA + index * ACTIVE_DESCRIPTOR_SLOT_STRIDE
        off = va_to_offset(sections, va)
        if off is None:
            continue
        first_dwords = [struct.unpack_from("<I", exe, off + i * 4)[0] for i in range(6)]
        bytes_of_interest = {
            "+0x58": exe[off + 0x58],
            "+0x59": exe[off + 0x59],
            "+0x61": exe[off + 0x61],
            "+0x62": exe[off + 0x62],
            "+0x67": exe[off + 0x67],
        }
        rows.append(
            {
                "id": index,
                "slotVaHex": hx(va),
                "firstDwordsHex": [hx(value) for value in first_dwords],
                "bytes": bytes_of_interest,
                "note": "file-backed initial bytes; runtime materializer overwrites +0/+4/+8 with descriptor scripts",
            }
        )
    return rows


def build_strict_inventory_opener_probe(exe: bytes, sections: list[dict[str, Any]]) -> dict[str, Any]:
    """Check whether known active-object scripts contain the normal ESC opener.

    The active-object inventory is intentionally stricter than blind script-data
    scanning.  If this list has no valid add-descriptor/cancel opener opcodes,
    repeated broad scans of the same inventory are not useful evidence.
    """
    inventory_path = OUT / "active_object_script_inventory.json"
    if not inventory_path.exists():
        return {
            "status": "missing-inventory",
            "inventoryPath": str(inventory_path.relative_to(ROOT)),
            "scriptCount": 0,
            "scriptsWithStackOps": [],
            "scriptsWithInputLikeOps": [],
            "descriptorAddScriptCount": 0,
            "cancelOpScriptCount": 0,
        }

    inventory = json.loads(inventory_path.read_text(encoding="utf-8"))
    scripts = inventory.get("scripts", []) if isinstance(inventory, dict) else []
    stack_rows: list[dict[str, Any]] = []
    input_rows: list[dict[str, Any]] = []
    descriptor_add_count = 0
    cancel_op_count = 0

    for script in scripts:
        script_va = script.get("scriptVa")
        if not isinstance(script_va, int) or not is_file_backed(sections, script_va):
            continue
        try:
            decoded = decode_stream(exe, sections, script_va, max_commands=200, max_bytes=0x800)
        except Exception as exc:  # pragma: no cover - defensive around exploratory decoder
            stack_rows.append(
                {
                    "scriptVaHex": hx(script_va),
                    "classification": script.get("classification", ""),
                    "decodedCommandCount": 0,
                    "stackOps": [],
                    "inputLikeOps": [],
                    "decodeError": str(exc),
                }
            )
            continue

        commands = decoded.get("commands", [])
        stack_ops = [
            command
            for command in commands
            if isinstance(command.get("opcode"), int)
            and command.get("opcode") in STRICT_INVENTORY_STACK_OPCODES
        ]
        input_like_ops = [
            command
            for command in commands
            if isinstance(command.get("opcode"), int)
            and command.get("opcode") in STRICT_INVENTORY_INPUTLIKE_OPCODES
        ]
        descriptor_add_count += sum(1 for command in stack_ops if command.get("opcode") == 0x62)
        cancel_op_count += sum(1 for command in stack_ops if command.get("opcode") == 0x9F)

        row = {
            "scriptVaHex": script.get("scriptVaHex", hx(script_va)),
            "classification": script.get("classification", ""),
            "decodedCommandCount": len(commands),
            "stackOps": [
                {
                    "vaHex": command.get("vaHex"),
                    "opcodeHex": command.get("opcodeHex") or hx(command.get("opcode"), 2),
                    "rawHex": command.get("rawHex", ""),
                    "summary": command.get("summary", ""),
                }
                for command in stack_ops
            ],
            "inputLikeOps": [
                {
                    "vaHex": command.get("vaHex"),
                    "opcodeHex": command.get("opcodeHex") or hx(command.get("opcode"), 2),
                    "rawHex": command.get("rawHex", ""),
                    "summary": command.get("summary", ""),
                }
                for command in input_like_ops
            ],
        }
        if stack_ops:
            stack_rows.append(row)
        if input_like_ops:
            input_rows.append(row)

    return {
        "status": "negative-evidence-no-field-opener-in-strict-inventory",
        "inventoryPath": str(inventory_path.relative_to(ROOT)),
        "scriptCount": len(scripts),
        "scriptsWithStackOps": stack_rows,
        "scriptsWithInputLikeOps": input_rows,
        "stackOpScriptCount": len(stack_rows),
        "inputLikeScriptCount": len(input_rows),
        "descriptorAddScriptCount": descriptor_add_count,
        "cancelOpScriptCount": cancel_op_count,
        "interpretation": (
            "The strict active-object +0xec inventory contains only 0x80/0x60 stack helpers "
            "and 0x88 input-like list helpers. It contains no 0x62 add-descriptor command "
            "and no 0x9f cancel/back command, so the normal-field ESC/X opener is outside "
            "this inventory or in a higher-level field controller."
        ),
    }


def build_call_target_rows(
    exe: bytes,
    sections: list[dict[str, Any]],
    handler_rows: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    opcode_handler_ranges = []
    for row in handler_rows:
        handler_va = row.get("handlerVa")
        if not isinstance(handler_va, int):
            continue
        byte_text = row.get("handlerBytes") or ""
        byte_count = len(byte_text.split())
        if byte_count <= 0:
            continue
        opcode_handler_ranges.append((handler_va, handler_va + byte_count, row))
    targets = [
        ("add/materialize descriptor", ADD_DESCRIPTOR_ROUTINE_VA),
        ("rebuild descriptor stack", REBUILD_DESCRIPTOR_ROUTINE_VA),
        ("remove/rebuild descriptor", REMOVE_DESCRIPTOR_ROUTINE_VA),
        ("VM object create", VM_OBJECT_CREATE_ROUTINE_VA),
        ("VM run script", VM_RUN_SCRIPT_ROUTINE_VA),
        ("VM object destroy", VM_OBJECT_DESTROY_ROUTINE_VA),
    ]
    rows: list[dict[str, Any]] = []
    for label, target in targets:
        calls = find_call_sites(exe, sections, target)
        classified_calls = []
        opcode_handler_call_count = 0
        for call in calls:
            handler = next(
                (
                    row
                    for start, end, row in opcode_handler_ranges
                    if start <= call < end
                ),
                None,
            )
            if handler is not None:
                opcode_handler_call_count += 1
                role = f"opcode {handler['opcodeHex']} handler: {handler['role']}"
            else:
                role = "direct non-opcode caller"
            classified_calls.append(
                {
                    "callVa": call,
                    "callVaHex": hx(call),
                    "role": role,
                }
            )
        rows.append(
            {
                "label": label,
                "targetVa": target,
                "targetVaHex": hx(target),
                "callCount": len(calls),
                "callSitesHex": [hx(call) for call in calls[:32]],
                "opcodeHandlerCallCount": opcode_handler_call_count,
                "nonOpcodeCallCount": len(calls) - opcode_handler_call_count,
                "classifiedCalls": classified_calls[:32],
            }
        )
    return rows


def build_global_rows(exe: bytes, sections: list[dict[str, Any]]) -> list[dict[str, Any]]:
    globals_to_scan = [
        ("active descriptor count", ACTIVE_DESCRIPTOR_COUNT_VA, "byte count; add maxes out at 3"),
        ("active descriptor id order", ACTIVE_DESCRIPTOR_ID_ORDER_VA, "order bytes selected by opcode 0x62/0x63"),
        ("descriptor root pointer table", ACTIVE_DESCRIPTOR_ROOT_TABLE_VA, "id -> descriptor root pointer"),
        ("slot table", ACTIVE_DESCRIPTOR_SLOT_TABLE_VA, "runtime rows; id*0xd8 stride"),
        ("slot pointer array", ACTIVE_DESCRIPTOR_SLOT_POINTERS_VA, "active stack pointer array used by cursor/cancel"),
        ("runtime object pointer array", ACTIVE_DESCRIPTOR_OBJECT_POINTERS_VA, "materialized VM/display object pointers"),
        ("object index table", ACTIVE_DESCRIPTOR_OBJECT_INDEX_TABLE_VA, "maps active order to object pointer slots"),
        ("saved descriptor state", ACTIVE_DESCRIPTOR_SAVED_STATE_VA, "x/y/state save-restore scratch for objects"),
    ]
    rows: list[dict[str, Any]] = []
    for label, va, meaning in globals_to_scan:
        refs = find_dword_refs(exe, sections, va)
        rows.append(
            {
                "label": label,
                "vaHex": hx(va),
                "meaning": meaning,
                "textRefCount": len(refs),
                "sampleRefsHex": [hx(ref) for ref in refs[:16]],
            }
        )
    return rows


def build_payload() -> dict[str, Any]:
    exe = EXE.read_bytes()
    sections = read_sections(exe)
    handler_rows = read_handler_rows(exe, sections)
    descriptor_roots = read_descriptor_roots(exe, sections)
    slot_rows = read_initial_slot_rows(exe, sections)
    call_targets = build_call_target_rows(exe, sections, handler_rows)
    call_target_by_va = {row["targetVa"]: row for row in call_targets}
    descriptor_add_calls = call_target_by_va[ADD_DESCRIPTOR_ROUTINE_VA]
    descriptor_remove_calls = call_target_by_va[REMOVE_DESCRIPTOR_ROUTINE_VA]
    global_rows = build_global_rows(exe, sections)
    descriptor_closures = build_descriptor_closure_rows(exe, sections, descriptor_roots)
    ascii_false_opcode_sample_count = sum(
        len(row.get("asciiFalseOpcodeSamples", [])) for row in descriptor_closures
    )
    numeric_false_opcode_sample_count = sum(
        len(row.get("numericFalseOpcodeSamples", [])) for row in descriptor_closures
    )
    region_draw_calls = build_region_draw_call_rows(exe, sections)
    top_region_probe = build_top_region_probe(exe, sections)
    strict_inventory_opener_probe = build_strict_inventory_opener_probe(exe, sections)
    context_region_initializers = build_context_region_initializer_rows(exe, sections)
    top_menu_object_sequence = decode_top_menu_object_sequence(exe, sections)
    top_context_region_initializers = [
        row for row in context_region_initializers if row["topMenuCandidate"]
    ]
    exact_top_context_region_initializers = [
        row
        for row in top_context_region_initializers
        if row["region"]
        and row["x"] == row["region"]["rect"][0]
        and row["y"] == row["region"]["rect"][1]
    ]
    top_region_closure_hits = [
        {"id": row["id"], "rootVaHex": row["rootVaHex"], "hits": row["topMenuRegionOps"]}
        for row in descriptor_closures
        if row["topMenuRegionOps"]
    ]
    region44_descriptor_ids = [
        row["id"]
        for row in descriptor_closures
        if any(region_op["opcodeHex"] == "0x44" for region_op in row["regionOps"])
    ]

    payload = {
        "status": "shared active descriptor stack static review",
        "summary": {
            "addRoutineVaHex": hx(ADD_DESCRIPTOR_ROUTINE_VA),
            "removeRoutineVaHex": hx(REMOVE_DESCRIPTOR_ROUTINE_VA),
            "rebuildRoutineVaHex": hx(REBUILD_DESCRIPTOR_ROUTINE_VA),
            "activeCountVaHex": hx(ACTIVE_DESCRIPTOR_COUNT_VA),
            "activeIdOrderVaHex": hx(ACTIVE_DESCRIPTOR_ID_ORDER_VA),
            "slotPointerArrayVaHex": hx(ACTIVE_DESCRIPTOR_SLOT_POINTERS_VA),
            "slotTableVaHex": hx(ACTIVE_DESCRIPTOR_SLOT_TABLE_VA),
            "slotStrideHex": hx(ACTIVE_DESCRIPTOR_SLOT_STRIDE, 2),
            "descriptorRootTableVaHex": hx(ACTIVE_DESCRIPTOR_ROOT_TABLE_VA),
            "maxActiveDescriptorStack": ACTIVE_DESCRIPTOR_MAX_STACK,
            "promotion": "descriptor materializer grounded; normal-field ESC/X top-menu opener blocked",
            "descriptorClosureRows": len(descriptor_closures),
            "descriptorClosureAsciiFalseOpcodeSamples": ascii_false_opcode_sample_count,
            "descriptorClosureNumericFalseOpcodeSamples": numeric_false_opcode_sample_count,
            "topRegionScriptDataHits": sum(row["scriptDataHitCount"] for row in top_region_probe),
            "topRegionClosureHits": len(top_region_closure_hits),
            "topContextInitializerRows": len(top_context_region_initializers),
            "exactTopContextInitializerRows": len(exact_top_context_region_initializers),
            "topMenuObjectSequenceVaHex": top_menu_object_sequence["sequenceStartVaHex"],
            "topMenuObjectSequenceDirectRefs": len(top_menu_object_sequence["sequenceDirectRefsHex"]),
            "topMenuObjectSequenceIntroContextRefs": len(top_menu_object_sequence["sequenceDirectRefsHex"]),
            "topMenuObjectSequenceFieldEscOpenerRefs": 0,
            "topMenuObjectSequenceDirectRefInterpretation": "the only direct ref currently lands in the intro/title resource context, not the normal-field ESC/X opener",
            "topMenuGroundedSlots": len(top_menu_object_sequence["groundedTopMenuSlots"]),
            "topMenuParentContextScriptVaHex": top_menu_object_sequence["parentContext"]["scriptVaHex"],
            "topMenuParentSelectedScriptVaHex": top_menu_object_sequence["parentContext"]["dynamicSelectedScriptVaHex"],
            "region44DescriptorIds": region44_descriptor_ids,
            "strictInventoryScriptCount": strict_inventory_opener_probe["scriptCount"],
            "strictInventoryStackOpScripts": strict_inventory_opener_probe["stackOpScriptCount"],
            "strictInventoryInputLikeScripts": strict_inventory_opener_probe["inputLikeScriptCount"],
            "strictInventoryDescriptorAddScripts": strict_inventory_opener_probe["descriptorAddScriptCount"],
            "strictInventoryCancelOpScripts": strict_inventory_opener_probe["cancelOpScriptCount"],
            "addRoutineDirectCallCount": descriptor_add_calls["callCount"],
            "addRoutineOpcodeHandlerCallCount": descriptor_add_calls["opcodeHandlerCallCount"],
            "addRoutineNonOpcodeCallCount": descriptor_add_calls["nonOpcodeCallCount"],
            "removeRoutineDirectCallCount": descriptor_remove_calls["callCount"],
            "removeRoutineOpcodeHandlerCallCount": descriptor_remove_calls["opcodeHandlerCallCount"],
            "removeRoutineNonOpcodeCallCount": descriptor_remove_calls["nonOpcodeCallCount"],
        },
        "opcodeHandlers": handler_rows,
        "globals": global_rows,
        "callTargets": call_targets,
        "regionDrawCallSites": region_draw_calls,
        "topRegionProbe": top_region_probe,
        "strictInventoryOpenerProbe": strict_inventory_opener_probe,
        "topMenuObjectSequence": top_menu_object_sequence,
        "contextRegionInitializers": context_region_initializers,
        "topContextRegionInitializers": top_context_region_initializers,
        "exactTopContextRegionInitializers": exact_top_context_region_initializers,
        "descriptorRootClosureScan": descriptor_closures,
        "topRegionClosureHits": top_region_closure_hits,
        "descriptorRoots": descriptor_roots,
        "initialSlotRows": slot_rows,
        "routineSemantics": [
            {
                "routine": "opcode 0x07 create child object",
                "vaHex": hx(next(row["handlerVa"] for row in handler_rows if row["opcode"] == 0x07)),
                "grounding": "reads stream+1 as child object kind/count, calls 0x435b5b with handler 0x402321, writes stream+4 to child object+0x40, stores child at parent object+0x58, then advances +8",
                "status": "grounded",
            },
            {
                "routine": "opcode 0x28 runtime object slot bridge",
                "vaHex": hx(next(row["handlerVa"] for row in handler_rows if row["opcode"] == 0x28)),
                "grounding": "mode 0 stores parent object+0x58 into 0x59dd70[stream+2]; mode 1 loads 0x59dd70[stream+2] into parent object+0xa8; command length 4 bytes",
                "status": "grounded",
            },
            {
                "routine": "opcode 0x62 handler",
                "vaHex": hx(next(row["handlerVa"] for row in handler_rows if row["opcode"] == 0x62)),
                "grounding": "reads stream+1 and calls add routine 0x00431fe8; command length 4 bytes",
                "status": "grounded",
            },
            {
                "routine": "add/materialize active descriptor",
                "vaHex": hx(ADD_DESCRIPTOR_ROUTINE_VA),
                "grounding": "rejects count >= 3, deduplicates existing object+0x16 id, appends id to 0x4576e9[count], writes 0x457750 + id*0xd8 to 0x59db30[count], increments 0x4576e8, then materializes descriptor-root scripts into VM/display objects",
                "status": "grounded",
            },
            {
                "routine": "opcode 0x63 handler",
                "vaHex": hx(next(row["handlerVa"] for row in handler_rows if row["opcode"] == 0x63)),
                "grounding": "reads stream+1 and calls remove routine 0x00432541; command length 4 bytes",
                "status": "grounded",
            },
            {
                "routine": "remove/rebuild active descriptor",
                "vaHex": hx(REMOVE_DESCRIPTOR_ROUTINE_VA),
                "grounding": "runs descriptor+4 teardown scripts for active objects, removes matching id from 0x4576e9, compacts 0x59db30, then rematerializes remaining descriptors from 0x442d95[id]",
                "status": "grounded",
            },
            {
                "routine": "opcode 0x80 direct slot bind",
                "vaHex": hx(next(row["handlerVa"] for row in handler_rows if row["opcode"] == 0x80)),
                "grounding": "stream byte2 selects id; stream byte1 selects destination stack slot; stores 0x457750 + id*0xd8 into 0x59db30[slot]",
                "status": "grounded",
            },
            {
                "routine": "opcode 0x99 rebuild pointer array",
                "vaHex": hx(next(row["handlerVa"] for row in handler_rows if row["opcode"] == 0x99)),
                "grounding": "clears stack/object helper slots, then repopulates 0x59db30[i] from active id order 0x4576e9[i]",
                "status": "grounded",
            },
            {
                "routine": "opcode 0x9f cancel/back",
                "vaHex": hx(next(row["handlerVa"] for row in handler_rows if row["opcode"] == 0x9F)),
                "grounding": "uses cursor 0x59e33e over 0x59db30, consumes cancel edge bit 0x0200 in mode 1, skips entries whose +0x62 has 0x82, and dispatches entry+0x59 through 0x422093/0x4226e9 when entry+0x58 == 1",
                "status": "grounded",
            },
            {
                "routine": "opcode 0x81 dynamic continuation select",
                "vaHex": hx(next(row["handlerVa"] for row in handler_rows if row["opcode"] == 0x81)),
                "grounding": "reads stream+1 as table index and stream+4 as dword table; stores table[index] into global 0x59de30; command length 8 bytes",
                "status": "grounded",
            },
            {
                "routine": "opcode 0x82 dynamic continuation call",
                "vaHex": hx(next(row["handlerVa"] for row in handler_rows if row["opcode"] == 0x82)),
                "grounding": "if 0x59de30 is nonzero, pushes the current stream pointer to object return stack at object+0x44 + depth*4, increments object+0x5c, then sets object+0x40 to 0x59de30",
                "status": "grounded",
            },
            {
                "routine": "opcode 0x86 object slot flag update",
                "vaHex": hx(next(row["handlerVa"] for row in handler_rows if row["opcode"] == 0x86)),
                "grounding": "mode 0 writes 0x08000000 and mode 1 writes 0x10000000 to object pointed by 0x59dd70[stream+2]; in the top-menu sequence it touches slots 7, 8, 9, and 10",
                "status": "grounded",
            },
        ],
        "findings": [
            {
                "item": "shared descriptor stack",
                "status": "grounded",
                "evidence": "0x62/0x63/0x80/0x99/0x9f all converge on 0x4576e8, 0x4576e9, 0x457750, and 0x59db30.",
            },
            {
                "item": "materialized display object scripts",
                "status": "grounded",
                "evidence": "add/remove routines load roots from 0x442d95[id], run descriptor+0 init scripts via 0x402360, and assign descriptor+8 to object+0x40.",
            },
            {
                "item": "normal-field menu opener",
                "status": "blocked",
                "evidence": "the stack machinery and menu-like object construction sequence are grounded, but the exact field-controller trigger for ESC/X remains unisolated. The newly traced upper context overlaps intro/title resources, so it must not be promoted as the field menu opener.",
            },
            {
                "item": "descriptor materializer direct callers",
                "status": "grounded / opener not found",
                "evidence": (
                    f"add routine direct calls={descriptor_add_calls['callCount']} "
                    f"(opcode-handler={descriptor_add_calls['opcodeHandlerCallCount']}, "
                    f"non-opcode={descriptor_add_calls['nonOpcodeCallCount']}); "
                    f"remove routine direct calls={descriptor_remove_calls['callCount']} "
                    f"(opcode-handler={descriptor_remove_calls['opcodeHandlerCallCount']}, "
                    f"non-opcode={descriptor_remove_calls['nonOpcodeCallCount']}). "
                    "The only add/remove direct callers are opcode 0x62/0x63 handlers, so the field opener is not a direct call to these materializers."
                ),
            },
            {
                "item": "top menu object direct ref",
                "status": "blocked for field opener",
                "evidence": "the only direct ref to 0x004ddc6c is 0x0047e6b0, reached through parent context 0x0047e624 -> 0x004a2d38. That dynamic table contains middata.mlk, compile.cns, aaa.cns, logo_00.cns, title.cns, map_k*, and btl_k* strings, so this is intro/title-adjacent evidence only.",
            },
            {
                "item": "top menu object construction sequence",
                "status": "grounded / upstream trigger pending",
                "evidence": "0x0047e6b0 points to script 0x004ddc6c. In that script, opcode 0x07 creates 0x004dee18/#6, 0x004dee58/#3, and 0x004dee98/#4, and the following opcode 0x28 stores them into runtime object slots 7, 8, and 9.",
            },
            {
                "item": "right-menu content labels",
                "status": "handled in right-panel review",
                "evidence": "UI/system strings and region #2/#3/#4 payloads around 0x004e8120..0x004e8e24 are covered by menu_right_panel_ui_review; this descriptor-stack report only tracks the stack/opener boundary.",
            },
            {
                "item": "upper context caution",
                "status": "grounded caution",
                "evidence": "parent candidate 0x0047e624 uses opcode 0x81 at 0x0047e664 to select 0x004a2cb0[0] -> 0x004a2d38, then opcode 0x82 jumps to it. The table contains middata.mlk/compile.cns/aaa.cns/logo_00.cns/title.cns/map_k*/btl_k* strings, so this branch is title/intro-adjacent.",
            },
            {
                "item": "direct region draw consumers",
                "status": "grounded",
                "evidence": "0x41b579 has direct callers: opcode 0x44 region0, opcode 0x45 stream region, fixed region #39/#40 text panels, and generic object bridge object+0x28.",
            },
            {
                "item": "descriptor root region commands",
                "status": "partial",
                "evidence": f"closure scan found region opcode 0x44 in descriptor ids {region44_descriptor_ids[:16]}, but no descriptor-root 0x45 hit for top-menu regions #6/#3/#4.",
            },
            {
                "item": "descriptor closure ASCII false opcodes",
                "status": "filtered",
                "evidence": f"{ascii_false_opcode_sample_count} sampled false opcode hits inside asset strings such as cara_*.cns and btl_*.cns were excluded from closure opcode counts.",
            },
            {
                "item": "descriptor closure numeric table false opcodes",
                "status": "filtered",
                "evidence": f"{numeric_false_opcode_sample_count} sampled false opcode hits inside monotonic 16-bit numeric tables were excluded from closure opcode counts; this removes the previous zh_dai.cns 0x62 add-descriptor-looking false hit.",
            },
            {
                "item": "descriptor-root add-opener search",
                "status": "blocked",
                "evidence": "after filtering asset-string and numeric-table false positives, descriptor-root closure scan contains no valid opcode 0x62 add-descriptor command. The normal-field ESC/X opener must be searched in a field-controller or higher-level VM root outside these descriptor roots.",
            },
            {
                "item": "strict active-object inventory opener scan",
                "status": "blocked / negative evidence",
                "evidence": (
                    f"{strict_inventory_opener_probe['scriptCount']} strict active-object scripts were decoded; "
                    f"{strict_inventory_opener_probe['stackOpScriptCount']} contain stack helpers, "
                    f"but valid 0x62 add-descriptor scripts={strict_inventory_opener_probe['descriptorAddScriptCount']} "
                    f"and 0x9f cancel/back scripts={strict_inventory_opener_probe['cancelOpScriptCount']}. "
                    "This path is therefore excluded as the normal-field ESC/X opener."
                ),
            },
        ],
        "nextTargets": [
            "Do not treat 0x0047e624/0x004a2cb0 as the field ESC opener without more proof; it is title/intro-adjacent.",
            "Find a normal field-state root that calls add descriptor 0x62/0x80/0x99 and reaches 0x004ddc6c without the intro/title resource context.",
            "Do not use descriptor-root closure 0x62 hits as opener proof unless they survive the ASCII/numeric-table filters.",
            "Do not rescan the strict active-object +0xec inventory for the ESC opener unless the inventory extraction criteria change; it contains no 0x62 add and no 0x9f cancel command.",
            "Do not search for a direct non-opcode caller of 0x00431fe8/0x00432541 as the opener; both routines are only called by opcode handlers. Find the upstream command stream/root that emits opcode 0x62 for the field status menu.",
        ],
    }
    return payload


def tag(status: str) -> str:
    cls = "good"
    if status in {"pending", "partial"} or "pending" in status or "partial" in status:
        cls = "warn"
    if status in {"blocked", "missing"} or "blocked" in status:
        cls = "bad"
    return f'<span class="tag {cls}">{h(status)}</span>'


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


def render_html(payload: dict[str, Any]) -> str:
    summary = payload["summary"]
    metrics = [
        ("add/remove", f"{summary['addRoutineVaHex']} / {summary['removeRoutineVaHex']}"),
        ("active count/order", f"{summary['activeCountVaHex']} / {summary['activeIdOrderVaHex']}"),
        ("slot table", f"{summary['slotTableVaHex']} stride {summary['slotStrideHex']}"),
        ("max stack", str(summary["maxActiveDescriptorStack"])),
        ("closure rows", str(summary["descriptorClosureRows"])),
        (
            "false opcodes",
            f"ASCII {summary['descriptorClosureAsciiFalseOpcodeSamples']} / "
            f"u16 {summary['descriptorClosureNumericFalseOpcodeSamples']}",
        ),
        ("top region hits", f"script={summary['topRegionScriptDataHits']} closure={summary['topRegionClosureHits']}"),
        ("top object init", f"exact {summary['exactTopContextInitializerRows']} / candidates {summary['topContextInitializerRows']}"),
        (
            "top object script",
            f"{summary['topMenuObjectSequenceVaHex']} refs {summary['topMenuObjectSequenceDirectRefs']} "
            f"(field {summary['topMenuObjectSequenceFieldEscOpenerRefs']}) slots {summary['topMenuGroundedSlots']}",
        ),
        ("parent context", f"{summary['topMenuParentContextScriptVaHex']} -> {summary['topMenuParentSelectedScriptVaHex']}"),
        (
            "strict inventory",
            f"{summary['strictInventoryScriptCount']} scripts / stack {summary['strictInventoryStackOpScripts']} / "
            f"0x62 {summary['strictInventoryDescriptorAddScripts']} / 0x9f {summary['strictInventoryCancelOpScripts']}",
        ),
        (
            "materializer callers",
            f"add {summary['addRoutineDirectCallCount']} "
            f"(opcode {summary['addRoutineOpcodeHandlerCallCount']} / non-opcode {summary['addRoutineNonOpcodeCallCount']}) · "
            f"remove {summary['removeRoutineDirectCallCount']} "
            f"(opcode {summary['removeRoutineOpcodeHandlerCallCount']} / non-opcode {summary['removeRoutineNonOpcodeCallCount']})",
        ),
    ]
    metric_html = "".join(
        f'<div class="metric"><span>{h(label)}</span><strong>{h(value)}</strong></div>'
        for label, value in metrics
    )
    opcode_rows = table(
        ["opcode", "handler", "역할", "bytes"],
        [
            [
                code(row["opcodeHex"]),
                code(row["handlerVaHex"]),
                h(row["role"]),
                code(row["handlerBytes"]),
            ]
            for row in payload["opcodeHandlers"]
        ],
    )
    routine_rows = table(
        ["routine", "VA", "상태", "근거"],
        [
            [h(row["routine"]), code(row["vaHex"]), tag(row["status"]), h(row["grounding"])]
            for row in payload["routineSemantics"]
        ],
    )
    global_rows = table(
        ["global", "VA", "refs", "의미", "sample refs"],
        [
            [
                h(row["label"]),
                code(row["vaHex"]),
                str(row["textRefCount"]),
                h(row["meaning"]),
                " ".join(code(ref) for ref in row["sampleRefsHex"][:8]),
            ]
            for row in payload["globals"]
        ],
    )
    call_rows = table(
        ["target", "VA", "calls", "opcode/non-opcode", "call sites"],
        [
            [
                h(row["label"]),
                code(row["targetVaHex"]),
                str(row["callCount"]),
                f"{row['opcodeHandlerCallCount']} / {row['nonOpcodeCallCount']}",
                "<br>".join(
                    f"{code(call['callVaHex'])} {h(call['role'])}"
                    for call in row["classifiedCalls"][:12]
                ),
            ]
            for row in payload["callTargets"]
        ],
    )
    region_call_rows = table(
        ["call", "역할", "fixed region"],
        [
            [
                code(row["callVaHex"]),
                h(row["role"]),
                (
                    f"#{row['fixedRegionIndex']} {h((row['fixedRegion'] or {}).get('rectText', ''))} "
                    f"{code((row['fixedRegion'] or {}).get('resourceIdHex', '-'))}"
                    if row["fixedRegionIndex"] is not None
                    else h("dynamic")
                ),
            ]
            for row in payload["regionDrawCallSites"]
        ],
    )
    top_probe_rows = table(
        ["region", "pattern", "script-data hits", "rect/template", "samples"],
        [
            [
                f"#{row['regionIndex']}",
                code(row["pattern"]),
                str(row["scriptDataHitCount"]),
                f"{h((row['region'] or {}).get('rectText', ''))} tpl {h((row['region'] or {}).get('templateIndex', ''))}",
                " ".join(code(hit) for hit in row["sampleHitsHex"]),
            ]
            for row in payload["topRegionProbe"]
        ],
    )
    strict_probe = payload["strictInventoryOpenerProbe"]
    strict_probe_rows = table(
        ["script", "분류", "cmds", "stack ops", "input-like ops"],
        [
            [
                code(row["scriptVaHex"]),
                h(row["classification"]),
                h(row["decodedCommandCount"]),
                "<br>".join(
                    f"{code(op['vaHex'])} {code(op['opcodeHex'])} {code(op['rawHex'])} {h(op['summary'])}"
                    for op in row.get("stackOps", [])
                )
                or "-",
                "<br>".join(
                    f"{code(op['vaHex'])} {code(op['opcodeHex'])} {code(op['rawHex'])} {h(op['summary'])}"
                    for op in row.get("inputLikeOps", [])
                )
                or "-",
            ]
            for row in (
                strict_probe.get("scriptsWithStackOps", [])
                + strict_probe.get("scriptsWithInputLikeOps", [])
            )
        ],
    )
    sequence = payload["topMenuObjectSequence"]
    parent_context = sequence["parentContext"]
    top_sequence_rows = table(
        ["VA", "op", "bytes", "역할", "세부"],
        [
            [
                code(row["vaHex"]),
                code(row["opcodeHex"]),
                code(row["rawHex"]),
                h(row["role"]),
                (
                    "<br>".join(
                        f"{h(key)}={code(value) if isinstance(value, str) and value.startswith('0x') else h(value)}"
                        for key, value in row["detail"].items()
                        if key != "initializerRegion"
                    )
                    + (
                        "<br>"
                        + h(
                            "init region "
                            f"#{(row['detail'].get('initializerRegion') or {}).get('regionIndex')} "
                            f"pos={(row['detail'].get('initializerRegion') or {}).get('x')},"
                            f"{(row['detail'].get('initializerRegion') or {}).get('y')} "
                            f"rect={((row['detail'].get('initializerRegion') or {}).get('region') or {}).get('rectText')}"
                        )
                        if row["detail"].get("initializerRegion")
                        else ""
                    )
                )
                or "-",
            ]
            for row in sequence["commands"]
        ],
    )
    top_pair_rows = table(
        ["create", "store", "child script", "runtime slot", "region", "position", "rect/template"],
        [
            [
                code(row["createCommandVaHex"]),
                code(row["storeCommandVaHex"]),
                code(row["childScriptVaHex"]),
                str(row["runtimeSlot"]),
                f"#{h(row['regionIndex'])}",
                f"{h(row['position'][0])},{h(row['position'][1])}",
                f"{h(row['regionRectText'])} tpl {h(row['templateIndex'])}",
            ]
            for row in sequence["createStorePairs"]
        ],
    )
    parent_context_rows = table(
        ["항목", "값"],
        [
            ["parent script", code(parent_context["scriptVaHex"])],
            ["parent refs", " ".join(code(ref) for ref in parent_context["scriptDirectRefsHex"]) or "-"],
            ["dynamic select", code(parent_context["dynamicSelectVaHex"])],
            ["dynamic call", code(parent_context["dynamicCallVaHex"])],
            ["dynamic table", code(parent_context["dynamicTableVaHex"])],
            ["table refs", " ".join(code(ref) for ref in parent_context["dynamicTableRefsHex"]) or "-"],
            ["selected script", code(parent_context["dynamicSelectedScriptVaHex"])],
            ["ascii strings", "<br>".join(code(text) for text in parent_context["dynamicTableAsciiStrings"]) or "-"],
            ["status", tag(parent_context["status"])],
        ],
    )
    selected_decode = parent_context.get("dynamicSelectedDecode") or {}
    selected_decode_rows = table(
        ["VA", "op", "len", "bytes", "해석"],
        [
            [
                code(row.get("vaHex")),
                code(row.get("opcodeHex")),
                h(row.get("length")),
                code(row.get("rawHex")),
                h(row.get("summary") or row.get("opcodeName") or ""),
            ]
            for row in (selected_decode.get("commands") or [])
        ],
    )
    context_region_rows = table(
        ["init", "packed", "region", "position", "region rect/template", "refs"],
        [
            [
                code(row["initializerVaHex"]),
                code(row["packedRegionHex"]),
                f"#{row['regionIndex']} group {code(row['regionGroupHex'])}",
                f"{h(row['x'])},{h(row['y'])}",
                (
                    f"{h((row['region'] or {}).get('rectText', ''))} "
                    f"tpl {h((row['region'] or {}).get('templateIndex', ''))}"
                ),
                f"{row['pointerRefCount']} "
                + " ".join(code(ref) for ref in row["pointerRefsHex"][:6]),
            ]
            for row in payload["contextRegionInitializers"][:40]
        ],
    )
    closure_rows = table(
        ["id", "root", "nodes/cmds", "opcodes", "region ops", "samples", "filtered false opcodes"],
        [
            [
                str(row["id"]),
                code(row["rootVaHex"]),
                f"{row['nodeCount']}/{row['commandCount']}",
                "<br>".join(f"{h(k)}={h(v)}" for k, v in row["opcodeCounts"].items()),
                "<br>".join(
                    f"{code(region['commandVaHex'])} {h(region['opcodeHex'])} -> #{h(region['regionIndex'])}"
                    for region in row["regionOps"][:8]
                )
                or "-",
                "<br>".join(
                    f"{code(sample['vaHex'])} {h(sample['opcodeHex'])} {code(sample['rawHex'])}"
                    for sample in row["commandSamples"][:5]
                )
                or "-",
                (
                    "<br>".join(
                    f"{code(sample['vaHex'])} {h(sample['opcodeHex'])} {code(sample['asciiToken'])}"
                    for sample in row.get("asciiFalseOpcodeSamples", [])[:4]
                    )
                    + (
                        "<br>"
                        if row.get("asciiFalseOpcodeSamples") and row.get("numericFalseOpcodeSamples")
                        else ""
                    )
                    + "<br>".join(
                        f"{code(sample['vaHex'])} {h(sample['opcodeHex'])} "
                        f"{' '.join(sample['u16ValuesHex'][:4])}"
                        for sample in row.get("numericFalseOpcodeSamples", [])[:4]
                    )
                )
                or "-",
            ]
            for row in payload["descriptorRootClosureScan"][:48]
        ],
    )
    root_rows = table(
        ["id", "root", "script+0", "script+4", "script+8", "bytes"],
        [
            [
                str(row["id"]),
                code(row["rootVaHex"]),
                code(row["script0VaHex"]),
                code(row["script4VaHex"]),
                code(row["script8VaHex"]),
                code(row["rootBytes"]),
            ]
            for row in payload["descriptorRoots"][:40]
        ],
    )
    slot_rows = table(
        ["id", "slot", "first dwords", "+58/+59/+61/+62/+67"],
        [
            [
                str(row["id"]),
                code(row["slotVaHex"]),
                "<br>".join(code(value) for value in row["firstDwordsHex"]),
                "<br>".join(f"{h(k)}={h(v)}" for k, v in row["bytes"].items()),
            ]
            for row in payload["initialSlotRows"]
        ],
    )
    finding_rows = table(
        ["항목", "상태", "근거"],
        [[h(row["item"]), tag(row["status"]), h(row["evidence"])] for row in payload["findings"]],
    )
    next_items = "".join(f"<li>{h(item)}</li>" for item in payload["nextTargets"])
    ready = {
        "page": "menu_descriptor_stack_review",
        "addRoutineVaHex": summary["addRoutineVaHex"],
        "removeRoutineVaHex": summary["removeRoutineVaHex"],
        "activeCountVaHex": summary["activeCountVaHex"],
        "slotTableVaHex": summary["slotTableVaHex"],
        "slotPointerArrayVaHex": summary["slotPointerArrayVaHex"],
        "descriptorRootTableVaHex": summary["descriptorRootTableVaHex"],
        "topMenuObjectSequenceVaHex": summary["topMenuObjectSequenceVaHex"],
        "topMenuGroundedSlots": summary["topMenuGroundedSlots"],
        "topMenuParentContextScriptVaHex": summary["topMenuParentContextScriptVaHex"],
        "promotion": summary["promotion"],
    }
    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>메뉴 descriptor stack 검토</title>
  <style>
    :root {{ color-scheme: light; --border:#d8dee6; --ink:#17202a; --muted:#607080; --panel:#fff; --head:#eef2f6; --bg:#f6f7f9; }}
    * {{ box-sizing:border-box; }}
    body {{ margin:0; background:var(--bg); color:var(--ink); font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; line-height:1.45; }}
    main {{ max-width:1500px; margin:0 auto; padding:18px; }}
    header {{ display:flex; justify-content:space-between; align-items:flex-start; gap:16px; margin-bottom:14px; }}
    h1 {{ margin:0; font-size:24px; }}
    h2 {{ margin:0; font-size:17px; }}
    a {{ color:#185abc; font-weight:700; text-decoration:none; }}
    a:hover {{ text-decoration:underline; }}
    nav {{ display:flex; flex-wrap:wrap; gap:10px; justify-content:flex-end; }}
    section {{ background:var(--panel); border:1px solid var(--border); border-radius:8px; margin:14px 0; overflow:hidden; }}
    .head {{ display:flex; justify-content:space-between; gap:12px; padding:12px 14px; background:var(--head); border-bottom:1px solid var(--border); }}
    .body {{ padding:14px; overflow:auto; }}
    .metrics {{ display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:10px; }}
    .metric {{ border:1px solid var(--border); border-radius:6px; background:#f8fafc; padding:10px; }}
    .metric span {{ display:block; color:var(--muted); font-size:12px; }}
    .metric strong {{ display:block; font-size:16px; margin-top:4px; }}
    table {{ width:100%; border-collapse:collapse; min-width:880px; }}
    th,td {{ padding:8px 10px; border-bottom:1px solid var(--border); vertical-align:top; text-align:left; font-size:13px; }}
    th {{ background:#f8fafc; color:#344050; }}
    code {{ font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; }}
    .tag {{ display:inline-block; padding:2px 7px; border-radius:999px; background:#edf2f7; color:#334155; font-size:12px; white-space:nowrap; }}
    .tag.good {{ color:#0f766e; background:#e6f4f1; }}
    .tag.warn {{ color:#a15c00; background:#fff4df; }}
    .tag.bad {{ color:#b42318; background:#fdebea; }}
    .muted {{ color:var(--muted); }}
    @media (max-width:760px) {{ header {{ display:block; }} nav {{ justify-content:flex-start; margin-top:10px; }} }}
  </style>
</head>
<body>
<main data-page="menu-descriptor-stack-review">
  <header>
    <div>
      <h1>메뉴 descriptor stack 검토</h1>
      <p class="muted">ESC/X 상태창 후보를 더 파기 위해 공통 active descriptor stack과 window region 소비 경로를 분리한다.</p>
    </div>
    <nav aria-label="review links">
      <a href="index.html">관리 홈</a>
      <a href="menu_cancel_window_consumer_review.html">메뉴 취소/UI</a>
      <a href="ui_window_review.html">창/프레임</a>
      <a href="input_handler_review.html">입력 검토</a>
      <a href="../out/menu_descriptor_stack_review.json">JSON</a>
    </nav>
  </header>
  <section>
    <div class="head"><h2>요약</h2><span class="muted">{h(summary['promotion'])}</span></div>
    <div class="body"><div class="metrics">{metric_html}</div></div>
  </section>
  <section><div class="head"><h2>판정</h2></div><div class="body">{finding_rows}</div></section>
  <section><div class="head"><h2>Descriptor Routine Semantics</h2></div><div class="body">{routine_rows}</div></section>
  <section><div class="head"><h2>VM Opcode Bridge</h2></div><div class="body">{opcode_rows}</div></section>
  <section><div class="head"><h2>Globals</h2></div><div class="body">{global_rows}</div></section>
  <section><div class="head"><h2>Call Targets</h2></div><div class="body">{call_rows}</div></section>
  <section><div class="head"><h2>Region Draw Consumers</h2><span class="muted">0x0041b579 call sites</span></div><div class="body">{region_call_rows}</div></section>
  <section><div class="head"><h2>Top Menu Region Probe</h2><span class="muted">script data only</span></div><div class="body">{top_probe_rows}</div></section>
  <section>
    <div class="head"><h2>Strict Active Object Inventory Probe</h2><span class="muted">{h(strict_probe['status'])}</span></div>
    <div class="body">
      <p class="muted">{h(strict_probe['interpretation'])}</p>
      {strict_probe_rows}
    </div>
  </section>
  <section>
    <div class="head"><h2>Top Menu Object Sequence</h2><span class="muted">{h(sequence['status'])}</span></div>
    <div class="body">
      <p class="muted">중간 record {code(sequence['intermediateRecordVaHex'])} 안의 포인터 {code(sequence['intermediateScriptPointerVaHex'])}가 스크립트 {code(sequence['sequenceStartVaHex'])}를 가리킨다. 이 record 자체의 상위 선택자는 아직 미확정이다.</p>
      <p class="muted">direct refs: {' '.join(code(ref) for ref in sequence['sequenceDirectRefsHex']) or '-'}</p>
      <h3>상위 context 경고</h3>
      <p class="muted">상위 후보 {code(parent_context['scriptVaHex'])}는 동적 continuation을 통해 {code(parent_context['dynamicSelectedScriptVaHex'])}로 점프한다. 이 테이블은 인트로/타이틀 자원 문자열과 겹치므로, 아직 필드 ESC 메뉴 opener로 승격하지 않는다.</p>
      {parent_context_rows}
      <h3>동적 selected script 디코드</h3>
      <p class="muted">이 디코드는 상태/메뉴 화면 자체의 object/descriptor 흐름 증거다. 다만 상위 opener가 필드 controller의 ESC/X와 직접 연결되지는 않았으므로 opener 확정으로 보지는 않는다.</p>
      {selected_decode_rows}
      <h3>child object 생성/slot 저장</h3>
      {top_pair_rows}
      <h3>명령열</h3>
      {top_sequence_rows}
    </div>
  </section>
  <section><div class="head"><h2>Object +0x28 Region Initializers</h2><span class="muted">low16 selects region; high16 is packed type/group</span></div><div class="body">{context_region_rows}</div></section>
  <section><div class="head"><h2>Descriptor Root Closure Scan</h2><span class="muted">recursive decode of 0x442d95[id] roots</span></div><div class="body">{closure_rows}</div></section>
  <section><div class="head"><h2>Descriptor Root Table Sample</h2><span class="muted">0x00442d95[id]</span></div><div class="body">{root_rows}</div></section>
  <section><div class="head"><h2>Initial Slot Row Sample</h2><span class="muted">0x00457750 + id*0xd8; runtime overwrites parts</span></div><div class="body">{slot_rows}</div></section>
  <section><div class="head"><h2>다음 분석 타겟</h2></div><div class="body"><ul>{next_items}</ul></div></section>
  <script>window.HWANSE_MENU_DESCRIPTOR_STACK_REVIEW_READY = {json.dumps(ready, ensure_ascii=False)};</script>
</main>
</body>
</html>
"""


def main() -> None:
    OUT.mkdir(exist_ok=True)
    WEB.mkdir(exist_ok=True)
    payload = build_payload()
    JSON_OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
    html_text = render_html(payload)
    HTML_OUT.write_text(html_text, encoding="utf-8")
    WEB_HTML_OUT.write_text(html_text, encoding="utf-8")
    print(JSON_OUT)
    print(WEB_HTML_OUT)


if __name__ == "__main__":
    main()
