#!/usr/bin/env python3
"""Document save-selector dispatch-table anchoring for the current route blocker."""
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 offset_to_va, read_sections, va_to_offset


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

SOURCE = "map1_01a"
TARGET = "map2_02d"
GENERAL_HANDLER_TABLE = 0x00440538
SAVE_SELECTOR_HANDLER_TABLE = 0x00440720
EVENT_OBJECT_HANDLER_TABLE = 0x0047F1D8
DEFAULT_HANDLER = 0x0040239F
GENERIC_DISPATCHER_ENTRY = 0x00402321
GENERIC_DISPATCHER_INDEXED_CALL = 0x0040234C
SAVE_SELECTOR_SLICE_OFFSET_BYTES = SAVE_SELECTOR_HANDLER_TABLE - GENERAL_HANDLER_TABLE
SAVE_SELECTOR_TABLE_BASE_ARITHMETIC_SCAN_WINDOW = 192

SELECTED_POINTER_OPCODES = (0x07, 0x08, 0x09)
BRANCH_OPCODES = (0x10, 0x11, 0x12, 0x13)
FRONTIER_OPCODES = (0x3F, 0x10, 0x26, 0x2C, 0x48, 0x12, 0x38, 0x16, 0x63, 0x50, 0x11)
ROUTE_RELEVANT_DISPATCH_SITES = (
    ("route-dispatch-stop", 0x005428F4, 0xE8),
    ("wrapper-child-pointer", 0x00542A08, 0xE8),
    ("direct-leaf-entry-0", 0x005429F4, 0xC0),
    ("direct-leaf-entry-1", 0x005429FC, 0xD4),
    ("predecessor-root-entry-stop", 0x004783E0, 0xD0),
    ("predecessor-fill-fragment-stop", 0x004844DC, 0xC0),
)
DISPATCH_TABLE_CONTEXT_MISSING_EVIDENCE_BY_GATE = {
    "save-selector-slice-runtime-dispatch": (
        "runtime dispatch proof for the 0x00440720 save-selector slice"
    ),
    "descriptor-boundary-runtime-bridge": (
        "runtime bridge from route descriptor boundaries into the save-selector slice"
    ),
    "dynamic-save-selector-table-base-candidate": (
        "dynamic register-base dispatch candidate using the save-selector table base"
    ),
    "route-relevant-slice-table-base-switch": (
        "route-relevant table-base switch proof for descriptor-boundary rows"
    ),
}
DISPATCH_TABLE_CONTEXT_EVIDENCE_REFS = [
    {
        "path": "Hwanse2.exe",
        "fields": [
            "0x0040234c generic indexed dispatch immediate",
            "0x00440538 general dispatch table",
            "0x00440720 save-selector dispatch slice",
            "0x0047f1d8 event-object dispatch table",
        ],
    },
    {
        "path": "out/save_selector_stream_traces.json",
        "fields": [
            "source",
            "target",
            "trace",
            "opcodeHex",
            "handlerVaHex",
        ],
    },
    {
        "path": "out/save_selector_dispatch_table_context.json",
        "fields": [
            "relativeOpcodeRows",
            "routeRelevantDispatchRows",
            "dynamicIndexedDispatchRows",
            "dynamicScopeTableCallbackRows",
            "dynamicSaveSelectorTableBaseCandidateRows",
        ],
    },
]

INDEXED_PATTERNS = (
    (bytes.fromhex("ff1485"), "call", "eax"),
    (bytes.fromhex("ff148d"), "call", "ecx"),
    (bytes.fromhex("ff1495"), "call", "edx"),
    (bytes.fromhex("ff2485"), "jmp", "eax"),
    (bytes.fromhex("ff248d"), "jmp", "ecx"),
    (bytes.fromhex("ff2495"), "jmp", "edx"),
)

REG32 = ("eax", "ecx", "edx", "ebx", "esp", "ebp", "esi", "edi")
DISPATCH_TABLES = (
    ("general-object", GENERAL_HANDLER_TABLE),
    ("save-selector", SAVE_SELECTOR_HANDLER_TABLE),
    ("event-object", EVENT_OBJECT_HANDLER_TABLE),
)


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


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


def section_for_offset(sections: list[dict], offset: int) -> dict | None:
    for section in sections:
        start = int(section["raw"])
        end = start + int(section["raw_size"])
        if start <= offset < end:
            return section
    return None


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 section_name_for_va(sections: list[dict], va: int | None) -> str | None:
    if va is None:
        return 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 find_dword_refs(exe: bytes, sections: list[dict], value: int) -> list[dict]:
    needle = struct.pack("<I", value)
    refs = []
    search = 0
    while True:
        hit = exe.find(needle, search)
        if hit < 0:
            break
        search = hit + 1
        section = section_for_offset(sections, hit)
        if not section:
            continue
        ref_va = offset_to_va(sections, hit)
        if ref_va is None:
            continue
        refs.append({
            "section": section["name"],
            "refVaHex": hex32(ref_va),
            "fileOffsetHex": f"0x{hit:06x}",
        })
    return refs


def find_indexed_dispatches(exe: bytes, sections: list[dict]) -> list[dict]:
    text = next(section for section in sections if section["name"] == ".text")
    raw_start = int(text["raw"])
    raw = exe[raw_start: raw_start + int(text["raw_size"])]
    rows = []
    for index in range(0, max(0, len(raw) - 7)):
        for pattern, kind, register in INDEXED_PATTERNS:
            if raw[index:index + len(pattern)] != pattern:
                continue
            imm_offset = raw_start + index + len(pattern)
            base = struct.unpack_from("<I", exe, imm_offset)[0]
            va = int(text["va"]) + index
            rows.append({
                "instructionVaHex": hex32(va),
                "kind": kind,
                "indexRegister": register,
                "tableBaseHex": hex32(base),
                "tableRole": table_role(base),
                "rawOffset": imm_offset - len(pattern),
                "nearbyTableBaseArithmeticRefs": nearby_dword_immediates(
                    exe,
                    sections,
                    imm_offset - len(pattern),
                    [
                        ("general-table", GENERAL_HANDLER_TABLE),
                        ("save-selector-table", SAVE_SELECTOR_HANDLER_TABLE),
                        ("save-selector-slice-delta", SAVE_SELECTOR_SLICE_OFFSET_BYTES),
                    ],
                ),
            })
    return rows


def signed_disp(exe: bytes, offset: int, size: int) -> int:
    if size == 1:
        value = exe[offset]
        return value - 0x100 if value & 0x80 else value
    if size == 4:
        return struct.unpack_from("<i", exe, offset)[0]
    return 0


def nearby_table_immediates(exe: bytes, sections: list[dict], raw_offset: int, window: int = 96) -> list[dict]:
    text = next(section for section in sections if section["name"] == ".text")
    start = max(int(text["raw"]), raw_offset - window)
    end = min(int(text["raw"]) + int(text["raw_size"]), raw_offset + 16)
    rows = []
    for role, table_va in DISPATCH_TABLES:
        needle = struct.pack("<I", table_va)
        cursor = start
        while True:
            hit = exe.find(needle, cursor, end)
            if hit < 0:
                break
            ref_va = offset_to_va(sections, hit)
            if ref_va is not None:
                rows.append({
                    "tableRole": role,
                    "tableVaHex": hex32(table_va),
                    "refVaHex": hex32(ref_va),
                    "relativeToInstructionHex": f"{hit - raw_offset:+#x}",
                })
            cursor = hit + 1
    rows.sort(key=lambda row: (row["refVaHex"], row["tableRole"]))
    return rows


def nearby_dword_immediates(
    exe: bytes,
    sections: list[dict],
    raw_offset: int,
    values: list[tuple[str, int]],
    window: int = SAVE_SELECTOR_TABLE_BASE_ARITHMETIC_SCAN_WINDOW,
) -> list[dict]:
    text = next(section for section in sections if section["name"] == ".text")
    start = max(int(text["raw"]), raw_offset - window)
    end = min(int(text["raw"]) + int(text["raw_size"]), raw_offset + 16)
    rows = []
    for role, value in values:
        needle = struct.pack("<I", value)
        cursor = start
        while True:
            hit = exe.find(needle, cursor, end)
            if hit < 0:
                break
            ref_va = offset_to_va(sections, hit)
            if ref_va is not None:
                rows.append({
                    "role": role,
                    "valueHex": hex32(value),
                    "refVaHex": hex32(ref_va),
                    "relativeToInstructionHex": f"{hit - raw_offset:+#x}",
                })
            cursor = hit + 1
    rows.sort(key=lambda row: (row["refVaHex"], row["role"]))
    return rows


def dynamic_dispatch_context(exe: bytes, sections: list[dict], raw_offset: int, row: dict) -> dict:
    text = next(section for section in sections if section["name"] == ".text")
    text_start = int(text["raw"])
    text_end = text_start + int(text["raw_size"])
    before = exe[max(text_start, raw_offset - 128):raw_offset]
    after = exe[raw_offset:min(text_end, raw_offset + 64)]
    has_sentinel_state = b"\x83\xfe\xff" in before
    has_triplet_index = b"\x8d\x34\x76" in before or b"\x8d\x0c\x76" in before
    has_seh_registration = (
        b"\x64\xff\x35\x00\x00\x00\x00" in before
        or b"\x64\x89\x25\x00\x00\x00\x00" in before
    )
    calls_unwind_helper = b"\xe8\x26\xf3\xff\xff" in before or b"\xe8\xe6\xf2\xff\xff" in after
    is_scope_table_callback = (
        has_sentinel_state
        and has_triplet_index
        and row.get("scale") == 4
        and row.get("displacement") in {4, 8}
    )
    if is_scope_table_callback:
        if has_seh_registration:
            classification = "seh-registration-scope-table-callback"
            reason = (
                "nearby fs:0 SEH registration plus state==-1 sentinel and index*3 "
                "triplet table walk"
            )
        else:
            classification = "seh-scope-table-callback"
            reason = (
                "state==-1 sentinel and index*3 triplet table walk; helper calls "
                "the SEH registration walker"
                if calls_unwind_helper
                else "state==-1 sentinel and index*3 triplet table walk"
            )
        scope_stride = 12
    else:
        classification = "unclassified-dynamic-indexed-dispatch"
        reason = "no local SEH scope-table pattern identified"
        scope_stride = None
    candidate = (
        row.get("nearbySaveSelectorTableImmediateCount", 0) > 0
        and classification == "unclassified-dynamic-indexed-dispatch"
    )
    return {
        "contextClassification": classification,
        "contextReason": reason,
        "scopeTableStrideBytes": scope_stride,
        "hasSehRegistrationPattern": has_seh_registration,
        "hasSentinelMinusOneStateCheck": has_sentinel_state,
        "hasTripletIndexScalePattern": has_triplet_index,
        "candidateForSaveSelectorTableBase": candidate,
    }


def find_dynamic_indexed_dispatches(exe: bytes, sections: list[dict]) -> list[dict]:
    """Find indirect call/jmp rows with a register table base and scaled index."""
    text = next(section for section in sections if section["name"] == ".text")
    raw_start = int(text["raw"])
    raw_end = raw_start + int(text["raw_size"])
    rows = []
    for offset in range(raw_start, raw_end - 2):
        if exe[offset] != 0xFF:
            continue
        modrm = exe[offset + 1]
        reg = (modrm >> 3) & 0x07
        if reg not in {2, 4}:
            continue
        if (modrm & 0x07) != 4:
            continue
        mod = (modrm >> 6) & 0x03
        if mod == 3:
            continue
        sib = exe[offset + 2]
        scale_bits = (sib >> 6) & 0x03
        index = (sib >> 3) & 0x07
        base = sib & 0x07
        if index == 4:
            continue
        if scale_bits != 2:
            continue
        no_base_disp32 = mod == 0 and base == 5
        if no_base_disp32:
            continue
        disp_size = 1 if mod == 1 else 4 if mod == 2 else 0
        if offset + 3 + disp_size > raw_end:
            continue
        displacement = signed_disp(exe, offset + 3, disp_size)
        instruction_va = offset_to_va(sections, offset)
        if instruction_va is None:
            continue
        nearby = nearby_table_immediates(exe, sections, offset)
        row = {
            "instructionVaHex": hex32(instruction_va),
            "kind": "call" if reg == 2 else "jmp",
            "baseRegister": REG32[base],
            "indexRegister": REG32[index],
            "scale": 1 << scale_bits,
            "isDwordScaledIndex": scale_bits == 2,
            "displacement": displacement,
            "displacementHex": f"{displacement:+#x}",
            "encodingHex": exe[offset: offset + 3 + disp_size].hex(),
            "rawOffset": offset,
            "nearbyTableImmediateRefs": nearby,
            "nearbyTableBaseArithmeticRefs": nearby_dword_immediates(
                exe,
                sections,
                offset,
                [
                    ("general-table", GENERAL_HANDLER_TABLE),
                    ("save-selector-table", SAVE_SELECTOR_HANDLER_TABLE),
                    ("save-selector-slice-delta", SAVE_SELECTOR_SLICE_OFFSET_BYTES),
                ],
            ),
            "nearbySaveSelectorTableImmediateCount": sum(
                1 for row in nearby if row.get("tableRole") == "save-selector"
            ),
            "nearbyKnownDispatchTableImmediateCount": len(nearby),
        }
        row.update(dynamic_dispatch_context(exe, sections, offset, row))
        rows.append(row)
    return rows


def table_role(base: int) -> str:
    if base == GENERAL_HANDLER_TABLE:
        return "general-object"
    if base == SAVE_SELECTOR_HANDLER_TABLE:
        return "save-selector"
    if base == EVENT_OBJECT_HANDLER_TABLE:
        return "event-object"
    return "other"


def dynamic_dispatch_classification_counts(rows: list[dict]) -> dict[str, int]:
    counts: dict[str, int] = {}
    for row in rows:
        key = str(row.get("contextClassification") or "unclassified")
        counts[key] = counts.get(key, 0) + 1
    return counts


def dynamic_scope_table_callback_rows(rows: list[dict]) -> list[dict]:
    return [
        {
            "instructionVaHex": row.get("instructionVaHex"),
            "kind": row.get("kind"),
            "baseRegister": row.get("baseRegister"),
            "indexRegister": row.get("indexRegister"),
            "scale": row.get("scale"),
            "displacementHex": row.get("displacementHex"),
            "contextClassification": row.get("contextClassification"),
            "contextReason": row.get("contextReason"),
            "scopeTableStrideBytes": row.get("scopeTableStrideBytes"),
            "candidateForSaveSelectorTableBase": row.get("candidateForSaveSelectorTableBase"),
            "nearbySaveSelectorTableImmediateCount": row.get("nearbySaveSelectorTableImmediateCount"),
            "nearbyKnownDispatchTableImmediateCount": row.get("nearbyKnownDispatchTableImmediateCount"),
        }
        for row in rows
        if str(row.get("contextClassification") or "").startswith("seh-")
    ]


def dynamic_save_selector_table_base_candidate_rows(rows: list[dict]) -> list[dict]:
    return [
        {
            "instructionVaHex": row.get("instructionVaHex"),
            "kind": row.get("kind"),
            "baseRegister": row.get("baseRegister"),
            "indexRegister": row.get("indexRegister"),
            "scale": row.get("scale"),
            "displacementHex": row.get("displacementHex"),
            "contextClassification": row.get("contextClassification"),
            "contextReason": row.get("contextReason"),
            "nearbyTableImmediateRefs": row.get("nearbyTableImmediateRefs") or [],
            "nearbySaveSelectorTableImmediateCount": row.get("nearbySaveSelectorTableImmediateCount"),
            "candidateForSaveSelectorTableBase": row.get("candidateForSaveSelectorTableBase"),
        }
        for row in rows
        if row.get("candidateForSaveSelectorTableBase") is True
    ]


def save_selector_table_base_arithmetic_rows(dispatch_rows: list[dict]) -> list[dict]:
    rows = []
    for row in dispatch_rows:
        raw_offset = row.get("rawOffset")
        if not isinstance(raw_offset, int):
            continue
        arithmetic_refs = row.get("nearbyTableBaseArithmeticRefs") or []
        general_count = sum(1 for ref in arithmetic_refs if ref.get("role") == "general-table")
        save_count = sum(1 for ref in arithmetic_refs if ref.get("role") == "save-selector-table")
        slice_delta_count = sum(1 for ref in arithmetic_refs if ref.get("role") == "save-selector-slice-delta")
        candidate = (
            row.get("contextClassification") not in {
                "seh-registration-scope-table-callback",
                "seh-scope-table-callback",
            }
            and (save_count > 0 or (general_count > 0 and slice_delta_count > 0))
        )
        if save_count > 0:
            classification = "direct-save-selector-table-immediate-near-dispatch"
        elif general_count > 0 and slice_delta_count > 0:
            classification = "general-table-plus-save-slice-delta-near-dispatch"
        elif general_count > 0:
            classification = "generic-table-immediate-only"
        elif slice_delta_count > 0:
            classification = "slice-delta-without-table-base"
        else:
            classification = "no-table-base-arithmetic-immediates"
        rows.append({
            "instructionVaHex": row.get("instructionVaHex"),
            "kind": row.get("kind"),
            "baseRegister": row.get("baseRegister"),
            "indexRegister": row.get("indexRegister"),
            "scale": row.get("scale"),
            "tableBaseHex": row.get("tableBaseHex"),
            "tableRole": row.get("tableRole"),
            "contextClassification": row.get("contextClassification"),
            "nearbyGeneralTableImmediateCount": general_count,
            "nearbySaveSelectorTableImmediateCount": save_count,
            "nearbySaveSelectorSliceDeltaImm32Count": slice_delta_count,
            "nearbyTableBaseArithmeticRefs": arithmetic_refs,
            "candidateForSaveSelectorTableBaseArithmetic": candidate,
            "classification": classification,
        })
    return rows


def nearby_refs_text(row: dict) -> str:
    return ", ".join(
        f"{ref.get('tableRole')}@{ref.get('refVaHex')}"
        for ref in row.get("nearbyTableImmediateRefs") or []
    ) or "-"


def table_ref_summary(exe: bytes, sections: list[dict], table: int, dispatches: list[dict]) -> dict:
    refs = find_dword_refs(exe, sections, table)
    indexed = [row for row in dispatches if row.get("tableBaseHex") == hex32(table)]
    return {
        "tableVaHex": hex32(table),
        "directDwordRefCount": len(refs),
        "textDwordRefCount": sum(1 for row in refs if row.get("section") == ".text"),
        "indexedDispatchCount": len(indexed),
        "refs": refs[:32],
        "indexedDispatches": indexed,
    }


def opcode_row(exe: bytes, sections: list[dict], opcode: int) -> dict:
    save_entry = SAVE_SELECTOR_HANDLER_TABLE + opcode * 4
    save_handler = dword_at(exe, sections, save_entry)
    general_raw_entry = GENERAL_HANDLER_TABLE + opcode * 4
    general_raw_handler = dword_at(exe, sections, general_raw_entry)
    absolute_index = (save_entry - GENERAL_HANDLER_TABLE) // 4
    general_absolute_entry = GENERAL_HANDLER_TABLE + absolute_index * 4
    general_absolute_handler = dword_at(exe, sections, general_absolute_entry)
    general_absolute_byte_reachable = 0 <= absolute_index <= 0xFF
    return {
        "relativeOpcode": opcode,
        "relativeOpcodeHex": f"0x{opcode:02x}",
        "saveEntryVaHex": hex32(save_entry),
        "saveHandlerVaHex": hex32(save_handler),
        "saveHandlerSection": section_name_for_va(sections, save_handler),
        "generalRawEntryVaHex": hex32(general_raw_entry),
        "generalRawHandlerVaHex": hex32(general_raw_handler),
        "generalRawHandlerSection": section_name_for_va(sections, general_raw_handler),
        "generalAbsoluteOpcode": absolute_index,
        "generalAbsoluteOpcodeHex": f"0x{absolute_index:02x}",
        "generalAbsoluteDispatchByteHex": (
            f"0x{absolute_index:02x}" if general_absolute_byte_reachable else None
        ),
        "generalAbsoluteByteReachable": general_absolute_byte_reachable,
        "generalAbsoluteEntryVaHex": hex32(general_absolute_entry),
        "generalAbsoluteHandlerVaHex": hex32(general_absolute_handler),
        "generalAbsoluteMatchesSaveHandler": general_absolute_handler == save_handler,
        "generalAbsoluteHandlerReachableViaByteDispatch": (
            general_absolute_byte_reachable and general_absolute_handler == save_handler
        ),
        "saveHandlerIsDefault": save_handler == DEFAULT_HANDLER,
    }


def route_relevant_dispatch_row(exe: bytes, sections: list[dict], role: str, site_va: int, opcode: int) -> dict:
    row = opcode_row(exe, sections, opcode)
    row.update({
        "role": role,
        "siteVaHex": hex32(site_va),
        "lowByteOpcode": opcode,
        "lowByteOpcodeHex": f"0x{opcode:02x}",
        "sliceHandlerIsDataDescriptor": row.get("saveHandlerSection") == ".data",
        "sliceHandlerIsCode": row.get("saveHandlerSection") == ".text",
        "rawGeneralHandlerIsDefault": row.get("generalRawHandlerVaHex") == hex32(DEFAULT_HANDLER),
        "rawGeneralHandlerIsCode": row.get("generalRawHandlerSection") == ".text",
        "rawGeneralDiffersFromSlice": row.get("generalRawHandlerVaHex") != row.get("saveHandlerVaHex"),
        "sliceHandlerReachableViaGenericByteDispatch": row.get(
            "generalAbsoluteHandlerReachableViaByteDispatch"
        ),
        "sliceHandlerRequiresSaveSelectorTableBase": not bool(
            row.get("generalAbsoluteByteReachable")
        ),
    })
    return row


def route_trace_opcodes(stream_traces: list[dict]) -> list[int]:
    seen = set()
    opcodes = []
    for trace in stream_traces:
        if trace.get("source") != SOURCE or trace.get("target") != TARGET:
            continue
        for step in trace.get("trace") or []:
            value = step.get("opcode")
            if value is None:
                opcode_hex = step.get("opcodeHex")
                value = int(opcode_hex, 16) if opcode_hex else None
            if value is None or value in seen:
                continue
            seen.add(value)
            opcodes.append(int(value))
    return opcodes


def build_summary(exe: bytes, stream_traces: list[dict] | None = None) -> dict:
    sections = read_sections(exe)
    stream_traces = stream_traces if stream_traces is not None else load_json(
        OUT / "save_selector_stream_traces.json",
        [],
    )
    dispatches = find_indexed_dispatches(exe, sections)
    dynamic_dispatches = find_dynamic_indexed_dispatches(exe, sections)
    general = table_ref_summary(exe, sections, GENERAL_HANDLER_TABLE, dispatches)
    save_selector = table_ref_summary(exe, sections, SAVE_SELECTOR_HANDLER_TABLE, dispatches)
    event_object = table_ref_summary(exe, sections, EVENT_OBJECT_HANDLER_TABLE, dispatches)
    slice_offset_entries = (SAVE_SELECTOR_HANDLER_TABLE - GENERAL_HANDLER_TABLE) // 4
    route_opcodes = [
        opcode for opcode in route_trace_opcodes(stream_traces)
        if opcode in set(FRONTIER_OPCODES)
    ] or list(FRONTIER_OPCODES)
    relative_rows = [opcode_row(exe, sections, opcode) for opcode in sorted(set(route_opcodes + list(SELECTED_POINTER_OPCODES) + list(BRANCH_OPCODES)))]
    selected_rows = [row for row in relative_rows if row["relativeOpcode"] in SELECTED_POINTER_OPCODES]
    branch_rows = [row for row in relative_rows if row["relativeOpcode"] in BRANCH_OPCODES]
    selected_pointer_relative_handlers_verified = all(
        row.get("saveHandlerVaHex") in {"0x0040ad9b", "0x0040adc9", "0x0040ae0e"}
        for row in selected_rows
    )
    route_relevant_dispatch_rows = [
        route_relevant_dispatch_row(exe, sections, role, site_va, opcode)
        for role, site_va, opcode in ROUTE_RELEVANT_DISPATCH_SITES
    ]
    route_relevant_slice_data_descriptor_roles = [
        row["role"]
        for row in route_relevant_dispatch_rows
        if row.get("sliceHandlerIsDataDescriptor") is True
    ]
    route_relevant_raw_general_default_roles = [
        row["role"]
        for row in route_relevant_dispatch_rows
        if row.get("rawGeneralHandlerIsDefault") is True
    ]
    route_relevant_slice_requires_table_base_switch_roles = [
        row["role"]
        for row in route_relevant_dispatch_rows
        if row.get("sliceHandlerRequiresSaveSelectorTableBase") is True
    ]
    route_relevant_slice_data_descriptor_requires_table_base_switch_roles = [
        row["role"]
        for row in route_relevant_dispatch_rows
        if row.get("sliceHandlerIsDataDescriptor") is True
        and row.get("sliceHandlerRequiresSaveSelectorTableBase") is True
    ]
    route_relevant_slice_data_descriptor_byte_reachable_roles = [
        row["role"]
        for row in route_relevant_dispatch_rows
        if row.get("sliceHandlerIsDataDescriptor") is True
        and row.get("sliceHandlerReachableViaGenericByteDispatch") is True
    ]
    save_selector_slice_anchored = (
        slice_offset_entries == 0x7A
        and bool(general.get("indexedDispatches"))
        and save_selector.get("indexedDispatchCount") == 0
        and save_selector.get("directDwordRefCount") == 0
        and all(row.get("generalAbsoluteMatchesSaveHandler") is True for row in relative_rows)
    )
    save_selector_slice_direct_runtime_dispatch_proof_found = (
        save_selector.get("indexedDispatchCount", 0) > 0
        or save_selector.get("textDwordRefCount", 0) > 0
    )
    dynamic_save_selector_table_base_switch_candidate_count = sum(
        row.get("candidateForSaveSelectorTableBase") is True
        for row in dynamic_dispatches
    )
    dynamic_scope_rows = dynamic_scope_table_callback_rows(dynamic_dispatches)
    dynamic_candidate_rows = dynamic_save_selector_table_base_candidate_rows(dynamic_dispatches)
    arithmetic_rows = save_selector_table_base_arithmetic_rows(
        dispatches + dynamic_dispatches
    )
    arithmetic_candidate_rows = [
        row
        for row in arithmetic_rows
        if row.get("candidateForSaveSelectorTableBaseArithmetic") is True
    ]
    dynamic_scope_table_callback_count = len(dynamic_scope_rows)
    dynamic_known_table_base_switch_candidate_count = sum(
        row.get("nearbyKnownDispatchTableImmediateCount", 0) > 0
        for row in dynamic_dispatches
    )
    descriptor_boundary_depends_on_save_selector_slice_model = (
        not save_selector_slice_direct_runtime_dispatch_proof_found
        and dynamic_save_selector_table_base_switch_candidate_count == 0
        and bool(route_relevant_slice_data_descriptor_roles)
        and any(row.get("rawGeneralDiffersFromSlice") is True for row in route_relevant_dispatch_rows)
    )
    failed_dispatch_table_gate_ids = []
    if not save_selector_slice_direct_runtime_dispatch_proof_found:
        failed_dispatch_table_gate_ids.append("save-selector-slice-runtime-dispatch")
    if descriptor_boundary_depends_on_save_selector_slice_model:
        failed_dispatch_table_gate_ids.append("descriptor-boundary-runtime-bridge")
    if (
        dynamic_save_selector_table_base_switch_candidate_count == 0
        and not arithmetic_candidate_rows
    ):
        failed_dispatch_table_gate_ids.append("dynamic-save-selector-table-base-candidate")
    if (
        route_relevant_slice_requires_table_base_switch_roles
        and not route_relevant_slice_data_descriptor_byte_reachable_roles
    ):
        failed_dispatch_table_gate_ids.append("route-relevant-slice-table-base-switch")
    missing_evidence = [
        DISPATCH_TABLE_CONTEXT_MISSING_EVIDENCE_BY_GATE[gate_id]
        for gate_id in failed_dispatch_table_gate_ids
    ]
    proof_found = not failed_dispatch_table_gate_ids
    return {
        "source": SOURCE,
        "target": TARGET,
        "generalHandlerTableHex": hex32(GENERAL_HANDLER_TABLE),
        "saveSelectorHandlerTableHex": hex32(SAVE_SELECTOR_HANDLER_TABLE),
        "eventObjectHandlerTableHex": hex32(EVENT_OBJECT_HANDLER_TABLE),
        "saveSelectorSliceOffsetBytes": SAVE_SELECTOR_SLICE_OFFSET_BYTES,
        "saveSelectorSliceOffsetBytesHex": hex32(SAVE_SELECTOR_SLICE_OFFSET_BYTES),
        "genericDispatcher": {
            "entryVaHex": hex32(GENERIC_DISPATCHER_ENTRY),
            "indexedCallVaHex": hex32(GENERIC_DISPATCHER_INDEXED_CALL),
            "tableVaHex": hex32(GENERAL_HANDLER_TABLE),
            "indexWidth": "u8",
            "indexSource": "byte at context+0x40 stream pointer",
        },
        "saveSelectorSliceOffsetEntries": slice_offset_entries,
        "saveSelectorSliceOffsetHex": f"0x{slice_offset_entries:02x}",
        "generalTable": general,
        "saveSelectorTable": save_selector,
        "eventObjectTable": event_object,
        "indexedDispatchRows": dispatches,
        "indexedDispatchRowCount": len(dispatches),
        "dynamicIndexedDispatchRows": dynamic_dispatches,
        "dynamicIndexedDispatchRowCount": len(dynamic_dispatches),
        "dynamicIndexedDispatchClassificationCounts": (
            dynamic_dispatch_classification_counts(dynamic_dispatches)
        ),
        "dynamicDwordScaledDispatchRowCount": sum(
            1 for row in dynamic_dispatches if row.get("isDwordScaledIndex") is True
        ),
        "dynamicKnownDispatchTableImmediateNearCount": dynamic_known_table_base_switch_candidate_count,
        "dynamicScopeTableCallbackCount": dynamic_scope_table_callback_count,
        "dynamicScopeTableCallbackSites": [
            row.get("instructionVaHex") for row in dynamic_scope_rows
        ],
        "dynamicScopeTableCallbackRows": dynamic_scope_rows,
        "dynamicSaveSelectorTableImmediateNearCount": (
            sum(row.get("nearbySaveSelectorTableImmediateCount", 0) for row in dynamic_dispatches)
        ),
        "dynamicSaveSelectorTableBaseCandidateCount": (
            dynamic_save_selector_table_base_switch_candidate_count
        ),
        "dynamicSaveSelectorTableBaseCandidateSites": [
            row.get("instructionVaHex") for row in dynamic_candidate_rows
        ],
        "dynamicSaveSelectorTableBaseCandidateRows": dynamic_candidate_rows,
        "dynamicSaveSelectorTableBaseSwitchStaticCandidateFound": (
            dynamic_save_selector_table_base_switch_candidate_count > 0
            or bool(arithmetic_candidate_rows)
        ),
        "saveSelectorTableBaseArithmeticScanWindowBytes": (
            SAVE_SELECTOR_TABLE_BASE_ARITHMETIC_SCAN_WINDOW
        ),
        "saveSelectorTableBaseArithmeticRowCount": len(arithmetic_rows),
        "saveSelectorTableBaseArithmeticRows": arithmetic_rows,
        "saveSelectorTableBaseArithmeticCandidateCount": len(arithmetic_candidate_rows),
        "saveSelectorTableBaseArithmeticCandidateRows": arithmetic_candidate_rows,
        "saveSelectorTableBaseArithmeticCandidateFound": bool(arithmetic_candidate_rows),
        "routeTraceRelativeOpcodes": [f"0x{opcode:02x}" for opcode in route_opcodes],
        "relativeOpcodeRows": relative_rows,
        "selectedPointerRelativeHandlerCount": len(selected_rows),
        "selectedPointerRelativeHandlersVerified": selected_pointer_relative_handlers_verified,
        "branchRelativeHandlerCount": len(branch_rows),
        "routeRelevantDispatchRows": route_relevant_dispatch_rows,
        "routeRelevantDispatchRowCount": len(route_relevant_dispatch_rows),
        "routeRelevantSliceDataDescriptorCount": len(route_relevant_slice_data_descriptor_roles),
        "routeRelevantSliceDataDescriptorRoles": route_relevant_slice_data_descriptor_roles,
        "routeRelevantRawGeneralDefaultCount": len(route_relevant_raw_general_default_roles),
        "routeRelevantRawGeneralDefaultRoles": route_relevant_raw_general_default_roles,
        "routeRelevantSliceRequiresTableBaseSwitchCount": len(
            route_relevant_slice_requires_table_base_switch_roles
        ),
        "routeRelevantSliceRequiresTableBaseSwitchRoles": (
            route_relevant_slice_requires_table_base_switch_roles
        ),
        "routeRelevantSliceDataDescriptorRequiresTableBaseSwitchCount": len(
            route_relevant_slice_data_descriptor_requires_table_base_switch_roles
        ),
        "routeRelevantSliceDataDescriptorRequiresTableBaseSwitchRoles": (
            route_relevant_slice_data_descriptor_requires_table_base_switch_roles
        ),
        "routeRelevantSliceDataDescriptorGenericByteReachableCount": len(
            route_relevant_slice_data_descriptor_byte_reachable_roles
        ),
        "routeRelevantSliceDataDescriptorGenericByteReachableRoles": (
            route_relevant_slice_data_descriptor_byte_reachable_roles
        ),
        "saveSelectorSliceAnchored": save_selector_slice_anchored,
        "saveSelectorSliceDirectRuntimeDispatchProofFound": save_selector_slice_direct_runtime_dispatch_proof_found,
        "saveSelectorDispatchRuntimeProofFound": save_selector_slice_direct_runtime_dispatch_proof_found,
        "dispatchTableProofFound": proof_found,
        "descriptorBoundaryDependsOnSaveSelectorSliceModel": descriptor_boundary_depends_on_save_selector_slice_model,
        "selectedRootExecutionDispatchRefFound": False,
        "proofFound": proof_found,
        "failedDispatchTableGateIds": failed_dispatch_table_gate_ids,
        "missingEvidence": missing_evidence,
        "evidenceRefs": DISPATCH_TABLE_CONTEXT_EVIDENCE_REFS,
        "evidenceRefCount": len(DISPATCH_TABLE_CONTEXT_EVIDENCE_REFS),
        "promotionStatus": "blocked",
        "conclusion": (
            "The current save-selector decoder is anchored to the 0x00440720 table slice, which starts "
            "0x7a entries after the generic 0x00440538 dispatch table. The EXE contains one direct indexed "
            "dispatch immediate for the generic table at 0x0040234c and no direct indexed dispatch or dword "
            "reference to 0x00440720 itself. Relative save-selector opcodes still map consistently to the "
            "expected handlers through that slice, including byte-reachable selected-pointer handlers "
            "0x07/0x08/0x09 and branch-state handlers 0x10..0x13, but this is static decoder anchoring only. "
            "Route-relevant low bytes that become data descriptors under the slice do not resolve the same way "
            "through the raw generic table, and the descriptor-stop rows require a save-selector table-base "
            "switch rather than the observed u8 generic indexed dispatcher. A .text scan finds only a small "
            "set of dynamic register-base indexed call sites; all three match an SEH scope-table callback "
            "pattern and none has a nearby immediate reference to 0x00440720. A separate table-base "
            "arithmetic scan covers direct absolute indexed dispatches plus those dynamic sites and finds "
            "no nearby general-table+0x1e8 or direct save-selector-table base candidate, so there is still no "
            "static table-base switch candidate for the save-selector slice. "
            "The descriptor boundary classification therefore depends on the static save-selector slice model "
            "until runtime proves that control is dispatching the current stream through that slice. It does "
            "not prove that selector root 2:0 executes on the normal route."
        ),
        "remainingProofs": [
            "prove at runtime that the current 2:0 root is selected and executed",
            "prove the VM/control-flow mode or dynamic table-base switch that interprets the current stream through the 0x00440720 slice",
            "prove that route-relevant descriptor boundaries are reached through the save-selector slice, not just modeled statically",
            "keep requiring a strict map1_01a source hotspot or equivalent runtime trigger",
        ],
    }


def markdown(summary: dict) -> str:
    dynamic_class_counts = json.dumps(
        summary.get("dynamicIndexedDispatchClassificationCounts") or {},
        sort_keys=True,
        separators=(",", ":"),
    )
    lines = [
        "# Save Selector Dispatch Table Context",
        "",
        f"- route: `{summary['source']} -> {summary['target']}`",
        f"- general handler table: `{summary['generalHandlerTableHex']}`",
        f"- save-selector handler table: `{summary['saveSelectorHandlerTableHex']}`",
        f"- save-selector slice offset: `{summary['saveSelectorSliceOffsetHex']}` entries",
        (
            "- generic dispatcher: "
            f"`{(summary.get('genericDispatcher') or {}).get('entryVaHex')}` "
            f"indexed call `{(summary.get('genericDispatcher') or {}).get('indexedCallVaHex')}` "
            f"index `{(summary.get('genericDispatcher') or {}).get('indexWidth')}`"
        ),
        f"- generic indexed dispatch count: {summary['generalTable']['indexedDispatchCount']}",
        f"- save-selector direct dword refs: {summary['saveSelectorTable']['directDwordRefCount']}",
        f"- save-selector indexed dispatch count: {summary['saveSelectorTable']['indexedDispatchCount']}",
        f"- dynamic register-base indexed dispatch count: {summary.get('dynamicIndexedDispatchRowCount')}",
        f"- dynamic dispatch classifications: `{dynamic_class_counts}`",
        f"- dynamic SEH scope-table callback count: {summary.get('dynamicScopeTableCallbackCount')}",
        f"- dynamic SEH scope-table callback sites: `{', '.join(summary.get('dynamicScopeTableCallbackSites') or []) or '-'}`",
        (
            "- dynamic save-selector table-base immediate candidates: "
            f"{summary.get('dynamicSaveSelectorTableImmediateNearCount')}"
        ),
        (
            "- dynamic save-selector table-base static candidates: "
            f"{summary.get('dynamicSaveSelectorTableBaseCandidateCount')}"
        ),
        (
            "- save-selector table-base arithmetic scan rows/candidates: "
            f"{summary.get('saveSelectorTableBaseArithmeticRowCount')} / "
            f"{summary.get('saveSelectorTableBaseArithmeticCandidateCount')}"
        ),
        f"- dynamic save-selector table-base candidate sites: `{', '.join(summary.get('dynamicSaveSelectorTableBaseCandidateSites') or []) or '-'}`",
        f"- selected-pointer relative handlers verified: {summary['selectedPointerRelativeHandlersVerified']}",
        f"- save-selector slice anchored: {summary['saveSelectorSliceAnchored']}",
        f"- save-selector direct runtime dispatch proof found: {summary['saveSelectorSliceDirectRuntimeDispatchProofFound']}",
        f"- proof found: {summary.get('proofFound')}",
        f"- failed dispatch table gates: `{', '.join(summary.get('failedDispatchTableGateIds') or []) or '-'}`",
        f"- missing evidence count: {len(summary.get('missingEvidence') or [])}",
        f"- evidence refs: {summary.get('evidenceRefCount')}",
        f"- descriptor boundary depends on save-selector slice model: {summary['descriptorBoundaryDependsOnSaveSelectorSliceModel']}",
        (
            "- route-relevant slice rows requiring table-base switch: "
            f"{summary.get('routeRelevantSliceRequiresTableBaseSwitchCount')}"
        ),
        (
            "- route-relevant data descriptors byte-reachable via generic dispatcher: "
            f"{summary.get('routeRelevantSliceDataDescriptorGenericByteReachableCount')}"
        ),
        f"- selected-root execution dispatch ref found: {summary['selectedRootExecutionDispatchRefFound']}",
        f"- promotion status: `{summary['promotionStatus']}`",
        "",
        summary["conclusion"],
        "",
        "## Table References",
        "",
        "| table | role | dword refs | .text refs | indexed dispatches |",
        "| --- | --- | ---: | ---: | ---: |",
    ]
    for role, table in [
        ("general", summary["generalTable"]),
        ("save-selector", summary["saveSelectorTable"]),
        ("event-object", summary["eventObjectTable"]),
    ]:
        lines.append(
            f"| `{table['tableVaHex']}` | {role} | {table['directDwordRefCount']} | "
            f"{table['textDwordRefCount']} | {table['indexedDispatchCount']} |"
        )
    lines.extend([
        "",
        "## Relative Opcode Rows",
        "",
        "| relative opcode | save entry | save handler | general raw handler | general absolute opcode | byte reachable | match |",
        "| --- | --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary["relativeOpcodeRows"]:
        lines.append(
            f"| `{row['relativeOpcodeHex']}` | `{row['saveEntryVaHex']}` | `{row['saveHandlerVaHex']}` | "
            f"`{row['generalRawHandlerVaHex']}` | `{row['generalAbsoluteOpcodeHex']}` | "
            f"{row.get('generalAbsoluteByteReachable')} | {row['generalAbsoluteMatchesSaveHandler']} |"
        )
    lines.extend([
        "",
        "## Route-Relevant Raw vs Slice Rows",
        "",
        "| role | site | low byte | slice handler | slice section | raw general handler | raw section | absolute opcode | byte reachable | requires table base | raw default | differs |",
        "| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary["routeRelevantDispatchRows"]:
        lines.append(
            f"| {row['role']} | `{row['siteVaHex']}` | `{row['lowByteOpcodeHex']}` | "
            f"`{row['saveHandlerVaHex']}` | {row['saveHandlerSection'] or '-'} | "
            f"`{row['generalRawHandlerVaHex']}` | {row['generalRawHandlerSection'] or '-'} | "
            f"`{row.get('generalAbsoluteOpcodeHex')}` | "
            f"{row.get('generalAbsoluteByteReachable')} | "
            f"{row.get('sliceHandlerRequiresSaveSelectorTableBase')} | "
            f"{row['rawGeneralHandlerIsDefault']} | {row['rawGeneralDiffersFromSlice']} |"
        )
    lines.extend([
        "",
        "## Dynamic Indexed Dispatch Rows",
        "",
        "| site | kind | base | index | scale | displacement | classification | save-selector candidate | nearby table immediates |",
        "| --- | --- | --- | --- | ---: | --- | --- | --- | --- |",
    ])
    for row in summary.get("dynamicIndexedDispatchRows") or []:
        nearby = nearby_refs_text(row)
        lines.append(
            f"| `{row.get('instructionVaHex')}` | {row.get('kind')} | "
            f"`{row.get('baseRegister')}` | `{row.get('indexRegister')}` | "
            f"{row.get('scale')} | `{row.get('displacementHex')}` | "
            f"{row.get('contextClassification')} | "
            f"{row.get('candidateForSaveSelectorTableBase')} | {nearby} |"
        )
    lines.extend([
        "",
        "## Dynamic SEH Scope-Table Callback Rows",
        "",
        "| site | kind | base | index | displacement | classification | reason | save-selector candidate |",
        "| --- | --- | --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary.get("dynamicScopeTableCallbackRows") or []:
        lines.append(
            f"| `{row.get('instructionVaHex')}` | {row.get('kind')} | "
            f"`{row.get('baseRegister')}` | `{row.get('indexRegister')}` | "
            f"`{row.get('displacementHex')}` | {row.get('contextClassification')} | "
            f"{row.get('contextReason')} | {row.get('candidateForSaveSelectorTableBase')} |"
        )
    lines.extend([
        "",
        "## Dynamic Save-Selector Table-Base Candidate Rows",
        "",
    ])
    if summary.get("dynamicSaveSelectorTableBaseCandidateRows"):
        lines.extend([
            "| site | kind | base | index | displacement | classification | nearby table immediates |",
            "| --- | --- | --- | --- | --- | --- | --- |",
        ])
        for row in summary.get("dynamicSaveSelectorTableBaseCandidateRows") or []:
            lines.append(
                f"| `{row.get('instructionVaHex')}` | {row.get('kind')} | "
                f"`{row.get('baseRegister')}` | `{row.get('indexRegister')}` | "
                f"`{row.get('displacementHex')}` | {row.get('contextClassification')} | "
                f"{nearby_refs_text(row)} |"
            )
    else:
        lines.append("- none")
    lines.extend([
        "",
        "## Save-Selector Table-Base Arithmetic Rows",
        "",
        "| site | kind | table base | classification | general refs | save-selector refs | slice-delta refs | arithmetic candidate |",
        "| --- | --- | --- | --- | ---: | ---: | ---: | --- |",
    ])
    for row in summary.get("saveSelectorTableBaseArithmeticRows") or []:
        lines.append(
            f"| `{row.get('instructionVaHex')}` | {row.get('kind')} | "
            f"`{row.get('tableBaseHex') or row.get('baseRegister') or '-'}` | "
            f"{row.get('classification')} | "
            f"{row.get('nearbyGeneralTableImmediateCount')} | "
            f"{row.get('nearbySaveSelectorTableImmediateCount')} | "
            f"{row.get('nearbySaveSelectorSliceDeltaImm32Count')} | "
            f"{row.get('candidateForSaveSelectorTableBaseArithmetic')} |"
        )
    lines.extend(["", "## Remaining Proofs", ""])
    lines.extend(f"- {item}" for item in summary["remainingProofs"])
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    table_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(table['tableVaHex'])}</code></td>"
        f"<td>{html.escape(role)}</td>"
        f"<td>{table['directDwordRefCount']}</td>"
        f"<td>{table['textDwordRefCount']}</td>"
        f"<td>{table['indexedDispatchCount']}</td>"
        "</tr>"
        for role, table in [
            ("general", summary["generalTable"]),
            ("save-selector", summary["saveSelectorTable"]),
            ("event-object", summary["eventObjectTable"]),
        ]
    )
    opcode_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['relativeOpcodeHex'])}</code></td>"
        f"<td><code>{html.escape(row['saveEntryVaHex'])}</code></td>"
        f"<td><code>{html.escape(row['saveHandlerVaHex'] or '-')}</code></td>"
        f"<td><code>{html.escape(row['generalRawHandlerVaHex'] or '-')}</code></td>"
        f"<td><code>{html.escape(row['generalAbsoluteOpcodeHex'])}</code></td>"
        f"<td>{row.get('generalAbsoluteByteReachable')}</td>"
        f"<td>{row['generalAbsoluteMatchesSaveHandler']}</td>"
        "</tr>"
        for row in summary["relativeOpcodeRows"]
    )
    route_relevant_rows = "\n".join(
        "<tr>"
        f"<td>{html.escape(row['role'])}</td>"
        f"<td><code>{html.escape(row['siteVaHex'])}</code></td>"
        f"<td><code>{html.escape(row['lowByteOpcodeHex'])}</code></td>"
        f"<td><code>{html.escape(row['saveHandlerVaHex'] or '-')}</code></td>"
        f"<td>{html.escape(row['saveHandlerSection'] or '-')}</td>"
        f"<td><code>{html.escape(row['generalRawHandlerVaHex'] or '-')}</code></td>"
        f"<td>{html.escape(row['generalRawHandlerSection'] or '-')}</td>"
        f"<td><code>{html.escape(str(row.get('generalAbsoluteOpcodeHex') or '-'))}</code></td>"
        f"<td>{row.get('generalAbsoluteByteReachable')}</td>"
        f"<td>{row.get('sliceHandlerRequiresSaveSelectorTableBase')}</td>"
        f"<td>{row['rawGeneralHandlerIsDefault']}</td>"
        f"<td>{row['rawGeneralDiffersFromSlice']}</td>"
        "</tr>"
        for row in summary["routeRelevantDispatchRows"]
    )
    dynamic_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('instructionVaHex')))}</code></td>"
        f"<td>{html.escape(str(row.get('kind')))}</td>"
        f"<td><code>{html.escape(str(row.get('baseRegister')))}</code></td>"
        f"<td><code>{html.escape(str(row.get('indexRegister')))}</code></td>"
        f"<td>{html.escape(str(row.get('scale')))}</td>"
        f"<td><code>{html.escape(str(row.get('displacementHex')))}</code></td>"
        f"<td>{html.escape(str(row.get('contextClassification')))}</td>"
        f"<td>{html.escape(str(row.get('candidateForSaveSelectorTableBase')))}</td>"
        f"<td>{html.escape(nearby_refs_text(row))}</td>"
        "</tr>"
        for row in summary.get("dynamicIndexedDispatchRows") or []
    )
    dynamic_scope_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('instructionVaHex')))}</code></td>"
        f"<td>{html.escape(str(row.get('kind')))}</td>"
        f"<td><code>{html.escape(str(row.get('baseRegister')))}</code></td>"
        f"<td><code>{html.escape(str(row.get('indexRegister')))}</code></td>"
        f"<td><code>{html.escape(str(row.get('displacementHex')))}</code></td>"
        f"<td>{html.escape(str(row.get('contextClassification')))}</td>"
        f"<td>{html.escape(str(row.get('contextReason')))}</td>"
        f"<td>{html.escape(str(row.get('candidateForSaveSelectorTableBase')))}</td>"
        "</tr>"
        for row in summary.get("dynamicScopeTableCallbackRows") or []
    )
    dynamic_candidate_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('instructionVaHex')))}</code></td>"
        f"<td>{html.escape(str(row.get('kind')))}</td>"
        f"<td><code>{html.escape(str(row.get('baseRegister')))}</code></td>"
        f"<td><code>{html.escape(str(row.get('indexRegister')))}</code></td>"
        f"<td><code>{html.escape(str(row.get('displacementHex')))}</code></td>"
        f"<td>{html.escape(str(row.get('contextClassification')))}</td>"
        f"<td>{html.escape(nearby_refs_text(row))}</td>"
        "</tr>"
        for row in summary.get("dynamicSaveSelectorTableBaseCandidateRows") or []
    ) or "<tr><td colspan=\"7\">none</td></tr>"
    arithmetic_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(str(row.get('instructionVaHex')))}</code></td>"
        f"<td>{html.escape(str(row.get('kind')))}</td>"
        f"<td><code>{html.escape(str(row.get('tableBaseHex') or row.get('baseRegister') or '-'))}</code></td>"
        f"<td>{html.escape(str(row.get('classification')))}</td>"
        f"<td>{html.escape(str(row.get('nearbyGeneralTableImmediateCount')))}</td>"
        f"<td>{html.escape(str(row.get('nearbySaveSelectorTableImmediateCount')))}</td>"
        f"<td>{html.escape(str(row.get('nearbySaveSelectorSliceDeltaImm32Count')))}</td>"
        f"<td>{html.escape(str(row.get('candidateForSaveSelectorTableBaseArithmetic')))}</td>"
        "</tr>"
        for row in summary.get("saveSelectorTableBaseArithmeticRows") or []
    ) or "<tr><td colspan=\"8\">none</td></tr>"
    proofs = "".join(f"<li>{html.escape(item)}</li>" for item in summary["remainingProofs"])
    return "\n".join([
        "<!doctype html><meta charset=\"utf-8\"><title>Save Selector Dispatch Table Context</title>",
        "<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;max-width:1180px;margin:24px auto}table{border-collapse:collapse;width:100%}td,th{border:1px solid #444;padding:6px 8px;vertical-align:top}code{color:#9bd4ff}</style>",
        "<h1>Save Selector Dispatch Table Context</h1>",
        f"<p>Route <code>{html.escape(summary['source'])} -&gt; {html.escape(summary['target'])}</code>; general table <code>{html.escape(summary['generalHandlerTableHex'])}</code>; save-selector table <code>{html.escape(summary['saveSelectorHandlerTableHex'])}</code>; slice offset <code>{html.escape(summary['saveSelectorSliceOffsetHex'])}</code>; generic dispatcher <code>{html.escape(str((summary.get('genericDispatcher') or {}).get('entryVaHex')))}</code> indexed call <code>{html.escape(str((summary.get('genericDispatcher') or {}).get('indexedCallVaHex')))}</code> uses <code>{html.escape(str((summary.get('genericDispatcher') or {}).get('indexWidth')))}</code>.</p>",
        f"<p>save-selector slice anchored: {summary['saveSelectorSliceAnchored']}; save-selector direct runtime dispatch proof found: {summary['saveSelectorSliceDirectRuntimeDispatchProofFound']}; proof found: {summary.get('proofFound')}; failed dispatch table gates: {html.escape(', '.join(summary.get('failedDispatchTableGateIds') or []) or '-')}; missing evidence count: {len(summary.get('missingEvidence') or [])}; evidence refs: {summary.get('evidenceRefCount')}; descriptor boundary depends on save-selector slice model: {summary['descriptorBoundaryDependsOnSaveSelectorSliceModel']}; route-relevant table-base switch rows: {summary.get('routeRelevantSliceRequiresTableBaseSwitchCount')}; data descriptors byte-reachable through generic dispatcher: {summary.get('routeRelevantSliceDataDescriptorGenericByteReachableCount')}; dynamic indexed dispatch rows: {summary.get('dynamicIndexedDispatchRowCount')}; dynamic dispatch classifications: {html.escape(json.dumps(summary.get('dynamicIndexedDispatchClassificationCounts') or {}, sort_keys=True, separators=(',', ':')))}; dynamic SEH scope-table callback count: {summary.get('dynamicScopeTableCallbackCount')}; dynamic SEH scope-table callback sites: {html.escape(', '.join(summary.get('dynamicScopeTableCallbackSites') or []) or '-')}; dynamic save-selector table-base immediate candidates: {summary.get('dynamicSaveSelectorTableImmediateNearCount')}; dynamic save-selector table-base static candidates: {summary.get('dynamicSaveSelectorTableBaseCandidateCount')}; save-selector table-base arithmetic scan rows/candidates: {summary.get('saveSelectorTableBaseArithmeticRowCount')}/{summary.get('saveSelectorTableBaseArithmeticCandidateCount')}; dynamic save-selector table-base candidate sites: {html.escape(', '.join(summary.get('dynamicSaveSelectorTableBaseCandidateSites') or []) or '-')}; selected-root execution dispatch ref found: {summary['selectedRootExecutionDispatchRefFound']}; promotion status: <code>{html.escape(summary['promotionStatus'])}</code>.</p>",
        f"<p>{html.escape(summary['conclusion'])}</p>",
        "<h2>Table References</h2>",
        "<table><thead><tr><th>table</th><th>role</th><th>dword refs</th><th>.text refs</th><th>indexed dispatches</th></tr></thead><tbody>",
        table_rows,
        "</tbody></table>",
        "<h2>Relative Opcode Rows</h2>",
        "<table><thead><tr><th>relative opcode</th><th>save entry</th><th>save handler</th><th>general raw handler</th><th>general absolute opcode</th><th>byte reachable</th><th>match</th></tr></thead><tbody>",
        opcode_rows,
        "</tbody></table>",
        "<h2>Route-Relevant Raw vs Slice Rows</h2>",
        "<table><thead><tr><th>role</th><th>site</th><th>low byte</th><th>slice handler</th><th>slice section</th><th>raw general handler</th><th>raw section</th><th>absolute opcode</th><th>byte reachable</th><th>requires table base</th><th>raw default</th><th>differs</th></tr></thead><tbody>",
        route_relevant_rows,
        "</tbody></table>",
        "<h2>Dynamic Indexed Dispatch Rows</h2>",
        "<table><thead><tr><th>site</th><th>kind</th><th>base</th><th>index</th><th>scale</th><th>displacement</th><th>classification</th><th>save-selector candidate</th><th>nearby table immediates</th></tr></thead><tbody>",
        dynamic_rows,
        "</tbody></table>",
        "<h2>Dynamic SEH Scope-Table Callback Rows</h2>",
        "<table><thead><tr><th>site</th><th>kind</th><th>base</th><th>index</th><th>displacement</th><th>classification</th><th>reason</th><th>save-selector candidate</th></tr></thead><tbody>",
        dynamic_scope_rows,
        "</tbody></table>",
        "<h2>Dynamic Save-Selector Table-Base Candidate Rows</h2>",
        "<table><thead><tr><th>site</th><th>kind</th><th>base</th><th>index</th><th>displacement</th><th>classification</th><th>nearby table immediates</th></tr></thead><tbody>",
        dynamic_candidate_rows,
        "</tbody></table>",
        "<h2>Save-Selector Table-Base Arithmetic Rows</h2>",
        "<table><thead><tr><th>site</th><th>kind</th><th>table base</th><th>classification</th><th>general refs</th><th>save-selector refs</th><th>slice-delta refs</th><th>arithmetic candidate</th></tr></thead><tbody>",
        arithmetic_rows,
        "</tbody></table>",
        f"<h2>Remaining Proofs</h2><ul>{proofs}</ul>",
    ])


def write_outputs(summary: dict, out_dir: Path = OUT, html_out: Path | None = None) -> None:
    out_dir.mkdir(parents=True, exist_ok=True)
    (out_dir / "save_selector_dispatch_table_context.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")) + "\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")


def main() -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--exe", type=Path, default=ROOT / "Hwanse2.exe")
    parser.add_argument("--stream-traces", type=Path, default=OUT / "save_selector_stream_traces.json")
    parser.add_argument("--out-dir", type=Path, default=OUT)
    parser.add_argument("--html-out", type=Path, default=None, help="Optional HTML report output path.")
    args = parser.parse_args()
    summary = build_summary(
        args.exe.read_bytes(),
        load_json(args.stream_traces, []),
    )
    write_outputs(summary, args.out_dir, args.html_out)
    print(f"wrote save-selector dispatch table context -> {args.out_dir / 'save_selector_dispatch_table_context.json'}")


if __name__ == "__main__":
    main()
