#!/usr/bin/env python3
"""Scan direct EXE references to save-selector selected-pointer roots."""
from __future__ import annotations

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

from probe_exe_scene_tables import offset_to_va, read_sections


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

SOURCE_SELECTOR = "0:0"
TARGET_SELECTOR = "1:0"
CURRENT_SELECTOR = "2:0"

SELECTED_POINTER_GLOBAL = 0x0059DE30
SELECTOR_GROUP_TABLE = 0x00442D35
CURRENT_FRONTIER_READER = 0x00542B0C
CURRENT_SOURCE_RECORD = 0x00542B44
CURRENT_TARGET_RECORD = 0x00542BAC
FAILED_SELECTED_POINTER_USAGE_GATE_IDS = [
    "static-current-selector-text-ref",
    "selected-pointer-runtime-current-root",
    "opcode08-current-root-activation",
    "real-selector-2-0-savedata",
    "strict-hotspot-or-runtime-producer",
]
SELECTED_POINTER_USAGE_MISSING_EVIDENCE = [
    ".text reference or decoded control-flow selecting current selector root 0x00540714",
    "runtime watchpoint showing 0x0059de30 becomes 0x00540714",
    "opcode 0x08 activation at 0x0040adfe with context+0x40 set from 0x00540714",
    "captured save bytes 0x0002=0x02 and 0x0003=0x00",
    "strict map1_01a source hotspot or equivalent runtime producer selecting selector 2:0",
]
SELECTED_POINTER_USAGE_EVIDENCE_REFS = [
    {
        "path": "Hwanse2.exe",
        "fields": [
            "selectedPointerGlobalHex",
            "selectedPointerWriterHookVas",
            "selectedPointerReaderHookVas",
            "selectedPointerHandlerContexts",
        ],
    },
    {
        "path": "out/save_scene_selectors.json",
        "fields": [
            "currentSelector",
            "currentSelectorRootHex",
            "fieldMaps",
        ],
    },
    {
        "path": "out/save_loader_trace.json",
        "fields": [
            "frontierExample.selectedPointerHex",
            "frontierExample.secondLevelPointerHex",
            "frontierExample.leafPointerHex",
        ],
    },
    {
        "path": "out/save_selector_real_savedata_evidence_gap.json",
        "fields": [
            "currentSelectorRealSaveCount",
            "selectedPointerRealSaveCount",
            "proofFound",
        ],
    },
    {
        "path": "out/runtime_selected_pointer_poll.json",
        "fields": [
            "currentRootHex",
            "observedSelectors",
            "anyReachedRouteSelectorContext",
        ],
    },
]

FALLBACK_SELECTORS = {
    SOURCE_SELECTOR: {
        "rowPointerHex": "0x00500c3c",
        "selectedPointerHex": "0x00501808",
    },
    TARGET_SELECTOR: {
        "rowPointerHex": "0x00476ab4",
        "selectedPointerHex": "0x00478364",
    },
    CURRENT_SELECTOR: {
        "rowPointerHex": "0x0053f328",
        "selectedPointerHex": "0x00540714",
    },
}


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


def parse_hex(value: str | None) -> int | None:
    return int(value, 16) if value else None


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


def selector_row(selectors: list[dict], label: str) -> dict:
    group_text, slot_text = label.split(":", 1)
    group = int(group_text)
    slot = int(slot_text)
    fallback = FALLBACK_SELECTORS[label]
    for row in selectors:
        if row.get("group") == group and row.get("slot") == slot:
            return {**fallback, **row}
    return fallback


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


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


def infer_instruction(exe: bytes, file_offset: int, ref_va: int) -> dict:
    patterns = [
        (b"\x8b\x04\x85", -3, "mov eax, dword [imm32 + eax*4]", "read"),
        (b"\x8b\x0c\x85", -3, "mov ecx, dword [imm32 + eax*4]", "read"),
        (b"\x8b\x14\x85", -3, "mov edx, dword [imm32 + eax*4]", "read"),
        (b"\x8a\x05", -2, "mov al, byte [imm32]", "read"),
        (b"\x8a\x0d", -2, "mov cl, byte [imm32]", "read"),
        (b"\x8b\x05", -2, "mov eax, dword [imm32]", "read"),
        (b"\x8b\x0d", -2, "mov ecx, dword [imm32]", "read"),
        (b"\x8b\x15", -2, "mov edx, dword [imm32]", "read"),
        (b"\x89\x05", -2, "mov dword [imm32], eax", "write"),
        (b"\x83\x3d", -2, "cmp dword [imm32], imm8", "read"),
        (b"\xa0", -1, "mov al, byte [imm32]", "read"),
        (b"\xa1", -1, "mov eax, dword [imm32]", "read"),
        (b"\xa2", -1, "mov byte [imm32], al", "write"),
        (b"\xa3", -1, "mov dword [imm32], eax", "write"),
    ]
    for pattern, relative_start, description, access in patterns:
        pattern_start = file_offset + relative_start
        if pattern_start < 0:
            continue
        if exe[pattern_start:file_offset] == pattern:
            instruction_va = ref_va + relative_start
            return {
                "instructionVaHex": hex32(instruction_va),
                "instruction": description,
                "access": access,
            }
    return {
        "instructionVaHex": hex32(ref_va),
        "instruction": "dword literal/data pointer",
        "access": "literal",
    }


def bytes_around(exe: bytes, section: dict, file_offset: int, before: int = 8, after: int = 12) -> str:
    start = max(section["raw"], file_offset - before)
    end = min(section["raw"] + section["raw_size"], file_offset + after)
    return exe[start:end].hex(" ")


def find_refs(exe: bytes, sections: list[dict], value: int) -> list[dict]:
    needle = struct.pack("<I", value)
    refs = []
    for section in sections:
        section_start = section["raw"]
        data = exe[section_start:section_start + section["raw_size"]]
        search = 0
        while True:
            hit = data.find(needle, search)
            if hit < 0:
                break
            file_offset = section_start + hit
            ref_va = offset_to_va(sections, file_offset)
            if ref_va is not None:
                instruction = infer_instruction(exe, file_offset, ref_va)
                refs.append({
                    "section": section["name"],
                    "refVaHex": hex32(ref_va),
                    "fileOffsetHex": f"0x{file_offset:06x}",
                    "bytesAround": bytes_around(exe, section, file_offset),
                    **instruction,
                })
            search = hit + 1
    return refs


def section_counts(refs: list[dict]) -> dict[str, int]:
    counts: dict[str, int] = {}
    for ref in refs:
        counts[ref["section"]] = counts.get(ref["section"], 0) + 1
    return dict(sorted(counts.items()))


def make_watch_values(selectors: list[dict], save_loader_trace: dict) -> list[dict]:
    source = selector_row(selectors, SOURCE_SELECTOR)
    target = selector_row(selectors, TARGET_SELECTOR)
    current = selector_row(selectors, CURRENT_SELECTOR)
    frontier = save_loader_trace.get("frontierExample") or {}
    values = [
        {
            "name": "selected-pointer-global",
            "value": SELECTED_POINTER_GLOBAL,
            "role": "runtime global holding the selected scene/progress pointer",
        },
        {
            "name": "selector-group-table",
            "value": SELECTOR_GROUP_TABLE,
            "role": "save selector group table indexed by save offset 0x0002",
        },
        {
            "name": "source-selector-row-0:0",
            "value": parse_hex(source.get("rowPointerHex")),
            "role": "row pointer for source-side selector 0:0",
        },
        {
            "name": "source-selector-root-0:0",
            "value": parse_hex(source.get("selectedPointerHex")),
            "role": "selected pointer root for source-side selector 0:0",
        },
        {
            "name": "target-selector-row-1:0",
            "value": parse_hex(target.get("rowPointerHex")),
            "role": "row pointer for target-side selector 1:0",
        },
        {
            "name": "target-selector-root-1:0",
            "value": parse_hex(target.get("selectedPointerHex")),
            "role": "selected pointer root for target-side selector 1:0",
        },
        {
            "name": "current-selector-row-2:0",
            "value": parse_hex(current.get("rowPointerHex")),
            "role": "row pointer for current selector 2:0",
        },
        {
            "name": "current-selector-root-2:0",
            "value": parse_hex(current.get("selectedPointerHex")),
            "role": "selected pointer root for current selector 2:0",
        },
        {
            "name": "current-second-level-table-2:0",
            "value": parse_hex(frontier.get("secondLevelPointerHex")),
            "role": "second-level table used by selector 2:0",
        },
        {
            "name": "current-frontier-leaf-2:0",
            "value": parse_hex(frontier.get("leafPointerHex")),
            "role": "sample frontier leaf for map1_01a -> map2_02d under selector 2:0",
        },
        {
            "name": "current-frontier-reader-2:0",
            "value": CURRENT_FRONTIER_READER,
            "role": "frontier reader/resource gate row before map1_01a -> map2_02d records",
        },
        {
            "name": "current-source-record-map1_01a",
            "value": CURRENT_SOURCE_RECORD,
            "role": "current selector field-map record for map1_01a",
        },
        {
            "name": "current-target-record-map2_02d",
            "value": CURRENT_TARGET_RECORD,
            "role": "current selector field-map record for map2_02d",
        },
    ]
    return [row for row in values if row["value"] is not None]


def add_ref_summary(exe: bytes, sections: list[dict], watch_values: list[dict]) -> list[dict]:
    rows = []
    for row in watch_values:
        value = int(row["value"])
        refs = find_refs(exe, sections, value)
        rows.append({
            "name": row["name"],
            "valueHex": hex32(value),
            "targetSection": section_name_for_va(sections, value),
            "role": row["role"],
            "refCount": len(refs),
            "textRefCount": sum(1 for ref in refs if ref["section"] == ".text"),
            "dataRefCount": sum(1 for ref in refs if ref["section"] != ".text"),
            "sectionCounts": section_counts(refs),
            "refs": refs,
        })
    return rows


def row_by_name(rows: list[dict], name: str) -> dict:
    return next(row for row in rows if row["name"] == name)


def build_hook_points(selected_global_refs: dict, save_loader_trace: dict) -> list[dict]:
    known_meanings = {
        "0x004234ba": {
            "mechanism": "save-loader-writer",
            "meaning": "save loader stores selector group/slot result into selected-pointer global",
            "routeRequirement": "captured save bytes must select group 2 slot 0",
        },
        "0x0040adb8": {
            "mechanism": "opcode07-indexed-writer",
            "meaning": "opcode 7 stores indexed pointer into selected-pointer global",
            "routeRequirement": "opcode 7 selected slot must resolve to selector 2:0 root",
        },
        "0x0040add6": {
            "mechanism": "opcode08-zero-check",
            "meaning": "opcode 8 checks whether selected-pointer global is zero before jumping",
            "routeRequirement": "selected-pointer global must already contain selector 2:0 root",
        },
        "0x0040adfe": {
            "mechanism": "opcode08-activator-read",
            "meaning": "opcode 8 reads selected-pointer global and jumps active stream to it",
            "routeRequirement": "selected-pointer global must already contain selector 2:0 root",
        },
        "0x0040ae37": {
            "mechanism": "opcode09-mode0-writer",
            "meaning": "opcode 9 mode 0 stores current stream pointer into selected-pointer global",
            "routeRequirement": "current stream must already be inside selector 2:0 range",
        },
        "0x0040ae4a": {
            "mechanism": "opcode09-mode1-writer",
            "meaning": "opcode 9 mode 1 stores stream operand pointer into selected-pointer global",
            "routeRequirement": "stream operand must point into selector 2:0 range",
        },
    }
    hooks = []
    by_instruction = {
        ref.get("instructionVaHex"): ref
        for ref in selected_global_refs.get("refs") or []
        if ref.get("section") == ".text"
    }
    for va_hex, info in known_meanings.items():
        ref = by_instruction.get(va_hex)
        hooks.append({
            "vaHex": va_hex,
            "present": ref is not None,
            "access": ref.get("access") if ref else None,
            "instruction": ref.get("instruction") if ref else None,
            "mechanism": info["mechanism"],
            "meaning": info["meaning"],
            "routeRequirement": info["routeRequirement"],
        })
    for runtime_use in save_loader_trace.get("selectedPointerRuntimeUses") or []:
        evidence = runtime_use.get("evidenceVaHex")
        if evidence and evidence not in known_meanings:
            hooks.append({
                "vaHex": evidence,
                "present": evidence in by_instruction,
                "access": by_instruction.get(evidence, {}).get("access"),
                "instruction": by_instruction.get(evidence, {}).get("instruction"),
                "mechanism": runtime_use.get("kind") or "runtime-use",
                "meaning": runtime_use.get("meaning"),
                "routeRequirement": "inspect runtime selected-pointer value on the route path",
            })
    return hooks


def hook_access_counts(hook_points: list[dict]) -> dict[str, int]:
    counts: dict[str, int] = {}
    for hook in hook_points:
        access = hook.get("access") or "unknown"
        counts[access] = counts.get(access, 0) + 1
    return dict(sorted(counts.items()))


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


def normalize_hex_bytes(text: str) -> str:
    return bytes.fromhex(text).hex(" ")


def bytes_at_va(exe: bytes, sections: list[dict], va: int, length: int) -> str | None:
    offset = va_to_offset(sections, va)
    if offset is None:
        return None
    return exe[offset:offset + length].hex(" ")


def byte_check(exe: bytes, sections: list[dict], va: int, expected: str, label: str) -> dict:
    expected_norm = normalize_hex_bytes(expected)
    actual = bytes_at_va(exe, sections, va, len(bytes.fromhex(expected))) or ""
    return {
        "label": label,
        "vaHex": hex32(va),
        "expectedBytes": expected_norm,
        "actualBytes": actual,
        "matches": actual == expected_norm,
    }


def build_handler_context(
    *,
    name: str,
    handler_va: int,
    hook_vas: list[int],
    checks: list[dict],
    context_effect: str,
    route_implication: str,
    trace_hook: str,
) -> dict:
    return {
        "name": name,
        "handlerVaHex": hex32(handler_va),
        "hookVaHexes": [hex32(va) for va in hook_vas],
        "verified": all(check["matches"] for check in checks),
        "byteChecks": checks,
        "contextEffect": context_effect,
        "routeImplication": route_implication,
        "traceHook": trace_hook,
    }


def build_selected_pointer_handler_contexts(exe: bytes, sections: list[dict]) -> list[dict]:
    return [
        build_handler_context(
            name="save-loader-selector-store",
            handler_va=0x0042349C,
            hook_vas=[0x004234BA],
            checks=[
                byte_check(exe, sections, 0x004234A3, "a0 da 76 45 00", "read selector group byte 0x004576da"),
                byte_check(exe, sections, 0x004234A8, "8b 04 85 35 2d 44 00", "index selector group table 0x00442d35"),
                byte_check(exe, sections, 0x004234B1, "8a 0d db 76 45 00", "read selector slot byte 0x004576db"),
                byte_check(exe, sections, 0x004234BA, "a3 30 de 59 00", "store selected pointer global 0x0059de30"),
            ],
            context_effect="save bytes 0x0002/0x0003 select a group/slot row, then store the selected root into 0x0059de30",
            route_implication="a captured save must contain selector group 2 slot 0 before this store can prove 0x00540714",
            trace_hook="watch write 0x004234ba or load a real selector 2:0 savedat",
        ),
        build_handler_context(
            name="opcode07-indexed-store",
            handler_va=0x0040AD9B,
            hook_vas=[0x0040ADB8],
            checks=[
                byte_check(exe, sections, 0x0040ADA4, "8b 40 40", "load current stream pointer from context+0x40"),
                byte_check(exe, sections, 0x0040ADA9, "8a 48 01", "read zero-extended slot byte at stream+1"),
                byte_check(exe, sections, 0x0040ADB2, "8b 40 04", "read pointer table at stream+4"),
                byte_check(exe, sections, 0x0040ADB5, "8b 04 88", "select table entry by slot byte"),
                byte_check(exe, sections, 0x0040ADB8, "a3 30 de 59 00", "store selected pointer global 0x0059de30"),
                byte_check(exe, sections, 0x0040ADC0, "83 40 40 08", "advance stream by 8"),
            ],
            context_effect="selected pointer = dword[[context+0x40]+4 + zero_extend(byte[[context+0x40]+1]) * 4]",
            route_implication="non-current roots would need an opcode 0x07 row selecting 0x00540714; current scans find none",
            trace_hook="break on 0x0040adb8 and inspect eax before store",
        ),
        build_handler_context(
            name="opcode08-activator",
            handler_va=0x0040ADC9,
            hook_vas=[0x0040ADD6, 0x0040ADFE],
            checks=[
                byte_check(exe, sections, 0x0040ADD2, "83 40 40 04", "advance stream by 4 before selected-pointer test"),
                byte_check(exe, sections, 0x0040ADD6, "83 3d 30 de 59 00 00", "test selected pointer global for zero"),
                byte_check(exe, sections, 0x0040ADF3, "89 44 8a 44", "push return stream into context stack"),
                byte_check(exe, sections, 0x0040ADFE, "a1 30 de 59 00", "read selected pointer global 0x0059de30"),
                byte_check(exe, sections, 0x0040AE06, "89 41 40", "replace context+0x40 with selected pointer"),
            ],
            context_effect="if 0x0059de30 is nonzero, save the current stream and set context+0x40 = 0x0059de30",
            route_implication="0x0040adfe is the narrowest runtime proof point for showing selector 2:0 becomes active bytecode",
            trace_hook="break on 0x0040adfe and require eax/0x0059de30 == 0x00540714",
        ),
        build_handler_context(
            name="opcode09-stream-store",
            handler_va=0x0040AE0E,
            hook_vas=[0x0040AE37, 0x0040AE4A],
            checks=[
                byte_check(exe, sections, 0x0040AE1A, "8b 40 40", "load current stream pointer from context+0x40"),
                byte_check(exe, sections, 0x0040AE1F, "8a 48 01", "read mode byte at stream+1"),
                byte_check(exe, sections, 0x0040AE37, "a3 30 de 59 00", "mode 0 stores post-advance stream pointer"),
                byte_check(exe, sections, 0x0040AE4A, "a3 30 de 59 00", "mode 1 stores stream+4 operand pointer"),
                byte_check(exe, sections, 0x0040AE60, "83 7d fc 00", "dispatch mode 0"),
                byte_check(exe, sections, 0x0040AE6A, "83 7d fc 01", "dispatch mode 1"),
            ],
            context_effect="mode 0 stores the current active stream, mode 1 stores dword[stream+4], other modes fall through without a selected-pointer store",
            route_implication="opcode 0x09 only promotes if the active stream or operand is already in selector 2:0; current evidence is internal only",
            trace_hook="break on 0x0040ae37/0x0040ae4a and classify the stored pointer range",
        ),
    ]


def build_summary(
    exe: bytes,
    save_scene_selectors: list[dict] | None = None,
    save_loader_trace: dict | None = None,
) -> dict:
    sections = read_sections(exe)
    save_scene_selectors = save_scene_selectors if save_scene_selectors is not None else load_json(
        OUT / "save_scene_selectors.json",
        [],
    )
    save_loader_trace = save_loader_trace if save_loader_trace is not None else load_json(
        OUT / "save_loader_trace.json",
        {},
    )
    watch_values = make_watch_values(save_scene_selectors, save_loader_trace)
    rows = add_ref_summary(exe, sections, watch_values)
    selected_global = row_by_name(rows, "selected-pointer-global")
    current_rows = [
        row_by_name(rows, "current-selector-row-2:0"),
        row_by_name(rows, "current-selector-root-2:0"),
        row_by_name(rows, "current-second-level-table-2:0"),
        row_by_name(rows, "current-frontier-leaf-2:0"),
        row_by_name(rows, "current-frontier-reader-2:0"),
        row_by_name(rows, "current-source-record-map1_01a"),
        row_by_name(rows, "current-target-record-map2_02d"),
    ]
    current_code_ref_count = sum(row["textRefCount"] for row in current_rows)
    hook_points = build_hook_points(selected_global, save_loader_trace)
    handler_contexts = build_selected_pointer_handler_contexts(exe, sections)
    writer_hook_vas = [row["vaHex"] for row in hook_points if row.get("access") == "write"]
    reader_hook_vas = [row["vaHex"] for row in hook_points if row.get("access") == "read"]
    no_direct_current_code_ref = current_code_ref_count == 0
    conclusion = (
        "Selector 2:0 is present as data reachable from the save selector table, but this exact scan finds no .text "
        "direct reference to the current selector row/root/leaf/route records. Static evidence still points to the "
        "selected-pointer global 0x0059de30 as the runtime choke point: prove group/slot 2:0 by save bytes or catch "
        "0x0059de30 containing 0x00540714 before opcode 8 switches context+0x40 to that stream."
        if no_direct_current_code_ref
        else "At least one selector 2:0 value has a direct .text reference; inspect that reference before treating 2:0 as table-only."
    )
    return {
        "scope": "direct little-endian dword references in loaded EXE sections",
        "sourceSelector": SOURCE_SELECTOR,
        "targetSelector": TARGET_SELECTOR,
        "currentSelector": CURRENT_SELECTOR,
        "selectedPointerGlobalHex": hex32(SELECTED_POINTER_GLOBAL),
        "selectorGroupTableHex": hex32(SELECTOR_GROUP_TABLE),
        "currentSelectorRootHex": row_by_name(rows, "current-selector-root-2:0")["valueHex"],
        "currentCodeRefCount": current_code_ref_count,
        "noStaticDirectCurrentSelectorCodeRef": no_direct_current_code_ref,
        "selectedPointerGlobalTextRefCount": selected_global["textRefCount"],
        "selectedPointerHookAccessCounts": hook_access_counts(hook_points),
        "selectedPointerWriterHookCount": len(writer_hook_vas),
        "selectedPointerReaderHookCount": len(reader_hook_vas),
        "selectedPointerWriterHookVas": writer_hook_vas,
        "selectedPointerReaderHookVas": reader_hook_vas,
        "selectedPointerWriteMechanisms": [
            row.get("mechanism") for row in hook_points if row.get("access") == "write"
        ],
        "selectedPointerReadMechanisms": [
            row.get("mechanism") for row in hook_points if row.get("access") == "read"
        ],
        "selectedPointerHandlerContextCount": len(handler_contexts),
        "selectedPointerHandlerContextsVerified": all(row["verified"] for row in handler_contexts),
        "selectedPointerHandlerContexts": handler_contexts,
        "routePromotionStatus": "blocked",
        "promotionStatus": "blocked",
        "proofFound": False,
        "selectedPointerUsageProofFound": False,
        "failedSelectedPointerUsageGateIds": FAILED_SELECTED_POINTER_USAGE_GATE_IDS,
        "missingEvidence": SELECTED_POINTER_USAGE_MISSING_EVIDENCE,
        "evidenceRefs": SELECTED_POINTER_USAGE_EVIDENCE_REFS,
        "evidenceRefCount": len(SELECTED_POINTER_USAGE_EVIDENCE_REFS),
        "watchValues": rows,
        "runtimeTraceHookPoints": hook_points,
        "nextEvidenceNeeded": [
            "construct or capture a savedata sample with save offsets 0x0002=2 and 0x0003=0",
            "watch writes to 0x0059de30 and verify whether the value becomes 0x00540714",
            "break at opcode 8 handler 0x0040adfe and prove context+0x40 is set from 0x00540714 on the route path",
            "find a strict map1_01a hotspot or runtime producer that selects selector 2:0",
        ],
        "conclusion": conclusion,
    }


def markdown(summary: dict) -> str:
    lines = [
        "# Save Selector Selected Pointer Usage",
        "",
        f"- scope: {summary['scope']}",
        f"- selected pointer global: `{summary['selectedPointerGlobalHex']}`",
        f"- selector group table: `{summary['selectorGroupTableHex']}`",
        f"- current selector/root: `{summary['currentSelector']}` / `{summary['currentSelectorRootHex']}`",
        f"- selected pointer global .text refs: {summary['selectedPointerGlobalTextRefCount']}",
        f"- selected pointer writer hooks: {summary['selectedPointerWriterHookCount']} ({', '.join(summary['selectedPointerWriterHookVas'])})",
        f"- selected pointer reader hooks: {summary['selectedPointerReaderHookCount']} ({', '.join(summary['selectedPointerReaderHookVas'])})",
        f"- current selector direct .text refs: {summary['currentCodeRefCount']}",
        f"- no static direct current selector code ref: {summary['noStaticDirectCurrentSelectorCodeRef']}",
        f"- promotion status: {summary['promotionStatus']}",
        f"- route promotion status: {summary['routePromotionStatus']}",
        f"- proof found: {summary['proofFound']}",
        f"- selected pointer usage proof found: {summary['selectedPointerUsageProofFound']}",
        f"- failed selected pointer usage gates: `{','.join(summary['failedSelectedPointerUsageGateIds'])}`",
        f"- missing evidence count: {len(summary['missingEvidence'])}",
        f"- evidence refs: {summary['evidenceRefCount']}",
        "",
        summary["conclusion"],
        "",
        "## Missing Evidence",
        "",
        *[f"- {item}" for item in summary["missingEvidence"]],
        "",
        "## Evidence Refs",
        "",
        "| path | fields |",
        "| --- | --- |",
        *[
            f"| `{row['path']}` | {', '.join(row.get('fields') or []) or '-'} |"
            for row in summary["evidenceRefs"]
        ],
        "",
        "## Watch Values",
        "",
        "| name | value | target section | refs | .text refs | section counts | role |",
        "| --- | --- | --- | ---: | ---: | --- | --- |",
    ]
    for row in summary["watchValues"]:
        counts = ", ".join(f"{section}:{count}" for section, count in row["sectionCounts"].items()) or "-"
        lines.append(
            f"| {row['name']} | `{row['valueHex']}` | {row.get('targetSection') or '-'} | "
            f"{row['refCount']} | {row['textRefCount']} | {counts} | {row['role']} |"
        )
    lines.extend([
        "",
        "## Runtime Trace Hook Points",
        "",
        "| VA | present | access | mechanism | instruction | route requirement | meaning |",
        "| --- | --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary["runtimeTraceHookPoints"]:
        lines.append(
            f"| `{row['vaHex']}` | {row['present']} | {row.get('access') or '-'} | "
            f"{row.get('mechanism') or '-'} | `{row.get('instruction') or '-'}` | "
            f"{row.get('routeRequirement') or '-'} | {row.get('meaning') or '-'} |"
        )
    lines.extend([
        "",
        "## Handler Context",
        "",
        f"- handler contexts verified: {summary['selectedPointerHandlerContextsVerified']}",
        "",
        "| handler | hooks | verified | context effect | route implication | trace hook |",
        "| --- | --- | --- | --- | --- | --- |",
    ])
    for row in summary["selectedPointerHandlerContexts"]:
        hooks = ", ".join(row["hookVaHexes"])
        lines.append(
            f"| {row['name']} `{row['handlerVaHex']}` | `{hooks}` | {row['verified']} | "
            f"{row['contextEffect']} | {row['routeImplication']} | {row['traceHook']} |"
        )
    lines.extend(["", "### Handler Byte Checks", ""])
    for row in summary["selectedPointerHandlerContexts"]:
        lines.append(f"- {row['name']}: handler bytes verified={row['verified']}")
        for check in row["byteChecks"]:
            lines.append(
                f"  - `{check['vaHex']}` {check['label']}: {check['matches']} "
                f"(`{check['actualBytes']}`)"
            )
    lines.extend(["", "## Next Evidence Needed", ""])
    lines.extend(f"- {item}" for item in summary["nextEvidenceNeeded"])
    lines.append("")
    return "\n".join(lines)


def html_page(summary: dict) -> str:
    value_rows = "\n".join(
        "<tr>"
        f"<td>{html.escape(row['name'])}</td>"
        f"<td><code>{html.escape(row['valueHex'])}</code></td>"
        f"<td>{html.escape(row.get('targetSection') or '-')}</td>"
        f"<td>{row['refCount']}</td>"
        f"<td>{row['textRefCount']}</td>"
        f"<td>{html.escape(', '.join(f'{section}:{count}' for section, count in row['sectionCounts'].items()) or '-')}</td>"
        f"<td>{html.escape(row['role'])}</td>"
        "</tr>"
        for row in summary["watchValues"]
    )
    hook_rows = "\n".join(
        "<tr>"
        f"<td><code>{html.escape(row['vaHex'])}</code></td>"
        f"<td>{row['present']}</td>"
        f"<td>{html.escape(row.get('access') or '-')}</td>"
        f"<td>{html.escape(row.get('mechanism') or '-')}</td>"
        f"<td><code>{html.escape(row.get('instruction') or '-')}</code></td>"
        f"<td>{html.escape(row.get('routeRequirement') or '-')}</td>"
        f"<td>{html.escape(row.get('meaning') or '-')}</td>"
        "</tr>"
        for row in summary["runtimeTraceHookPoints"]
    )
    context_rows = "\n".join(
        "<tr>"
        f"<td>{html.escape(row['name'])}<br><code>{html.escape(row['handlerVaHex'])}</code></td>"
        f"<td><code>{html.escape(', '.join(row['hookVaHexes']))}</code></td>"
        f"<td>{row['verified']}</td>"
        f"<td>{html.escape(row['contextEffect'])}</td>"
        f"<td>{html.escape(row['routeImplication'])}</td>"
        f"<td>{html.escape(row['traceHook'])}</td>"
        "</tr>"
        for row in summary["selectedPointerHandlerContexts"]
    )
    byte_check_items = "".join(
        "<li>"
        f"{html.escape(row['name'])}: handler bytes verified={row['verified']}<ul>"
        + "".join(
            "<li>"
            f"<code>{html.escape(check['vaHex'])}</code> {html.escape(check['label'])}: "
            f"{check['matches']} <code>{html.escape(check['actualBytes'])}</code>"
            "</li>"
            for check in row["byteChecks"]
        )
        + "</ul></li>"
        for row in summary["selectedPointerHandlerContexts"]
    )
    evidence_items = "".join(f"<li>{html.escape(item)}</li>" for item in summary["nextEvidenceNeeded"])
    missing_items = "".join(f"<li>{html.escape(item)}</li>" for item in summary["missingEvidence"])
    ref_rows = "".join(
        "<tr>"
        f"<td><code>{html.escape(row.get('path') or '-')}</code></td>"
        f"<td>{html.escape(', '.join(row.get('fields') or []) or '-')}</td>"
        "</tr>"
        for row in summary["evidenceRefs"]
    )
    return "\n".join([
        "<!doctype html><meta charset=\"utf-8\"><title>Save Selector Selected Pointer Usage</title>",
        "<style>body{font-family:system-ui,sans-serif;background:#111;color:#eee;max-width:1200px;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 Selected Pointer Usage</h1>",
        f"<p>Selected pointer global <code>{summary['selectedPointerGlobalHex']}</code>; current selector <code>{summary['currentSelector']}</code> root <code>{summary['currentSelectorRootHex']}</code>.</p>",
        (
            f"<p>selected pointer writer hooks: {summary['selectedPointerWriterHookCount']} "
            f"({html.escape(', '.join(summary['selectedPointerWriterHookVas']))}); reader hooks: "
            f"{summary['selectedPointerReaderHookCount']} ({html.escape(', '.join(summary['selectedPointerReaderHookVas']))}); "
            f"current selector direct .text refs: {summary['currentCodeRefCount']}; "
            f"no static direct current selector code ref: {summary['noStaticDirectCurrentSelectorCodeRef']}; "
            f"promotion status: <code>{summary['promotionStatus']}</code>; "
            f"route promotion status: <code>{summary['routePromotionStatus']}</code>; "
            f"proof found: {summary['proofFound']}; "
            f"selected pointer usage proof found: {summary['selectedPointerUsageProofFound']}; "
            "failed selected pointer usage gates: "
            f"<code>{html.escape(','.join(summary['failedSelectedPointerUsageGateIds']))}</code>; "
            f"missing evidence count: {len(summary['missingEvidence'])}; "
            f"evidence refs: {summary['evidenceRefCount']}</p>"
        ),
        f"<p>{html.escape(summary['conclusion'])}</p>",
        "<h2>Missing Evidence</h2>",
        f"<ul>{missing_items}</ul>",
        "<h2>Evidence Refs</h2>",
        f"<table><thead><tr><th>path</th><th>fields</th></tr></thead><tbody>{ref_rows}</tbody></table>",
        "<h2>Watch Values</h2>",
        "<table><thead><tr><th>name</th><th>value</th><th>target section</th><th>refs</th><th>.text refs</th><th>section counts</th><th>role</th></tr></thead><tbody>",
        value_rows,
        "</tbody></table>",
        "<h2>Runtime Trace Hook Points</h2>",
        "<table><thead><tr><th>VA</th><th>present</th><th>access</th><th>mechanism</th><th>instruction</th><th>route requirement</th><th>meaning</th></tr></thead><tbody>",
        hook_rows,
        "</tbody></table>",
        "<h2>Handler Context</h2>",
        f"<p>handler contexts verified: {summary['selectedPointerHandlerContextsVerified']}</p>",
        "<table><thead><tr><th>handler</th><th>hooks</th><th>verified</th><th>context effect</th><th>route implication</th><th>trace hook</th></tr></thead><tbody>",
        context_rows,
        "</tbody></table>",
        "<h3>Handler Byte Checks</h3>",
        f"<ul>{byte_check_items}</ul>",
        "<h2>Next Evidence Needed</h2>",
        f"<ul>{evidence_items}</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_selected_pointer_usage.json").write_text(
        json.dumps(summary, ensure_ascii=False, separators=(",", ":")),
        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("--selectors", type=Path, default=OUT / "save_scene_selectors.json")
    parser.add_argument("--loader-trace", type=Path, default=OUT / "save_loader_trace.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.selectors, []),
        load_json(args.loader_trace, {}),
    )
    write_outputs(summary, args.out_dir, args.html_out)
    print(f"wrote save selector selected pointer usage -> {args.out_dir / 'save_selector_selected_pointer_usage.json'}")


if __name__ == "__main__":
    main()
