#!/usr/bin/env python3
"""Summarize opcode 0x20 nested script paths that can set context+0xa8."""
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, va_to_offset


ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "out"
SOURCE = "map1_01a"
TARGET = "map2_02d"
FAILED_OPCODE20_NESTED_BASE_GATE_IDS = [
    "runtime-object-script-selection",
    "gate-time-context-a8-base",
    "gate-offset-fallthrough-proof",
    "strict-source-hotspot",
]
OPCODE20_NESTED_BASE_MISSING_EVIDENCE = [
    "runtime object script selected by opcode 0x20 mode 0",
    "context+0xa8 base value at the 0xe8/0xea gate time",
    "control-flow proof that the inherited gate offsets fall through to the frontier reader",
    "strict map1_01a source hotspot",
]
OPCODE20_NESTED_BASE_EVIDENCE_REFS = [
    {
        "path": "Hwanse2.exe",
        "description": "opcode 0x20 handler, nested runner, and general handler table bytes",
    },
    {
        "path": "out/save_selector_current_writer_paths.json",
        "description": "current opcode 0x20 activation row at 0x005428a8",
    },
    {
        "path": "out/save_selector_gate_offset_sources.json",
        "description": "0xe8/0xea gate offset source classification",
    },
    {
        "path": "out/save_selector_selection_buffer_bases.json",
        "description": "context+0xa8 base candidates and unresolved runtime pointer mode",
    },
]

SAVE_SELECTOR_HANDLER_TABLE = 0x00440720
GENERAL_HANDLER_TABLE = 0x00440538
CURRENT_OPCODE20_VA = 0x005428A8
CURRENT_WRITER_VA = 0x005428BC
OPCODE20_HANDLER = 0x0040C3F4
NESTED_DISPATCHER = 0x00402321
NESTED_RUNNER = 0x00402360

GENERAL_HANDLER_NOTES = {
    0x28: {
        "name": "save/load selection-buffer base pointer",
        "writesContextA8": True,
        "baseExpression": "dword[0x0059dd70 + stream[2]*4]",
        "evidenceVaHex": "0x0040575a",
        "meaning": "mode 1 restores context+0xa8 from the saved pointer table; mode 0 stores context+0x58 into that table.",
    },
    0x3F: {
        "name": "call stream+4 helper",
        "writesContextA8": False,
        "baseExpression": "-",
        "evidenceVaHex": "-",
        "meaning": "passes dword [stream+4] to 0x00423a2f; this handler is adjacent to the base-mode group but does not directly write context+0xa8.",
    },
    0x40: {
        "name": "select global selection buffer",
        "writesContextA8": True,
        "baseExpression": "0x0059e310",
        "evidenceVaHex": "0x00406405",
        "meaning": "sets context+0xa8 to the global selection buffer.",
    },
    0x41: {
        "name": "select indexed save block",
        "writesContextA8": True,
        "baseExpression": "0x00457750 + stream[1] * 0xd8",
        "evidenceVaHex": "0x0040643d",
        "meaning": "computes a save/runtime block slot base from stream+1.",
    },
    0x42: {
        "name": "select runtime object pointer",
        "writesContextA8": True,
        "baseExpression": "dword[0x0059db30 + context[0xf2]*4] or dword[0x0059db30 + stream[2]*4]",
        "evidenceVaHex": "0x00406480, 0x004064a0",
        "meaning": "selects an object pointer table entry either from context+0xf2 or stream+2.",
    },
    0x43: {
        "name": "select save/runtime block base",
        "writesContextA8": True,
        "baseExpression": "0x004576d8",
        "evidenceVaHex": "0x004064de",
        "meaning": "sets context+0xa8 to the save/runtime block base.",
    },
    0x44: {
        "name": "runtime setup helper",
        "writesContextA8": False,
        "baseExpression": "-",
        "evidenceVaHex": "-",
        "meaning": "calls 0x00421509 and may call 0x0041b579; it does not directly write context+0xa8 in this handler body.",
    },
}


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 dword_at(exe: bytes, sections: list[dict], va: int) -> int | None:
    offset = va_to_offset(sections, va)
    if offset is None or offset + 4 > len(exe):
        return None
    return struct.unpack_from("<I", exe, offset)[0]


def bytes_at(exe: bytes, sections: list[dict], va: int, size: int) -> bytes:
    offset = va_to_offset(sections, va)
    if offset is None or offset + size > len(exe):
        return b""
    return exe[offset: offset + size]


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


def table_handler(sections: list[dict], exe: bytes, table_va: int, opcode: int) -> dict:
    entry_va = table_va + opcode * 4
    handler_va = dword_at(exe, sections, entry_va)
    return {
        "opcode": opcode,
        "opcodeHex": hex8(opcode),
        "entryVaHex": hex32(entry_va),
        "handlerVaHex": hex32(handler_va) if handler_va is not None else None,
        "handlerSection": section_name_for_va(sections, handler_va),
    }


def decode_stream_value(value: int) -> dict:
    return {
        "value": value,
        "valueHex": hex32(value),
        "opcode": value & 0xFF,
        "opcodeHex": hex8(value & 0xFF),
        "streamPlus1": (value >> 8) & 0xFF,
        "streamPlus1Hex": hex8((value >> 8) & 0xFF),
        "streamPlus2": (value >> 16) & 0xFF,
        "streamPlus2Hex": hex8((value >> 16) & 0xFF),
        "streamPlus3": (value >> 24) & 0xFF,
        "streamPlus3Hex": hex8((value >> 24) & 0xFF),
    }


def current_opcode20_from_writer_paths(current_writer_paths: list[dict]) -> dict | None:
    for row in current_writer_paths:
        if row.get("writerVaHex") != hex32(CURRENT_WRITER_VA):
            continue
        context_rows = (row.get("activationContext") or {}).get("contextRows") or []
        for context_row in context_rows:
            if context_row.get("vaHex") == hex32(CURRENT_OPCODE20_VA):
                return {
                    "writerVaHex": row.get("writerVaHex"),
                    "rootHex": row.get("rootHex"),
                    "rootLabels": row.get("rootLabels") or [],
                    "rootFieldMaps": row.get("rootFieldMaps") or [],
                    "activationRow": context_row,
                }
    return None


def opcode20_signature_summary(exe: bytes, sections: list[dict]) -> dict:
    code = bytes_at(exe, sections, OPCODE20_HANDLER, 0xEC)
    signatures = [
        ("reads stream+1 mode", bytes.fromhex("8a4801")),
        ("loops save slot count 0x004576e8", bytes.fromhex("a0e8764500")),
        ("loads slot descriptor pointer from 0x00457750 + slot*0xd8", bytes.fromhex("8b84c050774500")),
        ("mode 0 uses descriptor+4 nested stream", bytes.fromhex("8b4004")),
        ("calls nested runner 0x00402360", bytes.fromhex("e8145fffff")),
        ("mode 1 uses descriptor+0 nested stream", bytes.fromhex("8b00")),
        ("advances save-selector stream by 4", bytes.fromhex("83404004")),
    ]
    return {
        "handlerBytesScanned": len(code),
        "signatureMatches": [
            {
                "label": label,
                "found": pattern in code,
            }
            for label, pattern in signatures
        ],
    }


def nested_runner_summary(exe: bytes, sections: list[dict]) -> dict:
    dispatcher = bytes_at(exe, sections, NESTED_DISPATCHER, 0x40)
    runner = bytes_at(exe, sections, NESTED_RUNNER, 0x40)
    return {
        "nestedRunnerVaHex": hex32(NESTED_RUNNER),
        "nestedDispatcherVaHex": hex32(NESTED_DISPATCHER),
        "nestedHandlerTableVaHex": hex32(GENERAL_HANDLER_TABLE),
        "dispatcherUsesGeneralTable": bytes.fromhex("ff148d38054400") in dispatcher,
        "runnerTemporarilySwapsContextStream": runner.count(bytes.fromhex("894140")) >= 2,
        "meaning": (
            "0x00402360 saves context+0x40, runs the nested stream through dispatcher 0x00402321, "
            "then restores context+0x40. The dispatcher uses handler table 0x00440538, not the save-selector table."
        ),
    }


def mode_rows() -> list[dict]:
    return [
        {
            "mode": 0,
            "modeHex": "0x00",
            "currentMode": True,
            "slotCountSourceHex": "0x004576e8",
            "slotBaseExpression": "0x00457750 + slot * 0xd8",
            "nestedStreamExpression": "dword[dword[slotBase] + 4]",
            "callVaHex": "0x0040c447",
            "runnerVaHex": hex32(NESTED_RUNNER),
            "meaning": "runs each active save/object slot's secondary script pointer stored at descriptor+4.",
        },
        {
            "mode": 1,
            "modeHex": "0x01",
            "currentMode": False,
            "slotCountSourceHex": "0x004576e8",
            "slotBaseExpression": "0x00457750 + slot * 0xd8 plus dword[0x00442da1]",
            "nestedStreamExpression": "dword[dword[0x00442da1] + 4], then dword[dword[slotBase] + 0]",
            "callVaHex": "0x0040c466, 0x0040c4a4",
            "runnerVaHex": hex32(NESTED_RUNNER),
            "meaning": "runs a party/runtime script first, then each slot's primary script pointer stored at descriptor+0.",
        },
    ]


def general_handler_rows(exe: bytes, sections: list[dict]) -> list[dict]:
    rows = []
    for opcode, note in GENERAL_HANDLER_NOTES.items():
        row = table_handler(sections, exe, GENERAL_HANDLER_TABLE, opcode)
        row.update(note)
        rows.append(row)
    return rows


def gate_summary(gate_offset_sources: dict) -> dict:
    gates = gate_offset_sources.get("gates") or []
    gate_rows = [
        {
            "gateVaHex": row.get("gateVaHex"),
            "selectionBufferOffsetHex": row.get("selectionBufferOffsetHex"),
            "currentRootWriterBeforeGateCount": row.get("currentRootWriterBeforeGateCount"),
            "globalWriterCount": row.get("globalWriterCount"),
            "sourceClassification": row.get("sourceClassification"),
        }
        for row in gates
        if row.get("selectionBufferOffsetHex") in {"0xe8", "0xea"}
    ]
    return {
        "controlPathGateStatus": gate_offset_sources.get("controlPathGateStatus"),
        "controlPathProofStatus": gate_offset_sources.get("controlPathProofStatus"),
        "gateOffsetsHex": gate_offset_sources.get("gateOffsetsHex") or [],
        "gates": gate_rows,
    }


def build_summary(
    exe: bytes,
    current_writer_paths: list[dict] | None = None,
    gate_offset_sources: dict | None = None,
    selection_buffer_bases: dict | None = None,
) -> dict:
    sections = read_sections(exe)
    current_writer_paths = current_writer_paths if current_writer_paths is not None else load_json(
        OUT / "save_selector_current_writer_paths.json",
        [],
    )
    gate_offset_sources = gate_offset_sources if gate_offset_sources is not None else load_json(
        OUT / "save_selector_gate_offset_sources.json",
        {},
    )
    selection_buffer_bases = selection_buffer_bases if selection_buffer_bases is not None else load_json(
        OUT / "save_selector_selection_buffer_bases.json",
        {},
    )

    current_value = dword_at(exe, sections, CURRENT_OPCODE20_VA)
    if current_value is None:
        raise ValueError(f"cannot read current opcode 0x20 row at {hex32(CURRENT_OPCODE20_VA)}")
    current_decoded = decode_stream_value(current_value)
    if current_decoded["opcode"] != 0x20:
        raise ValueError(f"{hex32(CURRENT_OPCODE20_VA)} is not opcode 0x20")

    save_handler = table_handler(sections, exe, SAVE_SELECTOR_HANDLER_TABLE, 0x20)
    current_context = current_opcode20_from_writer_paths(current_writer_paths)
    general_rows = general_handler_rows(exe, sections)
    direct_base_setters = [row for row in general_rows if row.get("writesContextA8") is True]
    current_mode = current_decoded["streamPlus1"]
    current_mode_row = next(row for row in mode_rows() if row["mode"] == current_mode)
    gate_info = gate_summary(gate_offset_sources)
    static_direct_ref_count = selection_buffer_bases.get("knownStaticGateOffsetDirectRefCount")

    conclusion = (
        "The current activation row at 0x005428a8 is save-selector opcode 0x20 with mode 0. "
        "Mode 0 does not directly choose map1_01a -> map2_02d; it iterates runtime save/object slots, "
        "calls nested runner 0x00402360 with descriptor+4 script pointers, and those nested scripts dispatch through "
        "general handler table 0x00440538. Several general handlers can replace context+0xa8 with different "
        "selection-buffer bases. Because the actual runtime object scripts and selected base before the 0xe8/0xea "
        "gates are still not proven, this narrows the blocker but does not promote the route."
    )

    return {
        "source": SOURCE,
        "target": TARGET,
        "currentWriterVaHex": hex32(CURRENT_WRITER_VA),
        "currentOpcode20VaHex": hex32(CURRENT_OPCODE20_VA),
        "currentOpcode20ValueHex": current_decoded["valueHex"],
        "currentOpcode20OpcodeHex": current_decoded["opcodeHex"],
        "currentOpcode20StreamPlus1Hex": current_decoded["streamPlus1Hex"],
        "currentOpcode20StreamPlus2Hex": current_decoded["streamPlus2Hex"],
        "currentMode": f"mode {current_mode}",
        "currentModeHex": hex8(current_mode),
        "currentModeIsNestedObjectPlus4": current_mode == 0,
        "saveSelectorOpcode20HandlerVaHex": save_handler.get("handlerVaHex"),
        "saveSelectorOpcode20HandlerMatchesExpected": save_handler.get("handlerVaHex") == hex32(OPCODE20_HANDLER),
        "saveSelectorOpcode20HandlerSection": save_handler.get("handlerSection"),
        "opcode20SignatureSummary": opcode20_signature_summary(exe, sections),
        "nestedRunner": nested_runner_summary(exe, sections),
        "modes": mode_rows(),
        "currentModeRow": current_mode_row,
        "generalHandlerTableVaHex": hex32(GENERAL_HANDLER_TABLE),
        "generalHandlerRows": general_rows,
        "directContextA8SetterCount": len(direct_base_setters),
        "runtimePointerModeStillRequired": True,
        "gateOffsetProofStatus": gate_info.get("controlPathProofStatus") or "blocked",
        "controlPathGateStatus": gate_info.get("controlPathGateStatus"),
        "gateOffsetsHex": gate_info.get("gateOffsetsHex") or [],
        "gateRows": gate_info.get("gates") or [],
        "staticGateOffsetDirectRefCount": static_direct_ref_count,
        "activationContext": current_context,
        "proofFound": False,
        "opcode20NestedBaseProofFound": False,
        "failedOpcode20NestedBaseGateIds": FAILED_OPCODE20_NESTED_BASE_GATE_IDS,
        "missingEvidence": OPCODE20_NESTED_BASE_MISSING_EVIDENCE,
        "evidenceRefs": OPCODE20_NESTED_BASE_EVIDENCE_REFS,
        "evidenceRefCount": len(OPCODE20_NESTED_BASE_EVIDENCE_REFS),
        "promotionStatus": "blocked",
        "conclusion": conclusion,
    }


def markdown(summary: dict) -> str:
    signature_matches = summary["opcode20SignatureSummary"].get("signatureMatches") or []
    signature_found_count = sum(1 for row in signature_matches if row.get("found"))
    nested_runner = summary["nestedRunner"]
    lines = [
        "# Save Selector Opcode 0x20 Nested Base Modes",
        "",
        f"- route: {summary['source']} -> {summary['target']}",
        f"- current writer: `{summary['currentWriterVaHex']}`",
        f"- current opcode 0x20 row: `{summary['currentOpcode20VaHex']}` value `{summary['currentOpcode20ValueHex']}`",
        f"- stream+1 mode: `{summary['currentOpcode20StreamPlus1Hex']}` ({summary['currentMode']})",
        f"- save-selector handler: `{summary['saveSelectorOpcode20HandlerVaHex']}`",
        f"- opcode 0x20 handler signatures found: {signature_found_count}/{len(signature_matches)}",
        f"- nested runner: `{nested_runner['nestedRunnerVaHex']}`",
        f"- nested dispatcher uses general handler table: {nested_runner['dispatcherUsesGeneralTable']}",
        f"- nested runner swaps/restores context stream: {nested_runner['runnerTemporarilySwapsContextStream']}",
        f"- nested dispatcher table: `{summary['generalHandlerTableVaHex']}`",
        f"- direct context+0xa8 setter count in nested table slice: {summary['directContextA8SetterCount']}",
        f"- gate offsets: {', '.join(f'`{item}`' for item in summary['gateOffsetsHex']) or '-'}",
        f"- gate offset proof status: `{summary['gateOffsetProofStatus']}`",
        f"- runtime pointer mode still required: {summary['runtimePointerModeStillRequired']}",
        f"- proofFound: `{summary['proofFound']}`",
        f"- opcode20NestedBaseProofFound: `{summary['opcode20NestedBaseProofFound']}`",
        f"- promotion status: `{summary['promotionStatus']}`",
        "",
        summary["conclusion"],
        "",
        "## Failed Gates",
        "",
    ]
    lines.extend(f"- `{item}`" for item in summary["failedOpcode20NestedBaseGateIds"])
    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([
        "",
        "## Opcode 0x20 Modes",
        "",
        "| mode | current | slot count | slot base | nested stream | call | meaning |",
        "| --- | --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary["modes"]:
        lines.append(
            f"| `{row['modeHex']}` | {row['currentMode']} | `{row['slotCountSourceHex']}` | "
            f"`{row['slotBaseExpression']}` | `{row['nestedStreamExpression']}` | "
            f"`{row['callVaHex']}` | {row['meaning']} |"
        )
    lines.extend([
        "",
        "## Handler Signatures",
        "",
        "| signature | found |",
        "| --- | --- |",
    ])
    for row in signature_matches:
        lines.append(f"| {row['label']} | {row['found']} |")
    lines.extend([
        "",
        "## Nested Runner",
        "",
        f"- runner: `{nested_runner['nestedRunnerVaHex']}`",
        f"- dispatcher: `{nested_runner['nestedDispatcherVaHex']}`",
        f"- general handler table: `{nested_runner['nestedHandlerTableVaHex']}`",
        f"- dispatcher uses general table: {nested_runner['dispatcherUsesGeneralTable']}",
        f"- runner temporarily swaps context stream: {nested_runner['runnerTemporarilySwapsContextStream']}",
        f"- meaning: {nested_runner['meaning']}",
    ])
    lines.extend([
        "",
        "## Nested General Handlers",
        "",
        "| opcode | handler | writes context+0xa8 | base expression | evidence | meaning |",
        "| --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary["generalHandlerRows"]:
        lines.append(
            f"| `{row['opcodeHex']}` | `{row['handlerVaHex']}` | {row['writesContextA8']} | "
            f"`{row['baseExpression']}` | `{row['evidenceVaHex']}` | {row['meaning']} |"
        )
    lines.extend([
        "",
        "## Gate Rows",
        "",
        "| gate | offset | current-root writers before gate | global writers | source |",
        "| --- | --- | ---: | ---: | --- |",
    ])
    for row in summary["gateRows"]:
        lines.append(
            f"| `{row['gateVaHex']}` | `{row['selectionBufferOffsetHex']}` | "
            f"{row['currentRootWriterBeforeGateCount']} | {row['globalWriterCount']} | "
            f"{row['sourceClassification']} |"
        )
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    failed_gates = "".join(
        f"<li><code>{html.escape(item)}</code></li>"
        for item in summary["failedOpcode20NestedBaseGateIds"]
    )
    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"]
    )
    mode_rows_html = []
    for row in summary["modes"]:
        mode_rows_html.append(
            "<tr>"
            f"<td><code>{html.escape(row['modeHex'])}</code></td>"
            f"<td>{row['currentMode']}</td>"
            f"<td><code>{html.escape(row['slotCountSourceHex'])}</code></td>"
            f"<td><code>{html.escape(row['slotBaseExpression'])}</code></td>"
            f"<td><code>{html.escape(row['nestedStreamExpression'])}</code></td>"
            f"<td><code>{html.escape(row['callVaHex'])}</code></td>"
            f"<td>{html.escape(row['meaning'])}</td>"
            "</tr>"
        )
    handler_rows_html = []
    for row in summary["generalHandlerRows"]:
        handler_rows_html.append(
            "<tr>"
            f"<td><code>{html.escape(row['opcodeHex'])}</code></td>"
            f"<td><code>{html.escape(row['handlerVaHex'] or '-')}</code></td>"
            f"<td>{row['writesContextA8']}</td>"
            f"<td><code>{html.escape(row['baseExpression'])}</code></td>"
            f"<td><code>{html.escape(row['evidenceVaHex'])}</code></td>"
            f"<td>{html.escape(row['meaning'])}</td>"
            "</tr>"
        )
    gate_rows_html = []
    for row in summary["gateRows"]:
        gate_rows_html.append(
            "<tr>"
            f"<td><code>{html.escape(row['gateVaHex'] or '-')}</code></td>"
            f"<td><code>{html.escape(row['selectionBufferOffsetHex'] or '-')}</code></td>"
            f"<td>{row['currentRootWriterBeforeGateCount']}</td>"
            f"<td>{row['globalWriterCount']}</td>"
            f"<td>{html.escape(row['sourceClassification'] or '-')}</td>"
            "</tr>"
        )
    signature_rows_html = []
    for row in summary["opcode20SignatureSummary"]["signatureMatches"]:
        signature_rows_html.append(
            "<tr>"
            f"<td>{html.escape(row['label'])}</td>"
            f"<td>{row['found']}</td>"
            "</tr>"
        )

    return "\n".join([
        "<!doctype html>",
        '<html lang="en">',
        "<head>",
        '  <meta charset="utf-8">',
        '  <meta name="viewport" content="width=device-width, initial-scale=1">',
        "  <title>Save Selector Opcode 0x20 Nested Base Modes</title>",
        "  <style>",
        "    :root { color-scheme: dark; font-family: system-ui, sans-serif; background: #101010; color: #eee; }",
        "    body { margin: 0; padding: 24px; }",
        "    h1 { margin: 0 0 8px; font-size: 24px; }",
        "    h2 { margin: 24px 0 8px; font-size: 18px; }",
        "    p { max-width: 1120px; color: #bbb; line-height: 1.45; }",
        "    table { width: 100%; border-collapse: collapse; margin: 12px 0 20px; font-size: 13px; }",
        "    th, td { border-bottom: 1px solid #303030; padding: 7px 8px; text-align: left; vertical-align: top; }",
        "    th { position: sticky; top: 0; background: #181818; color: #ddd; }",
        "    code { color: #d7f0ff; }",
        "  </style>",
        "</head>",
        "<body>",
        "  <h1>Save Selector Opcode 0x20 Nested Base Modes</h1>",
        f"  <p>route {html.escape(summary['source'])} -&gt; {html.escape(summary['target'])}; current row <code>{html.escape(summary['currentOpcode20VaHex'])}</code> value <code>{html.escape(summary['currentOpcode20ValueHex'])}</code>; stream+1 mode <code>{html.escape(summary['currentOpcode20StreamPlus1Hex'])}</code>; nested runner <code>{html.escape(summary['nestedRunner']['nestedRunnerVaHex'])}</code>; handler table <code>{html.escape(summary['generalHandlerTableVaHex'])}</code>; gate offset proof status <code>{html.escape(summary['gateOffsetProofStatus'])}</code>; runtime pointer mode still required: {summary['runtimePointerModeStillRequired']}; proofFound <code>{summary['proofFound']}</code>; promotion status <code>{html.escape(summary['promotionStatus'])}</code>.</p>",
        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>Opcode 0x20 Modes</h2>",
        "  <table><thead><tr><th>mode</th><th>current</th><th>slot count</th><th>slot base</th><th>nested stream</th><th>call</th><th>meaning</th></tr></thead><tbody>",
        *mode_rows_html,
        "  </tbody></table>",
        "  <h2>Nested General Handlers</h2>",
        "  <table><thead><tr><th>opcode</th><th>handler</th><th>writes context+0xa8</th><th>base expression</th><th>evidence</th><th>meaning</th></tr></thead><tbody>",
        *handler_rows_html,
        "  </tbody></table>",
        "  <h2>Gate Rows</h2>",
        "  <table><thead><tr><th>gate</th><th>offset</th><th>root writers before gate</th><th>global writers</th><th>source</th></tr></thead><tbody>",
        *gate_rows_html,
        "  </tbody></table>",
        "  <h2>Signatures</h2>",
        "  <table><thead><tr><th>signature</th><th>found</th></tr></thead><tbody>",
        *signature_rows_html,
        "  </tbody></table>",
        "</body>",
        "</html>",
        "",
    ])


def write_outputs(summary: dict, out_dir: Path = OUT) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "save_selector_opcode20_nested_base_modes.json").write_text(
        json.dumps(summary, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )
    (out_dir / "save_selector_opcode20_nested_base_modes.html").write_text(html_page(summary), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    args = parser.parse_args()
    summary = build_summary(args.exe.read_bytes())
    write_outputs(summary, args.out_dir)
    print(f"wrote save selector opcode 0x20 nested base modes -> {args.out_dir / 'save_selector_opcode20_nested_base_modes.html'}")


if __name__ == "__main__":
    main()
