#!/usr/bin/env python3
"""Trace static callers of the repeated 0x442c75 object payload.

The 0x442c75 payload itself is an input-reactive display/helper loop.  This
script follows the next layer out: command streams that execute ``opcode 0x5e
mode=1`` with payload 0x442c75, and nearby command-boundary object initializers
that write a concrete script pointer into ``object +0xec``.

The goal is intentionally narrow.  A route is promoted only if a decoded stream
reaches the map loader or writes an explicit map/root/coordinate target.  CNS
resource proximity by itself remains a resource-context hint, not a route.
"""
from __future__ import annotations

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

from probe_exe_scene_tables import find_cns_strings, read_sections, va_to_offset


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

PAYLOAD_VA = 0x00442C75
MAP_LOADER_FUNCTION = 0x0042449C
GENERIC_HANDLER_TABLE = 0x00440538


OPCODE_NAMES = {
    0x00: "destroy/stop",
    0x01: "stop-tick",
    0x02: "delay/countdown",
    0x03: "jump",
    0x04: "call/push-continuation",
    0x05: "return/pop-continuation",
    0x06: "loop/wait-target",
    0x07: "create-child-object",
    0x08: "object-field-initializer",
    0x0C: "indexed-work-table-loader",
    0x10: "byte-store/expression",
    0x11: "resource/sprite-command",
    0x12: "state-compare-or-store",
    0x13: "conditional-branch",
    0x15: "conditional-branch/value",
    0x16: "resource/sprite-command",
    0x18: "dword-store/expression",
    0x1D: "input-mask-branch",
    0x20: "attach-script",
    0x21: "frame-step",
    0x24: "call-or-action",
    0x26: "resource/sprite-command",
    0x28: "runtime-object-slot",
    0x29: "runtime-object-slot-destroy",
    0x2A: "linked-object-script-fanout",
    0x2F: "prompt/text-command",
    0x84: "prompt-completion-wait",
    0x31: "byte/flag-command",
    0x32: "branch-if-flag",
    0x38: "repeat-render/palette-command",
    0x39: "render/palette-command",
    0x3F: "resource-substream-runner",
    0x40: "bind/input-base",
    0x4A: "active-object-flag-mask",
    0x4C: "delay-or-count",
    0x4E: "global-byte-store",
    0x50: "spawn-helper-sweep-strip",
    0x55: "global-position-step",
    0x58: "position/rect-command",
    0x5E: "transient-object-helper",
    0x60: "runtime-index-queue-reset",
    0x61: "runtime-index-queue-process",
    0x62: "runtime-index-add",
    0x63: "runtime-index-remove",
    0x64: "active-object-tile-to-draw-position",
    0x65: "active-object-position-branch",
    0x66: "active-object-sequence-gate",
    0x67: "active-object-position-gate",
    0x6D: "active-group-key-store",
    0x6E: "active-group-key-branch",
    0x70: "active-object-motion/state",
    0x72: "active-object-sequence-motion",
    0x7F: "skip/no-op",
    0x80: "descriptor-slot-cache-store",
    0x81: "selected-root-store",
    0x82: "selected-root-call",
    0x83: "selected-root-call-alt",
    0xD1: "expression-command",
    0xD3: "condition/test-branch",
    0xE6: "active-descriptor-count-branch",
}


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


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


def read_at(exe: bytes, sections: list[dict], va: int, size: int) -> bytes:
    offset = va_to_offset(sections, va)
    if offset is None:
        raise ValueError(f"VA {hex32(va)} is outside raw sections")
    return exe[offset: offset + size]


def u8(exe: bytes, sections: list[dict], va: int) -> int:
    return read_at(exe, sections, va, 1)[0]


def u16(exe: bytes, sections: list[dict], va: int) -> int:
    return struct.unpack("<H", read_at(exe, sections, va, 2))[0]


def u32(exe: bytes, sections: list[dict], va: int) -> int:
    return struct.unpack("<I", read_at(exe, sections, va, 4))[0]


def is_va(sections: list[dict], value: int) -> bool:
    return va_to_offset(sections, value) is not None


def handler_for(exe: bytes, sections: list[dict], opcode: int) -> int | None:
    try:
        return u32(exe, sections, GENERIC_HANDLER_TABLE + opcode * 4)
    except ValueError:
        return None


def text_preview(exe: bytes, sections: list[dict], va: int, size: int = 0x180) -> str:
    """Best-effort preview for prompt-like payloads.

    The prompt stream uses 0x40-prefixed control markers mixed with EUC-KR text.
    This preview is only for review evidence; it is not a full prompt parser.
    """
    if not is_va(sections, va):
        return ""
    raw = read_at(exe, sections, va, size)
    out = bytearray()
    index = 0
    while index < len(raw):
        byte = raw[index]
        if byte == 0x40 and index + 3 < len(raw):
            out.extend(b"\n")
            index += 4
            continue
        if byte == 0 or byte < 0x20:
            out.extend(b" ")
        else:
            out.append(byte)
        index += 1
    decoded = out.decode("cp949", "ignore")
    cleaned_lines = []
    for line in decoded.splitlines():
        line = " ".join(line.split())
        if any("\uac00" <= char <= "\ud7a3" for char in line):
            # Strip common pointer-decoding noise that remains after control markers.
            line = line.replace("뵎I", "").replace("닊I", "").strip()
            if line:
                cleaned_lines.append(line)
    return "\n".join(cleaned_lines)[:500]


def null_string_list_preview(exe: bytes, sections: list[dict], va: int, size: int = 0x180, limit: int = 12) -> list[str]:
    if not is_va(sections, va):
        return []
    raw = read_at(exe, sections, va, size)
    names: list[str] = []
    for token in raw.split(b"\x00"):
        if not token:
            break
        if any(byte < 0x20 or byte > 0x7E for byte in token):
            break
        try:
            text = token.decode("ascii")
        except UnicodeDecodeError:
            break
        if "." not in text and "_" not in text and not text.isalnum():
            break
        names.append(text)
        if len(names) >= limit:
            break
    return names


def c_string_preview(exe: bytes, sections: list[dict], va: int, max_len: int = 96) -> str:
    if not is_va(sections, va):
        return ""
    raw = read_at(exe, sections, va, max_len)
    token = raw.split(b"\x00")[0]
    if not token or any(byte < 0x20 or byte > 0x7E for byte in token):
        return ""
    try:
        return token.decode("ascii")
    except UnicodeDecodeError:
        return ""


def section_name_for(sections: list[dict], 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 decode_initializer(exe: bytes, sections: list[dict], va: int) -> dict[str, Any] | None:
    if read_at(exe, sections, va, 4) != b"\x08\x00\x00\x00":
        return None
    cursor = va + 4
    writes: list[dict[str, Any]] = []
    for _ in range(32):
        subcmd = u8(exe, sections, cursor)
        if subcmd == 0xFF:
            end_va = cursor + 4
            return {
                "va": va,
                "vaHex": hex32(va),
                "endVa": end_va,
                "endVaHex": hex32(end_va),
                "length": end_va - va,
                "writes": writes,
                "ecWrites": [row for row in writes if row["fieldOffset"] == 0xEC],
                "activeGateWrites": [
                    row
                    for row in writes
                    if row["fieldOffset"] == 0x14 and row["value"] == 0x0101
                ],
            }
        field = u8(exe, sections, cursor + 1)
        if subcmd == 1:
            value = u8(exe, sections, cursor + 2)
            writes.append({
                "width": 1,
                "fieldOffset": field,
                "field": f"+0x{field:02x}",
                "value": value,
                "valueHex": hex8(value),
            })
            cursor += 4
        elif subcmd == 2:
            value = u16(exe, sections, cursor + 2)
            writes.append({
                "width": 2,
                "fieldOffset": field,
                "field": f"+0x{field:02x}",
                "value": value,
                "valueHex": f"0x{value:04x}",
            })
            cursor += 4
        elif subcmd == 3:
            value = u32(exe, sections, cursor + 4)
            writes.append({
                "width": 4,
                "fieldOffset": field,
                "field": f"+0x{field:02x}",
                "value": value,
                "valueHex": hex32(value),
            })
            cursor += 8
        else:
            return None
    return None


def decode_indexed_work_table_loader(exe: bytes, sections: list[dict], va: int) -> dict[str, Any] | None:
    if u8(exe, sections, va) != 0x0C:
        return None
    cursor = va + 4
    writes: list[dict[str, Any]] = []
    for _ in range(4096):
        subcmd = u8(exe, sections, cursor)
        if subcmd == 0xFF:
            end_va = cursor + 4
            known_write_count = sum(1 for write in writes if write["width"] in {1, 2, 4})
            return {
                "va": va,
                "vaHex": hex32(va),
                "endVa": end_va,
                "endVaHex": hex32(end_va),
                "length": end_va - va,
                "tableVaHex": "0x0059db60",
                "knownWriteCount": known_write_count,
                "writes": writes,
            }
        index = u8(exe, sections, cursor + 1)
        if subcmd == 1:
            value = u8(exe, sections, cursor + 2)
            writes.append({
                "subcmd": subcmd,
                "index": index,
                "width": 1,
                "value": value,
                "valueHex": hex8(value),
            })
            cursor += 4
        elif subcmd == 2:
            value = u16(exe, sections, cursor + 2)
            writes.append({
                "subcmd": subcmd,
                "index": index,
                "width": 2,
                "value": value,
                "valueHex": f"0x{value:04x}",
            })
            cursor += 4
        elif subcmd == 3:
            value = u32(exe, sections, cursor + 4)
            writes.append({
                "subcmd": subcmd,
                "index": index,
                "width": 4,
                "value": value,
                "valueHex": hex32(value),
            })
            cursor += 8
        else:
            writes.append({
                "subcmd": subcmd,
                "index": index,
                "width": 0,
                "value": None,
                "valueHex": "",
            })
            cursor += 4
    return None


def command_length(exe: bytes, sections: list[dict], va: int, opcode: int) -> int:
    if opcode == 0x08:
        init = decode_initializer(exe, sections, va)
        return init["length"] if init else 4
    if opcode == 0x0C:
        loader = decode_indexed_work_table_loader(exe, sections, va)
        return loader["length"] if loader else 4
    if opcode == 0x5E:
        mode = u8(exe, sections, va + 1)
        return 8 if mode == 1 else 4
    if opcode in {0x00, 0x01, 0x02, 0x05, 0x10, 0x31, 0x40, 0x60, 0x61, 0x62, 0x63, 0x6D, 0x72, 0x7F, 0x80, 0x82, 0x83, 0x84, 0xD1}:
        return 4
    if opcode in {0x07, 0x2A, 0x2F, 0x38, 0x39, 0x3F, 0x81}:
        return 8
    if opcode in {0x03, 0x06, 0x12, 0x13, 0x18, 0x20, 0x21, 0x26, 0x32, 0x4A, 0x55, 0x58, 0x64, 0x66, 0x67, 0x6E, 0x70, 0xE6, 0xD3}:
        return 8
    if opcode == 0x04:
        return 8
    if opcode in {0x11, 0x15, 0x16, 0x1D, 0x2F, 0x46, 0x4C, 0x65}:
        return 12
    return 4


RESOURCE_MINI_OPCODE_NAMES = {
    0x00: "stop",
    0x01: "load-resource/draw-op-1",
    0x02: "load-resource/draw-op-2",
    0x03: "load-resource/draw-op-3",
    0x04: "load-resource/draw-op-4",
    0x05: "load-resource/draw-op-5",
    0x06: "load-resource/draw-op-6",
    0x07: "load-resource/draw-op-7",
    0x08: "load-resource/draw-op-8",
    0x09: "load-resource-by-name",
    0x0A: "resource-region/draw-command",
    0x0B: "release/clear-resource",
    0x0C: "resource-index-command",
    0x0D: "refresh-resource-state",
    0x0F: "resource-callback-command",
    0x10: "load-map/tile-resource",
    0x11: "load-resource/draw-op-0x11",
    0x12: "load-resource/draw-op-0x12",
    0x13: "load-resource/draw-op-0x13",
    0x14: "load-resource/draw-op-0x14",
    0x16: "load-resource/draw-op-0x16",
    0x17: "load-resource/draw-op-0x17",
    0x18: "set-resource-runtime-mode",
}


RESOURCE_MINI_LENGTHS = {
    0x00: 1,
    0x01: 12,
    0x02: 12,
    0x03: 16,
    0x04: 12,
    0x05: 12,
    0x06: 12,
    0x07: 16,
    0x08: 12,
    0x09: 8,
    0x0A: 12,
    0x0B: 4,
    0x0C: 4,
    0x0D: 4,
    0x0F: 8,
    0x10: 8,
    0x11: 12,
    0x12: 12,
    0x13: 16,
    0x14: 12,
    0x16: 12,
    0x17: 12,
    0x18: 4,
}


def decode_resource_mini_command(exe: bytes, sections: list[dict], va: int) -> dict[str, Any]:
    opcode = u8(exe, sections, va)
    if opcode > 0x20:
        return {
            "va": va,
            "vaHex": hex32(va),
            "opcode": opcode,
            "opcodeHex": hex8(opcode),
            "opcodeName": "end-marker",
            "length": 0,
            "rawHex": "",
            "summary": f"resource mini-stream ends before byte {hex8(opcode)} (> 0x20)",
            "stop": True,
        }
    length = RESOURCE_MINI_LENGTHS.get(opcode, 4)
    raw = read_at(exe, sections, va, length)
    row: dict[str, Any] = {
        "va": va,
        "vaHex": hex32(va),
        "opcode": opcode,
        "opcodeHex": hex8(opcode),
        "opcodeName": RESOURCE_MINI_OPCODE_NAMES.get(opcode, "unknown-resource-mini-op"),
        "length": length,
        "rawHex": raw.hex(" "),
    }
    if opcode == 0x00:
        row["summary"] = "resource mini-stream stop"
        row["stop"] = True
        return row
    dword_tail_ops = {0x01, 0x04, 0x05, 0x08, 0x11, 0x14, 0x17}
    word_pair_tail_ops = {0x02, 0x06, 0x12, 0x16}
    if opcode in dword_tail_ops and length >= 12:
        slot = u8(exe, sections, va + 1)
        resource_id = u16(exe, sections, va + 2)
        name_va = u32(exe, sections, va + 4)
        tail = u32(exe, sections, va + 8)
        name = c_string_preview(exe, sections, name_va)
        tail_name = c_string_preview(exe, sections, tail)
        row.update({
            "slot": slot,
            "resourceId": resource_id,
            "resourceIdHex": f"0x{resource_id:04x}",
            "nameVa": name_va,
            "nameVaHex": hex32(name_va),
            "name": name,
            "tail": tail,
            "tailHex": hex32(tail),
            "tailName": tail_name,
        })
        row["summary"] = (
            f"{row['opcodeName']} slot={hex8(slot)} resource={resource_id:#06x} "
            f"name={name or hex32(name_va)} tail={tail_name or hex32(tail)}"
        )
    elif opcode in word_pair_tail_ops and length >= 12:
        slot = u8(exe, sections, va + 1)
        resource_id = u16(exe, sections, va + 2)
        name_va = u32(exe, sections, va + 4)
        arg_a = u16(exe, sections, va + 8)
        arg_b = u16(exe, sections, va + 10)
        name = c_string_preview(exe, sections, name_va)
        row.update({
            "slot": slot,
            "resourceId": resource_id,
            "resourceIdHex": f"0x{resource_id:04x}",
            "nameVa": name_va,
            "nameVaHex": hex32(name_va),
            "name": name,
            "argA": arg_a,
            "argB": arg_b,
        })
        row["summary"] = (
            f"{row['opcodeName']} slot={hex8(slot)} resource={resource_id:#06x} "
            f"name={name or hex32(name_va)} args=0x{arg_a:04x},0x{arg_b:04x}"
        )
    elif opcode in {0x03, 0x07, 0x13} and length >= 16:
        slot = u8(exe, sections, va + 1)
        resource_id = u16(exe, sections, va + 2)
        name_va = u32(exe, sections, va + 4)
        arg_a = u16(exe, sections, va + 8)
        arg_b = u16(exe, sections, va + 10)
        arg_c = u32(exe, sections, va + 12)
        name = c_string_preview(exe, sections, name_va)
        row.update({
            "slot": slot,
            "resourceId": resource_id,
            "resourceIdHex": f"0x{resource_id:04x}",
            "nameVa": name_va,
            "nameVaHex": hex32(name_va),
            "name": name,
            "argA": arg_a,
            "argB": arg_b,
            "argC": arg_c,
            "argCHex": hex32(arg_c),
        })
        row["summary"] = (
            f"{row['opcodeName']} slot={hex8(slot)} resource={resource_id:#06x} "
            f"name={name or hex32(name_va)} args=0x{arg_a:04x},0x{arg_b:04x},{hex32(arg_c)}"
        )
    elif opcode == 0x09 and length >= 8:
        slot = u8(exe, sections, va + 1)
        name_va = u32(exe, sections, va + 4)
        name = c_string_preview(exe, sections, name_va)
        row.update({"slot": slot, "nameVa": name_va, "nameVaHex": hex32(name_va), "name": name})
        row["summary"] = f"load resource by name slot={hex8(slot)} name={name or hex32(name_va)}"
    elif opcode == 0x0A and length >= 12:
        mode = u8(exe, sections, va + 1)
        resource_id = u16(exe, sections, va + 2)
        arg_a = u16(exe, sections, va + 4)
        arg_b = u16(exe, sections, va + 6)
        arg_c = u16(exe, sections, va + 8)
        arg_d = u16(exe, sections, va + 10)
        row.update({
            "mode": mode,
            "resourceId": resource_id,
            "resourceIdHex": f"0x{resource_id:04x}",
            "args": [arg_a, arg_b, arg_c, arg_d],
        })
        row["summary"] = (
            f"resource-region/draw mode={hex8(mode)} resource={resource_id:#06x} "
            f"args=({arg_a},{arg_b},{arg_c},{arg_d})"
        )
    elif opcode in {0x0B, 0x0C} and length >= 4:
        resource_id = u16(exe, sections, va + 2)
        row.update({"resourceId": resource_id, "resourceIdHex": f"0x{resource_id:04x}"})
        row["summary"] = f"{row['opcodeName']} resource={resource_id:#06x}"
    elif opcode == 0x0D:
        row["summary"] = "refresh resource state"
    elif opcode in {0x0F, 0x10} and length >= 8:
        mode = u8(exe, sections, va + 1)
        name_va = u32(exe, sections, va + 4)
        name = c_string_preview(exe, sections, name_va)
        row.update({"mode": mode, "nameVa": name_va, "nameVaHex": hex32(name_va), "name": name})
        row["summary"] = f"{row['opcodeName']} mode={hex8(mode)} name={name or hex32(name_va)}"
    elif opcode == 0x18:
        mode = u8(exe, sections, va + 1)
        row.update({"mode": mode})
        row["summary"] = f"set resource runtime mode={hex8(mode)}"
    else:
        row["summary"] = row["opcodeName"]
    return row


def decode_resource_mini_stream(exe: bytes, sections: list[dict], va: int, max_commands: int = 32) -> dict[str, Any]:
    commands: list[dict[str, Any]] = []
    cursor = va
    for _ in range(max_commands):
        if not is_va(sections, cursor):
            break
        row = decode_resource_mini_command(exe, sections, cursor)
        commands.append(row)
        if row.get("stop") or row.get("length", 0) <= 0:
            break
        cursor += int(row["length"])
    return {
        "va": va,
        "vaHex": hex32(va),
        "commands": commands,
    }


def decode_command(exe: bytes, sections: list[dict], va: int) -> dict[str, Any]:
    opcode = u8(exe, sections, va)
    length = command_length(exe, sections, va, opcode)
    raw = read_at(exe, sections, va, length)
    row: dict[str, Any] = {
        "va": va,
        "vaHex": hex32(va),
        "opcode": opcode,
        "opcodeHex": hex8(opcode),
        "opcodeName": OPCODE_NAMES.get(opcode, "unknown"),
        "handlerVaHex": hex32(handler_for(exe, sections, opcode)),
        "length": length,
        "rawHex": raw.hex(" "),
    }
    if opcode == 0x08:
        init = decode_initializer(exe, sections, va)
        if init:
            row.update(init)
            row["summary"] = "object field initializer"
            if init["ecWrites"]:
                row["summary"] += f"; writes object +0xec -> {init['ecWrites'][0]['valueHex']}"
            if init["activeGateWrites"]:
                row["summary"] += "; writes object +0x14 = 0x0101"
        return row
    if opcode == 0x0C:
        loader = decode_indexed_work_table_loader(exe, sections, va)
        if loader:
            row.update(loader)
            preview = ", ".join(
                f"[{write['index']}]={write['valueHex']}"
                for write in loader["writes"][:6]
            )
            row["summary"] = (
                "indexed work/global table loader; "
                f"entries={len(loader['writes'])} knownWrites={loader.get('knownWriteCount', 0)} "
                "table=0x0059db60"
                + (f" sample {preview}" if preview else "")
            )
        return row
    if opcode == 0x03 and length >= 8:
        target = u32(exe, sections, va + 4)
        row["targetVa"] = target
        row["targetVaHex"] = hex32(target)
        row["summary"] = f"jump -> {hex32(target)}"
    elif opcode == 0x02:
        count = u16(exe, sections, va + 2)
        row["count"] = count
        row["summary"] = f"delay/countdown count={count}"
    elif opcode == 0x06 and length >= 8:
        count = u16(exe, sections, va + 2)
        target = u32(exe, sections, va + 4)
        row["count"] = count
        row["targetVa"] = target
        row["targetVaHex"] = hex32(target)
        row["summary"] = (
            f"loop/wait target={hex32(target)} count={count}; "
            "handler stores target in object+0x3c and count in object+0x60 on first pass, "
            "then jumps back to object+0x3c until the countdown reaches zero"
        )
        if is_va(sections, target):
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": target,
                "valueHex": hex32(target),
                "section": section_name_for(sections, target),
            }]
    elif opcode == 0x07 and length >= 8:
        kind = u8(exe, sections, va + 1)
        child_script = u32(exe, sections, va + 4)
        row["kind"] = kind
        row["childScriptVa"] = child_script
        row["childScriptVaHex"] = hex32(child_script)
        row["summary"] = f"create child object kind={hex8(kind)} script={hex32(child_script)}; handler advances +0x08"
        if is_va(sections, child_script):
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": child_script,
                "valueHex": hex32(child_script),
                "section": section_name_for(sections, child_script),
            }]
    elif opcode == 0x5E:
        mode = u8(exe, sections, va + 1)
        row["mode"] = mode
        row["summary"] = f"transient helper mode {mode}"
        if mode == 1 and length >= 8:
            payload = u32(exe, sections, va + 4)
            row["payloadVa"] = payload
            row["payloadVaHex"] = hex32(payload)
            row["summary"] += f"; payload {hex32(payload)}"
    elif opcode in {0x60, 0x61, 0x62, 0x63}:
        if opcode == 0x60:
            row["summary"] = "reset runtime descriptor index queue at 0x004576e8; handler advances +0x04"
        elif opcode == 0x61:
            row["summary"] = "process/rebuild runtime descriptor index queue at 0x004576e8; handler advances +0x04"
        else:
            operand = u8(exe, sections, va + 1)
            row["operandIndex"] = operand
            row["summary"] = (
                ("add" if opcode == 0x62 else "remove")
                + f" runtime descriptor index operand={hex8(operand)} via 0x004576e8 queue; handler advances +0x04"
            )
    elif opcode == 0x64 and length >= 8:
        mode = u8(exe, sections, va + 1)
        x = u16(exe, sections, va + 2)
        y = u16(exe, sections, va + 4)
        object_id = u8(exe, sections, va + 6)
        state = u8(exe, sections, va + 7)
        row["mode"] = mode
        row["x"] = x
        row["y"] = y
        row["objectId"] = object_id
        row["state"] = state
        row["summary"] = (
            "tile-to-draw position "
            f"mode={mode} tile=({x},{y}) objectId=0x{object_id:02x} state=0x{state:02x}"
        )
    elif opcode == 0x04:
        target = u32(exe, sections, va + 4)
        row["targetVa"] = target
        row["targetVaHex"] = hex32(target)
        row["continuationVa"] = va + 8
        row["continuationVaHex"] = hex32(va + 8)
        row["summary"] = (
            f"call/push-continuation -> {hex32(target)}; "
            f"pushes return address {hex32(va + 8)} to object return stack object+0x44[object+0x5c], "
            "increments object+0x5c, then jumps to target"
        )
        if is_va(sections, target):
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": target,
                "valueHex": hex32(target),
                "section": section_name_for(sections, target),
            }]

    elif opcode == 0x05:
        row["summary"] = (
            "return/pop-continuation; decrements object+0x5c and resumes at "
            "object return stack object+0x44[object+0x5c]"
        )

    elif opcode == 0x2F and length >= 8:
        mode = u8(exe, sections, va + 1)
        payload = u32(exe, sections, va + 4)
        row["mode"] = mode
        row["payloadVa"] = payload
        row["payloadVaHex"] = hex32(payload)
        row["summary"] = f"prompt/text command mode={hex8(mode)} payload={hex32(payload)}; handler advances +0x08"
        if is_va(sections, payload):
            value_row = {
                "offset": 4,
                "offsetHex": "+0x04",
                "value": payload,
                "valueHex": hex32(payload),
                "section": section_name_for(sections, payload),
            }
            preview = text_preview(exe, sections, payload)
            if preview:
                value_row["textPreview"] = preview
                row["textPayloadRefs"] = [value_row]
                first_line = preview.splitlines()[0] if preview.splitlines() else preview
                row["summary"] += f"; text payload: {first_line}"
            row["pointerOperands"] = [value_row]
    elif opcode == 0x84:
        row["summary"] = "prompt-completion wait; holds VM until global prompt callback clears"
    elif opcode == 0x1D and length >= 12:
        mode = u8(exe, sections, va + 1)
        mask = u32(exe, sections, va + 4)
        target = u32(exe, sections, va + 8)
        row["mode"] = mode
        row["mask"] = mask
        row["maskHex"] = hex32(mask)
        row["targetVa"] = target
        row["targetVaHex"] = hex32(target)
        row["summary"] = (
            f"input-mask branch mode={hex8(mode)} mask={hex32(mask)} target={hex32(target)}; "
            "handler either advances +0x0c or jumps to target"
        )
        if is_va(sections, target):
            row["pointerOperands"] = [{
                "offset": 8,
                "offsetHex": "+0x08",
                "value": target,
                "valueHex": hex32(target),
                "section": section_name_for(sections, target),
            }]
    elif opcode == 0x24:
        mode = u8(exe, sections, va + 1)
        sub_a = u8(exe, sections, va + 2)
        sub_b = u8(exe, sections, va + 3)
        row["mode"] = mode
        row["subA"] = sub_a
        row["subB"] = sub_b
        row["summary"] = (
            f"action/evaluate mode={hex8(mode)} args={hex8(sub_a)},{hex8(sub_b)}; "
            "handler advances +4"
        )
    elif opcode == 0x28:
        mode = u8(exe, sections, va + 1)
        slot = u8(exe, sections, va + 2)
        row["mode"] = mode
        row["slot"] = slot
        row["summary"] = (
            ("store current child object" if mode == 0 else "load runtime object slot")
            + f" slot={slot} through 0x0059dd70[{slot}]; handler advances +0x04"
        )
    elif opcode == 0x29:
        slot = u8(exe, sections, va + 1)
        row["slot"] = slot
        row["summary"] = f"destroy/clear runtime object slot={slot} through 0x0059dd70[{slot}]; handler advances +0x04"
    elif opcode == 0x2A and length >= 8:
        raw_slot = u8(exe, sections, va + 1)
        slot = raw_slot & 0x7F
        restore_cursor = bool(raw_slot & 0x80)
        mask = u16(exe, sections, va + 2)
        child_script = u32(exe, sections, va + 4)
        row.update(
            {
                "rawSlot": raw_slot,
                "slot": slot,
                "restoreCursor": restore_cursor,
                "filterMask": mask,
                "filterMaskHex": f"0x{mask:04x}",
                "childScriptVa": child_script,
                "childScriptVaHex": hex32(child_script),
            }
        )
        row["summary"] = (
            "linked-object script fanout; "
            f"slot={slot} restoreChildCursor={restore_cursor} object+0x14 mask=0x{mask:04x} "
            f"childScript={hex32(child_script)}; parent stream advances +0x08"
        )
        if is_va(sections, child_script):
            row["pointerOperands"] = [
                {
                    "offset": 4,
                    "offsetHex": "+0x04",
                    "value": child_script,
                    "valueHex": hex32(child_script),
                    "section": section_name_for(sections, child_script),
                }
            ]
    elif opcode == 0x4A and length >= 8:
        mode = u8(exe, sections, va + 1)
        filter_word = u16(exe, sections, va + 2)
        flags = u32(exe, sections, va + 4)
        row["mode"] = mode
        row["filterWord"] = filter_word
        row["filterWordHex"] = f"0x{filter_word:04x}"
        row["flags"] = flags
        row["flagsHex"] = hex32(flags)
        row["summary"] = (
            "active-object flag "
            + ("set" if mode == 1 else "clear" if mode == 0 else f"mode {mode}")
            + f"; filter object+0x14 mask={filter_word:#06x}; flags={hex32(flags)}"
        )
    elif opcode == 0x38 and length >= 8:
        mode = u8(exe, sections, va + 1)
        row["mode"] = mode
        if mode == 0:
            count = u8(exe, sections, va + 2)
            value = u32(exe, sections, va + 4)
            row.update({
                "repeatCount": count,
                "value": value,
                "valueHex": hex32(value),
                "calledFunctionVaHex": "0x004010d2",
            })
            row["summary"] = (
                "repeat render/palette command mode=0; "
                f"count={count} value={hex32(value)}; calls 0x004010d2 once per repeat"
            )
        elif mode == 1:
            arg_a = u8(exe, sections, va + 2)
            arg_b = u8(exe, sections, va + 3)
            value = u32(exe, sections, va + 4)
            count = u8(exe, sections, va + 7)
            row.update({
                "argA": arg_a,
                "argB": arg_b,
                "value": value,
                "valueHex": hex32(value),
                "repeatCount": count,
                "calledFunctionVaHex": "0x00401518",
            })
            row["summary"] = (
                "repeat render/palette command mode=1; "
                f"args={hex8(arg_a)},{hex8(arg_b)} value={hex32(value)} count={count}; "
                "passes stream+4 plus args to 0x00401518 once per repeat"
            )
        elif mode == 2:
            arg_a = u8(exe, sections, va + 2)
            arg_b = u8(exe, sections, va + 3)
            count = u8(exe, sections, va + 4)
            row.update({
                "argA": arg_a,
                "argB": arg_b,
                "repeatCount": count,
                "calledFunctionVaHex": "0x0040146a",
            })
            row["summary"] = (
                "repeat render/palette command mode=2; "
                f"args={hex8(arg_a)},{hex8(arg_b)} count={count}; "
                "calls 0x0040146a once per repeat"
            )
        else:
            row["summary"] = f"repeat render/palette command unknown mode={hex8(mode)}; handler still advances +0x08"
    elif opcode == 0x39 and length >= 8:
        mode = u8(exe, sections, va + 1)
        row["mode"] = mode
        if mode in {0, 3}:
            value = u32(exe, sections, va + 4)
            row["value"] = value
            row["valueHex"] = hex32(value)
            row["calledFunctionVaHex"] = "0x00401036" if mode == 0 else "0x00401699"
            row["summary"] = (
                f"render/palette command mode={mode}; value={hex32(value)}; "
                f"calls {row['calledFunctionVaHex']}; handler advances +0x08"
            )
            if is_va(sections, value):
                row["pointerOperands"] = [{
                    "offset": 4,
                    "offsetHex": "+0x04",
                    "value": value,
                    "valueHex": hex32(value),
                    "section": section_name_for(sections, value),
                }]
        elif mode in {1, 2}:
            arg_a = u8(exe, sections, va + 2)
            arg_b = u8(exe, sections, va + 3)
            arg_c = u8(exe, sections, va + 4)
            row.update({
                "argA": arg_a,
                "argB": arg_b,
                "argC": arg_c,
                "calledFunctionVaHex": "0x004015b9" if mode == 1 else "0x00401632",
            })
            row["summary"] = (
                f"render/palette command mode={mode}; "
                f"args={hex8(arg_a)},{hex8(arg_b)},{hex8(arg_c)}; "
                f"calls {row['calledFunctionVaHex']}; handler advances +0x08"
            )
        else:
            payload = u32(exe, sections, va + 4)
            row["payloadVa"] = payload
            row["payloadVaHex"] = hex32(payload)
            row["summary"] = f"render/palette command unknown mode={hex8(mode)} payload={hex32(payload)}; handler advances +0x08"
        if "pointerOperands" not in row and row.get("payloadVa") is not None and is_va(sections, row["payloadVa"]):
            payload = row["payloadVa"]
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": payload,
                "valueHex": hex32(payload),
                "section": section_name_for(sections, payload),
            }]
    elif opcode == 0x46 and length >= 12:
        mode = u8(exe, sections, va + 1)
        resource_id = u16(exe, sections, va + 2)
        x0 = u16(exe, sections, va + 4)
        y0 = u16(exe, sections, va + 6)
        x1 = u16(exe, sections, va + 8)
        y1 = u16(exe, sections, va + 10)
        row.update({
            "mode": mode,
            "resourceId": resource_id,
            "resourceIdHex": f"0x{resource_id:04x}",
            "rect": [x0, y0, x1, y1],
        })
        row["summary"] = (
            f"draw/fill rect mode={hex8(mode)} resource={resource_id:#06x} "
            f"rect=({x0},{y0})-({x1},{y1}); handler advances +0x0c"
        )
    elif opcode == 0x4E:
        value = u8(exe, sections, va + 1)
        row["value"] = value
        row["summary"] = f"store byte {hex8(value)} into global 0x0059e37c; handler advances +0x04"
    elif opcode == 0x26 and length >= 8:
        mode = u8(exe, sections, va + 1) & 0x7F
        submode = u8(exe, sections, va + 2) & 0x1F
        high_flags = u8(exe, sections, va + 2) & 0xE0
        aux = u8(exe, sections, va + 3)
        payload = u32(exe, sections, va + 4)
        row.update({
            "mode": mode,
            "submode": submode,
            "highFlags": high_flags,
            "aux": aux,
            "payloadVa": payload,
            "payloadVaHex": hex32(payload),
        })
        if mode == 0x30:
            names = null_string_list_preview(exe, sections, payload)
            row["resourceNames"] = names
            row["summary"] = (
                f"resource list install/load mode=0x30 sub={hex8(submode)} "
                f"high={hex8(high_flags)} aux={hex8(aux)} payload={hex32(payload)}; "
                "handler stores payload to 0x0059e2d4 and calls resource loader 0x0042cf5a"
            )
            if names:
                row["summary"] += "; resources: " + ", ".join(names[:5]) + (" ..." if len(names) > 5 else "")
        else:
            row["summary"] = (
                f"resource/sprite command mode={hex8(mode)} sub={hex8(submode)} "
                f"high={hex8(high_flags)} aux={hex8(aux)} payload={hex32(payload)}"
            )
        if is_va(sections, payload):
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": payload,
                "valueHex": hex32(payload),
                "section": section_name_for(sections, payload),
            }]
    elif opcode == 0x11 and length >= 12:
        group = u8(exe, sections, va + 1)
        field = u16(exe, sections, va + 2)
        literal = u32(exe, sections, va + 4)
        arg_a = u16(exe, sections, va + 8)
        arg_b = u16(exe, sections, va + 10)
        row.update({
            "group": group,
            "fieldOffset": field,
            "literal": literal,
            "argA": arg_a,
            "argB": arg_b,
        })
        row["summary"] = (
            "field arithmetic/write command; "
            f"group={hex8(group)} field=+0x{field:04x} literal={literal} "
            f"args=0x{arg_a:04x},0x{arg_b:04x}"
        )
        if field in {0x00E8, 0x00EA}:
            row["summary"] += "; field matches active object position word candidate"
        if is_va(sections, literal):
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": literal,
                "valueHex": hex32(literal),
                "section": section_name_for(sections, literal),
            }]
    elif opcode == 0x12 and length >= 8:
        group = u8(exe, sections, va + 1)
        field = u16(exe, sections, va + 2)
        literal = u32(exe, sections, va + 4)
        row.update({
            "group": group,
            "fieldOffset": field,
            "literal": literal,
        })
        row["summary"] = (
            "dword field arithmetic/write command; "
            f"group={hex8(group)} field=+0x{field:04x} literal={literal}"
        )
        if field == 0x00EC:
            row["summary"] += "; field matches active object script pointer slot candidate"
        if is_va(sections, literal):
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": literal,
                "valueHex": hex32(literal),
                "section": section_name_for(sections, literal),
            }]
    elif opcode == 0x13 and length >= 8:
        mode = u8(exe, sections, va + 1)
        test_value = u16(exe, sections, va + 2)
        target = u32(exe, sections, va + 4)
        row["mode"] = mode
        row["testValue"] = test_value
        row["targetVa"] = target
        row["targetVaHex"] = hex32(target)
        row["summary"] = (
            "conditional branch via compare helper; "
            f"mode={hex8(mode)} test=0x{test_value:04x} -> {hex32(target)}; else +0x08"
        )
        if is_va(sections, target):
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": target,
                "valueHex": hex32(target),
                "section": section_name_for(sections, target),
            }]
    elif opcode == 0x31:
        mode = u8(exe, sections, va + 1)
        bit = u16(exe, sections, va + 2)
        row["mode"] = mode
        row["bitIndex"] = bit
        row["summary"] = (
            f"{'set' if mode == 1 else 'clear' if mode == 0 else 'update'} global flag bit {bit} "
            "in 0x0059db60 bitset"
        )
    elif opcode == 0x3F and length >= 8:
        payload = u32(exe, sections, va + 4)
        row["payloadVa"] = payload
        row["payloadVaHex"] = hex32(payload)
        row["summary"] = f"resource mini-stream runner via 0x00423a2f payload={hex32(payload)}; handler advances +0x08"
        if is_va(sections, payload):
            mini = decode_resource_mini_stream(exe, sections, payload, max_commands=8)
            row["resourceMiniStream"] = mini
            mini_summaries = [
                command.get("summary", "")
                for command in mini.get("commands", [])
                if not command.get("stop") and command.get("summary")
            ]
            if mini_summaries:
                row["summary"] += "; " + " / ".join(mini_summaries[:2])
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": payload,
                "valueHex": hex32(payload),
                "section": section_name_for(sections, payload),
            }]
    elif opcode == 0x32 and length >= 8:
        expected = u8(exe, sections, va + 1)
        bit = u16(exe, sections, va + 2)
        target = u32(exe, sections, va + 4)
        row["expected"] = expected
        row["bitIndex"] = bit
        row["targetVa"] = target
        row["targetVaHex"] = hex32(target)
        row["summary"] = (
            f"branch if global flag bit {bit} == {expected} -> {hex32(target)}; else +0x08"
        )
    elif opcode == 0x6D:
        group = u8(exe, sections, va + 1)
        slot = u8(exe, sections, va + 2)
        row["group"] = group
        row["slot"] = slot
        row["summary"] = (
            f"store active script group key 0x574544={hex8(group)}, 0x574543={hex8(slot)}; "
            "handler advances +0x04"
        )
    elif opcode == 0x6E:
        group = u8(exe, sections, va + 1)
        slot = u8(exe, sections, va + 2)
        target = u32(exe, sections, va + 4)
        row["group"] = group
        row["slot"] = slot
        row["targetVa"] = target
        row["targetVaHex"] = hex32(target)
        row["summary"] = (
            f"branch if active script group key equals ({hex8(group)},{hex8(slot)}) "
            f"-> {hex32(target)}; else advances +0x08"
        )
        if is_va(sections, target):
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": target,
                "valueHex": hex32(target),
                "section": section_name_for(sections, target),
            }]
    elif opcode == 0x55 and length >= 8:
        advance_mode = u8(exe, sections, va + 1)
        axis_mode = u8(exe, sections, va + 2)
        x_value = u16(exe, sections, va + 4)
        y_value = u16(exe, sections, va + 6)
        row["summary"] = (
            "step global position words 0x4576dc/0x4576de toward target; "
            f"advanceMode={hex8(advance_mode)} axis={hex8(axis_mode)} target=({x_value},{y_value})"
        )
    elif opcode == 0x65 and length >= 12:
        mode = u8(exe, sections, va + 1)
        x_value = u16(exe, sections, va + 2)
        y_value = u16(exe, sections, va + 4)
        object_kind = u8(exe, sections, va + 6)
        target = u32(exe, sections, va + 8)
        row["targetVa"] = target
        row["targetVaHex"] = hex32(target)
        row["summary"] = (
            "branch if active object position matches; "
            f"mode={hex8(mode)} x={x_value} y={y_value} kind={hex8(object_kind)} -> {hex32(target)}; else +0x0c"
        )
    elif opcode == 0x66 and length >= 8:
        mode = u8(exe, sections, va + 1)
        object_kind = u8(exe, sections, va + 2)
        queued_value = u8(exe, sections, va + 3)
        sequence_target = u8(exe, sections, va + 4)
        row["summary"] = (
            "scan active objects by object+0x16; compare/increment object+0x70; "
            f"mode={hex8(mode)} kind={hex8(object_kind)} value={hex8(queued_value)} target={hex8(sequence_target)}"
        )
    elif opcode == 0x67 and length >= 8:
        mode = u8(exe, sections, va + 1)
        object_kind = u8(exe, sections, va + 2)
        axis_mode = u8(exe, sections, va + 3)
        x_value = u16(exe, sections, va + 4)
        y_value = u16(exe, sections, va + 6)
        row["summary"] = (
            "scan active objects by object+0x16 and compare object+0xe8/+0xea positions; "
            f"mode={hex8(mode)} kind={hex8(object_kind)} axis={hex8(axis_mode)} x={x_value} y={y_value}"
        )
    elif opcode == 0x70 and length >= 8:
        mode = u8(exe, sections, va + 1)
        x_value = u16(exe, sections, va + 2)
        y_value = u16(exe, sections, va + 4)
        object_kind = u8(exe, sections, va + 6)
        state = u8(exe, sections, va + 7)
        row["summary"] = (
            "scan linked active objects and set object position/motion/state fields; "
            f"mode={hex8(mode)} x={x_value} y={y_value} kind={hex8(object_kind)} state={hex8(state)}"
        )
    elif opcode == 0x72:
        mode = u8(exe, sections, va + 1)
        object_kind = u8(exe, sections, va + 2)
        state = u8(exe, sections, va + 3)
        row["summary"] = (
            "active object sequence/motion scan; "
            f"mode={hex8(mode)} kind={hex8(object_kind)} state={hex8(state)}"
        )
    elif opcode == 0x7F:
        row["summary"] = "skip/no-op; handler advances +0x04"
    elif opcode == 0xE6:
        target = u32(exe, sections, va + 4)
        row["targetVa"] = target
        row["targetVaHex"] = hex32(target)
        row["summary"] = (
            "branch on active descriptor count 0x004576e8; "
            f"if count is zero -> {hex32(target)}, otherwise advances +0x08"
        )
        if is_va(sections, target):
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": target,
                "valueHex": hex32(target),
                "section": section_name_for(sections, target),
            }]
    elif opcode == 0x80:
        slot = u8(exe, sections, va + 1)
        descriptor_index = u8(exe, sections, va + 2)
        descriptor_va = 0x00457750 + descriptor_index * 0xD8
        row["slot"] = slot
        row["descriptorIndex"] = descriptor_index
        row["descriptorVa"] = descriptor_va
        row["descriptorVaHex"] = hex32(descriptor_va)
        row["summary"] = (
            "cache descriptor base into 0x0059db30 slot table; "
            f"slot={hex8(slot)} descriptorIndex={hex8(descriptor_index)} "
            f"descriptorBase={hex32(descriptor_va)}"
        )
    elif opcode == 0x81 and length >= 8:
        slot = u8(exe, sections, va + 1)
        table = u32(exe, sections, va + 4)
        row["slot"] = slot
        row["tableVa"] = table
        row["tableVaHex"] = hex32(table)
        row["summary"] = (
            "store selected root from inline pointer table into 0x0059de30; "
            f"slot={hex8(slot)} table={hex32(table)}; handler advances +0x08"
        )
        if is_va(sections, table):
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": table,
                "valueHex": hex32(table),
                "section": section_name_for(sections, table),
            }]
    elif opcode == 0x82:
        row["summary"] = (
            "call selected root if 0x0059de30 is set; handler first advances +0x04, "
            "pushes that continuation, then jumps to 0x0059de30"
        )
    elif opcode == 0x83:
        row["summary"] = "selected-root alternate call/cleanup helper; handler advances +0x04"
    elif opcode == 0xD3 and length >= 8:
        mode = u8(exe, sections, va + 1)
        test_value = u16(exe, sections, va + 2)
        target = u32(exe, sections, va + 4)
        row["mode"] = mode
        row["testValue"] = test_value
        row["targetVa"] = target
        row["targetVaHex"] = hex32(target)
        row["summary"] = f"condition/test mode={hex8(mode)} value={test_value} -> {hex32(target)}"
        if is_va(sections, target):
            row["pointerOperands"] = [{
                "offset": 4,
                "offsetHex": "+0x04",
                "value": target,
                "valueHex": hex32(target),
                "section": section_name_for(sections, target),
            }]
    elif length >= 8:
        values = []
        for offset in range(4, length, 4):
            value = u32(exe, sections, va + offset)
            if is_va(sections, value) or value == MAP_LOADER_FUNCTION:
                value_row = {
                    "offset": offset,
                    "offsetHex": f"+0x{offset:02x}",
                    "value": value,
                    "valueHex": hex32(value),
                    "section": section_name_for(sections, value),
                }
                if opcode == 0x2F and value_row["section"] == ".data":
                    preview = text_preview(exe, sections, value)
                    if preview:
                        value_row["textPreview"] = preview
                values.append(value_row)
        if values:
            row["pointerOperands"] = values
            preview_values = [
                item["textPreview"].splitlines()[0]
                for item in values
                if item.get("textPreview")
            ]
            if preview_values:
                row["textPayloadRefs"] = [item for item in values if item.get("textPreview")]
                row["summary"] = "text payload: " + " / ".join(preview_values[:2])
            else:
                row["summary"] = ", ".join(item["valueHex"] for item in values)
    return row


def decode_stream(
    exe: bytes,
    sections: list[dict],
    start_va: int,
    max_commands: int = 32,
    max_bytes: int = 0x180,
) -> dict[str, Any]:
    rows: list[dict[str, Any]] = []
    cursor = start_va
    visited: set[int] = set()
    route_proof = False
    map_loader_ref = False
    object_ec_writes: list[dict[str, Any]] = []
    text_payload_refs: list[dict[str, Any]] = []
    for _ in range(max_commands):
        if cursor in visited:
            rows.append({
                "va": cursor,
                "vaHex": hex32(cursor),
                "opcodeHex": "",
                "opcodeName": "loop-detected",
                "length": 0,
                "summary": "decode stopped because this cursor was already visited",
            })
            break
        if cursor - start_va >= max_bytes:
            break
        visited.add(cursor)
        try:
            row = decode_command(exe, sections, cursor)
        except ValueError:
            break
        rows.append(row)
        if row.get("targetVa") == MAP_LOADER_FUNCTION or row.get("payloadVa") == MAP_LOADER_FUNCTION:
            map_loader_ref = True
            route_proof = True
        for operand in row.get("pointerOperands") or []:
            if operand.get("value") == MAP_LOADER_FUNCTION:
                map_loader_ref = True
                route_proof = True
        if row.get("opcode") == 0x08 and row.get("ecWrites"):
            object_ec_writes.extend(row["ecWrites"])
        if row.get("textPayloadRefs"):
            text_payload_refs.extend(row["textPayloadRefs"])
        if row.get("opcode") == 0x00:
            break
        if row.get("opcode") == 0x03:
            break
        if row.get("opcode") == 0x05:
            break
        cursor += int(row.get("length", 4) or 4)
    return {
        "startVa": start_va,
        "startVaHex": hex32(start_va),
        "commands": rows,
        "objectEcWrites": object_ec_writes,
        "textPayloadRefs": text_payload_refs,
        "mapLoaderRefFound": map_loader_ref,
        "routeProofFound": route_proof,
        "decodedCommandCount": len(rows),
    }


def find_payload_callers(exe: bytes, sections: list[dict]) -> list[int]:
    pattern = b"\x5e\x01\x00\x00" + struct.pack("<I", PAYLOAD_VA)
    callers: list[int] = []
    for section in sections:
        if section["name"] not in {".data", ".rdata", ".text"}:
            continue
        raw_start = int(section["raw"])
        raw_end = raw_start + int(section["raw_size"])
        data = exe[raw_start:raw_end]
        index = 0
        while True:
            hit = data.find(pattern, index)
            if hit < 0:
                break
            callers.append(int(section["va"]) + hit)
            index = hit + 1
    return sorted(callers)


def cns_refs_near(
    exe: bytes,
    sections: list[dict],
    cns_strings: dict[int, str],
    center_va: int,
    before: int = 0x80,
    after: int = 0x600,
) -> list[dict[str, Any]]:
    start = center_va - before
    size = before + after
    refs: list[dict[str, Any]] = []
    try:
        data = read_at(exe, sections, start, size)
    except ValueError:
        return refs
    for offset in range(0, max(len(data) - 3, 0)):
        value = struct.unpack_from("<I", data, offset)[0]
        if value in cns_strings:
            refs.append({
                "atVa": start + offset,
                "atVaHex": hex32(start + offset),
                "targetVa": value,
                "targetVaHex": hex32(value),
                "name": cns_strings[value],
            })
    # Stable de-duplication by (atVa, name).
    seen: set[tuple[int, str]] = set()
    unique: list[dict[str, Any]] = []
    for row in refs:
        key = (row["atVa"], row["name"])
        if key in seen:
            continue
        seen.add(key)
        unique.append(row)
    return unique


def initializer_blocks_near(exe: bytes, sections: list[dict], center_va: int) -> list[dict[str, Any]]:
    start = center_va - 0x40
    size = 0x500
    rows: list[dict[str, Any]] = []
    try:
        data = read_at(exe, sections, start, size)
    except ValueError:
        return rows
    index = 0
    while True:
        hit = data.find(b"\x08\x00\x00\x00", index)
        if hit < 0:
            break
        va = start + hit
        init = decode_initializer(exe, sections, va)
        if init and init["ecWrites"]:
            rows.append(init)
        index = hit + 1
    return rows


def caller_row(
    exe: bytes,
    sections: list[dict],
    cns_strings: dict[int, str],
    caller_va: int,
) -> dict[str, Any]:
    context_start = caller_va - 0x0C
    entry_pointer = u32(exe, sections, context_start)
    initializers = initializer_blocks_near(exe, sections, caller_va)
    nested_scripts = []
    for init in initializers:
        for write in init["ecWrites"]:
            target = write["value"]
            if is_va(sections, target):
                nested_scripts.append(decode_stream(exe, sections, target))
    map_loader = any(stream["mapLoaderRefFound"] for stream in nested_scripts)
    route_proof = any(stream["routeProofFound"] for stream in nested_scripts)
    cns_refs = cns_refs_near(exe, sections, cns_strings, caller_va)
    return {
        "callerVa": caller_va,
        "callerVaHex": hex32(caller_va),
        "contextStartVa": context_start,
        "contextStartVaHex": hex32(context_start),
        "entryPointerVa": entry_pointer,
        "entryPointerVaHex": hex32(entry_pointer),
        "entryPointerLooksLocal": abs(entry_pointer - context_start) < 0x1000,
        "commandRawHex": read_at(exe, sections, caller_va, 8).hex(" "),
        "nearbyCnsRefs": cns_refs,
        "nearbyCnsNames": sorted({row["name"] for row in cns_refs}),
        "initializerBlocksWritingObjectEc": initializers,
        "nestedObjectEcScripts": nested_scripts,
        "nestedObjectEcScriptCount": len(nested_scripts),
        "mapLoaderRefFound": map_loader,
        "routeProofFound": route_proof,
        "classification": (
            "route-proof"
            if route_proof
            else "active-object-script-context"
            if nested_scripts
            else "resource-context-caller"
        ),
    }


def build_summary(exe_path: Path) -> dict[str, Any]:
    exe = exe_path.read_bytes()
    sections = read_sections(exe)
    cns_strings = find_cns_strings(exe, sections)
    callers = [caller_row(exe, sections, cns_strings, va) for va in find_payload_callers(exe, sections)]
    nested_targets = sorted({
        write["value"]
        for caller in callers
        for init in caller["initializerBlocksWritingObjectEc"]
        for write in init["ecWrites"]
        if is_va(sections, write["value"])
    })
    text_payloads = []
    seen_text_payloads: set[int] = set()
    for caller in callers:
        for stream in caller["nestedObjectEcScripts"]:
            for ref in stream["textPayloadRefs"]:
                value = ref["value"]
                if value in seen_text_payloads:
                    continue
                seen_text_payloads.add(value)
                text_payloads.append(ref)
    route_proof = any(row["routeProofFound"] for row in callers)
    map_loader = any(row["mapLoaderRefFound"] for row in callers)
    active_object_contexts = [row for row in callers if row["nestedObjectEcScriptCount"]]
    return {
        "title": "Object payload 0x442c75 callers",
        "summary": {
            "payloadVaHex": hex32(PAYLOAD_VA),
            "callerCount": len(callers),
            "activeObjectContextCount": len(active_object_contexts),
            "nestedObjectEcScriptCount": sum(row["nestedObjectEcScriptCount"] for row in callers),
            "nestedObjectEcTargetsHex": [hex32(value) for value in nested_targets],
            "textPayloadRefCount": len(text_payloads),
            "routeProofFound": route_proof,
            "mapLoaderRefFound": map_loader,
            "sceneAutoTransitionClaim": False,
            "classification": "0x442c75 is reused by manual interaction/resource contexts; nested +0xec scripts currently resolve to prompt/item/switch payloads, not route proof",
        },
        "callers": callers,
        "textPayloadRefs": text_payloads,
        "nonClaims": [
            "No caller is promoted to a concrete map transition without a map loader or selected map/root write.",
            "Nearby map CNS resources are treated as context only.",
            "This analysis keeps map movement manual: overlap can arm scripts, but scene-auto movement is not inferred.",
            "The active chain decoded here is classified as switch/chest/item interaction because its data operands preview as prompts and item messages.",
        ],
        "nextSteps": [
            "do not use the 0x4467ec chain as a map-route proof; it is an interaction prompt chain",
            "continue looking for active object scripts whose data operands reference map roots, coordinates, or the map loader",
            "use manual trigger samples as constraints, not as automatic route proof",
        ],
    }


def markdown(summary: dict[str, Any]) -> str:
    s = summary["summary"]
    lines = [
        "# Object Payload 0x442c75 Callers",
        "",
        f"- payload: `{s['payloadVaHex']}`",
        f"- caller count: {s['callerCount']}",
        f"- active object contexts: {s['activeObjectContextCount']}",
        f"- nested object +0xec scripts: {s['nestedObjectEcScriptCount']}",
        f"- nested object +0xec targets: {', '.join(f'`{item}`' for item in s['nestedObjectEcTargetsHex']) or 'none'}",
        f"- text payload refs: {s['textPayloadRefCount']}",
        f"- route proof found: {s['routeProofFound']}",
        f"- map loader ref found: {s['mapLoaderRefFound']}",
        f"- scene auto transition claim: {s['sceneAutoTransitionClaim']}",
        "",
        "## 결론",
        "",
        "`0x442c75` 호출부 6곳 중 3곳은 근처 초기화 블록에서 `object +0xec`를 다른 스크립트로 직접 채운다.",
        "그러나 nested script가 참조하는 `0x2f` 데이터는 스위치/상자/아이템 발견/획득 프롬프트로 디코드된다.",
        "따라서 이 3개는 수동 이동 경로가 아니라 수동 상호작용 오브젝트 체인으로 분류한다.",
        "다만 현재 디코드 범위에서는 맵 로더 `0x0042449c` 또는 목적지 맵/root/좌표 write가 확인되지 않았으므로 경로로 승격하지 않는다.",
        "",
        "## Caller Summary",
        "",
        "| caller | entry ptr | class | CNS context | +0xec targets | route proof |",
        "|---|---|---|---|---|---|",
    ]
    for row in summary["callers"]:
        cns = ", ".join(f"`{name}`" for name in row["nearbyCnsNames"][:6])
        if len(row["nearbyCnsNames"]) > 6:
            cns += ", ..."
        targets = []
        for init in row["initializerBlocksWritingObjectEc"]:
            targets.extend(write["valueHex"] for write in init["ecWrites"])
        lines.append(
            f"| `{row['callerVaHex']}` | `{row['entryPointerVaHex']}` | {row['classification']} | "
            f"{cns or '-'} | {', '.join(f'`{target}`' for target in targets) or '-'} | {row['routeProofFound']} |"
        )
    lines += [
        "",
        "## Nested +0xec Scripts",
        "",
    ]
    for row in summary["callers"]:
        for stream in row["nestedObjectEcScripts"]:
            lines.append(f"### `{stream['startVaHex']}` from caller `{row['callerVaHex']}`")
            lines.append("")
            lines.append(f"- decoded commands: {stream['decodedCommandCount']}")
            lines.append(f"- route proof found: {stream['routeProofFound']}")
            lines.append(f"- map loader ref found: {stream['mapLoaderRefFound']}")
            lines.append("")
            lines.append("| VA | opcode | meaning | summary |")
            lines.append("|---|---|---|---|")
            for command in stream["commands"][:20]:
                lines.append(
                    f"| `{command['vaHex']}` | `{command.get('opcodeHex', '')}` | "
                    f"{command.get('opcodeName', '')} | {command.get('summary', '')} |"
                )
            lines.append("")
    lines += [
        "## Text Payload Evidence",
        "",
        "| VA | preview |",
        "|---|---|",
    ]
    for ref in summary["textPayloadRefs"][:16]:
        preview = "<br>".join(ref["textPreview"].splitlines()[:4])
        lines.append(f"| `{ref['valueHex']}` | {preview} |")
    lines.append("")
    lines += [
        "## 하지 않는 주장",
        "",
    ]
    for item in summary["nonClaims"]:
        lines.append(f"- {item}")
    lines += [
        "",
        "## 다음 작업",
        "",
    ]
    for item in summary["nextSteps"]:
        lines.append(f"- {item}")
    lines.append("")
    return "\n".join(lines)


def render_html(summary: dict[str, Any]) -> str:
    s = summary["summary"]
    rows = []
    for row in summary["callers"]:
        cns = ", ".join(html.escape(name) for name in row["nearbyCnsNames"][:8]) or "-"
        targets = []
        for init in row["initializerBlocksWritingObjectEc"]:
            targets.extend(write["valueHex"] for write in init["ecWrites"])
        rows.append(
            "<tr>"
            f"<td><code>{row['callerVaHex']}</code></td>"
            f"<td><code>{row['entryPointerVaHex']}</code></td>"
            f"<td>{html.escape(row['classification'])}</td>"
            f"<td>{cns}</td>"
            f"<td>{', '.join(f'<code>{html.escape(target)}</code>' for target in targets) or '-'}</td>"
            f"<td>{row['routeProofFound']}</td>"
            "</tr>"
        )
    nested = []
    for row in summary["callers"]:
        for stream in row["nestedObjectEcScripts"]:
            nested.append(f"<h3><code>{stream['startVaHex']}</code> from <code>{row['callerVaHex']}</code></h3>")
            nested.append(
                f"<p>route proof found: {stream['routeProofFound']} / "
                f"map loader ref found: {stream['mapLoaderRefFound']}</p>"
            )
            nested.append("<table><thead><tr><th>VA</th><th>opcode</th><th>meaning</th><th>summary</th></tr></thead><tbody>")
            for command in stream["commands"][:24]:
                nested.append(
                    "<tr>"
                    f"<td><code>{command['vaHex']}</code></td>"
                    f"<td><code>{html.escape(command.get('opcodeHex', ''))}</code></td>"
                    f"<td>{html.escape(command.get('opcodeName', ''))}</td>"
                    f"<td>{html.escape(command.get('summary', ''))}</td>"
                    "</tr>"
                )
            nested.append("</tbody></table>")
    return "\n".join([
        "<!doctype html>",
        '<html lang="ko">',
        "<head>",
        '  <meta charset="utf-8" />',
        "  <title>Object Payload 0x442c75 Callers</title>",
        "  <style>",
        "    body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;margin:24px;line-height:1.5;color:#1f2937;background:#f8fafc}",
        "    code{background:#e5e7eb;border-radius:4px;padding:1px 4px}",
        "    table{border-collapse:collapse;width:100%;background:white;margin:12px 0 24px}",
        "    th,td{border:1px solid #d1d5db;padding:8px;text-align:left;vertical-align:top}",
        "    th{background:#f3f4f6}",
        "    .marker{font-size:12px;color:#475569}",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>Object Payload 0x442c75 Callers</h1>",
        f"  <p class=\"marker\">payload 0x442c75 callers: {s['callerCount']}</p>",
        f"  <p class=\"marker\">active object contexts: {s['activeObjectContextCount']}</p>",
        f"  <p class=\"marker\">nested object +0xec scripts: {s['nestedObjectEcScriptCount']}</p>",
        f"  <p class=\"marker\">nested object +0xec targets: {', '.join(s['nestedObjectEcTargetsHex'])}</p>",
        f"  <p class=\"marker\">text payload refs: {s['textPayloadRefCount']}</p>",
        f"  <p class=\"marker\">route proof found: {s['routeProofFound']}</p>",
        f"  <p class=\"marker\">map loader ref found: {s['mapLoaderRefFound']}</p>",
        f"  <p class=\"marker\">scene auto transition claim: {s['sceneAutoTransitionClaim']}</p>",
        "  <p><code>0x442c75</code> is reused by interaction/resource contexts. The decoded active chain points to prompt/item/switch payloads, not a route proof.</p>",
        "  <script>",
        "    window.HWANSE_OBJECT_PAYLOAD_442C75_CALLERS = {",
        f"      callerCount: {s['callerCount']},",
        f"      activeObjectContextCount: {s['activeObjectContextCount']},",
        f"      nestedObjectEcScriptCount: {s['nestedObjectEcScriptCount']},",
        f"      routeProofFound: {str(s['routeProofFound']).lower()},",
        f"      mapLoaderRefFound: {str(s['mapLoaderRefFound']).lower()},",
        f"      sceneAutoTransitionClaim: {str(s['sceneAutoTransitionClaim']).lower()}",
        "    };",
        "  </script>",
        "  <h2>Caller Summary</h2>",
        "  <table><thead><tr><th>caller</th><th>entry ptr</th><th>class</th><th>CNS context</th><th>+0xec targets</th><th>route proof</th></tr></thead><tbody>",
        *rows,
        "  </tbody></table>",
        "  <h2>Nested +0xec Scripts</h2>",
        *nested,
        "  <h2>Text Payload Evidence</h2>",
        "  <table><thead><tr><th>VA</th><th>preview</th></tr></thead><tbody>",
        *[
            "<tr>"
            f"<td><code>{ref['valueHex']}</code></td>"
            f"<td>{'<br>'.join(html.escape(line) for line in ref['textPreview'].splitlines()[:5])}</td>"
            "</tr>"
            for ref in summary["textPayloadRefs"][:20]
        ],
        "  </tbody></table>",
        "  <h2>하지 않는 주장</h2>",
        "  <ul>",
        *[f"    <li>{html.escape(item)}</li>" for item in summary["nonClaims"]],
        "  </ul>",
        "</body>",
        "</html>",
    ])


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=EXE)
    parser.add_argument("--md-out", type=Path, help="Optional legacy markdown output path.")
    args = parser.parse_args()

    OUT.mkdir(exist_ok=True)
    summary = build_summary(args.exe)
    (OUT / "object_payload_442c75_callers.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\n",
        encoding="utf-8",
    )
    if args.md_out is not None:
        args.md_out.write_text(markdown(summary), encoding="utf-8")
    print(json.dumps(summary["summary"], ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()
