#!/usr/bin/env python3
"""Summarize static indirect candidates for opcode 0x24 mode1 source byte."""
from __future__ import annotations

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

sys.path.insert(0, str(Path(__file__).resolve().parent))

from probe_exe_scene_tables import read_sections


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
GLOBAL_BUFFER_BASE = 0x0059E310
MODE1_SOURCE = 0x0059E348
MODE1_OFFSET = MODE1_SOURCE - GLOBAL_BUFFER_BASE
WINDOW_BYTES = 32
NEARBY_BASE_SCAN_START = 0x0059E300
NEARBY_BASE_SCAN_END = MODE1_SOURCE
NEARBY_BASE_WINDOW_BYTES = 96
REG_NAMES = ["eax", "ecx", "edx", "ebx", "esp", "ebp", "esi", "edi"]
FAILED_OPCODE24_MODE1_INDIRECT_GATE_IDS = [
    "static-base-plus-mode1-offset-producer",
    "nearby-base-mode1-window-write",
    "runtime-watchpoint-trace",
]
OPCODE24_MODE1_INDIRECT_MISSING_EVIDENCE = [
    "static base+0x38 producer for opcode 0x24 mode1 source 0x0059e348",
    "nearby-base short-window write covering 0x0059e348",
    "runtime watchpoint trace proving the 0x0059e348 producer",
]
OPCODE24_MODE1_INDIRECT_EVIDENCE_REFS = [
    {"path": "Hwanse2.exe", "description": "global base, base+offset, and nearby-base scans"},
    {"path": "out/save_selector_opcode24_mode1_source_writes.json", "description": "direct/static source-write scan for 0x0059e348"},
    {"path": "out/save_selector_selection_buffer_bases.json", "description": "known global selection-buffer base assignments"},
    {"path": "out/runtime_trace_feasibility.json", "description": "runtime watchpoint trace availability blocker"},
]


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


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


def load_json(path: Path, fallback: Any) -> Any:
    if not path.exists():
        return fallback
    return json.loads(path.read_text(encoding="utf-8"))


def text_section(exe: bytes) -> tuple[dict, bytes]:
    section = next(section for section in read_sections(exe) if section["name"] == ".text")
    return section, exe[section["raw"] : section["raw"] + section["raw_size"]]


def find_dword_refs(data: bytes, target: int) -> list[int]:
    needle = struct.pack("<I", target)
    refs = []
    pos = data.find(needle)
    while pos >= 0:
        refs.append(pos)
        pos = data.find(needle, pos + 1)
    return refs


def classify_dword_ref(data: bytes, pos: int) -> dict:
    if pos >= 6 and data[pos - 6 : pos] == b"\xc7\x80\xa8\x00\x00\x00":
        return {
            "accessKind": "address-write",
            "width": 4,
            "instruction": "mov dword ptr [eax+0xa8], imm32",
            "instructionPrefixBytes": 6,
            "instructionKind": "context-a8-imm32",
            "register": None,
        }
    if pos >= 2 and data[pos - 2 : pos] in {b"\x66\xa1", b"\x66\xa3"}:
        write = data[pos - 1] == 0xA3
        return {
            "accessKind": "write" if write else "read",
            "width": 2,
            "instruction": f"mov {'ds:[addr], ax' if write else 'ax, ds:[addr]'}",
            "instructionPrefixBytes": 2,
            "instructionKind": "direct-moffs16",
            "register": "ax",
        }
    if pos >= 1:
        opcode = data[pos - 1]
        if opcode in {0xA0, 0xA1, 0xA2, 0xA3}:
            if opcode == 0xA0:
                return {
                    "accessKind": "read",
                    "width": 1,
                    "instruction": "mov al, ds:[addr]",
                    "instructionPrefixBytes": 1,
                    "instructionKind": "direct-moffs8",
                    "register": "al",
                }
            if opcode == 0xA1:
                return {
                    "accessKind": "read",
                    "width": 4,
                    "instruction": "mov eax, ds:[addr]",
                    "instructionPrefixBytes": 1,
                    "instructionKind": "direct-moffs32",
                    "register": "eax",
                }
            if opcode == 0xA2:
                return {
                    "accessKind": "write",
                    "width": 1,
                    "instruction": "mov ds:[addr], al",
                    "instructionPrefixBytes": 1,
                    "instructionKind": "direct-moffs8",
                    "register": "al",
                }
            return {
                "accessKind": "write",
                "width": 4,
                "instruction": "mov ds:[addr], eax",
                "instructionPrefixBytes": 1,
                "instructionKind": "direct-moffs32",
                "register": "eax",
            }
        if 0xB8 <= opcode <= 0xBF:
            reg = REG_NAMES[opcode - 0xB8]
            return {
                "accessKind": "address",
                "width": 4,
                "instruction": f"mov {reg}, imm32",
                "instructionPrefixBytes": 1,
                "instructionKind": "mov-reg-imm32",
                "register": reg,
            }
        if opcode == 0x68:
            return {
                "accessKind": "address",
                "width": 4,
                "instruction": "push imm32",
                "instructionPrefixBytes": 1,
                "instructionKind": "push-imm32",
                "register": None,
            }
    if pos >= 3 and data[pos - 3] == 0x66 and data[pos - 2] in {0x8B, 0x89}:
        modrm = data[pos - 1]
        if modrm & 0xC7 == 0x05:
            write = data[pos - 2] == 0x89
            return {
                "accessKind": "write" if write else "read",
                "width": 2,
                "instruction": f"opcode 0x66 0x{data[pos - 2]:02x} modrm 0x{modrm:02x} direct disp32",
                "instructionPrefixBytes": 3,
                "instructionKind": "direct-modrm16",
                "register": None,
            }
    if pos >= 2 and data[pos - 2] in {0x8A, 0x8B, 0x88, 0x89}:
        opcode = data[pos - 2]
        modrm = data[pos - 1]
        if modrm & 0xC7 == 0x05:
            write = opcode in {0x88, 0x89}
            return {
                "accessKind": "write" if write else "read",
                "width": 1 if opcode in {0x88, 0x8A} else 4,
                "instruction": f"opcode 0x{opcode:02x} modrm 0x{modrm:02x} direct disp32",
                "instructionPrefixBytes": 2,
                "instructionKind": "direct-modrm",
                "register": None,
            }
    if pos >= 2 and data[pos - 2] in {0xC6, 0xC7}:
        opcode = data[pos - 2]
        modrm = data[pos - 1]
        if modrm == 0x05:
            return {
                "accessKind": "write",
                "width": 1 if opcode == 0xC6 else 4,
                "instruction": f"opcode 0x{opcode:02x} modrm 0x05 direct disp32",
                "instructionPrefixBytes": 2,
                "instructionKind": "direct-imm-store",
                "register": None,
            }
    return {
        "accessKind": "unknown",
        "width": None,
        "instruction": "unclassified dword immediate",
        "instructionPrefixBytes": 0,
        "instructionKind": "unknown",
        "register": None,
    }


def scan_target_refs(exe: bytes, target: int) -> list[dict]:
    text, data = text_section(exe)
    rows = []
    for pos in find_dword_refs(data, target):
        ref = classify_dword_ref(data, pos)
        instruction_index = max(0, pos - (ref.get("instructionPrefixBytes") or 0))
        rows.append({
            "targetHex": hex32(target),
            "refVaHex": hex32(text["va"] + pos),
            "instructionVaHex": hex32(text["va"] + instruction_index),
            "dataIndex": pos,
            "accessKind": ref["accessKind"],
            "width": ref["width"],
            "instruction": ref["instruction"],
            "instructionKind": ref["instructionKind"],
            "register": ref["register"],
        })
    rows.sort(key=lambda row: row["refVaHex"])
    return rows


def decode_next_base_offset(data: bytes, after_imm: int, reg_index: int) -> dict | None:
    if after_imm + 3 <= len(data) and data[after_imm] == 0x83 and data[after_imm + 1] == 0xC0 + reg_index:
        offset = struct.unpack_from("b", data, after_imm + 2)[0]
        return {
            "offset": offset,
            "offsetHex": hex8(offset & 0xFF),
            "nextInstruction": f"add {REG_NAMES[reg_index]}, imm8",
            "nextInstructionBytes": 3,
        }
    if after_imm + 6 <= len(data) and data[after_imm] == 0x81 and data[after_imm + 1] == 0xC0 + reg_index:
        offset = struct.unpack_from("<i", data, after_imm + 2)[0]
        return {
            "offset": offset,
            "offsetHex": hex32(offset & 0xFFFFFFFF),
            "nextInstruction": f"add {REG_NAMES[reg_index]}, imm32",
            "nextInstructionBytes": 6,
        }
    if reg_index == 0 and after_imm + 5 <= len(data) and data[after_imm] == 0x05:
        offset = struct.unpack_from("<i", data, after_imm + 1)[0]
        return {
            "offset": offset,
            "offsetHex": hex32(offset & 0xFFFFFFFF),
            "nextInstruction": "add eax, imm32",
            "nextInstructionBytes": 5,
        }
    if after_imm + 6 <= len(data) and data[after_imm] == 0x8D:
        modrm = data[after_imm + 1]
        mod = (modrm >> 6) & 0x03
        rm = modrm & 0x07
        if mod == 2 and rm == reg_index:
            offset = struct.unpack_from("<i", data, after_imm + 2)[0]
            return {
                "offset": offset,
                "offsetHex": hex32(offset & 0xFFFFFFFF),
                "nextInstruction": f"lea {REG_NAMES[(modrm >> 3) & 0x07]}, [{REG_NAMES[reg_index]}+imm32]",
                "nextInstructionBytes": 6,
            }
    return None


def decode_mem_access_with_base(data: bytes, index: int, base_reg: int) -> dict | None:
    prefix66 = index < len(data) and data[index] == 0x66
    opcode_index = index + 1 if prefix66 else index
    if opcode_index + 2 > len(data):
        return None
    opcode = data[opcode_index]
    if opcode not in {0x88, 0x89, 0x8A, 0x8B, 0xC6, 0xC7}:
        return None
    modrm = data[opcode_index + 1]
    mod = (modrm >> 6) & 0x03
    rm = modrm & 0x07
    disp_index = opcode_index + 2
    base = rm
    if rm == 4:
        if disp_index >= len(data):
            return None
        sib = data[disp_index]
        disp_index += 1
        base = sib & 0x07
        if mod == 0 and base == 5:
            return None
    elif mod == 0 and rm == 5:
        return None
    if base != base_reg or mod not in {1, 2}:
        return None
    if mod == 1:
        if disp_index >= len(data):
            return None
        displacement = struct.unpack_from("b", data, disp_index)[0]
        length = disp_index + 1 - index
    else:
        if disp_index + 4 > len(data):
            return None
        displacement = struct.unpack_from("<i", data, disp_index)[0]
        length = disp_index + 4 - index
    access = "write" if opcode in {0x88, 0x89, 0xC6, 0xC7} else "read"
    if opcode in {0x88, 0x8A, 0xC6}:
        width = 1
    elif prefix66:
        width = 2
    else:
        width = 4
    return {
        "accessKind": access,
        "width": width,
        "opcodeHex": hex8(opcode),
        "modrmHex": hex8(modrm),
        "displacement": displacement,
        "displacementHex": hex32(displacement & 0xFFFFFFFF) if mod == 2 else hex8(displacement & 0xFF),
        "instruction": f"opcode 0x{opcode:02x} modrm 0x{modrm:02x} [{REG_NAMES[base_reg]}+{hex32(displacement & 0xFFFFFFFF) if mod == 2 else hex8(displacement & 0xFF)}]",
        "byteLength": length,
    }


def decode_mem_access_at(data: bytes, index: int, base_reg: int, target_offset: int) -> dict | None:
    access = decode_mem_access_with_base(data, index, base_reg)
    if not access or access["displacement"] != target_offset:
        return None
    return access


def scan_base_offset_rows(exe: bytes, base_rows: list[dict]) -> tuple[list[dict], list[dict]]:
    text, data = text_section(exe)
    offset_rows = []
    window_rows = []
    for row in base_rows:
        if row.get("instructionKind") != "mov-reg-imm32":
            continue
        reg = row.get("register")
        if reg not in REG_NAMES:
            continue
        reg_index = REG_NAMES.index(reg)
        pos = int(row["dataIndex"])
        after_imm = pos + 4
        decoded = decode_next_base_offset(data, after_imm, reg_index)
        if decoded:
            offset_rows.append({
                "baseLoadVaHex": row["instructionVaHex"],
                "register": reg,
                "offset": decoded["offset"],
                "offsetHex": decoded["offsetHex"],
                "targetHex": hex32(GLOBAL_BUFFER_BASE + decoded["offset"]),
                "nextInstructionVaHex": hex32(text["va"] + after_imm),
                "nextInstruction": decoded["nextInstruction"],
                "equalsMode1Offset": decoded["offset"] == MODE1_OFFSET,
            })
        window_end = min(len(data), after_imm + WINDOW_BYTES)
        for index in range(after_imm, window_end):
            access = decode_mem_access_at(data, index, reg_index, MODE1_OFFSET)
            if access:
                window_rows.append({
                    "baseLoadVaHex": row["instructionVaHex"],
                    "accessVaHex": hex32(text["va"] + index),
                    "register": reg,
                    **access,
                })
    offset_rows.sort(key=lambda item: item["baseLoadVaHex"])
    window_rows.sort(key=lambda item: item["accessVaHex"])
    return offset_rows, window_rows


def scan_nearby_base_window_rows(exe: bytes) -> list[dict]:
    text, data = text_section(exe)
    rows = []
    for address in range(NEARBY_BASE_SCAN_START, NEARBY_BASE_SCAN_END + 1):
        for row in scan_target_refs(exe, address):
            if row.get("instructionKind") != "mov-reg-imm32":
                continue
            reg = row.get("register")
            if reg not in REG_NAMES:
                continue
            reg_index = REG_NAMES.index(reg)
            pos = int(row["dataIndex"])
            after_imm = pos + 4
            window_end = min(len(data), after_imm + NEARBY_BASE_WINDOW_BYTES)
            for index in range(after_imm, window_end):
                access = decode_mem_access_with_base(data, index, reg_index)
                if not access:
                    continue
                target = address + access["displacement"]
                width = int(access.get("width") or 0)
                covers_mode1 = target <= MODE1_SOURCE < target + max(width, 1)
                if not covers_mode1:
                    continue
                rows.append({
                    "baseHex": hex32(address),
                    "baseLoadVaHex": row["instructionVaHex"],
                    "accessVaHex": hex32(text["va"] + index),
                    "register": reg,
                    "targetHex": hex32(target & 0xFFFFFFFF),
                    "coversMode1Source": covers_mode1,
                    **access,
                })
    rows.sort(key=lambda item: (item["baseHex"], item["baseLoadVaHex"], item["accessVaHex"]))
    return rows


def compact_row(row: dict) -> dict:
    return {key: value for key, value in row.items() if key != "dataIndex"}


def build_summary(
    exe: bytes,
    mode1_source_writes: dict | None = None,
    selection_buffer_bases: dict | None = None,
) -> dict:
    mode1_source_writes = mode1_source_writes or {}
    selection_buffer_bases = selection_buffer_bases or {}
    base_rows = scan_target_refs(exe, GLOBAL_BUFFER_BASE)
    mode1_rows = scan_target_refs(exe, MODE1_SOURCE)
    offset_rows, window_rows = scan_base_offset_rows(exe, base_rows)
    nearby_base_window_rows = scan_nearby_base_window_rows(exe)
    global_assignment_rows = [
        row
        for row in base_rows
        if row.get("instructionKind") == "context-a8-imm32"
    ]
    base_mov_rows = [
        row
        for row in base_rows
        if row.get("instructionKind") == "mov-reg-imm32"
    ]
    base_direct_data_rows = [
        row
        for row in base_rows
        if row.get("accessKind") in {"read", "write"}
    ]
    mode1_address_rows = [
        row
        for row in mode1_rows
        if row.get("instructionKind") in {"mov-reg-imm32", "push-imm32"}
    ]
    mode1_write_rows = [
        row
        for row in mode1_rows
        if row.get("accessKind") == "write"
    ]
    mode1_offset_rows = [
        row for row in offset_rows if row.get("equalsMode1Offset")
    ]
    mode1_window_writes = [
        row
        for row in window_rows
        if row.get("accessKind") == "write"
    ]
    nearby_base_window_writes = [
        row
        for row in nearby_base_window_rows
        if row.get("accessKind") == "write"
    ]
    known_base_assignments = [
        row
        for row in selection_buffer_bases.get("immediateAssignments") or []
        if row.get("baseHex") == hex32(GLOBAL_BUFFER_BASE)
    ]
    direct_summary = {
        "exactMode1RefCount": mode1_source_writes.get("exactMode1RefCount", len(mode1_rows)),
        "coveringWriteCount": mode1_source_writes.get("coveringWriteCount"),
        "indexedWriteCandidateCount": mode1_source_writes.get("indexedWriteCandidateCount"),
        "addressProducerCandidateCount": mode1_source_writes.get("addressProducerCandidateCount"),
    }
    no_static_base_indirect_candidate = (
        len(mode1_write_rows) == 0
        and len(mode1_address_rows) == 0
        and len(mode1_offset_rows) == 0
        and len(mode1_window_writes) == 0
        and len(nearby_base_window_writes) == 0
    )
    conclusion = (
        "The static 0x0059e310 base-reference scan found global selection-buffer assignments and direct "
        "flag/input/object-state uses, but no immediate 0x0059e310+0x38 materialization and no short-window "
        "store to [base+0x38]. A wider nearby-base scan also found no short-window write through an alternate "
        "base such as 0x0059e340+0x08. The only exact 0x0059e348 reference is still the opcode 0x24 mode1 read, "
        "so this scan does not prove an indirect producer. This is a bounded static scan, not a full "
        "runtime data-flow proof; route promotion remains blocked until a runtime producer or stricter "
        "control-flow proof appears."
        if no_static_base_indirect_candidate
        else "At least one static base-derived candidate reaches 0x0059e348; inspect candidate rows before treating the mode1 source as unresolved."
    )
    return {
        "mode1SourceHex": hex32(MODE1_SOURCE),
        "globalBufferBaseHex": hex32(GLOBAL_BUFFER_BASE),
        "mode1OffsetHex": hex8(MODE1_OFFSET),
        "windowBytes": WINDOW_BYTES,
        "nearbyBaseScanRangeHex": f"{hex32(NEARBY_BASE_SCAN_START)}..{hex32(NEARBY_BASE_SCAN_END)}",
        "nearbyBaseWindowBytes": NEARBY_BASE_WINDOW_BYTES,
        "globalBaseDirectRefCount": len(base_rows),
        "globalBaseAssignmentCount": len(global_assignment_rows),
        "globalBaseMovImmediateCount": len(base_mov_rows),
        "globalBaseDirectDataAccessCount": len(base_direct_data_rows),
        "globalBaseKnownContextAssignmentCount": len(known_base_assignments),
        "mode1DirectRefCount": len(mode1_rows),
        "mode1DirectWriteCount": len(mode1_write_rows),
        "mode1AddressImmediateCount": len(mode1_address_rows),
        "basePlusOffsetRowCount": len(offset_rows),
        "basePlusMode1OffsetCandidateCount": len(mode1_offset_rows),
        "baseWindowMode1AccessCandidateCount": len(window_rows),
        "baseWindowMode1WriteCandidateCount": len(mode1_window_writes),
        "nearbyBaseWindowMode1AccessCandidateCount": len(nearby_base_window_rows),
        "nearbyBaseWindowMode1WriteCandidateCount": len(nearby_base_window_writes),
        "directProducerSummary": direct_summary,
        "knownContextBaseAssignments": known_base_assignments,
        "basePlusOffsetRows": offset_rows,
        "basePlusMode1OffsetCandidates": mode1_offset_rows,
        "baseWindowMode1AccessCandidates": window_rows,
        "baseWindowMode1WriteCandidates": mode1_window_writes,
        "nearbyBaseWindowMode1AccessCandidates": nearby_base_window_rows,
        "nearbyBaseWindowMode1WriteCandidates": nearby_base_window_writes,
        "globalBaseDirectRefs": [compact_row(row) for row in base_rows],
        "mode1DirectRefs": [compact_row(row) for row in mode1_rows],
        "noStaticBaseIndirectCandidate": no_static_base_indirect_candidate,
        "proofFound": False,
        "opcode24Mode1IndirectProofFound": False,
        "failedOpcode24Mode1IndirectGateIds": FAILED_OPCODE24_MODE1_INDIRECT_GATE_IDS,
        "missingEvidence": OPCODE24_MODE1_INDIRECT_MISSING_EVIDENCE,
        "evidenceRefs": OPCODE24_MODE1_INDIRECT_EVIDENCE_REFS,
        "evidenceRefCount": len(OPCODE24_MODE1_INDIRECT_EVIDENCE_REFS),
        "promotionStatus": "blocked",
        "conclusion": conclusion,
    }


def markdown(summary: dict) -> str:
    lines = [
        "# Save Selector Opcode 0x24 Mode1 Indirect Context",
        "",
        f"- mode1 source: `{summary['mode1SourceHex']}`",
        f"- global buffer base: `{summary['globalBufferBaseHex']}`",
        f"- mode1 offset from global base: `{summary['mode1OffsetHex']}`",
        f"- global base direct refs: {summary['globalBaseDirectRefCount']}",
        f"- global base context assignments: {summary['globalBaseAssignmentCount']}",
        f"- global base mov-immediate refs: {summary['globalBaseMovImmediateCount']}",
        f"- exact mode1 refs: {summary['mode1DirectRefCount']}",
        f"- exact mode1 direct writes: {summary['mode1DirectWriteCount']}",
        f"- exact mode1 address immediates: {summary['mode1AddressImmediateCount']}",
        f"- base plus mode1-offset candidates: {summary['basePlusMode1OffsetCandidateCount']}",
        f"- short-window [base+0x38] write candidates: {summary['baseWindowMode1WriteCandidateCount']}",
        f"- nearby-base scan range: `{summary['nearbyBaseScanRangeHex']}`",
        f"- nearby-base short-window write candidates: {summary['nearbyBaseWindowMode1WriteCandidateCount']}",
        f"- proofFound: `{summary['proofFound']}`",
        f"- opcode24Mode1IndirectProofFound: `{summary['opcode24Mode1IndirectProofFound']}`",
        f"- promotion status: `{summary['promotionStatus']}`",
        "",
        summary["conclusion"],
        "",
        "## Failed Gates",
        "",
    ]
    lines.extend(f"- `{item}`" for item in summary["failedOpcode24Mode1IndirectGateIds"])
    lines.extend([
        "",
        "## Missing Evidence",
        "",
    ])
    lines.extend(f"- {item}" for item in summary["missingEvidence"])
    lines.extend([
        "",
        "## Evidence Refs",
        "",
    ])
    lines.extend(
        f"- `{row['path']}`: {row['description']}"
        for row in summary["evidenceRefs"]
    )
    lines.extend([
        "",
        "## Base Plus Offset Rows",
        "",
        "| base load | register | offset | target | next instruction | mode1 offset |",
        "| --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary["basePlusOffsetRows"]:
        lines.append(
            f"| `{row['baseLoadVaHex']}` | {row['register']} | `{row['offsetHex']}` | "
            f"`{row['targetHex']}` | `{row['nextInstructionVaHex']} {row['nextInstruction']}` | "
            f"{row['equalsMode1Offset']} |"
        )
    if not summary["basePlusOffsetRows"]:
        lines.append("| - | - | - | - | - | - |")
    lines.extend([
        "",
        "## Short-Window Base+0x38 Access Candidates",
        "",
        "| base load | access | register | kind | width | instruction |",
        "| --- | --- | --- | --- | ---: | --- |",
    ])
    for row in summary["baseWindowMode1AccessCandidates"]:
        lines.append(
            f"| `{row['baseLoadVaHex']}` | `{row['accessVaHex']}` | {row['register']} | "
            f"{row['accessKind']} | {row['width']} | `{row['instruction']}` |"
        )
    if not summary["baseWindowMode1AccessCandidates"]:
        lines.append("| - | - | - | - | - | - |")
    lines.extend([
        "",
        "## Nearby-Base Mode1 Access Candidates",
        "",
        "| base | base load | access | register | target | kind | width | instruction |",
        "| --- | --- | --- | --- | --- | --- | ---: | --- |",
    ])
    for row in summary["nearbyBaseWindowMode1AccessCandidates"]:
        lines.append(
            f"| `{row['baseHex']}` | `{row['baseLoadVaHex']}` | `{row['accessVaHex']}` | "
            f"{row['register']} | `{row['targetHex']}` | {row['accessKind']} | {row['width']} | "
            f"`{row['instruction']}` |"
        )
    if not summary["nearbyBaseWindowMode1AccessCandidates"]:
        lines.append("| - | - | - | - | - | - | - | - |")
    lines.extend([
        "",
        "## Exact Mode1 Direct References",
        "",
        "| ref | access | width | instruction |",
        "| --- | --- | ---: | --- |",
    ])
    for row in summary["mode1DirectRefs"]:
        lines.append(
            f"| `{row['instructionVaHex']}` | {row['accessKind']} | "
            f"{row['width'] if row['width'] is not None else '-'} | `{row['instruction']}` |"
        )
    lines.extend([
        "",
        "## Global Base Direct References",
        "",
        "| ref | access | width | kind | instruction |",
        "| --- | --- | ---: | --- | --- |",
    ])
    for row in summary["globalBaseDirectRefs"]:
        lines.append(
            f"| `{row['instructionVaHex']}` | {row['accessKind']} | "
            f"{row['width'] if row['width'] is not None else '-'} | {row['instructionKind']} | "
            f"`{row['instruction']}` |"
        )
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    offset_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['baseLoadVaHex'])}</code></td>"
        f"<td>{html.escape(row['register'])}</td>"
        f"<td><code>{html.escape(row['offsetHex'])}</code></td>"
        f"<td><code>{html.escape(row['targetHex'])}</code></td>"
        f"<td><code>{html.escape(row['nextInstructionVaHex'])} {html.escape(row['nextInstruction'])}</code></td>"
        f"<td>{row['equalsMode1Offset']}</td>"
        "</tr>"
        for row in summary["basePlusOffsetRows"]
    )
    window_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['baseLoadVaHex'])}</code></td>"
        f"<td><code>{html.escape(row['accessVaHex'])}</code></td>"
        f"<td>{html.escape(row['register'])}</td>"
        f"<td>{html.escape(row['accessKind'])}</td>"
        f"<td>{row['width']}</td>"
        f"<td><code>{html.escape(row['instruction'])}</code></td>"
        "</tr>"
        for row in summary["baseWindowMode1AccessCandidates"]
    )
    nearby_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['baseHex'])}</code></td>"
        f"<td><code>{html.escape(row['baseLoadVaHex'])}</code></td>"
        f"<td><code>{html.escape(row['accessVaHex'])}</code></td>"
        f"<td>{html.escape(row['register'])}</td>"
        f"<td><code>{html.escape(row['targetHex'])}</code></td>"
        f"<td>{html.escape(row['accessKind'])}</td>"
        f"<td>{row['width']}</td>"
        f"<td><code>{html.escape(row['instruction'])}</code></td>"
        "</tr>"
        for row in summary["nearbyBaseWindowMode1AccessCandidates"]
    )
    mode1_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['instructionVaHex'])}</code></td>"
        f"<td>{html.escape(row['accessKind'])}</td>"
        f"<td>{row['width'] if row['width'] is not None else '-'}</td>"
        f"<td><code>{html.escape(row['instruction'])}</code></td>"
        "</tr>"
        for row in summary["mode1DirectRefs"]
    )
    base_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['instructionVaHex'])}</code></td>"
        f"<td>{html.escape(row['accessKind'])}</td>"
        f"<td>{row['width'] if row['width'] is not None else '-'}</td>"
        f"<td>{html.escape(row['instructionKind'])}</td>"
        f"<td><code>{html.escape(row['instruction'])}</code></td>"
        "</tr>"
        for row in summary["globalBaseDirectRefs"]
    )
    failed_gates = "".join(
        f"<li><code>{html.escape(item)}</code></li>"
        for item in summary["failedOpcode24Mode1IndirectGateIds"]
    )
    missing_evidence = "".join(
        f"<li>{html.escape(item)}</li>"
        for item in summary["missingEvidence"]
    )
    evidence_refs = "".join(
        f"<li><code>{html.escape(row['path'])}</code>: {html.escape(row['description'])}</li>"
        for row in summary["evidenceRefs"]
    )
    return "\n".join([
        "<!doctype html><meta charset=\"utf-8\"><title>Save Selector Opcode 0x24 Mode1 Indirect Context</title>",
        "<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;max-width:1180px;margin:24px auto}table{border-collapse:collapse}td,th{border:1px solid #444;padding:6px 8px;vertical-align:top}code{color:#9bd4ff}</style>",
        "<h1>Save Selector Opcode 0x24 Mode1 Indirect Context</h1>",
        "<ul>",
        f"<li>mode1 source: <code>{html.escape(summary['mode1SourceHex'])}</code></li>",
        f"<li>global buffer base: <code>{html.escape(summary['globalBufferBaseHex'])}</code></li>",
        f"<li>mode1 offset: <code>{html.escape(summary['mode1OffsetHex'])}</code></li>",
        f"<li>global base direct refs: {summary['globalBaseDirectRefCount']}</li>",
        f"<li>global base context assignments: {summary['globalBaseAssignmentCount']}</li>",
        f"<li>global base mov-immediate refs: {summary['globalBaseMovImmediateCount']}</li>",
        f"<li>exact mode1 refs: {summary['mode1DirectRefCount']}</li>",
        f"<li>exact mode1 direct writes: {summary['mode1DirectWriteCount']}</li>",
        f"<li>exact mode1 address immediates: {summary['mode1AddressImmediateCount']}</li>",
        f"<li>base plus mode1-offset candidates: {summary['basePlusMode1OffsetCandidateCount']}</li>",
        f"<li>short-window [base+0x38] write candidates: {summary['baseWindowMode1WriteCandidateCount']}</li>",
        f"<li>nearby-base scan range: <code>{html.escape(summary['nearbyBaseScanRangeHex'])}</code></li>",
        f"<li>nearby-base short-window write candidates: {summary['nearbyBaseWindowMode1WriteCandidateCount']}</li>",
        f"<li>proofFound: <code>{html.escape(str(summary['proofFound']))}</code></li>",
        f"<li>promotion status: <code>{html.escape(summary['promotionStatus'])}</code></li>",
        "</ul>",
        f"<p>{html.escape(summary['conclusion'])}</p>",
        "<h2>Failed Gates</h2>",
        f"<ul>{failed_gates}</ul>",
        "<h2>Missing Evidence</h2>",
        f"<ul>{missing_evidence}</ul>",
        "<h2>Evidence Refs</h2>",
        f"<ul>{evidence_refs}</ul>",
        "<h2>Base Plus Offset Rows</h2>",
        "<table><thead><tr><th>base load</th><th>register</th><th>offset</th><th>target</th><th>next instruction</th><th>mode1 offset</th></tr></thead><tbody>",
        offset_rows or '<tr><td colspan="6">No base-plus-offset rows.</td></tr>',
        "</tbody></table>",
        "<h2>Short-Window Base+0x38 Access Candidates</h2>",
        "<table><thead><tr><th>base load</th><th>access</th><th>register</th><th>kind</th><th>width</th><th>instruction</th></tr></thead><tbody>",
        window_rows or '<tr><td colspan="6">No short-window [base+0x38] access candidates.</td></tr>',
        "</tbody></table>",
        "<h2>Nearby-Base Mode1 Access Candidates</h2>",
        "<table><thead><tr><th>base</th><th>base load</th><th>access</th><th>register</th><th>target</th><th>kind</th><th>width</th><th>instruction</th></tr></thead><tbody>",
        nearby_rows or '<tr><td colspan="8">No nearby-base short-window access candidates.</td></tr>',
        "</tbody></table>",
        "<h2>Exact Mode1 Direct References</h2>",
        "<table><thead><tr><th>ref</th><th>access</th><th>width</th><th>instruction</th></tr></thead><tbody>",
        mode1_rows,
        "</tbody></table>",
        "<h2>Global Base Direct References</h2>",
        "<table><thead><tr><th>ref</th><th>access</th><th>width</th><th>kind</th><th>instruction</th></tr></thead><tbody>",
        base_rows,
        "</tbody></table>",
    ])


def write_outputs(summary: dict, out_dir: Path = OUT, html_out: Path | None = None) -> Path:
    out_dir.mkdir(parents=True, exist_ok=True)
    json_out = out_dir / "save_selector_opcode24_mode1_indirect_context.json"
    json_out.write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    if html_out is not None:
        html_out.parent.mkdir(parents=True, exist_ok=True)
        html_out.write_text(html_page(summary), encoding="utf-8")
    return json_out


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--mode1-source-writes", type=Path, default=OUT / "save_selector_opcode24_mode1_source_writes.json")
    parser.add_argument("--selection-buffer-bases", type=Path, default=OUT / "save_selector_selection_buffer_bases.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--html-out", type=Path)
    args = parser.parse_args()
    summary = build_summary(
        args.exe.read_bytes(),
        load_json(args.mode1_source_writes, {}),
        load_json(args.selection_buffer_bases, {}),
    )
    json_out = write_outputs(summary, args.out_dir, args.html_out)
    print(f"wrote opcode24 mode1 indirect context -> {json_out}")


if __name__ == "__main__":
    main()
