GKLBB

当你经历了暴风雨,你也就成为了暴风雨

导航

应用安全 --- IDAPython脚本 之 函数调用链可视化

调用链可视化

image

 ida脚本如下,优化的方向是提前去除所有的无关函数不进行计算,将去除杂质的节点独立计算每个调用链分开展示。应该将调用多的函数在中心展示,越往外围越是调用数越少

需要分析准确性

 

# -*- coding: utf-8 -*-
"""
IDA Call Graph — 全函数调用链可视化(业务/非业务/未知 三色分类)
新增:调用链CSV导出(节点函数、调用者、被调用者)
"""

import ida_funcs
import ida_xref
import ida_name
import ida_idaapi
import ida_entry
import ida_segment
import ida_nalt
import idc
import idautils
import json
import os
import math
import random
import re
import subprocess
import csv
from collections import defaultdict, deque

# =====================================================
# 非业务函数识别规则库
# =====================================================
_SYS_EXACT = {
    # C 标准 I/O
    "printf", "fprintf", "sprintf", "snprintf", "vprintf", "vfprintf",
    "vsprintf", "vsnprintf", "puts", "fputs", "putchar", "putc", "fputc",
    "scanf", "fscanf", "sscanf", "vscanf", "vfscanf", "vsscanf",
    "gets", "fgets", "getchar", "getc", "fgetc", "ungetc",
    # 内存管理
    "malloc", "calloc", "realloc", "free", "aligned_alloc", "posix_memalign",
    "memcpy", "memmove", "memset", "memcmp", "memchr",
    # 字符串
    "strlen", "strcpy", "strncpy", "strcat", "strncat", "strcmp", "strncmp",
    "strchr", "strrchr", "strstr", "strtok", "strtok_r", "strdup", "strndup",
    "strerror", "strerror_r", "strpbrk", "strspn", "strcspn", "strsep",
    # 数字转换
    "atoi", "atol", "atoll", "atof",
    "strtol", "strtoul", "strtoll", "strtoull", "strtod", "strtof", "strtold",
    # 文件 I/O
    "fopen", "fclose", "fread", "fwrite", "fseek", "ftell", "rewind",
    "fflush", "feof", "ferror", "clearerr", "fileno", "fdopen", "freopen",
    # POSIX I/O
    "open", "close", "read", "write", "lseek", "dup", "dup2", "pipe",
    "fcntl", "ioctl", "select", "poll",
    "epoll_create", "epoll_ctl", "epoll_wait",
    # 进程控制
    "exit", "abort", "_exit", "_Exit", "atexit", "__cxa_atexit",
    # 算法/工具
    "qsort", "bsearch", "abs", "labs", "llabs", "div", "ldiv", "lldiv",
    "rand", "srand", "random", "srandom",
    # 时间
    "time", "clock", "difftime", "mktime", "strftime",
    "localtime", "gmtime", "asctime", "ctime",
    "gettimeofday", "clock_gettime", "nanosleep", "usleep", "sleep",
    # 字符分类
    "isalpha", "isdigit", "isalnum", "isspace", "isupper", "islower",
    "isprint", "ispunct", "iscntrl", "isxdigit", "isgraph",
    "toupper", "tolower",
    # 信号
    "signal", "raise", "sigaction", "sigprocmask",
    "kill", "sigpending", "sigwait",
    # 环境
    "getenv", "setenv", "unsetenv", "putenv", "system",
    # 调试/断言
    "assert", "__assert_fail", "__assert_rtn", "__assert2", "perror",
    # 数学
    "sin", "cos", "tan", "asin", "acos", "atan", "atan2",
    "sqrt", "pow", "exp", "log", "log2", "log10",
    "ceil", "floor", "trunc", "round", "fabs", "fmod",
    "sinf", "cosf", "tanf", "sqrtf", "powf", "expf", "logf",
    # 内存映射
    "mmap", "munmap", "mprotect", "madvise", "mremap", "brk", "sbrk",
    # 进程/线程
    "fork", "vfork", "execve", "execv", "execvp",
    "wait", "waitpid", "waitid",
    "getpid", "getppid", "getuid", "geteuid", "getgid", "getegid",
    # 文件系统
    "chdir", "getcwd", "chmod", "chown",
    "stat", "fstat", "lstat", "access",
    "link", "unlink", "rename", "symlink", "readlink",
    "mkdir", "rmdir", "opendir", "readdir", "closedir",
    # 网络
    "socket", "bind", "listen", "accept", "connect",
    "send", "recv", "sendto", "recvfrom",
    "setsockopt", "getsockopt", "getaddrinfo", "freeaddrinfo",
    "htons", "ntohs", "htonl", "ntohl",
    # pthreads
    "pthread_create", "pthread_join", "pthread_detach", "pthread_exit",
    "pthread_mutex_init", "pthread_mutex_lock", "pthread_mutex_unlock",
    "pthread_cond_wait", "pthread_cond_signal", "pthread_cond_broadcast",
    "sem_init", "sem_wait", "sem_post",
    # 动态链接
    "dlopen", "dlclose", "dlsym", "dlerror",
    # 系统日志
    "syslog", "openlog", "closelog",
    # Android 专属
    "__android_log_print", "__android_log_vprint",
    "__system_property_get",
    # 其他
    "syscall", "sysconf",
    # C++ operator new/delete
    "_Znwm", "_Znwj", "_Znam", "_Znaj",
    "_ZdlPv", "_ZdlPvm", "_ZdaPv", "_ZdaPvm",
    "operator new", "operator delete",
    "operator new[]", "operator delete[]",
    # C++ ABI
    "__cxa_allocate_exception", "__cxa_free_exception",
    "__cxa_throw", "__cxa_begin_catch", "__cxa_end_catch",
    "__cxa_rethrow", "__cxa_guard_acquire", "__cxa_guard_release",
    "__cxa_pure_virtual", "__cxa_finalize", "__cxa_demangle",
    "__gxx_personality_v0", "__dynamic_cast",
    # 编译器内建
    "__stack_chk_fail", "__stack_chk_guard",
    "__udivdi3", "__divdi3", "__moddi3",
    "__libc_start_main", "__libc_csu_init",
    "_Unwind_Resume", "_Unwind_RaiseException",
    # 编译器生成帧函数
    "frame_dummy", "register_tm_clones", "deregister_tm_clones",
    "_init", "_fini", "_start",
    # Windows CRT
    "GetProcAddress", "LoadLibraryA", "LoadLibraryW",
    "VirtualAlloc", "VirtualFree", "HeapAlloc", "HeapFree",
    "CreateFileA", "CreateFileW", "ReadFile", "WriteFile", "CloseHandle",
    "GetLastError", "SetLastError",
    "MultiByteToWideChar", "WideCharToMultiByte",
    "ExitProcess", "GetCurrentProcessId", "GetCurrentThreadId",
}

_SYS_EXACT_LOWER = {x.lower() for x in _SYS_EXACT}

_SYS_PREFIXES = (
    "_ZNSt6__ndk1", "_ZNKSt6__ndk1", "_ZNSt3__1", "_ZNKSt3__1",
    "_ZN7_JNIEnv", "_ZN8_JavaVM",
    "_ZN12_GLOBAL__N_1", "_ZNK12_GLOBAL__N_1",
    "_ZL", "_ZN9libunwind", "_ZNKSt", "_ZNSt", "_ZSt",
    "_ZNSs", "_ZNSo", "_ZNSi",
    "_Znw", "_Zna", "_Zdl", "_Zda",
    "_ZTI", "_ZTS", "_ZTV", "_ZTT",
    "_ZN10__cxxabiv1", "_ZN9__gnu_cxx",
    "_cxa", "_gxx", "_gcc", "_gnu", "_aeabi",
    "_unw", "libunwind", "emutls",
    "_libc", "_lll", "_pthread",
    "frame", "GLOBAL",
    "__stack_chk", "__memcpy_chk", "__memmove_chk",
    "__strcpy_chk", "__strncpy_chk", "__sprintf_chk", "__snprintf_chk",
    "__printf_chk", "__vprintf_chk",
    "Unwind", "__on_dlclose", "__cxa",
    "imp_", "__imp_",
    "j_", "nullsub_",
    "_scrt", "_acrt", "RTC_", "_security",
    "__android_log",
    "_ubsan", "_asan", "_msan", "_tsan",
)

_SYS_PATTERNS = [
    re.compile(r'^\??\$L[A-Z]'),
    re.compile(r'^x86_get_pc_thunk_'),
    re.compile(r'^\?\?main[Cc][Rr][Tt]'),
    re.compile(r'^\?\?security_init_cookie'),
    re.compile(r'^\?\?security_check_cookie'),
    re.compile(r'^\?\?RTC'),
    re.compile(r'^\?\?CxxFrameHandler'),
    re.compile(r'^\?\?CxxThrowException'),
    re.compile(r'^\?\?scrt_'),
    re.compile(r'^acrt'),
    re.compile(r'^__tcf\d'),
    re.compile(r'^tcf\d'),
    re.compile(r'^\.\(_?LCFI|cfi\)\d'),
    re.compile(r'^\?\?guard'),
    re.compile(r'^operator\s+new(?:\[\])?\s*(?:\(|$)'),
    re.compile(r'^operator\s+delete(?:\[\])?\s*(?:\(|$)'),
    re.compile(r'^std::'),
    re.compile(r'^__cxxabiv1::'),
    re.compile(r'^__gnu_cxx::'),
    re.compile(r'^unwind_phase\d'),
    re.compile(r'^emutls'),
    re.compile(r'^abort_message$'),
    re.compile(r'^\.\w'),
    re.compile(r'^\(anonymous namespace\)::'),
    re.compile(r'^itanium_demangle::'),
    re.compile(r'^libunwind::'),
]

_SYS_SEGMENTS = {
    '.plt', '.plt.got', '.plt.sec', '.got', '.got.plt',
    '__stubs', '__stub_helper', '__la_symbol_ptr',
    '.idata', '.didat',
    '.init', '.fini', '.init_array', '.fini_array', '.preinit_array',
    '__mod_init_func', '__mod_term_func',
    'UNDEF', 'extern',
}

_DEMANGLED_NON_BIZ_PREFIXES = (
    "std::", "__cxxabiv1::", "__gnu_cxx::", "std::__ndk1::", "std::__1::",
    "operator new", "operator delete",
    "typeinfo for ", "vtable for ", "typeinfo name for ",
    "construction vtable", "VTT for ",
    "guard variable for ", "reference temporary for ",
    "_JNIEnv::", "_JavaVM::",
    "(anonymous namespace)::", "itanium_demangle::", "libunwind::",
)

# =====================================================
# 业务入口点白名单
# =====================================================
def _is_real_business_entry(name):
    if not name:
        return False
    if name.startswith('Java_'):
        return True
    if name in ('JNI_OnLoad', 'JNI_OnUnload'):
        return True
    if name in ('main', '_main', 'WinMain', 'wWinMain', 'DllMain', 'wmain'):
        return True
    return False

# =====================================================
# 名称识别函数
# =====================================================
def is_non_business_by_name(name):
    if not name:
        return False, ""
    if name.startswith("sub_") or name.startswith("nullsub_"):
        return False, ""
    name_lower = name.lower()
    if name_lower in _SYS_EXACT_LOWER:
        return True, "exact:{}".format(name)
    for prefix in _SYS_PREFIXES:
        if name.startswith(prefix):
            return True, "prefix:{}".format(prefix)
    for pat in _SYS_PATTERNS:
        if pat.match(name):
            return True, "regex:{}".format(pat.pattern)
    demangled = idc.demangle_name(name, idc.get_inf_attr(idc.INF_SHORT_DN))
    if demangled:
        dem_lower = demangled.lower()
        for prefix in _DEMANGLED_NON_BIZ_PREFIXES:
            if dem_lower.startswith(prefix.lower()):
                return True, "demangled:{}".format(demangled[:60])
        for pat in _SYS_PATTERNS:
            if pat.match(demangled):
                return True, "regex_demangled:{}".format(pat.pattern)
    return False, ""

def is_system_function(ea):
    flags = idc.get_func_attr(ea, idc.FUNCATTR_FLAGS)
    if flags != idc.BADADDR:
        if flags & idc.FUNC_LIB:
            return True, "FUNC_LIB"
        if flags & idc.FUNC_THUNK:
            return True, "FUNC_THUNK"
    seg = ida_segment.getseg(ea)
    if seg:
        seg_name = ida_segment.get_segm_name(seg)
        if seg_name in _SYS_SEGMENTS:
            return True, "segment:{}".format(seg_name)
        if seg.type == ida_segment.SEG_XTRN:
            return True, "SEG_XTRN"
        if seg_name.startswith('.plt') or seg_name in ('__stubs', '__stub_helper'):
            return True, "plt_segment"
    name = ida_name.get_name(ea)
    if name:
        ok, reason = is_non_business_by_name(name)
        if ok:
            return True, reason
    return False, ""

# =====================================================
# 函数分类
# =====================================================
def get_function_name(ea):
    name = ida_name.get_name(ea)
    if not name:
        name = "sub_{:X}".format(ea)
    return name

def find_entry_points():
    entries = set()
    for i in range(ida_entry.get_entry_qty()):
        ordinal = ida_entry.get_entry_ordinal(i)
        ea = ida_entry.get_entry(ordinal)
        if ea == ida_idaapi.BADADDR:
            continue
        func = ida_funcs.get_func(ea)
        if not func:
            continue
        ep_name = ida_name.get_name(ea) or ""
        if _is_real_business_entry(ep_name):
            entries.add(func.start_ea)
    for ep_name in ['main', '_main', 'WinMain', 'wWinMain', 'DllMain',
                    'JNI_OnLoad', 'JNI_OnUnload', 'wmain']:
        ea = ida_name.get_name_ea(ida_idaapi.BADADDR, ep_name)
        if ea != ida_idaapi.BADADDR:
            func = ida_funcs.get_func(ea)
            if func:
                entries.add(func.start_ea)
    return entries

def classify_function(ea, entry_points):
    func = ida_funcs.get_func(ea)
    if not func:
        return "unknown"
    name = get_function_name(ea)
    if _is_real_business_entry(name):
        return "business"
    is_sys, _ = is_system_function(ea)
    if is_sys:
        return "non-business"
    if name.startswith("sub_") or name.startswith("nullsub_"):
        return "unknown"
    if ea in entry_points:
        return "business"
    if func.size() > 0:
        return "business"
    return "unknown"

# =====================================================
# 传播规则:系统库函数调用的 unknown → non-business
# =====================================================
def propagate_lib_callees(func_categories):
    print("[*] Propagating lib-callee rule...")
    call_map = defaultdict(list)
    all_funcs_set = set(func_categories.keys())
    for func_ea in all_funcs_set:
        func = ida_funcs.get_func(func_ea)
        if not func:
            continue
        for head in idautils.FuncItems(func_ea):
            for xref in idautils.XrefsFrom(head, 0):
                if xref.type not in (ida_xref.fl_CF, ida_xref.fl_CN,
                                     ida_xref.fl_JF, ida_xref.fl_JN):
                    continue
                target = xref.to
                target_func = ida_funcs.get_func(target)
                if target_func:
                    target_ea = target_func.start_ea
                else:
                    continue
                if target_ea == func_ea:
                    continue
                if target_ea in all_funcs_set:
                    call_map[func_ea].append(target_ea)

    propagated = set()
    queue = deque()
    for ea, cat in func_categories.items():
        if cat == "non-business":
            queue.append(ea)
    visited_as_source = set()
    rounds = 0
    while queue:
        rounds += 1
        new_in_this_round = 0
        batch = list(queue)
        queue.clear()
        for nb_ea in batch:
            if nb_ea in visited_as_source:
                continue
            visited_as_source.add(nb_ea)
            for callee_ea in call_map.get(nb_ea, []):
                if func_categories.get(callee_ea) == "unknown":
                    func_categories[callee_ea] = "non-business"
                    propagated.add(callee_ea)
                    new_in_this_round += 1
                    if callee_ea not in visited_as_source:
                        queue.append(callee_ea)
        if new_in_this_round == 0:
            break
    print("[*] Propagation done: {} rounds, {} promoted".format(rounds, len(propagated)))
    return func_categories, propagated, rounds

def scan_and_classify_all():
    all_funcs = list(idautils.Functions())
    total = len(all_funcs)
    entry_points = find_entry_points()
    W = 88
    print("\n" + "=" * W)
    print(" FUNCTION SCAN & CLASSIFICATION")
    print("=" * W)
    print(" Total functions: {}".format(total))
    print(" Entry points found: {}".format(len(entry_points)))
    print("=" * W)

    func_categories = {}
    counts = {"business": 0, "non-business": 0, "unknown": 0}
    for func_ea in all_funcs:
        cat = classify_function(func_ea, entry_points)
        func_categories[func_ea] = cat
        counts[cat] += 1

    print("\n [Before propagation]")
    print(" Business    : {}".format(counts["business"]))
    print(" Non-business: {}".format(counts["non-business"]))
    print(" Unknown     : {}".format(counts["unknown"]))

    func_categories, propagated_eas, prop_rounds = propagate_lib_callees(func_categories)

    new_counts = {"business": 0, "non-business": 0, "unknown": 0}
    for cat in func_categories.values():
        new_counts[cat] += 1

    print("\n [After propagation ({} rounds, +{} promoted)]".format(
        prop_rounds, len(propagated_eas)))
    print(" Business    : {}".format(new_counts["business"]))
    print(" Non-business: {}".format(new_counts["non-business"]))
    print(" Unknown     : {}".format(new_counts["unknown"]))
    print("=" * W)
    return func_categories, entry_points

# =====================================================
# 调用图构建
# =====================================================
def build_full_call_graph(func_categories, entry_points):
    print("[*] Building full call graph...")
    nodes = {}
    edges = []
    edge_set = set()
    all_funcs_set = set(func_categories.keys())

    for idx, func_ea in enumerate(sorted(all_funcs_set)):
        if idx % 500 == 0 and idx > 0:
            print("  {}/{}".format(idx, len(all_funcs_set)))
        func = ida_funcs.get_func(func_ea)
        if not func:
            continue
        cat = func_categories.get(func_ea, "unknown")
        is_entry = func_ea in entry_points
        if func_ea not in nodes:
            nodes[func_ea] = {
                "name": get_function_name(func_ea),
                "category": cat,
                "is_entry": is_entry,
                "out": 0, "in": 0,
                "size": func.size(),
            }
        for head in idautils.FuncItems(func_ea):
            for xref in idautils.XrefsFrom(head, 0):
                if xref.type not in (ida_xref.fl_CF, ida_xref.fl_CN,
                                     ida_xref.fl_JF, ida_xref.fl_JN):
                    continue
                target = xref.to
                target_func = ida_funcs.get_func(target)
                if xref.type in (ida_xref.fl_JF, ida_xref.fl_JN):
                    if target_func and target_func.start_ea == func_ea:
                        continue
                if target_func:
                    target = target_func.start_ea
                elif not ida_name.get_name(target):
                    continue
                if target == func_ea:
                    continue
                if target not in nodes:
                    t_func = ida_funcs.get_func(target)
                    t_cat = func_categories.get(target, "unknown")
                    nodes[target] = {
                        "name": get_function_name(target),
                        "category": t_cat,
                        "is_entry": target in entry_points,
                        "out": 0, "in": 0,
                        "size": t_func.size() if t_func else 0,
                    }
                key = (func_ea, target)
                if key not in edge_set:
                    edge_set.add(key)
                    edges.append({"from": func_ea, "to": target})
                    nodes[func_ea]["out"] += 1
                    nodes[target]["in"] += 1
    print("[*] Graph: {} nodes, {} edges".format(len(nodes), len(edges)))
    return nodes, edges

# =====================================================
# 连通分量 / 最长链
# =====================================================
def find_components(nodes, edges):
    adj = defaultdict(set)
    for e in edges:
        adj[e["from"]].add(e["to"])
        adj[e["to"]].add(e["from"])
    visited = set()
    components = []
    for ea in nodes:
        if ea in visited:
            continue
        comp = []
        queue = deque([ea])
        visited.add(ea)
        while queue:
            cur = queue.popleft()
            comp.append(cur)
            for nb in adj[cur]:
                if nb not in visited and nb in nodes:
                    visited.add(nb)
                    queue.append(nb)
        components.append(comp)
    components.sort(key=lambda c: len(c))
    return components

def find_longest_chain(nodes, edges):
    adj = defaultdict(list)
    in_deg = defaultdict(int)
    all_nodes = set(nodes.keys())
    for e in edges:
        if e["from"] in all_nodes and e["to"] in all_nodes:
            adj[e["from"]].append(e["to"])
            in_deg[e["to"]] += 1
    roots = [ea for ea in all_nodes if in_deg[ea] == 0]
    if not roots:
        roots = list(all_nodes)
    best_path = []
    if len(all_nodes) <= 500:
        def dfs(node, path, visited):
            nonlocal best_path
            if len(path) > len(best_path):
                best_path = list(path)
            for nxt in adj[node]:
                if nxt not in visited:
                    visited.add(nxt)
                    path.append(nxt)
                    dfs(nxt, path, visited)
                    path.pop()
                    visited.discard(nxt)
            if len(best_path) > len(all_nodes) * 0.8:
                return
        for root in roots:
            dfs(root, [root], {root})
    else:
        topo_order = []
        temp_in_deg = {ea: in_deg.get(ea, 0) for ea in all_nodes}
        queue = deque([ea for ea in all_nodes if temp_in_deg[ea] == 0])
        while queue:
            node = queue.popleft()
            topo_order.append(node)
            for nxt in adj[node]:
                temp_in_deg[nxt] -= 1
                if temp_in_deg[nxt] == 0:
                    queue.append(nxt)
        if len(topo_order) == len(all_nodes):
            dist = {ea: 1 for ea in all_nodes}
            parent = {ea: None for ea in all_nodes}
            for node in topo_order:
                for nxt in adj[node]:
                    if dist[node] + 1 > dist[nxt]:
                        dist[nxt] = dist[node] + 1
                        parent[nxt] = node
            end_node = max(dist, key=dist.get)
            path = []
            cur = end_node
            while cur is not None:
                path.append(cur)
                cur = parent[cur]
            best_path = list(reversed(path))
        else:
            max_depth = min(200, len(all_nodes))
            sorted_roots = sorted(roots, key=lambda x: -len(adj[x]))[:50]
            for root in sorted_roots:
                stack = [(root, [root], {root})]
                while stack:
                    node, path, visited = stack.pop()
                    if len(path) > len(best_path):
                        best_path = list(path)
                    if len(path) >= max_depth:
                        continue
                    for nxt in adj[node]:
                        if nxt not in visited:
                            stack.append((nxt, path + [nxt], visited | {nxt}))
    print("[*] Longest chain: {} nodes".format(len(best_path)))
    return best_path

def enumerate_all_chains(nodes, edges, max_chains=50000):
    call_map = defaultdict(list)
    all_nodes = set(nodes.keys())
    for e in edges:
        if e["from"] in all_nodes and e["to"] in all_nodes:
            call_map[e["from"]].append(e["to"])
    in_deg = defaultdict(int)
    for e in edges:
        if e["from"] in all_nodes and e["to"] in all_nodes:
            in_deg[e["to"]] += 1
    roots = [ea for ea in all_nodes if in_deg[ea] == 0]
    if not roots:
        roots = sorted(all_nodes)
    used_edges = set()
    chains = []

    def dfs(node, path, visited):
        available = [
            t for t in call_map.get(node, [])
            if (node, t) not in used_edges and t not in visited
        ]
        if not available:
            if len(path) >= 2:
                chains.append(list(path))
            return
        for nxt in available:
            if len(chains) >= max_chains:
                return
            used_edges.add((node, nxt))
            visited.add(nxt)
            path.append(nxt)
            dfs(nxt, path, visited)
            path.pop()
            visited.discard(nxt)

    for root in sorted(roots, key=lambda x: nodes[x]["name"]):
        if len(chains) >= max_chains:
            break
        dfs(root, [root], {root})
    for ea in sorted(all_nodes, key=lambda x: nodes[x]["name"]):
        if len(chains) >= max_chains:
            break
        remaining = [t for t in call_map.get(ea, []) if (ea, t) not in used_edges]
        if not remaining:
            continue
        dfs(ea, [ea], {ea})
    chains.sort(key=lambda c: -len(c))
    print("[*] {} chains, {} unique edges".format(len(chains), len(used_edges)))
    return chains

# =====================================================
# ★ 新增:调用链 CSV 导出
# =====================================================
def generate_callchain_csv(nodes, edges, output_path):
    """
    导出调用链为CSV格式。
    
    CSV列说明:
      - node_address    : 节点函数地址(十六进制)
      - node_name       : 节点函数名称
      - node_category   : 节点函数分类(business/non-business/unknown)
      - node_category_cn: 节点函数分类(中文)
      - node_size_b     : 节点函数代码大小(字节)
      - node_in_degree  : 被调用次数(入度)
      - node_out_degree : 调用其他函数次数(出度)
      - caller_address  : 调用者地址(无调用者则为空)
      - caller_name     : 调用者函数名称
      - caller_category : 调用者分类
      - callee_address  : 被调用者地址(无被调用者则为空)
      - callee_name     : 被调用者函数名称
      - callee_category : 被调用者分类
      - edge_direction  : 调用关系方向描述
    """
    CAT_CN = {
        "business": "业务函数",
        "non-business": "非业务函数",
        "unknown": "未知函数",
    }

    # 构建调用关系索引
    # callers_of[ea] = [(caller_ea, caller_info), ...]
    # callees_of[ea] = [(callee_ea, callee_info), ...]
    callers_of = defaultdict(list)   # ea -> [caller_ea, ...]
    callees_of = defaultdict(list)   # ea -> [callee_ea, ...]

    for e in edges:
        f_ea = e["from"]
        t_ea = e["to"]
        if f_ea in nodes and t_ea in nodes:
            callees_of[f_ea].append(t_ea)
            callers_of[t_ea].append(f_ea)

    rows = []

    for ea in sorted(nodes.keys()):
        info = nodes[ea]
        node_addr = "0x{:X}".format(ea)
        node_name = info["name"]
        node_cat = info.get("category", "unknown")
        node_cat_cn = CAT_CN.get(node_cat, node_cat)
        node_size = info.get("size", 0)
        node_in = info.get("in", 0)
        node_out = info.get("out", 0)

        caller_list = callers_of.get(ea, [])
        callee_list = callees_of.get(ea, [])

        # 若既无调用者也无被调用者(孤立节点),单独输出一行
        if not caller_list and not callee_list:
            rows.append({
                "node_address":     node_addr,
                "node_name":        node_name,
                "node_category":    node_cat,
                "node_category_cn": node_cat_cn,
                "node_size_b":      node_size,
                "node_in_degree":   node_in,
                "node_out_degree":  node_out,
                "caller_address":   "",
                "caller_name":      "",
                "caller_category":  "",
                "callee_address":   "",
                "callee_name":      "",
                "callee_category":  "",
                "edge_direction":   "isolated",
            })
            continue

        # 展开每条调用关系:对每条 caller→node 和 node→callee 各输出一行
        # 若没有调用者,callee方向仍输出
        # 若没有被调用者,caller方向仍输出

        # 生成 (caller_ea_or_None, callee_ea_or_None) 组合
        # 策略:以边为单位,每条边输出一行
        #   - 对于 caller→node 的边:
        #       node = 当前节点, caller = 上游, callee = ""
        #   - 对于 node→callee 的边:
        #       node = 当前节点, caller = "", callee = 下游

        for caller_ea in caller_list:
            c_info = nodes.get(caller_ea, {})
            c_cat = c_info.get("category", "unknown")
            rows.append({
                "node_address":     node_addr,
                "node_name":        node_name,
                "node_category":    node_cat,
                "node_category_cn": node_cat_cn,
                "node_size_b":      node_size,
                "node_in_degree":   node_in,
                "node_out_degree":  node_out,
                "caller_address":   "0x{:X}".format(caller_ea),
                "caller_name":      c_info.get("name", ""),
                "caller_category":  c_cat,
                "callee_address":   "",
                "callee_name":      "",
                "callee_category":  "",
                "edge_direction":   "{} --> {}".format(
                    c_info.get("name", "?"), node_name),
            })

        for callee_ea in callee_list:
            t_info = nodes.get(callee_ea, {})
            t_cat = t_info.get("category", "unknown")
            rows.append({
                "node_address":     node_addr,
                "node_name":        node_name,
                "node_category":    node_cat,
                "node_category_cn": node_cat_cn,
                "node_size_b":      node_size,
                "node_in_degree":   node_in,
                "node_out_degree":  node_out,
                "caller_address":   "",
                "caller_name":      "",
                "caller_category":  "",
                "callee_address":   "0x{:X}".format(callee_ea),
                "callee_name":      t_info.get("name", ""),
                "callee_category":  t_cat,
                "edge_direction":   "{} --> {}".format(
                    node_name, t_info.get("name", "?")),
            })

    # 写入CSV(UTF-8 BOM,Excel可直接打开)
    fieldnames = [
        "node_address", "node_name", "node_category", "node_category_cn",
        "node_size_b", "node_in_degree", "node_out_degree",
        "caller_address", "caller_name", "caller_category",
        "callee_address", "callee_name", "callee_category",
        "edge_direction",
    ]
    with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)

    print("[+] CallChain CSV: {} ({} rows)".format(output_path, len(rows)))
    return rows


def generate_edge_csv(nodes, edges, output_path):
    """
    以边为核心导出CSV(每条调用关系独立一行,更适合数据分析)。
    
    CSV列:
      - edge_id         : 边序号
      - caller_address  : 调用者地址
      - caller_name     : 调用者名称
      - caller_category : 调用者分类
      - caller_cat_cn   : 调用者分类(中文)
      - caller_size_b   : 调用者代码大小
      - callee_address  : 被调用者地址
      - callee_name     : 被调用者名称
      - callee_category : 被调用者分类
      - callee_cat_cn   : 被调用者分类(中文)
      - callee_size_b   : 被调用者代码大小
      - edge_type       : 调用类型(biz->biz / biz->sys / sys->sys 等)
    """
    CAT_CN = {
        "business": "业务函数",
        "non-business": "非业务函数",
        "unknown": "未知函数",
    }
    CAT_SHORT = {
        "business": "B",
        "non-business": "S",
        "unknown": "?",
    }

    fieldnames = [
        "edge_id",
        "caller_address", "caller_name", "caller_category", "caller_cat_cn", "caller_size_b",
        "callee_address", "callee_name", "callee_category", "callee_cat_cn", "callee_size_b",
        "edge_type",
    ]

    rows = []
    for i, e in enumerate(edges, 1):
        f_ea = e["from"]
        t_ea = e["to"]
        fi = nodes.get(f_ea, {})
        ti = nodes.get(t_ea, {})
        f_cat = fi.get("category", "unknown")
        t_cat = ti.get("category", "unknown")
        edge_type = "{}->{}".format(CAT_SHORT.get(f_cat, "?"),
                                     CAT_SHORT.get(t_cat, "?"))
        rows.append({
            "edge_id":          i,
            "caller_address":   "0x{:X}".format(f_ea),
            "caller_name":      fi.get("name", ""),
            "caller_category":  f_cat,
            "caller_cat_cn":    CAT_CN.get(f_cat, f_cat),
            "caller_size_b":    fi.get("size", 0),
            "callee_address":   "0x{:X}".format(t_ea),
            "callee_name":      ti.get("name", ""),
            "callee_category":  t_cat,
            "callee_cat_cn":    CAT_CN.get(t_cat, t_cat),
            "callee_size_b":    ti.get("size", 0),
            "edge_type":        edge_type,
        })

    with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)

    print("[+] Edge CSV: {} ({} rows, {} edges)".format(
        output_path, len(rows), len(edges)))
    return rows


def generate_chain_sequence_csv(nodes, edges, output_path, max_chains=10000):
    """
    枚举所有调用链路径,每条链展开为多行(序列化表示)。
    
    CSV列:
      - chain_id        : 链编号
      - chain_length    : 链总长度(节点数)
      - step            : 在链中的位置(0=起点)
      - node_address    : 当前节点地址
      - node_name       : 当前节点名称
      - node_category   : 当前节点分类
      - prev_address    : 前一节点地址(起点为空)
      - prev_name       : 前一节点名称
      - next_address    : 后一节点地址(终点为空)
      - next_name       : 后一节点名称
      - chain_path      : 完整链路径(箭头连接)
    """
    print("[*] Enumerating chains for sequence CSV...")
    chains = enumerate_all_chains(nodes, edges, max_chains=max_chains)

    fieldnames = [
        "chain_id", "chain_length", "step",
        "node_address", "node_name", "node_category",
        "prev_address", "prev_name",
        "next_address", "next_name",
        "chain_path",
    ]

    rows = []
    for ci, chain in enumerate(chains, 1):
        path_str = " -> ".join(nodes[ea]["name"] for ea in chain if ea in nodes)
        for si, ea in enumerate(chain):
            if ea not in nodes:
                continue
            info = nodes[ea]
            prev_ea = chain[si - 1] if si > 0 else None
            next_ea = chain[si + 1] if si < len(chain) - 1 else None
            prev_info = nodes.get(prev_ea, {}) if prev_ea else {}
            next_info = nodes.get(next_ea, {}) if next_ea else {}
            rows.append({
                "chain_id":       ci,
                "chain_length":   len(chain),
                "step":           si,
                "node_address":   "0x{:X}".format(ea),
                "node_name":      info.get("name", ""),
                "node_category":  info.get("category", "unknown"),
                "prev_address":   "0x{:X}".format(prev_ea) if prev_ea else "",
                "prev_name":      prev_info.get("name", ""),
                "next_address":   "0x{:X}".format(next_ea) if next_ea else "",
                "next_name":      next_info.get("name", ""),
                "chain_path":     path_str,
            })

    with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
        writer = csv.DictWriter(f, fieldnames=fieldnames)
        writer.writeheader()
        writer.writerows(rows)

    print("[+] Chain Sequence CSV: {} ({} rows, {} chains)".format(
        output_path, len(rows), len(chains)))
    return rows

# =====================================================
# 文本导出
# =====================================================
def generate_text(nodes, edges, components, longest_chain, output_path):
    bin_name = ida_nalt.get_root_filename() or "binary"
    print("[*] Enumerating chains...")
    chains = enumerate_all_chains(nodes, edges)
    biz = sum(1 for n in nodes.values() if n["category"] == "business")
    nb = sum(1 for n in nodes.values() if n["category"] == "non-business")
    unk = sum(1 for n in nodes.values() if n["category"] == "unknown")
    TAG = {"business": "[B]", "non-business": "[S]", "unknown": "[?]"}
    L = [
        "# FULL FUNCTION CALL CHAINS",
        "# Binary: {}".format(bin_name),
        "# Nodes:{} Edges:{} Components:{}".format(len(nodes), len(edges), len(components)),
        "# Business:{} Non-business:{} Unknown:{}".format(biz, nb, unk),
        "# Chains:{} LongestChain:{}".format(len(chains), len(longest_chain)),
        "#",
    ]
    nw = len(str(len(chains))) if chains else 1
    for ci, chain in enumerate(chains, 1):
        names = ["{}{}".format(TAG.get(nodes[ea]["category"], "[?]"), nodes[ea]["name"])
                 for ea in chain]
        L.append("#{:<{w}} [{:>3}] {}".format(ci, len(chain), " -> ".join(names), w=nw))
    chained = {ea for chain in chains for ea in chain}
    isolated = sorted([ea for ea in nodes if ea not in chained],
                      key=lambda x: nodes[x]["name"])
    if isolated:
        L.append("#")
        L.append("# ISOLATED NODES:")
        for ii, ea in enumerate(isolated, len(chains) + 1):
            cat = nodes[ea]["category"]
            tag = TAG.get(cat, "[?]")
            L.append("#{:<{w}} [ 1] {}{}".format(ii, tag, nodes[ea]["name"], w=nw))
    text = "\n".join(L)
    with open(output_path, 'w', encoding='utf-8') as f:
        f.write(text)
    print("[+] TXT: {}".format(output_path))
    return text

# =====================================================
# 力导向布局
# =====================================================
def _degree(ea, nodes):
    return nodes[ea]["in"] + nodes[ea]["out"]

def _force_directed_layout(comp, nodes, call_map, reverse_map, iterations=300):
    n = len(comp)
    if n == 1:
        return {comp[0]: (0.0, 0.0)}
    comp_set = set(comp)
    center_ea = sorted(comp, key=lambda x: -_degree(x, nodes))[0]
    adj = defaultdict(set)
    for ea in comp:
        for t in call_map.get(ea, []):
            if t in comp_set:
                adj[ea].add(t); adj[t].add(ea)
        for t in reverse_map.get(ea, []):
            if t in comp_set:
                adj[ea].add(t); adj[t].add(ea)
    random.seed(42)
    radius = math.sqrt(n) * 80
    pos = {center_ea: (0.0, 0.0)}
    for ea in comp:
        if ea == center_ea:
            continue
        a = random.uniform(0, 2 * math.pi)
        r = random.uniform(radius * 0.2, radius)
        pos[ea] = (r * math.cos(a), r * math.sin(a))
    k_r = 8000.0 * max(1, math.sqrt(n))
    k_a = 0.005; k_c = 0.002; damp = 0.9; dt = 1.0; md = 10.0
    mass = {ea: 1.0 + _degree(ea, nodes) * 0.5 for ea in comp}
    vel = {ea: (0.0, 0.0) for ea in comp}
    ea_list = list(comp)
    for it in range(iterations):
        forces = {ea: (0.0, 0.0) for ea in comp}
        for i in range(n):
            ei = ea_list[i]; xi, yi = pos[ei]; fx, fy = forces[ei]
            for j in range(i + 1, n):
                ej = ea_list[j]; xj, yj = pos[ej]
                dx, dy = xi - xj, yi - yj
                dist = math.sqrt(dx*dx + dy*dy)
                if dist < md:
                    dist = md
                    dx, dy = random.uniform(-1, 1), random.uniform(-1, 1)
                    d2 = math.sqrt(dx*dx+dy*dy)
                    if d2:
                        dx /= d2; dy /= d2
                f = k_r / (dist*dist); ux, uy = dx/dist, dy/dist
                fx += ux*f; fy += uy*f
                fjx, fjy = forces[ej]
                forces[ej] = (fjx - ux*f, fjy - uy*f)
            forces[ei] = (fx, fy)
        for ei in comp:
            xi, yi = pos[ei]; fx, fy = forces[ei]
            for ej in adj[ei]:
                xj, yj = pos[ej]; dx, dy = xj-xi, yj-yi
                dist = math.sqrt(dx*dx+dy*dy)
                if dist < md: continue
                ideal = 120 + (_degree(ei, nodes)+_degree(ej, nodes))*5
                f = k_a*(dist-ideal); fx += (dx/dist)*f; fy += (dy/dist)*f
            forces[ei] = (fx, fy)
        cx = sum(pos[ea][0] for ea in comp)/n
        cy = sum(pos[ea][1] for ea in comp)/n
        for ea in comp:
            fx, fy = forces[ea]
            forces[ea] = (fx+(cx-pos[ea][0])*k_c, fy+(cy-pos[ea][1])*k_c)
        max_move = 0
        for ea in comp:
            if ea == center_ea: continue
            vx, vy = vel[ea]; fx, fy = forces[ea]; m = mass[ea]
            vx = (vx+fx/m*dt)*damp; vy = (vy+fy/m*dt)*damp
            sp = math.sqrt(vx*vx+vy*vy)
            ms = 50.0/(1+it*0.01)
            if sp > ms:
                vx = vx/sp*ms; vy = vy/sp*ms
            vel[ea] = (vx, vy)
            nx, ny = pos[ea][0]+vx*dt, pos[ea][1]+vy*dt
            move = abs(nx-pos[ea][0])+abs(ny-pos[ea][1])
            if move > max_move: max_move = move
            pos[ea] = (nx, ny)
        if max_move < 0.1 and it > 50: break
    return pos

def layout_components(components, nodes, edges):
    ea_to_pos = {}
    call_map = defaultdict(list)
    reverse_map = defaultdict(list)
    for e in edges:
        call_map[e["from"]].append(e["to"])
        reverse_map[e["to"]].append(e["from"])
    sorted_comps = sorted(components, key=lambda c: -len(c))
    comp_results = []
    for comp in sorted_comps:
        n = len(comp)
        if n == 1:
            pos = {comp[0]: (0.0, 0.0)}
        else:
            pos = _force_directed_layout(
                comp, nodes, call_map, reverse_map,
                min(500, max(100, n*3)))
        xs = [pos[ea][0] for ea in comp]
        ys = [pos[ea][1] for ea in comp]
        x0, y0 = min(xs), min(ys)
        pad = 80
        w = (max(xs)-x0)+pad*2; h = (max(ys)-y0)+pad*2
        norm = {ea: (pos[ea][0]-x0+pad, pos[ea][1]-y0+pad) for ea in comp}
        comp_results.append((comp, norm, w, h))
    MAX_ROW = 6000; GAP = 300; cx = cy = 0.0; rh = 0.0
    for comp, norm, w, h in comp_results:
        if cx > 0 and cx+w > MAX_ROW:
            cx = 0.0; cy += rh+GAP; rh = 0.0
        for ea in comp:
            lx, ly = norm[ea]
            ea_to_pos[ea] = (cx+lx, cy+ly)
        cx += w+GAP
        if h > rh: rh = h
    print("[*] Layout: {} components".format(len(comp_results)))
    return ea_to_pos

def compute_node_size(info):
    code_size = info.get("size", 0)
    out_deg = info.get("out", 0)
    base = 8 + min(math.log2(code_size+1)*4, 52) if code_size > 0 else 8
    factor = min(0.6 + out_deg*0.04, 1.0) if out_deg > 0 else 0.6
    return max(6, min(base * factor, 60))

# =====================================================
# HTML 可视化(新增 Chain CSV 按钮)
# =====================================================
def generate_html(nodes, edges, components, txt_content, longest_chain, output_path):
    COLOR_MAP = {
        "business":     {"bg": "#1565C0", "label": u"业务"},
        "non-business": {"bg": "#E91E63", "label": u"非业务"},
        "unknown":      {"bg": "#9E9E9E", "label": u"未知"},
    }
    print("[*] Layout...")
    ea_to_pos = layout_components(components, nodes, edges)
    ea_to_comp = {}
    comp_info = []
    for ci, comp in enumerate(components):
        for ea in comp:
            ea_to_comp[ea] = ci
        comp_info.append({"id": ci, "size": len(comp)})

    ea_to_id = {}
    vn, ve = [], []
    adj_down, adj_up = defaultdict(list), defaultdict(list)

    for idx, (ea, info) in enumerate(sorted(nodes.items())):
        ea_to_id[ea] = idx
        cat = info.get("category", "unknown")
        c = COLOR_MAP.get(cat, COLOR_MAP["unknown"])
        sz = compute_node_size(info)
        if info.get("is_entry", False):
            sz = max(sz, 30)
        short = info["name"][:19] + "..." if len(info["name"]) > 22 else info["name"]
        label = u"{}\n\u2191{} \u2193{}".format(short, info["in"], info["out"])
        fc = "#FFF" if cat != "unknown" else "#333"
        px, py = ea_to_pos.get(ea, (0, 0))
        ci = ea_to_comp.get(ea, -1)
        vn.append({
            "id": idx, "label": label, "x": px, "y": py,
            "color": {"background": c["bg"], "border": c["bg"],
                      "highlight": {"background": c["bg"], "border": c["bg"]}},
            "shadow": {"enabled": True, "color": "rgba(0,0,0,0.1)", "size": 8, "x": 0, "y": 3},
            "shape": "dot", "size": sz,
            "font": {"color": fc, "size": 10, "face": "Arial", "multi": True, "align": "center"},
            "borderWidth": 0, "borderWidthSelected": 0,
            "category": cat, "isEntry": info.get("is_entry", False),
            "ea": "0x{:X}".format(ea),
            "fullName": info["name"],
            "outDeg": info["out"], "inDeg": info["in"],
            "totalDeg": info["in"] + info["out"],
            "funcSize": info["size"],
            "catLabel": c["label"], "bgColor": c["bg"], "ftColor": fc,
            "compId": ci,
            "compSize": len(components[ci]) if 0 <= ci < len(components) else 0,
        })

    for i, e in enumerate(edges):
        if e["from"] in ea_to_id and e["to"] in ea_to_id:
            fid, tid = ea_to_id[e["from"]], ea_to_id[e["to"]]
            adj_down[fid].append(tid)
            adj_up[tid].append(fid)
            sc = nodes[e["from"]].get("category", "unknown")
            dc = nodes[e["to"]].get("category", "unknown")
            if sc == "business" and dc == "business":
                ec = "rgba(21,101,192,0.35)"
            elif sc == "non-business" or dc == "non-business":
                ec = "rgba(233,30,99,0.15)"
            else:
                ec = "rgba(80,80,80,0.15)"
            inv_nb = (sc == "non-business" or dc == "non-business")
            ve.append({
                "id": i, "from": fid, "to": tid,
                "arrows": {"to": {"enabled": True, "scaleFactor": 0.5, "type": "arrow"}},
                "color": {"color": ec, "highlight": "#C62828"},
                "width": 1.0,
                "smooth": {"type": "continuous", "roundness": 0.3},
                "selectionWidth": 2,
                "involvesNonBiz": inv_nb,
            })

    adj_dd = {str(k): v for k, v in adj_down.items()}
    adj_ud = {str(k): v for k, v in adj_up.items()}
    txt_esc = json.dumps(txt_content, ensure_ascii=False)

    lc_ids = [ea_to_id[ea] for ea in longest_chain if ea in ea_to_id]
    lc_edge_ids = []
    for i in range(len(lc_ids) - 1):
        for ei, e in enumerate(ve):
            if e["from"] == lc_ids[i] and e["to"] == lc_ids[i + 1]:
                lc_edge_ids.append(e["id"]); break

    biz_n = sum(1 for n in vn if n["category"] == "business")
    nb_n  = sum(1 for n in vn if n["category"] == "non-business")
    unk_n = sum(1 for n in vn if n["category"] == "unknown")

    # ★ HTML模板(新增 "ChainCSV" 导出按钮及对应JS函数)
    page = r'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Call Graph — Business(Blue)/Non-Biz(Pink)/Unknown(Gray)</title>
<script src="https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#FAFAFA;color:#333;font-family:'Segoe UI',Arial,sans-serif;overflow:hidden}
#bar{position:fixed;top:0;left:0;right:0;height:44px;background:#fff;
  border-bottom:1px solid #E0E0E0;display:flex;align-items:center;
  padding:0 12px;gap:5px;z-index:100;box-shadow:0 1px 4px rgba(0,0,0,0.03);overflow-x:auto}
.t{color:#1565C0;font-size:13px;font-weight:bold;white-space:nowrap}
.s{color:#AAA;font-size:9px;white-space:nowrap}
.tag{padding:1px 6px;border-radius:4px;font-size:8px;font-weight:bold;white-space:nowrap}
.tag-biz{background:#E3F2FD;color:#1565C0}
.tag-nb{background:#FCE4EC;color:#C2185B}
.tag-unk{background:#F5F5F5;color:#757575}
#search{background:#F0F0F0;color:#333;border:1px solid #DDD;padding:4px 10px;
  border-radius:6px;width:150px;font-size:10px;flex-shrink:0}
#search:focus{border-color:#1565C0;outline:none;background:#fff}
.b{background:#F0F0F0;color:#555;border:1px solid #DDD;padding:3px 9px;
  border-radius:6px;cursor:pointer;font-size:9px;white-space:nowrap;
  transition:all .12s;flex-shrink:0}
.b:hover{background:#E3F2FD;border-color:#1565C0;color:#1565C0}
.b.red{background:#C62828;border-color:#C62828;color:#fff}
.b.grn{background:#2E7D32;border-color:#2E7D32;color:#fff}
.b.grn:hover{background:#388E3C}
.b.purple{background:#7B1FA2;border-color:#7B1FA2;color:#fff}
.b.purple:hover{background:#9C27B0}
.b.pink{background:#E91E63;border-color:#E91E63;color:#fff}
.b.pink:hover{background:#F06292}
.b.pink.active{background:#880E4F;border-color:#880E4F}
.b.orange{background:#E65100;border-color:#E65100;color:#fff}
.b.orange:hover{background:#F57C00}
.b.teal{background:#00695C;border-color:#00695C;color:#fff}
.b.teal:hover{background:#00897B}
.sep{width:1px;height:20px;background:#E0E0E0;flex-shrink:0}
#graph{position:fixed;top:44px;left:0;right:0;bottom:0;background:#FAFAFA;cursor:grab}
#info{position:fixed;bottom:12px;left:12px;background:#fff;border:1px solid #E0E0E0;
  border-radius:10px;padding:10px 14px;max-width:360px;font-size:9px;
  z-index:99;display:none;line-height:1.8;box-shadow:0 3px 14px rgba(0,0,0,0.05)}
#info .x{position:absolute;top:5px;right:8px;cursor:pointer;color:#ccc;font-size:14px}
#info .x:hover{color:#C62828}
#legend{position:fixed;top:52px;right:12px;background:rgba(255,255,255,0.97);
  border:1px solid #E0E0E0;border-radius:8px;padding:8px 12px;font-size:8px;
  z-index:99;line-height:2;box-shadow:0 2px 8px rgba(0,0,0,0.03)}
.lr{display:flex;align-items:center;gap:6px}
.ld{width:9px;height:9px;border-radius:50%}
#sr{position:fixed;top:44px;left:220px;background:#fff;border:1px solid #E0E0E0;
  border-radius:0 0 8px 8px;max-height:240px;overflow-y:auto;width:240px;
  z-index:101;display:none;box-shadow:0 3px 12px rgba(0,0,0,0.04)}
.si{padding:4px 9px;cursor:pointer;font-size:9px;border-bottom:1px solid #F5F5F5;
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.si:hover{background:#F5F8FF}
.sa{color:#BBB;margin-left:3px}
#back{position:fixed;bottom:12px;right:12px;z-index:99;display:none}
#mode{position:fixed;top:48px;left:50%;transform:translateX(-50%);
  background:#C62828;color:#fff;padding:3px 12px;
  border-radius:0 0 6px 6px;font-size:9px;z-index:99;display:none}
#stats{position:fixed;bottom:12px;left:50%;transform:translateX(-50%);
  background:rgba(255,255,255,0.95);border:1px solid #E0E0E0;
  border-radius:7px;padding:5px 12px;font-size:9px;z-index:99;color:#888;white-space:nowrap}

/* Export Panel */
#expPanel{position:fixed;top:52px;right:160px;background:#fff;
  border:1px solid #E0E0E0;border-radius:8px;padding:12px 16px;
  min-width:320px;z-index:102;display:none;
  box-shadow:0 4px 20px rgba(0,0,0,0.1);font-size:9px}
#expPanel h3{font-size:11px;color:#1565C0;margin-bottom:10px;
  border-bottom:1px solid #EEE;padding-bottom:6px}
#expPanel .ep-row{display:flex;align-items:center;justify-content:space-between;
  padding:4px 0;border-bottom:1px solid #F5F5F5}
#expPanel .ep-row:last-child{border-bottom:none}
#expPanel .ep-label{color:#555;font-size:9px;min-width:80px}
#expPanel .ep-btns{display:flex;gap:4px;flex-wrap:wrap}
#expPanel .ep-stat{display:flex;gap:8px;margin-bottom:8px;
  padding:6px;background:#F8F9FA;border-radius:6px}
#expPanel .ep-stat-item{text-align:center;flex:1}
#expPanel .ep-stat-n{font-size:14px;font-weight:bold}
#expPanel .ep-stat-l{font-size:7px;color:#999}
#expPanel .x{position:absolute;top:8px;right:10px;cursor:pointer;color:#CCC;font-size:14px}
#expPanel .x:hover{color:#C62828}

/* TXT Modal */
#txtModal{position:fixed;top:0;left:0;right:0;bottom:0;
  background:rgba(0,0,0,0.4);z-index:200;display:none;
  justify-content:center;align-items:center}
#txtBox{background:#fff;border-radius:10px;width:84vw;height:84vh;
  display:flex;flex-direction:column;
  box-shadow:0 12px 40px rgba(0,0,0,0.15);overflow:hidden}
#txtHead{display:flex;align-items:center;justify-content:space-between;
  padding:8px 14px;border-bottom:1px solid #EEE;background:#FAFAFA}
#txtHead span{font-size:12px;font-weight:bold}
#txtPre{flex:1;overflow:auto;padding:14px;margin:0;
  font-family:'Consolas','Courier New',monospace;font-size:10px;
  line-height:1.55;color:#333;background:#fff;white-space:pre;tab-size:4}

/* Chain Panel */
#chainPanel{position:fixed;top:52px;left:12px;background:rgba(255,255,255,0.98);
  border:1px solid #E0E0E0;border-radius:8px;padding:10px 14px;
  max-width:360px;max-height:60vh;overflow-y:auto;font-size:9px;
  z-index:99;display:none;line-height:1.8;box-shadow:0 3px 14px rgba(0,0,0,0.08)}
#chainPanel .x{position:absolute;top:5px;right:8px;cursor:pointer;color:#ccc;font-size:14px}
#chainPanel .x:hover{color:#C62828}
.chain-step{padding:2px 4px;margin:1px 0;border-radius:3px;cursor:pointer;transition:background .15s}
.chain-step:hover{background:#F3E5F5}
.chain-step.active{background:#E1BEE7;font-weight:bold}
</style>
</head>
<body>
<div id="bar">
  <span class="t">CallGraph</span>
  <span class="tag tag-biz">B:__BIZ_N__</span>
  <span class="tag tag-nb">NB:__NONBIZ_N__</span>
  <span class="tag tag-unk">U:__UNK_N__</span>
  <span class="s">__TN__n __TE__e __TC__g</span>
  <input id="search" placeholder="Search..." oninput="doSearch(this.value)">
  <div class="sep"></div>
  <button class="b" onclick="fitAll()">Fit</button>
  <button class="b" onclick="resetAll()">All</button>
  <div class="sep"></div>
  <button class="b" onclick="showBySize(1,1)">Iso</button>
  <button class="b" onclick="showBySize(2,5)">S</button>
  <button class="b" onclick="showBySize(6,30)">M</button>
  <button class="b" onclick="showBySize(31,999999)">L</button>
  <div class="sep"></div>
  <button class="b" onclick="showCat('business')" style="color:#1565C0;border-color:#1565C0">Biz</button>
  <button class="b" onclick="showCat('non-business')" style="color:#E91E63;border-color:#E91E63">NonBiz</button>
  <button class="b" onclick="showCat('unknown')" style="color:#9E9E9E;border-color:#9E9E9E">Unk</button>
  <div class="sep"></div>
  <button id="toggleNBBtn" class="b pink" onclick="toggleNonBiz()">Hide NonBiz</button>
  <div class="sep"></div>
  <button class="b" onclick="isolateComp()">Comp</button>
  <button class="b" onclick="isolateChain()">Chain</button>
  <button class="b" onclick="focusCenter()">Center</button>
  <div class="sep"></div>
  <button class="b purple" onclick="showLongestChain()">Longest</button>
  <div class="sep"></div>
  <button class="b grn" onclick="showTxt()">TXT</button>
  <button class="b grn" onclick="dlTxt()">Save</button>
  <button class="b grn" onclick="dlNodeTxt()">Node</button>
  <button class="b" onclick="expPNG()">PNG</button>
  <div class="sep"></div>
  <!-- ★ 新增:调用链CSV导出按钮 -->
  <button class="b teal" onclick="expCallChainCSV()" title="导出调用链CSV(节点/调用者/被调用者)">&#128202; ChainCSV</button>
  <button class="b teal" onclick="expEdgeCSV()" title="导出边关系CSV">&#128279; EdgeCSV</button>
  <div class="sep"></div>
  <button class="b orange" onclick="toggleExpPanel()" title="导出函数分类列表">&#128190; Export</button>
</div>

<!-- Export Panel -->
<div id="expPanel">
  <span class="x" onclick="closeExpPanel()">x</span>
  <h3>&#128190; 函数分类导出</h3>
  <div class="ep-stat">
    <div class="ep-stat-item">
      <div class="ep-stat-n" style="color:#1565C0" id="epBizN">-</div>
      <div class="ep-stat-l">业务函数</div>
    </div>
    <div class="ep-stat-item">
      <div class="ep-stat-n" style="color:#E91E63" id="epNbN">-</div>
      <div class="ep-stat-l">非业务函数</div>
    </div>
    <div class="ep-stat-item">
      <div class="ep-stat-n" style="color:#9E9E9E" id="epUnkN">-</div>
      <div class="ep-stat-l">未知函数</div>
    </div>
    <div class="ep-stat-item">
      <div class="ep-stat-n" style="color:#333" id="epTotalN">-</div>
      <div class="ep-stat-l">合计</div>
    </div>
  </div>
  <div class="ep-row">
    <span class="ep-label">&#128202; CSV(节点)</span>
    <div class="ep-btns">
      <button class="b grn" onclick="expCSV('all')">全部</button>
      <button class="b" style="color:#1565C0;border-color:#1565C0" onclick="expCSV('business')">仅业务</button>
      <button class="b" style="color:#E91E63;border-color:#E91E63" onclick="expCSV('non-business')">仅非业务</button>
    </div>
  </div>
  <div class="ep-row">
    <span class="ep-label">&#x1F517; CSV(调用链)</span>
    <div class="ep-btns">
      <button class="b teal" onclick="expCallChainCSV()">全部调用链</button>
      <button class="b teal" onclick="expEdgeCSV()">边关系</button>
    </div>
  </div>
  <div class="ep-row">
    <span class="ep-label">&#x1F4C4; JSON</span>
    <div class="ep-btns">
      <button class="b grn" onclick="expJSON('all')">全部</button>
      <button class="b" style="color:#1565C0;border-color:#1565C0" onclick="expJSON('business')">仅业务</button>
    </div>
  </div>
  <div class="ep-row">
    <span class="ep-label">&#x1F4DD; TXT</span>
    <div class="ep-btns">
      <button class="b grn" onclick="expClassTxt('all')">全部</button>
      <button class="b" style="color:#1565C0;border-color:#1565C0" onclick="expClassTxt('business')">仅业务</button>
    </div>
  </div>
  <div class="ep-row">
    <span class="ep-label">&#x1F50D; 预览</span>
    <div class="ep-btns">
      <button class="b" onclick="previewClass('all')">全部</button>
      <button class="b" style="color:#1565C0;border-color:#1565C0" onclick="previewClass('business')">业务</button>
      <button class="b" style="color:#E91E63;border-color:#E91E63" onclick="previewClass('non-business')">非业务</button>
      <button class="b" style="color:#9E9E9E;border-color:#9E9E9E" onclick="previewClass('unknown')">未知</button>
    </div>
  </div>
  <div style="margin-top:8px;font-size:7px;color:#BBB">按地址升序排列</div>
</div>

<div id="sr"></div>
<div id="graph"></div>
<div id="info">
  <span class="x" onclick="this.parentElement.style.display='none'">x</span>
  <div id="ic"></div>
</div>
<div id="chainPanel">
  <span class="x" onclick="closeLongestChain()">x</span>
  <div id="chainContent"></div>
</div>
<div id="legend">
  <div class="lr"><span class="ld" style="background:#1565C0"></span>业务函数 Business</div>
  <div class="lr"><span class="ld" style="background:#E91E63"></span>非业务函数 Non-Business</div>
  <div class="lr"><span class="ld" style="background:#9E9E9E"></span>未知函数 Unknown</div>
  <div class="lr"><span class="ld" style="background:#7B1FA2"></span>最长链 Longest Chain</div>
  <div style="margin-top:4px;padding-top:4px;border-top:1px solid #eee;font-size:7px;color:#999">
    圆大小 = log(代码量) × 出度因子
  </div>
</div>
<div id="mode"></div>
<div id="back">
  <button class="b red" style="padding:6px 16px;font-size:10px;border-radius:7px" onclick="resetAll()">Back</button>
</div>
<div id="stats"></div>

<!-- TXT Modal -->
<div id="txtModal" onclick="if(event.target===this)this.style.display='none'">
  <div id="txtBox">
    <div id="txtHead">
      <span id="txtTitle">Report</span>
      <div style="display:flex;gap:5px">
        <button class="b grn" id="txtDlBtn" onclick="dlCurrentTxt()">Save</button>
        <button class="b" onclick="document.getElementById('txtModal').style.display='none'">X</button>
      </div>
    </div>
    <pre id="txtPre"></pre>
  </div>
</div>

<script>
var RN=__NODES__,RE=__EDGES__,AD=__ADJ_DOWN__,AU=__ADJ_UP__,
    COMPS=__COMPS__,TXT=__TXT__,
    LC=__LONGEST_CHAIN__,LCE=__LONGEST_CHAIN_EDGES__;

var nodes=new vis.DataSet(RN),edges=new vis.DataSet(RE);
var ctr=document.getElementById('graph');
var iso=false,chainActive=false,nbHidden=false;
var cn={};
RN.forEach(function(n){var c=n.compId;if(!cn[c])cn[c]=[];cn[c].push(n.id);});
var centerNode=RN.reduce(function(a,b){return(a.totalDeg||0)>=(b.totalDeg||0)?a:b;},RN[0]||{id:0});
var origN={},origE={};
RN.forEach(function(n){
  origN[n.id]={bg:n.color.background,border:n.color.border,
    hlBg:n.color.highlight.background,hlBorder:n.color.highlight.border,
    size:n.size,bw:n.borderWidth||0,cat:n.category};
});
RE.forEach(function(e){origE[e.id]={color:e.color.color,width:e.width,invNB:e.involvesNonBiz};});

var _currentTxtContent='',_currentTxtFilename='export.txt';

var net=new vis.Network(ctr,{nodes:nodes,edges:edges},{
  layout:{randomSeed:42,improvedLayout:false},
  physics:{enabled:false},
  edges:{smooth:{type:'continuous',roundness:0.3},color:{inherit:false},selectionWidth:2},
  nodes:{shadow:true,borderWidth:0,borderWidthSelected:0,chosen:false},
  interaction:{hover:false,tooltipDelay:999999,dragNodes:true,
    dragView:false,zoomView:false,keyboard:false,
    multiselect:false,navigationButtons:false,hoverConnectedEdges:false}
});

var vw={x:0,y:0,s:1},pn={a:false,sx:0,sy:0,vx:0,vy:0};
function sy(){var v=net.getViewPosition();vw.x=v.x;vw.y=v.y;vw.s=net.getScale();}
ctr.addEventListener('mousedown',function(e){
  if(e.button!==0||net.getNodeAt({x:e.offsetX,y:e.offsetY})!==undefined)return;
  sy();pn.a=true;pn.sx=e.clientX;pn.sy=e.clientY;pn.vx=vw.x;pn.vy=vw.y;
  ctr.style.cursor='grabbing';e.preventDefault();},true);
window.addEventListener('mousemove',function(e){if(!pn.a)return;
  vw.x=pn.vx-(e.clientX-pn.sx)/vw.s;vw.y=pn.vy-(e.clientY-pn.sy)/vw.s;
  net.moveTo({position:{x:vw.x,y:vw.y},animation:false});},true);
window.addEventListener('mouseup',function(e){if(!pn.a)return;
  vw.x=pn.vx-(e.clientX-pn.sx)/vw.s;vw.y=pn.vy-(e.clientY-pn.sy)/vw.s;
  net.moveTo({position:{x:vw.x,y:vw.y},animation:false});
  pn.a=false;ctr.style.cursor='grab';},true);
ctr.addEventListener('wheel',function(e){e.preventDefault();sy();
  var f=e.deltaY<0?1.15:1/1.15,ns=Math.min(40,Math.max(0.002,vw.s*f));
  var p=net.DOMtoCanvas({x:e.offsetX,y:e.offsetY}),r=vw.s/ns;
  vw.x=p.x-(p.x-vw.x)*r;vw.y=p.y-(p.y-vw.y)*r;vw.s=ns;
  net.moveTo({position:{x:vw.x,y:vw.y},scale:vw.s,animation:false});
},{passive:false});

setTimeout(function(){
  net.fit({animation:{duration:500}});
  var b=0,nb=0,u=0;
  RN.forEach(function(n){
    if(n.category==='business')b++;
    else if(n.category==='non-business')nb++;
    else u++;
  });
  us('Biz:'+b+' NonBiz:'+nb+' Unk:'+u+' | edges:'+RE.length);
  document.getElementById('epBizN').textContent=b;
  document.getElementById('epNbN').textContent=nb;
  document.getElementById('epUnkN').textContent=u;
  document.getElementById('epTotalN').textContent=RN.length;
},200);

function us(m){var e=document.getElementById('stats');e.textContent=m;e.style.display=m?'block':'none';}

// =====================================================
// ★ 调用链CSV导出(核心新增函数)
// =====================================================
var _CAT_MAP={'business':'业务函数','non-business':'非业务函数','unknown':'未知函数'};
var _CAT_COLOR={'business':'#1565C0','non-business':'#E91E63','unknown':'#9E9E9E'};
var _CAT_SHORT={'business':'B','non-business':'S','unknown':'?'};

/**
 * expCallChainCSV —— 导出调用链CSV
 * 列:node_address, node_name, node_category, node_category_cn,
 *     node_size_b, node_in_degree, node_out_degree,
 *     caller_address, caller_name, caller_category,
 *     callee_address, callee_name, callee_category,
 *     edge_direction
 */
function expCallChainCSV(){
  var rows=[];
  // BOM for Excel UTF-8
  rows.push('\uFEFF'+'node_address,node_name,node_category,node_category_cn,'
    +'node_size_b,node_in_degree,node_out_degree,'
    +'caller_address,caller_name,caller_category,'
    +'callee_address,callee_name,callee_category,'
    +'edge_direction');

  // 构建索引:callers_of[id]=[callerIds...], callees_of[id]=[calleeIds...]
  var callersOf={},calleesOf={};
  RN.forEach(function(n){callersOf[n.id]=[];calleesOf[n.id]=[];});
  RE.forEach(function(e){
    if(calleesOf[e.from])calleesOf[e.from].push(e.to);
    if(callersOf[e.to])callersOf[e.to].push(e.from);
  });

  // 节点按地址排序
  var sorted=RN.slice().sort(function(a,b){
    return parseInt(a.ea,16)-parseInt(b.ea,16);
  });

  sorted.forEach(function(n){
    var cList=callersOf[n.id]||[];
    var eList=calleesOf[n.id]||[];
    var nodeBase=[
      n.ea,
      csvQ(n.fullName),
      n.category,
      _CAT_MAP[n.category]||n.category,
      n.funcSize,
      n.inDeg,
      n.outDeg
    ];

    // 孤立节点
    if(!cList.length&&!eList.length){
      rows.push(nodeBase.concat(['','','','','','','isolated']).join(','));
      return;
    }

    // caller→node 的边
    cList.forEach(function(cid){
      var cn2=nodes.get(cid)||{};
      rows.push(nodeBase.concat([
        cn2.ea||'',
        csvQ(cn2.fullName||''),
        cn2.category||'',
        '','','',
        csvQ((cn2.fullName||'?')+' --> '+n.fullName)
      ]).join(','));
    });

    // node→callee 的边
    eList.forEach(function(tid){
      var tn=nodes.get(tid)||{};
      rows.push(nodeBase.concat([
        '','','',
        tn.ea||'',
        csvQ(tn.fullName||''),
        tn.category||'',
        csvQ(n.fullName+' --> '+(tn.fullName||'?'))
      ]).join(','));
    });
  });

  dl(rows.join('\r\n'),'callchain_node_caller_callee.csv');
  closeExpPanel();
}

/**
 * expEdgeCSV —— 以边为单位导出CSV
 * 每行 = 一条调用关系(caller→callee)
 */
function expEdgeCSV(){
  var rows=[];
  rows.push('\uFEFF'+'edge_id,caller_address,caller_name,caller_category,caller_cat_cn,caller_size_b,'
    +'callee_address,callee_name,callee_category,callee_cat_cn,callee_size_b,edge_type');

  var sortedEdges=RE.slice().sort(function(a,b){return a.id-b.id;});
  sortedEdges.forEach(function(e,i){
    var fn=nodes.get(e.from)||{};
    var tn=nodes.get(e.to)||{};
    var fcat=fn.category||'unknown';
    var tcat=tn.category||'unknown';
    var etype=(_CAT_SHORT[fcat]||'?')+'->'+(_CAT_SHORT[tcat]||'?');
    rows.push([
      i+1,
      fn.ea||'', csvQ(fn.fullName||''), fcat, _CAT_MAP[fcat]||fcat, fn.funcSize||0,
      tn.ea||'', csvQ(tn.fullName||''), tcat, _CAT_MAP[tcat]||tcat, tn.funcSize||0,
      etype
    ].join(','));
  });

  dl(rows.join('\r\n'),'callchain_edges.csv');
  closeExpPanel();
}

/** CSV字段转义:含逗号/引号/换行时加双引号 */
function csvQ(s){
  s=String(s||'');
  if(s.indexOf(',')>=0||s.indexOf('"')>=0||s.indexOf('\n')>=0){
    return '"'+s.replace(/"/g,'""')+'"';
  }
  return s;
}

// =====================================================
// Export Panel
// =====================================================
function toggleExpPanel(){
  var p=document.getElementById('expPanel');
  p.style.display=p.style.display==='none'||!p.style.display?'block':'none';
}
function closeExpPanel(){document.getElementById('expPanel').style.display='none';}
document.addEventListener('click',function(e){
  var panel=document.getElementById('expPanel');
  var btn=document.querySelector('[onclick="toggleExpPanel()"]');
  if(panel.style.display==='block'&&!panel.contains(e.target)&&e.target!==btn)
    panel.style.display='none';
});

function _filterNodes(cat){
  var sorted=RN.slice().sort(function(a,b){return parseInt(a.ea,16)-parseInt(b.ea,16);});
  if(cat==='all')return sorted;
  return sorted.filter(function(n){return n.category===cat;});
}

function expCSV(cat){
  var list=_filterNodes(cat);
  var rows=['\uFEFF'+'Address,Name,Category_EN,Category_CN,Code_Size_B,In_Degree,Out_Degree,Is_Entry,Component_ID,Component_Size'];
  list.forEach(function(n){
    rows.push([n.ea,csvQ(n.fullName),n.category,_CAT_MAP[n.category]||n.category,
      n.funcSize,n.inDeg,n.outDeg,n.isEntry?'true':'false',n.compId,n.compSize].join(','));
  });
  dl(rows.join('\r\n'),'func_classification'+(cat==='all'?'':'_'+cat)+'.csv');
  closeExpPanel();
}

function expJSON(cat){
  var list=_filterNodes(cat);
  var bizN=RN.filter(function(n){return n.category==='business';}).length;
  var nbN=RN.filter(function(n){return n.category==='non-business';}).length;
  var unkN=RN.filter(function(n){return n.category==='unknown';}).length;
  var result={meta:{filter:cat,total:RN.length,exported:list.length,
    business:bizN,nonBusiness:nbN,unknown:unkN,edges:RE.length},
    functions:list.map(function(n){return{address:n.ea,name:n.fullName,
      category:n.category,category_cn:_CAT_MAP[n.category]||n.category,
      code_size_b:n.funcSize,in_degree:n.inDeg,out_degree:n.outDeg,
      is_entry:n.isEntry,component_id:n.compId,component_size:n.compSize};})};
  dl(JSON.stringify(result,null,2),'func_classification'+(cat==='all'?'':'_'+cat)+'.json');
  closeExpPanel();
}

function expClassTxt(cat){
  var list=_filterNodes(cat);var W=80;var L=[];
  var bizN=RN.filter(function(n){return n.category==='business';}).length;
  var nbN=RN.filter(function(n){return n.category==='non-business';}).length;
  var unkN=RN.filter(function(n){return n.category==='unknown';}).length;
  L.push('='.repeat(W));L.push(' 函数分类报告');
  L.push(' 过滤:'+(cat==='all'?'全部':_CAT_MAP[cat]||cat)+' 导出:'+list.length+'/'+RN.length);
  L.push(' 业务:'+bizN+' 非业务:'+nbN+' 未知:'+unkN);
  L.push('='.repeat(W));L.push('');
  var groups={'business':{label:'■ 业务函数',items:[]},'non-business':{label:'■ 非业务函数',items:[]},'unknown':{label:'■ 未知函数',items:[]}};
  list.forEach(function(n){groups[n.category].items.push(n);});
  var order=cat==='all'?['business','non-business','unknown']:[cat];
  order.forEach(function(g){
    var grp=groups[g];if(!grp.items.length)return;
    L.push(grp.label+' ('+grp.items.length+')');L.push('-'.repeat(W));
    L.push(' '+['Address'.padEnd(12),'CodeSize'.padEnd(10),'In'.padEnd(5),'Out'.padEnd(5),'Name'].join(' '));
    grp.items.forEach(function(n){
      L.push(' '+[n.ea.padEnd(12),(n.funcSize+'B').padEnd(10),
        String(n.inDeg).padEnd(5),String(n.outDeg).padEnd(5),n.fullName].join(' '));
    });L.push('');
  });
  L.push('='.repeat(W));
  dl(L.join('\n'),'func_classification'+(cat==='all'?'':'_'+cat)+'.txt');
  closeExpPanel();
}

function previewClass(cat){
  var list=_filterNodes(cat);var W=80;var L=[];
  L.push('='.repeat(W));
  L.push(' 函数分类预览 — '+(cat==='all'?'全部 ('+RN.length+')':(_CAT_MAP[cat]||cat)+' ('+list.length+')'));
  L.push('='.repeat(W));L.push('');
  var TAG={'business':'[B]','non-business':'[S]','unknown':'[?]'};
  list.forEach(function(n,i){
    L.push((i+1+'').padStart(5)+'. '+TAG[n.category]+' '
      +n.ea.padEnd(12)+(n.funcSize+'B').padEnd(10)
      +'in:'+String(n.inDeg).padEnd(4)+'out:'+String(n.outDeg).padEnd(4)+n.fullName);
  });
  L.push('');L.push('='.repeat(W));
  var content=L.join('\n');
  _currentTxtContent=content;
  _currentTxtFilename='func_classification'+(cat==='all'?'':'_'+cat)+'.txt';
  document.getElementById('txtTitle').textContent='预览: '+(cat==='all'?'全部':_CAT_MAP[cat]||cat)+' ('+list.length+'个)';
  document.getElementById('txtPre').textContent=content;
  document.getElementById('txtModal').style.display='flex';
  closeExpPanel();
}

// =====================================================
// Longest Chain
// =====================================================
function showLongestChain(){
  if(!LC||!LC.length){alert('No chain');return;}
  chainActive=true;
  var cs=new Set(LC),ces=new Set(LCE);
  nodes.update(RN.map(function(n){
    if(cs.has(n.id))return{id:n.id,hidden:false,
      color:{background:'#7B1FA2',border:'#4A148C',
        highlight:{background:'#9C27B0',border:'#4A148C'}},
      borderWidth:3,size:Math.max((origN[n.id]||{}).size||12,20),
      shadow:{enabled:true,color:'rgba(123,31,162,0.3)',size:15,x:0,y:0}};
    return{id:n.id,hidden:false,
      color:{background:'rgba(200,200,200,0.3)',border:'rgba(200,200,200,0.3)',
        highlight:{background:'rgba(200,200,200,0.5)',border:'rgba(200,200,200,0.5)'}},
      borderWidth:0,size:Math.max(((origN[n.id]||{}).size||12)*0.6,6),
      shadow:{enabled:false}};}));
  edges.update(RE.map(function(e){
    if(ces.has(e.id))return{id:e.id,hidden:false,
      color:{color:'#7B1FA2',highlight:'#4A148C'},width:3.5,
      arrows:{to:{enabled:true,scaleFactor:0.7,type:'arrow'}}};
    return{id:e.id,hidden:false,color:{color:'rgba(200,200,200,0.08)'},width:0.5};}));
  buildChainPanel();
  if(LC.length)net.fit({nodes:LC,animation:{duration:600}});
  iso=true;sb();sm('Chain ('+LC.length+' nodes)');
}

function buildChainPanel(){
  var html='<div style="font-weight:bold;color:#7B1FA2;margin-bottom:6px;font-size:11px">Longest Chain ('+LC.length+')</div>';
  html+='<div style="color:#999;font-size:8px;margin-bottom:8px">Click to focus</div>';
  LC.forEach(function(nid,idx){
    var nd=nodes.get(nid);if(!nd)return;
    var cc=_CAT_COLOR[nd.category]||'#999';
    var sl=idx===0?'START':idx===LC.length-1?'END':'#'+idx;
    var sc=idx===0?'#4CAF50':idx===LC.length-1?'#F44336':'#7B1FA2';
    html+='<div class="chain-step" id="cs_'+idx+'" onclick="fcn('+nid+','+idx+')">';
    html+='<span style="color:'+sc+';font-weight:bold;font-size:8px">['+sl+']</span> ';
    html+='<span style="display:inline-block;width:6px;height:6px;border-radius:50%;background:'+cc+';margin-right:3px"></span>';
    html+=esc(nd.fullName)+' <span style="color:#BBB;font-size:7px">'+nd.ea+'</span>';
    html+='</div>';
    if(idx<LC.length-1)html+='<div style="text-align:center;color:#7B1FA2">↓</div>';
  });
  html+='<div style="margin-top:8px;padding-top:6px;border-top:1px solid #EEE"><button class="b" onclick="closeLongestChain()">Close</button></div>';
  document.getElementById('chainContent').innerHTML=html;
  document.getElementById('chainPanel').style.display='block';
}

function fcn(nid,idx){
  net.focus(nid,{scale:2.0,animation:{duration:400}});
  net.selectNodes([nid]);showInfo(nid);
  document.querySelectorAll('.chain-step').forEach(function(el){el.classList.remove('active');});
  var el=document.getElementById('cs_'+idx);if(el)el.classList.add('active');
}

function closeLongestChain(){
  chainActive=false;
  document.getElementById('chainPanel').style.display='none';
  nodes.update(RN.map(function(n){
    var oc=origN[n.id]||{};
    return{id:n.id,hidden:false,
      color:{background:oc.bg,border:oc.border,
        highlight:{background:oc.hlBg||oc.bg,border:oc.hlBorder||oc.border}},
      borderWidth:oc.bw||0,size:oc.size||12,
      shadow:{enabled:true,color:'rgba(0,0,0,0.1)',size:8,x:0,y:3}};}));
  edges.update(RE.map(function(e){
    var oe=origE[e.id]||{};
    return{id:e.id,hidden:false,color:{color:oe.color,highlight:'#C62828'},width:oe.width||1};}));
  iso=false;nbHidden=false;
  var btn=document.getElementById('toggleNBBtn');
  btn.textContent='Hide NonBiz';btn.classList.remove('active');
  ['back','mode'].forEach(function(x){document.getElementById(x).style.display='none';});
  net.fit({animation:{duration:400}});
}

// =====================================================
// 视图控制
// =====================================================
function showCat(cat){
  if(chainActive)closeLongestChain();
  var k=new Set();RN.forEach(function(n){if(n.category===cat)k.add(n.id);});
  af(k);iso=true;sb();sm(cat+' ('+k.size+'n)');
  setTimeout(function(){net.fit({animation:{duration:300}});},100);
}
function showBySize(lo,hi){
  if(chainActive)closeLongestChain();
  var k=new Set();
  COMPS.forEach(function(c){if(c.size>=lo&&c.size<=hi)(cn[c.id]||[]).forEach(function(n){k.add(n);});});
  af(k);iso=true;sb();sm('size '+lo+'-'+hi+' ('+k.size+'n)');
  setTimeout(function(){net.fit({animation:{duration:300}});},100);
}
function isolateComp(){
  var s=net.getSelectedNodes();if(!s.length){alert('Select node');return;}
  if(chainActive)closeLongestChain();
  var n=nodes.get(s[0]);if(!n)return;
  var k=new Set(cn[n.compId]||[]);
  af(k);iso=true;sb();sm('comp#'+n.compId+' ('+k.size+'n)');
  setTimeout(function(){net.fit({animation:{duration:300}});},100);
}
function isolateChain(){
  var s=net.getSelectedNodes();if(!s.length){alert('Select node');return;}
  if(chainActive)closeLongestChain();
  var k=ta(s[0],500);af(k);iso=true;sb();
  var n=nodes.get(s[0]);sm('chain:'+(n?n.fullName:'')+' ('+k.size+'n)');
  setTimeout(function(){net.fit({animation:{duration:300}});},100);
}
function focusCenter(){
  if(chainActive)closeLongestChain();
  if(centerNode&&centerNode.id!==undefined){
    net.focus(centerNode.id,{scale:1.5,animation:{duration:600}});
    net.selectNodes([centerNode.id]);showInfo(centerNode.id);
  }
}
function af(k){
  nodes.update(RN.map(function(n){return{id:n.id,hidden:!k.has(n.id)};}));
  edges.update(RE.map(function(e){return{id:e.id,hidden:!(k.has(e.from)&&k.has(e.to))};}));
}
function sb(){document.getElementById('back').style.display='block';}
function sm(t){var m=document.getElementById('mode');m.textContent=t;m.style.display='block';}
function resetAll(){
  if(chainActive){closeLongestChain();return;}
  nbHidden=false;
  var btn=document.getElementById('toggleNBBtn');
  btn.textContent='Hide NonBiz';btn.classList.remove('active');
  nodes.update(RN.map(function(n){
    var oc=origN[n.id]||{};
    return{id:n.id,hidden:false,
      color:{background:oc.bg,border:oc.border,
        highlight:{background:oc.hlBg||oc.bg,border:oc.hlBorder||oc.border}},
      borderWidth:oc.bw||0,size:oc.size||12,
      shadow:{enabled:true,color:'rgba(0,0,0,0.1)',size:8,x:0,y:3}};}));
  edges.update(RE.map(function(e){
    var oe=origE[e.id]||{};
    return{id:e.id,hidden:false,color:{color:oe.color,highlight:'#C62828'},width:oe.width||1};}));
  net.unselectAll();iso=false;
  ['back','info','mode','chainPanel'].forEach(function(x){document.getElementById(x).style.display='none';});
  net.fit({animation:{duration:400}});
}
function fitAll(){net.fit({animation:{duration:400}});}
function toggleNonBiz(){
  var btn=document.getElementById('toggleNBBtn');
  if(chainActive)closeLongestChain();
  nbHidden=!nbHidden;
  if(nbHidden){
    btn.textContent='Show NonBiz';btn.classList.add('active');
    nodes.update(RN.map(function(n){return{id:n.id,hidden:n.category==='non-business'};}));
    edges.update(RE.map(function(e){return{id:e.id,hidden:e.involvesNonBiz};}));
    iso=true;sb();sm('Non-business hidden');
  }else{btn.textContent='Hide NonBiz';btn.classList.remove('active');resetAll();return;}
  setTimeout(function(){net.fit({animation:{duration:300}});},100);
}

// =====================================================
// 图遍历辅助
// =====================================================
function td(id,l){var s=new Set([id]),q=[id];
  while(q.length){var c=q.shift();(AD[c]||[]).forEach(function(n){if(!s.has(n)){s.add(n);if(s.size<l)q.push(n);}});}return s;}
function tu(id,l){var s=new Set([id]),q=[id];
  while(q.length){var c=q.shift();(AU[c]||[]).forEach(function(n){if(!s.has(n)){s.add(n);if(s.size<l)q.push(n);}});}return s;}
function ta(id,l){var s=tu(id,l);td(id,l).forEach(function(v){s.add(v);});return s;}

// =====================================================
// 节点信息
// =====================================================
function showInfo(id){
  var n=nodes.get(id);if(!n)return;
  var cc=_CAT_COLOR[n.category]||'#999';
  var isC=(centerNode&&n.id===centerNode.id)?' <b style="color:#C62828">[CENTER]</b>':'';
  var ci=LC.indexOf(n.id);
  var isL=ci>=0?' <b style="color:#7B1FA2">[chain#'+ci+']</b>':'';
  var badge='<span style="background:'+cc+';color:#fff;padding:1px 5px;border-radius:3px;font-size:8px">'+n.catLabel+'</span>';
  document.getElementById('ic').innerHTML=
    "<div style='font-family:monospace;line-height:1.8'>"
    +"<b style='color:"+cc+"'>"+esc(n.fullName)+"</b> "+badge+isC+isL+"<br>"
    +"<span style='color:#999'>addr</span> "+n.ea+" <span style='color:#999'>size</span> "+n.funcSize+"B<br>"
    +"<span style='color:#999'>comp#</span>"+n.compId+" ("+n.compSize+"n)<br>"
    +"<span style='color:#999'>in</span><b style='color:"+cc+"'>"+n.inDeg+"</b>"
    +" <span style='color:#999'>out</span><b style='color:"+cc+"'>"+n.outDeg+"</b>"
    +" <span style='color:#999'>deg</span><b style='color:"+cc+"'>"+(n.totalDeg||0)+"</b>"
    +"<br><span style='color:#999'>circle</span> "+Math.round(n.size)+"px"
    +"</div>";
  document.getElementById('info').style.display='block';
}
net.on('click',function(p){if(p.nodes.length)showInfo(p.nodes[0]);});
net.on('doubleClick',function(p){
  if(!p.nodes.length)return;
  var n=nodes.get(p.nodes[0]);if(!n)return;
  if(chainActive)closeLongestChain();
  var k=new Set(cn[n.compId]||[]);
  af(k);iso=true;sb();sm('comp#'+n.compId+' ('+k.size+'n)');
  net.selectNodes([p.nodes[0]]);
  setTimeout(function(){net.fit({animation:{duration:300}});},100);
});

// =====================================================
// 搜索
// =====================================================
function doSearch(q){
  var box=document.getElementById('sr');
  if(!q||q.length<2){box.style.display='none';return;}
  q=q.toLowerCase();
  var m=RN.filter(function(n){
    return n.fullName.toLowerCase().indexOf(q)>=0||n.ea.toLowerCase().indexOf(q)>=0;
  }).slice(0,25);
  if(!m.length){box.style.display='none';return;}
  box.innerHTML='';
  m.forEach(function(n){
    var d=document.createElement('div');d.className='si';
    var cc=_CAT_COLOR[n.category]||'#999';
    d.innerHTML='<span style="color:'+cc+'">●</span> '+esc(n.fullName)
      +' <span class="sa">'+n.ea+'</span>'
      +' <span style="color:'+cc+';font-size:7px">'+n.catLabel+'</span>';
    d.onclick=function(){
      box.style.display='none';document.getElementById('search').value='';
      if(chainActive)closeLongestChain();
      if(iso)resetAll();
      setTimeout(function(){net.focus(n.id,{scale:1.8,animation:true});
        net.selectNodes([n.id]);showInfo(n.id);},iso?400:50);
    };
    box.appendChild(d);
  });
  box.style.display='block';
}
document.addEventListener('click',function(e){
  if(e.target.id!=='search')document.getElementById('sr').style.display='none';
});

// =====================================================
// TXT 导出
// =====================================================
function buildNodeTxt(){
  var sel=net.getSelectedNodes();if(!sel.length)return null;
  var nid=sel[0],nd=nodes.get(nid);if(!nd)return null;
  var W=80,L=[];
  L.push('='.repeat(W));L.push(' '+nd.fullName+' ['+nd.catLabel+']');
  L.push(' '+nd.ea+' '+nd.catLabel+' '+nd.funcSize+'B comp#'+nd.compId);
  L.push('='.repeat(W));L.push('');
  var up=AU[nid]||[],dn=AD[nid]||[];
  L.push('CALLERS ('+up.length+')');L.push('-'.repeat(W));
  if(!up.length)L.push(' (none)');
  up.forEach(function(c){var n2=nodes.get(c);if(n2)L.push(' ['+n2.catLabel+'] '+n2.fullName+' --> '+nd.fullName);});
  L.push('');L.push('CALLEES ('+dn.length+')');L.push('-'.repeat(W));
  if(!dn.length)L.push(' (none)');
  dn.forEach(function(c){var n2=nodes.get(c);if(n2)L.push(' '+nd.fullName+' --> ['+n2.catLabel+'] '+n2.fullName);});
  L.push('');L.push('='.repeat(W));
  return L.join('\n');
}
function showTxt(){
  _currentTxtContent=TXT;_currentTxtFilename='full_callgraph.txt';
  document.getElementById('txtTitle').textContent='Full Call Graph Report';
  document.getElementById('txtPre').textContent=TXT;
  document.getElementById('txtModal').style.display='flex';
}
function dlTxt(){dl(TXT,'full_callgraph.txt');}
function dlNodeTxt(){
  var t=buildNodeTxt();if(!t){alert('Select node');return;}
  var nd=nodes.get(net.getSelectedNodes()[0]);
  _currentTxtContent=t;_currentTxtFilename=(nd?nd.fullName:'node')+'.txt';
  document.getElementById('txtTitle').textContent=nd?nd.fullName:'Node Report';
  document.getElementById('txtPre').textContent=t;
  document.getElementById('txtModal').style.display='flex';
}
function dlCurrentTxt(){
  if(_currentTxtContent)dl(_currentTxtContent,_currentTxtFilename);
  else dl(TXT,'full_callgraph.txt');
}

// =====================================================
// 通用工具
// =====================================================
function dl(t,n){
  var b=new Blob([t],{type:'text/plain;charset=utf-8'}),a=document.createElement('a');
  a.download=n;a.href=URL.createObjectURL(b);a.click();URL.revokeObjectURL(a.href);
}
function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML;}
function expPNG(){
  var c=ctr.getElementsByTagName('canvas')[0];if(!c)return;
  var a=document.createElement('a');a.download='callgraph.png';
  a.href=c.toDataURL('image/png');a.click();
}
</script>
</body>
</html>'''

    page = page.replace('__NODES__',    json.dumps(vn, ensure_ascii=False))
    page = page.replace('__EDGES__',    json.dumps(ve, ensure_ascii=False))
    page = page.replace('__ADJ_DOWN__', json.dumps(adj_dd, ensure_ascii=False))
    page = page.replace('__ADJ_UP__',   json.dumps(adj_ud, ensure_ascii=False))
    page = page.replace('__COMPS__',    json.dumps(comp_info, ensure_ascii=False))
    page = page.replace('__TXT__',      txt_esc)
    page = page.replace('__LONGEST_CHAIN__',       json.dumps(lc_ids, ensure_ascii=False))
    page = page.replace('__LONGEST_CHAIN_EDGES__', json.dumps(lc_edge_ids, ensure_ascii=False))
    page = page.replace('__TN__',      str(len(vn)))
    page = page.replace('__TE__',      str(len(ve)))
    page = page.replace('__TC__',      str(len(components)))
    page = page.replace('__BIZ_N__',   str(biz_n))
    page = page.replace('__NONBIZ_N__',str(nb_n))
    page = page.replace('__UNK_N__',   str(unk_n))

    with open(output_path, 'w', encoding='utf-8') as f:
        f.write(page)
    print("[+] HTML: {}".format(output_path))

# =====================================================
# DOT / ASCII 导出
# =====================================================
def generate_dot(nodes, edges):
    tc = {"business": ("#1565C0","#FFFFFF"),
          "non-business": ("#E91E63","#FFFFFF"),
          "unknown": ("#9E9E9E","#333333")}
    L = ['digraph FullCallGraph {',
         '  rankdir=LR;',
         '  node [shape=box,style="rounded,filled",fontsize=10];', '']
    for ea, info in sorted(nodes.items(), key=lambda x: x[1]["name"]):
        sn = info["name"].replace('"', '\\"')
        fill, fc = tc.get(info.get("category","unknown"), tc["unknown"])
        L.append('  "{}{:X}" [label="{}",fillcolor="{}",fontcolor="{}"];'.format(
            sn, ea, sn, fill, fc))
    L.append('')
    for e in edges:
        s = nodes[e["from"]]; d = nodes[e["to"]]
        si = '{}{:X}'.format(s["name"].replace('"','\\"'), e["from"])
        di = '{}{:X}'.format(d["name"].replace('"','\\"'), e["to"])
        L.append('  "{}" -> "{}";'.format(si, di))
    L.append('}')
    return '\n'.join(L)

def generate_fallback_ascii(nodes, edges):
    call_map = defaultdict(list)
    called_by = defaultdict(list)
    for e in edges:
        call_map[e["from"]].append(e["to"])
        called_by[e["to"]].append(e["from"])
    W = 90
    L = ["+{}+".format("="*(W-2)),
         "|{:^{w}}|".format("FULL FUNCTION CALL GRAPH", w=W-2),
         "|{:^{w}}|".format("{} nodes, {} edges".format(len(nodes),len(edges)), w=W-2),
         "+{}+".format("="*(W-2)), ""]
    roots = sorted(
        [ea for ea in nodes if not called_by.get(ea)],
        key=lambda x: (-nodes[x].get("out",0), nodes[x]["name"]))
    if not roots:
        roots = sorted(nodes.keys(), key=lambda x: (-_degree(x,nodes), nodes[x]["name"]))[:3]
    visited = set(); layers = []; current = list(roots)
    while current:
        layer = [ea for ea in current if ea not in visited]
        for ea in layer: visited.add(ea)
        if not layer: break
        layers.append(layer)
        nxt = []
        for ea in layer:
            for t in call_map.get(ea,[]):
                if t not in visited: nxt.append(t)
        current = nxt
    remaining = [ea for ea in nodes if ea not in visited]
    if remaining: layers.append(remaining)
    tags = {"business":"[B]","non-business":"[S]","unknown":"[?]"}
    for li, layer in enumerate(layers):
        lbl = "ROOTS" if li==0 else "Layer {}".format(li)
        L.extend(["", " +-- {} --+".format(lbl)])
        for ea in sorted(layer, key=lambda x: nodes[x]["name"]):
            info = nodes[ea]
            callees = [nodes[t]["name"] for t in call_map.get(ea,[]) if t in nodes]
            L.append(" | {} {} in:{} out:{} 0x{:X}".format(
                tags.get(info.get("category","unknown"),"[?]"),
                info["name"][:40], info["in"], info["out"], ea))
            for ce in callees[:5]:
                L.append(" |   --> {}".format(ce[:35]))
            if len(callees)>5: L.append(" |   ... (+{} more)".format(len(callees)-5))
    L.extend(["", "+{}+".format("="*(W-2))])
    return "\n".join(L)

def export_ascii_via_graph_easy(nodes, edges, dot_path, ascii_path):
    dot_content = generate_dot(nodes, edges)
    with open(dot_path, 'w', encoding='utf-8') as f:
        f.write(dot_content)
    print("[+] DOT: {}".format(dot_path))
    ascii_text = None
    try:
        r = subprocess.run(['graph-easy','--from=dot','--as=ascii'],
            input=dot_content, capture_output=True, text=True, timeout=120)
        if r.returncode==0 and r.stdout.strip():
            ascii_text = r.stdout
            print("[+] graph-easy OK")
        else:
            print("[!] graph-easy error:", r.stderr[:200] if r.stderr else "")
    except FileNotFoundError:
        print("[!] graph-easy not installed")
    except subprocess.TimeoutExpired:
        print("[!] graph-easy timeout")
    except Exception as ex:
        print("[!] graph-easy:", ex)
    if ascii_text is None:
        print("[*] Fallback ASCII renderer...")
        ascii_text = generate_fallback_ascii(nodes, edges)
    with open(ascii_path, 'w', encoding='utf-8') as f:
        f.write(ascii_text)
    print("[+] ASCII: {}".format(ascii_path))
    return ascii_text

# =====================================================
# 主流程
# =====================================================
print("")
print("=" * 60)
print("  IDA Call Graph — Business / Non-Business / Unknown")
print("  + Lib-Callee Propagation  + CallChain CSV Export")
print("=" * 60)
print("")

_func_categories, _entry_points = scan_and_classify_all()

if not _func_categories:
    print("[!] No functions found")
else:
    _nodes, _edges = build_full_call_graph(_func_categories, _entry_points)

    if not _nodes:
        print("[!] No nodes")
    else:
        _inp  = ida_nalt.get_input_file_path() or ""
        _dir  = os.path.dirname(_inp) if _inp else os.path.expanduser("~")
        _bin  = ida_nalt.get_root_filename() or "binary"
        _base = os.path.splitext(_bin)[0]

        W = 88
        print("\n" + "="*W)
        print(" FULL GRAPH — {} nodes, {} edges".format(len(_nodes), len(_edges)))
        print("="*W)
        _cc = defaultdict(int)
        for info in _nodes.values(): _cc[info["category"]] += 1
        print(" Business    : {}".format(_cc.get("business",0)))
        print(" Non-business: {}".format(_cc.get("non-business",0)))
        print(" Unknown     : {}".format(_cc.get("unknown",0)))
        print("="*W)

        print("\n[*] Finding components...")
        _comps = find_components(_nodes, _edges)
        print("[*] {} components".format(len(_comps)))

        print("[*] Longest chain...")
        _lc = find_longest_chain(_nodes, _edges)

        # 文本报告
        _txt_path = os.path.join(_dir, "{}_full_callgraph.txt".format(_base))
        _txt = generate_text(_nodes, _edges, _comps, _lc, _txt_path)

        # HTML 可视化
        _html_path = os.path.join(_dir, "{}_full_callgraph.html".format(_base))
        generate_html(_nodes, _edges, _comps, _txt, _lc, _html_path)

        # DOT / ASCII
        _dot_path   = os.path.join(_dir, "{}_full_callgraph.dot".format(_base))
        _ascii_path = os.path.join(_dir, "{}_full_callgraph_ascii.txt".format(_base))
        export_ascii_via_graph_easy(_nodes, _edges, _dot_path, _ascii_path)

        # ★ 新增:调用链CSV导出(三种格式)
        # 1. 节点为主视角(节点函数 + 调用者 + 被调用者)
        _cc_csv_path = os.path.join(_dir, "{}_callchain_node_caller_callee.csv".format(_base))
        generate_callchain_csv(_nodes, _edges, _cc_csv_path)

        # 2. 边为主视角(每条调用关系一行)
        _edge_csv_path = os.path.join(_dir, "{}_callchain_edges.csv".format(_base))
        generate_edge_csv(_nodes, _edges, _edge_csv_path)

        # 3. 链路径序列(枚举所有路径,每步一行)
        _seq_csv_path = os.path.join(_dir, "{}_callchain_sequence.csv".format(_base))
        generate_chain_sequence_csv(_nodes, _edges, _seq_csv_path, max_chains=10000)

        print("\n" + "="*60)
        print("  OUTPUT FILES")
        print("="*60)
        print("  TXT        : {}".format(_txt_path))
        print("  HTML       : {}".format(_html_path))
        print("  DOT        : {}".format(_dot_path))
        print("  ASCII      : {}".format(_ascii_path))
        print("  ★ CSV(节点) : {}".format(_cc_csv_path))
        print("  ★ CSV(边)   : {}".format(_edge_csv_path))
        print("  ★ CSV(链序) : {}".format(_seq_csv_path))
        print("="*60)

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 只导出指定函数的

# -*- coding: utf-8 -*-
"""
IDAPython 完整调用链分析工具(双向:调用者 + 被调用者)
功能:输入函数名称,递归展开调用链,打印并导出
支持:Thunk函数透明化、递归检测、多种导出格式、GUI输入、双向分析
"""

import idautils
import idaapi
import ida_name
import ida_kernwin
import idc
import os
import json
from collections import deque

# ============================================================
# 方向常量
# ============================================================
DIR_CALLEES = 0   # 被调用者:这个函数调用了谁
DIR_CALLERS = 1   # 调用者:谁调用了这个函数
DIR_BOTH    = 2   # 双向

DIR_LABELS = {
    DIR_CALLEES: "Callees (who does it call)",
    DIR_CALLERS: "Callers (who calls it)",
    DIR_BOTH:    "Both directions",
}

# ============================================================
# 核心工具函数
# ============================================================

def get_func_name_clean(ea):
    """获取函数名称(兼容新旧版本IDA)"""
    name = idc.get_func_name(ea)
    if not name:
        try:
            name = ida_name.get_name(ea, ida_name.GN_VISIBLE)
        except TypeError:
            name = ida_name.get_name(ea)
    return name if name else "sub_%X" % ea


def is_thunk(ea):
    """判断是否为thunk函数"""
    func = idaapi.get_func(ea)
    if not func:
        return False
    return bool(func.flags & idaapi.FUNC_THUNK)


def resolve_thunk(ea, depth=0):
    """
    解析thunk函数,返回最终目标函数的(地址, 名称)。
    支持多级thunk链,depth防止无限递归。
    """
    if depth > 10:
        return ea, get_func_name_clean(ea)

    func = idaapi.get_func(ea)
    if not func:
        return ea, get_func_name_clean(ea)

    if not (func.flags & idaapi.FUNC_THUNK):
        return ea, get_func_name_clean(ea)

    current = func.start_ea
    while current != idaapi.BADADDR and current < func.end_ea:
        mnem = idc.print_insn_mnem(current)
        if mnem and mnem.lower() in ('jmp', 'b', 'br', 'bx'):
            target = idc.get_operand_value(current, 0)
            if target and target != idaapi.BADADDR and target != ea:
                return resolve_thunk(target, depth + 1)
        current = idc.next_head(current, func.end_ea)

    for xref in idautils.XrefsFrom(func.start_ea, 0):
        if xref.type in (idaapi.fl_JN, idaapi.fl_JF, idaapi.fl_CF, idaapi.fl_CN):
            if xref.to != ea and xref.to != idaapi.BADADDR:
                return resolve_thunk(xref.to, depth + 1)

    return ea, get_func_name_clean(ea)


def is_library_or_external(ea):
    """判断是否为外部/库函数"""
    func = idaapi.get_func(ea)
    if not func:
        return True
    seg = idaapi.getseg(ea)
    if seg:
        seg_name = idaapi.get_segm_name(seg)
        if seg_name and seg_name.lower() in (
                '.idata', '.plt', 'extern', '.got', '.got.plt'):
            return True
        if seg.type == idaapi.SEG_XTRN:
            return True
    return False


# ============================================================
# 被调用者分析引擎 (Callee)
# ============================================================

def get_direct_callees(func_ea):
    """获取func_ea直接调用的所有函数地址集合"""
    callees = set()
    func = idaapi.get_func(func_ea)
    if not func:
        return callees

    current = func.start_ea
    while current != idaapi.BADADDR and current < func.end_ea:
        for xref in idautils.XrefsFrom(current, 0):
            if xref.type in (idaapi.fl_CF, idaapi.fl_CN):
                target_ea = xref.to
                if target_ea == idaapi.BADADDR:
                    continue
                resolved_ea, _ = resolve_thunk(target_ea)
                callees.add(resolved_ea)
        current = idc.next_head(current, func.end_ea)

    return callees


def build_callee_graph(func_ea, max_depth=0):
    """
    BFS构建被调用者图。
    返回: (graph, all_nodes, depth_map)
        graph:  {caller_ea: set(callee_ea, ...)}
        箭头含义: caller -> callee  (A调用B)
    """
    graph = {}
    all_nodes = set()
    depth_map = {}
    queue = deque()

    all_nodes.add(func_ea)
    queue.append((func_ea, 0))
    depth_map[func_ea] = 0

    visited_for_expansion = set()

    while queue:
        current_ea, depth = queue.popleft()

        if current_ea in visited_for_expansion:
            continue
        visited_for_expansion.add(current_ea)

        if max_depth > 0 and depth >= max_depth:
            continue

        if is_library_or_external(current_ea):
            continue

        callees = get_direct_callees(current_ea)
        if not callees:
            continue

        resolved_callees = set()
        for c_ea in callees:
            resolved_ea, _ = resolve_thunk(c_ea)
            resolved_callees.add(resolved_ea)
            all_nodes.add(resolved_ea)

            if resolved_ea not in depth_map:
                depth_map[resolved_ea] = depth + 1

            if resolved_ea not in visited_for_expansion:
                queue.append((resolved_ea, depth + 1))

        if resolved_callees:
            graph[current_ea] = resolved_callees

    return graph, all_nodes, depth_map


# ============================================================
# 调用者分析引擎 (Caller)
# ============================================================

def get_direct_callers(func_ea):
    """获取直接调用func_ea的所有函数地址集合"""
    callers = set()
    func = idaapi.get_func(func_ea)

    # 收集所有对该函数的代码交叉引用(被调用)
    target_addrs = set()
    target_addrs.add(func_ea)
    if func:
        target_addrs.add(func.start_ea)

    for target in target_addrs:
        for xref in idautils.XrefsTo(target, 0):
            if xref.type in (idaapi.fl_CF, idaapi.fl_CN):
                caller_func = idaapi.get_func(xref.frm)
                if caller_func:
                    resolved_ea, _ = resolve_thunk(caller_func.start_ea)
                    callers.add(resolved_ea)

    # 如果自身是thunk的目标,也检查thunk的调用者
    for xref in idautils.XrefsTo(func_ea, 0):
        if xref.type in (idaapi.fl_JN, idaapi.fl_JF):
            thunk_func = idaapi.get_func(xref.frm)
            if thunk_func and is_thunk(thunk_func.start_ea):
                # 递归获取thunk的调用者
                thunk_callers = get_direct_callers(thunk_func.start_ea)
                callers.update(thunk_callers)

    # 排除自身
    callers.discard(func_ea)
    return callers


def build_caller_graph(func_ea, max_depth=0):
    """
    BFS构建调用者图。
    返回: (graph, all_nodes, depth_map)
        graph:  {callee_ea: set(caller_ea, ...)}
        箭头含义: caller -> callee  (即graph[被调用函数] = {调用它的函数们})
        注意:为了统一输出,graph存储方式为 {node: set(parents)}
    """
    graph = {}
    all_nodes = set()
    depth_map = {}
    queue = deque()

    all_nodes.add(func_ea)
    queue.append((func_ea, 0))
    depth_map[func_ea] = 0

    visited_for_expansion = set()

    while queue:
        current_ea, depth = queue.popleft()

        if current_ea in visited_for_expansion:
            continue
        visited_for_expansion.add(current_ea)

        if max_depth > 0 and depth >= max_depth:
            continue

        callers = get_direct_callers(current_ea)
        if not callers:
            continue

        resolved_callers = set()
        for c_ea in callers:
            resolved_ea, _ = resolve_thunk(c_ea)
            resolved_callers.add(resolved_ea)
            all_nodes.add(resolved_ea)

            if resolved_ea not in depth_map:
                depth_map[resolved_ea] = depth + 1

            if resolved_ea not in visited_for_expansion:
                queue.append((resolved_ea, depth + 1))

        if resolved_callers:
            graph[current_ea] = resolved_callers

    return graph, all_nodes, depth_map


# ============================================================
# 输出格式化(通用,支持方向参数)
# ============================================================

def _dir_label(direction):
    """返回方向描述字符串"""
    if direction == DIR_CALLERS:
        return "callers"
    return "callees"


def _edge_label(direction):
    """返回边关系描述"""
    if direction == DIR_CALLERS:
        return "called by"
    return "calls"


def _child_label(direction):
    """返回子节点含义"""
    if direction == DIR_CALLERS:
        return "callers"
    return "calls"


def format_tree(func_ea, graph, direction=DIR_CALLEES):
    """生成树形文本"""
    lines = []
    root_name = get_func_name_clean(func_ea)
    children = graph.get(func_ea, set())
    is_ext = is_library_or_external(func_ea)
    ext_tag = " [EXT]" if is_ext else ""
    child_lbl = _child_label(direction)
    lines.append("%s (0x%X) [%s %d]%s" % (
        root_name, func_ea, child_lbl, len(children), ext_tag))

    printed = set()

    def _walk(node_ea, chain, is_last_list):
        chain = chain | {node_ea}
        children = sorted(graph.get(node_ea, set()),
                          key=lambda ea: get_func_name_clean(ea))

        for i, c_ea in enumerate(children):
            is_last = (i == len(children) - 1)
            c_name = get_func_name_clean(c_ea)
            c_children = graph.get(c_ea, set())
            c_is_ext = is_library_or_external(c_ea)

            prefix = ""
            for _, last in enumerate(is_last_list):
                prefix += "    " if last else ""
            connector = "└── " if is_last else "├── "

            ext_tag = " [EXT]" if c_is_ext else ""

            if c_ea in chain:
                lines.append("%s%s%s (0x%X) ◄ RECURSIVE%s" % (
                    prefix, connector, c_name, c_ea, ext_tag))
                continue

            if c_ea in printed and c_children:
                lines.append("%s%s%s (0x%X) [%s %d] ◄ see above%s" % (
                    prefix, connector, c_name, c_ea,
                    child_lbl, len(c_children), ext_tag))
                continue

            lines.append("%s%s%s (0x%X) [%s %d]%s" % (
                prefix, connector, c_name, c_ea,
                child_lbl, len(c_children), ext_tag))
            printed.add(c_ea)

            if c_children:
                _walk(c_ea, chain, is_last_list + [is_last])

    _walk(func_ea, {func_ea}, [])
    return "\n".join(lines)


def format_flat_bfs(func_ea, graph, all_nodes, direction=DIR_CALLEES):
    """生成BFS扁平文本"""
    lines = []
    root_name = get_func_name_clean(func_ea)
    edge_lbl = _edge_label(direction)
    dir_lbl = _dir_label(direction)
    lines.append("[Root] %s (0x%X)  [direction: %s]" % (root_name, func_ea, dir_lbl))
    lines.append("")

    visited = {func_ea}
    current_level = {func_ea}
    level = 0

    while current_level:
        next_level = set()
        level += 1
        for node_ea in sorted(current_level,
                               key=lambda ea: get_func_name_clean(ea)):
            node_name = get_func_name_clean(node_ea)
            children = graph.get(node_ea, set())
            for c_ea in sorted(children,
                                key=lambda ea: get_func_name_clean(ea)):
                if c_ea not in visited:
                    c_name = get_func_name_clean(c_ea)
                    ext_tag = " [EXT]" if is_library_or_external(c_ea) else ""
                    lines.append("  Level %d: %s (0x%X) %s --> %s (0x%X)%s" % (
                        level, node_name, node_ea, edge_lbl, c_name, c_ea, ext_tag))
                    next_level.add(c_ea)
        visited.update(next_level)
        current_level = next_level

    lines.append("")
    lines.append("Total unique %s: %d" % (dir_lbl, len(all_nodes) - 1))
    return "\n".join(lines)


def format_csv(func_ea, graph, all_nodes, direction=DIR_CALLEES):
    """生成CSV格式"""
    if direction == DIR_CALLERS:
        header = "callee_name,callee_addr,caller_name,caller_addr,caller_is_external"
    else:
        header = "caller_name,caller_addr,callee_name,callee_addr,callee_is_external"

    lines = [header]

    for parent_ea in sorted(graph.keys()):
        parent_name = get_func_name_clean(parent_ea)
        for c_ea in sorted(graph[parent_ea]):
            c_name = get_func_name_clean(c_ea)
            c_ext = "1" if is_library_or_external(c_ea) else "0"

            if direction == DIR_CALLERS:
                # parent是被调用者,child是调用者
                lines.append("%s,0x%X,%s,0x%X,%s" % (
                    parent_name, parent_ea, c_name, c_ea, c_ext))
            else:
                lines.append("%s,0x%X,%s,0x%X,%s" % (
                    parent_name, parent_ea, c_name, c_ea, c_ext))

    return "\n".join(lines)


def format_dot(func_ea, graph, all_nodes, direction=DIR_CALLEES):
    """生成Graphviz DOT格式"""
    root_name = get_func_name_clean(func_ea)
    dir_lbl = _dir_label(direction)
    lines = []
    lines.append('digraph %s_chain {' % dir_lbl)
    lines.append('    rankdir=%s;' % ("BT" if direction == DIR_CALLERS else "TB"))
    lines.append('    label="Direction: %s";' % dir_lbl)
    lines.append('    labelloc=t;')
    lines.append('    node [shape=box, style=filled, fillcolor=lightyellow, '
                 'fontname="Consolas", fontsize=10];')
    lines.append('    edge [color=gray40, arrowsize=0.7];')
    lines.append('')

    # 根节点高亮
    lines.append('    "%s\\n0x%X" [fillcolor=lightcoral, style=filled, '
                 'penwidth=2];' % (root_name, func_ea))

    for n_ea in sorted(all_nodes):
        if n_ea == func_ea:
            continue
        n_name = get_func_name_clean(n_ea)
        if is_library_or_external(n_ea):
            lines.append('    "%s\\n0x%X" [fillcolor=lightgray, style=filled, '
                         'shape=ellipse];' % (n_name, n_ea))
        elif n_ea not in graph:
            lines.append('    "%s\\n0x%X" [fillcolor=lightblue, '
                         'style=filled];' % (n_name, n_ea))

    lines.append('')

    for parent_ea in sorted(graph.keys()):
        parent_name = get_func_name_clean(parent_ea)
        for c_ea in sorted(graph[parent_ea]):
            c_name = get_func_name_clean(c_ea)
            if direction == DIR_CALLERS:
                # child调用parent: child -> parent
                lines.append('    "%s\\n0x%X" -> "%s\\n0x%X";' % (
                    c_name, c_ea, parent_name, parent_ea))
            else:
                # parent调用child: parent -> child
                lines.append('    "%s\\n0x%X" -> "%s\\n0x%X";' % (
                    parent_name, parent_ea, c_name, c_ea))

    lines.append('}')
    return "\n".join(lines)


def format_json(func_ea, graph, all_nodes, direction=DIR_CALLEES):
    """生成JSON格式"""
    root_name = get_func_name_clean(func_ea)
    dir_lbl = _dir_label(direction)
    data = {
        "direction": dir_lbl,
        "root": {
            "name": root_name,
            "address": "0x%X" % func_ea
        },
        "total_nodes": len(all_nodes),
        "edges": [],
        "nodes": []
    }

    for parent_ea in sorted(graph.keys()):
        parent_name = get_func_name_clean(parent_ea)
        for c_ea in sorted(graph[parent_ea]):
            c_name = get_func_name_clean(c_ea)
            if direction == DIR_CALLERS:
                data["edges"].append({
                    "caller": {"name": c_name, "address": "0x%X" % c_ea},
                    "callee": {"name": parent_name, "address": "0x%X" % parent_ea}
                })
            else:
                data["edges"].append({
                    "caller": {"name": parent_name, "address": "0x%X" % parent_ea},
                    "callee": {"name": c_name, "address": "0x%X" % c_ea}
                })

    for n_ea in sorted(all_nodes):
        n_name = get_func_name_clean(n_ea)
        n_children = graph.get(n_ea, set())
        data["nodes"].append({
            "name": n_name,
            "address": "0x%X" % n_ea,
            "connections": len(n_children),
            "is_root": (n_ea == func_ea),
            "is_external": is_library_or_external(n_ea)
        })

    return json.dumps(data, indent=2, ensure_ascii=False)


def format_markdown(func_ea, graph, all_nodes, direction=DIR_CALLEES):
    """生成Markdown格式"""
    root_name = get_func_name_clean(func_ea)
    dir_lbl = _dir_label(direction)
    lines = []
    lines.append("# Call Chain (%s): `%s`" % (dir_lbl, root_name))
    lines.append("")
    lines.append("**Direction:** %s" % dir_lbl)
    lines.append("")
    lines.append("**Root:** `%s` (`0x%X`)" % (root_name, func_ea))
    lines.append("")
    lines.append("**Total functions:** %d" % len(all_nodes))
    lines.append("")

    ext_count = sum(1 for n in all_nodes if is_library_or_external(n))
    int_count = len(all_nodes) - ext_count
    lines.append("**Internal:** %d | **External/Library:** %d" % (int_count, ext_count))
    lines.append("")

    if direction == DIR_CALLERS:
        lines.append("## Call Graph (caller → callee)")
        lines.append("")
        lines.append("| Caller | Addr | → | Callee | Addr | External |")
        lines.append("|--------|------|---|--------|------|----------|")
        for parent_ea in sorted(graph.keys()):
            parent_name = get_func_name_clean(parent_ea)
            for c_ea in sorted(graph[parent_ea]):
                c_name = get_func_name_clean(c_ea)
                c_ext = "" if is_library_or_external(c_ea) else ""
                lines.append("| `%s` | `0x%X` | → | `%s` | `0x%X` | %s |" % (
                    c_name, c_ea, parent_name, parent_ea, c_ext))
    else:
        lines.append("## Call Graph (caller → callee)")
        lines.append("")
        lines.append("| Caller | Addr | → | Callee | Addr | External |")
        lines.append("|--------|------|---|--------|------|----------|")
        for parent_ea in sorted(graph.keys()):
            parent_name = get_func_name_clean(parent_ea)
            for c_ea in sorted(graph[parent_ea]):
                c_name = get_func_name_clean(c_ea)
                c_ext = "" if is_library_or_external(c_ea) else ""
                lines.append("| `%s` | `0x%X` | → | `%s` | `0x%X` | %s |" % (
                    parent_name, parent_ea, c_name, c_ea, c_ext))

    lines.append("")
    lines.append("## Tree View")
    lines.append("")
    lines.append("```")
    lines.append(format_tree(func_ea, graph, direction))
    lines.append("```")
    return "\n".join(lines)


# ============================================================
# GUI对话框
# ============================================================

class CallChainForm(ida_kernwin.Form):
    """可视化输入对话框"""

    def __init__(self):
        idb_path = idc.get_idb_path()
        if idb_path:
            default_dir = os.path.dirname(idb_path)
        else:
            default_dir = os.path.expanduser("~")

        cur_ea = idc.get_screen_ea()
        cur_func = idaapi.get_func(cur_ea)
        default_name = ""
        if cur_func:
            default_name = get_func_name_clean(cur_func.start_ea)

        form_str = r"""STARTITEM 0
BUTTON YES* Analyze
BUTTON CANCEL Cancel
Call Chain Analyzer

<##Function Name\::{txtFuncName}>
<##Max Depth (0=unlimited)\::{intMaxDepth}>
<##Export Directory\::{txtExportDir}>

Analysis Direction:
<Callees (who does this function call):{rCallees}>
<Callers (who calls this function):{rCallers}>
<Both directions:{rBoth}>{rgDirection}>

Export Format:
<Tree (txt):{rTree}>
<Flat BFS (txt):{rFlat}>
<CSV:{rCSV}>
<Graphviz DOT:{rDOT}>
<JSON:{rJSON}>
<Markdown:{rMarkdown}>
<All Formats:{rAll}>{rgFormat}>

Options:
<Print to console:{cPrint}>
<Export to file:{cExport}>{cgOptions}>
"""
        ida_kernwin.Form.__init__(self, form_str, {
            'txtFuncName': ida_kernwin.Form.StringInput(value=default_name),
            'intMaxDepth': ida_kernwin.Form.NumericInput(
                value=0, tp=ida_kernwin.Form.FT_DEC),
            'txtExportDir': ida_kernwin.Form.DirInput(value=default_dir),
            'rgDirection': ida_kernwin.Form.RadGroupControl(
                ("rCallees", "rCallers", "rBoth")),
            'rgFormat': ida_kernwin.Form.RadGroupControl(
                ("rTree", "rFlat", "rCSV", "rDOT", "rJSON", "rMarkdown", "rAll")),
            'cgOptions': ida_kernwin.Form.ChkGroupControl(
                ("cPrint", "cExport"), value=3),
        })


def get_export_configs(format_idx, func_ea, graph, all_nodes, direction):
    """根据格式索引返回 [(后缀, 生成函数), ...]"""
    dir_tag = "callers" if direction == DIR_CALLERS else "callees"
    formatters = {
        0: [(".%s.tree.txt" % dir_tag,
             lambda: format_tree(func_ea, graph, direction))],
        1: [(".%s.bfs.txt" % dir_tag,
             lambda: format_flat_bfs(func_ea, graph, all_nodes, direction))],
        2: [(".%s.csv" % dir_tag,
             lambda: format_csv(func_ea, graph, all_nodes, direction))],
        3: [(".%s.dot" % dir_tag,
             lambda: format_dot(func_ea, graph, all_nodes, direction))],
        4: [(".%s.json" % dir_tag,
             lambda: format_json(func_ea, graph, all_nodes, direction))],
        5: [(".%s.md" % dir_tag,
             lambda: format_markdown(func_ea, graph, all_nodes, direction))],
    }

    if format_idx == 6:  # All
        result = []
        for fmt_list in formatters.values():
            result.extend(fmt_list)
        return result
    else:
        return formatters.get(format_idx, formatters[0])


# ============================================================
# 主入口
# ============================================================

def run_call_chain_analysis():
    """主入口:弹出GUI对话框进行分析"""
    form = CallChainForm()
    form.Compile()
    ok = form.Execute()

    if ok != 1:
        form.Free()
        return

    func_name   = form.txtFuncName.value.strip()
    max_depth   = form.intMaxDepth.value
    export_dir  = form.txtExportDir.value.strip()
    direction   = form.rgDirection.value    # 0=callees, 1=callers, 2=both
    format_idx  = form.rgFormat.value
    do_print    = bool(form.cgOptions.value & 1)
    do_export   = bool(form.cgOptions.value & 2)
    form.Free()

    if not func_name:
        ida_kernwin.warning("Please enter a function name!")
        return

    if direction == DIR_BOTH:
        # 两个方向都执行
        _do_analysis(func_name, max_depth, export_dir, format_idx,
                     do_print, do_export, DIR_CALLEES)
        print("")
        print("=" * 80)
        print("")
        _do_analysis(func_name, max_depth, export_dir, format_idx,
                     do_print, do_export, DIR_CALLERS)
    else:
        _do_analysis(func_name, max_depth, export_dir, format_idx,
                     do_print, do_export, direction)


def _find_func_ea(func_name):
    """根据名称查找函数地址,支持多种前缀尝试"""
    func_ea = idc.get_name_ea_simple(func_name)
    if func_ea != idaapi.BADADDR:
        return func_ea

    for prefix in ['_', '__', 'j_', '__imp_', '_imp_']:
        test_ea = idc.get_name_ea_simple(prefix + func_name)
        if test_ea != idaapi.BADADDR:
            return test_ea

    return idaapi.BADADDR


def _do_analysis(func_name, max_depth, export_dir, format_idx,
                 do_print, do_export, direction):
    """执行单方向分析核心逻辑"""

    dir_lbl = _dir_label(direction)

    # 查找函数
    func_ea = _find_func_ea(func_name)
    if func_ea == idaapi.BADADDR:
        msg = "Function '%s' not found!" % func_name
        print("[ERROR] " + msg)
        ida_kernwin.warning(msg)
        return

    # 解析thunk
    original_ea = func_ea
    func_ea, resolved_name = resolve_thunk(func_ea)
    if func_ea != original_ea:
        print("[INFO] '%s' is a thunk -> resolved to '%s' (0x%X)" % (
            func_name, resolved_name, func_ea))
        func_name = resolved_name

    # 构建调用图
    depth_str = ("max depth %d" % max_depth) if max_depth > 0 else "unlimited depth"
    print("[*] Building %s graph for '%s' (0x%X), %s ..." % (
        dir_lbl, func_name, func_ea, depth_str))

    try:
        ida_kernwin.show_wait_box(
            "Analyzing %s for '%s'..." % (dir_lbl, func_name))

        if direction == DIR_CALLERS:
            graph, all_nodes, depth_map = build_caller_graph(func_ea, max_depth)
        else:
            graph, all_nodes, depth_map = build_callee_graph(func_ea, max_depth)

        ida_kernwin.hide_wait_box()
    except Exception as e:
        ida_kernwin.hide_wait_box()
        ida_kernwin.warning("Error: %s" % str(e))
        import traceback
        traceback.print_exc()
        return

    total_related = len(all_nodes) - 1
    total_edges = sum(len(v) for v in graph.values())
    ext_count = sum(1 for n in all_nodes if is_library_or_external(n))
    max_d = max(depth_map.values()) if depth_map else 0

    print("[*] Done [%s]. %d functions, %d edges, max depth %d, %d external." % (
        dir_lbl, total_related, total_edges, max_d, ext_count))

    if total_related == 0:
        ida_kernwin.info("No %s found for '%s'." % (dir_lbl, func_name))
        return

    # 头部信息
    header = "=" * 80 + "\n"
    header += "Call Chain Analysis [%s]\n" % dir_lbl.upper()
    header += "Root:       %s (0x%X)\n" % (func_name, func_ea)
    header += "Direction:  %s\n" % dir_lbl
    header += "Functions:  %d (internal: %d, external: %d)\n" % (
        total_related, total_related - ext_count + 1, ext_count)
    header += "Edges:      %d\n" % total_edges
    header += "Max Depth:  %d\n" % max_d
    header += "IDB:        %s\n" % os.path.basename(idc.get_idb_path() or "unknown")
    header += "=" * 80

    # 打印到控制台
    if do_print:
        print(header)
        print("")
        tree_text = format_tree(func_ea, graph, direction)
        print(tree_text)
        print("")
        print("Total unique functions: %d" % len(all_nodes))
        print("=" * 80)

    # 导出到文件
    if do_export:
        if not export_dir or not os.path.isdir(export_dir):
            ida_kernwin.warning("Invalid export directory: %s" % export_dir)
            return

        safe_name = "".join(
            c if c.isalnum() or c in ('_', '-') else '_' for c in func_name)
        configs = get_export_configs(
            format_idx, func_ea, graph, all_nodes, direction)

        exported_files = []
        for suffix, gen_func in configs:
            filename = "chain_%s%s" % (safe_name, suffix)
            filepath = os.path.join(export_dir, filename)

            try:
                content = gen_func()
                full_content = header + "\n\n" + content

                with open(filepath, 'w', encoding='utf-8') as f:
                    f.write(full_content)

                exported_files.append(filepath)
                print("[+] Exported: %s" % filepath)
            except Exception as e:
                print("[!] Failed to export %s: %s" % (filepath, str(e)))

        if exported_files:
            msg = "Exported %d file(s) [%s]:\n\n" % (len(exported_files), dir_lbl)
            for fp in exported_files:
                msg += "  %s\n" % fp
            ida_kernwin.info(msg)


# ============================================================
# 命令行快捷接口
# ============================================================

def analyze(func_name, export_path=None, fmt="tree", max_depth=0,
            direction="callees"):
    """
    命令行快捷接口。

    参数:
        func_name:   函数名称
        export_path: 导出路径 (None=仅打印)
        fmt:         "tree" | "flat" | "csv" | "dot" | "json" | "md"
        max_depth:   最大深度, 0=无限
        direction:   "callees" | "callers" | "both"

    用法:
        analyze("main")
        analyze("main", direction="callers")
        analyze("sub_401000", "/tmp/out.dot", "dot", direction="both")
        analyze("WinMain", "C:\\out.json", "json", max_depth=5)
    """
    dir_map = {"callees": DIR_CALLEES, "callers": DIR_CALLERS, "both": DIR_BOTH}
    dir_val = dir_map.get(direction, DIR_CALLEES)

    directions_to_run = []
    if dir_val == DIR_BOTH:
        directions_to_run = [DIR_CALLEES, DIR_CALLERS]
    else:
        directions_to_run = [dir_val]

    func_ea = _find_func_ea(func_name)
    if func_ea == idaapi.BADADDR:
        print("[ERROR] Function '%s' not found!" % func_name)
        return

    func_ea, resolved_name = resolve_thunk(func_ea)

    for d in directions_to_run:
        dir_lbl = _dir_label(d)
        print("[*] Analyzing %s of '%s' (0x%X) ..." % (
            dir_lbl, resolved_name, func_ea))

        if d == DIR_CALLERS:
            graph, all_nodes, depth_map = build_caller_graph(func_ea, max_depth)
        else:
            graph, all_nodes, depth_map = build_callee_graph(func_ea, max_depth)

        fmt_map = {
            "tree": lambda: format_tree(func_ea, graph, d),
            "flat": lambda: format_flat_bfs(func_ea, graph, all_nodes, d),
            "csv":  lambda: format_csv(func_ea, graph, all_nodes, d),
            "dot":  lambda: format_dot(func_ea, graph, all_nodes, d),
            "json": lambda: format_json(func_ea, graph, all_nodes, d),
            "md":   lambda: format_markdown(func_ea, graph, all_nodes, d),
        }

        gen = fmt_map.get(fmt, fmt_map["tree"])
        content = gen()

        print("")
        print("--- %s ---" % dir_lbl.upper())
        print(content)
        print("\nTotal: %d functions, %d edges" % (
            len(all_nodes), sum(len(v) for v in graph.values())))

        if export_path:
            # 双向时自动加后缀区分
            if dir_val == DIR_BOTH:
                base, ext = os.path.splitext(export_path)
                actual_path = "%s_%s%s" % (base, dir_lbl, ext)
            else:
                actual_path = export_path

            with open(actual_path, 'w', encoding='utf-8') as f:
                f.write(content)
            print("[+] Exported to: %s" % actual_path)

        if len(directions_to_run) > 1:
            print("")
            print("=" * 80)
            print("")


# ============================================================
# 运行入口
# ============================================================
if __name__ == "__main__":
    run_call_chain_analysis()
else:
    run_call_chain_analysis()

 

 

 

 

 

 

 

 

 

 

 

 

 

以下只是做为归档保留,参考使用

image

 

2D图

# -*- coding: utf-8 -*-
"""
IDA 全量函数调用链有向图导出
深色大节点 平滑拖拽无抖动 邻接表追踪
"""

import ida_funcs
import ida_xref
import ida_name
import ida_idaapi
import ida_entry
import ida_segment
import ida_nalt
import idc
import idautils
import json
import os
import html
from collections import defaultdict


def get_function_name(ea):
    name = ida_name.get_name(ea)
    if not name:
        name = "sub_{:X}".format(ea)
    if len(name) > 60:
        name = name[:30] + "..." + name[-8:]
    return name


def classify_function(ea):
    flags = idc.get_func_attr(ea, idc.FUNCATTR_FLAGS)
    if flags == -1:
        return "normal"
    if flags & idc.FUNC_LIB:
        return "library"
    if flags & idc.FUNC_THUNK:
        return "thunk"
    seg = ida_segment.getseg(ea)
    if seg:
        seg_name = ida_segment.get_segm_name(seg)
        if seg_name in ('.plt', '.plt.got', '.got', '__stubs',
                        '__stub_helper', '.idata', '_imp'):
            return "import"
        if seg.type == ida_segment.SEG_XTRN:
            return "import"
    name = ida_name.get_name(ea)
    if name and name.startswith(('j', '_imp', 'imp')):
        return "thunk"
    return "normal"


def find_entry_points():
    entries = set()
    for i in range(ida_entry.get_entry_qty()):
        ordinal = ida_entry.get_entry_ordinal(i)
        ea = ida_entry.get_entry(ordinal)
        if ea != ida_idaapi.BADADDR:
            func = ida_funcs.get_func(ea)
            if func:
                entries.add(func.start_ea)
    for name in ['main', '_main', 'start', '_start', 'WinMain',
                 'DllMain', 'JNI_OnLoad', '__libc_start_main',
                 'entry', '_entry', 'wmain']:
        ea = ida_name.get_name_ea(ida_idaapi.BADADDR, name)
        if ea != ida_idaapi.BADADDR:
            func = ida_funcs.get_func(ea)
            if func:
                entries.add(func.start_ea)
    return entries


def build_call_graph():
    print("[*] 扫描函数...")
    nodes = {}
    edges = []
    edge_set = set()
    all_funcs = list(idautils.Functions())
    total = len(all_funcs)
    print("[*] 共 {} 个函数".format(total))
    entry_points = find_entry_points()
    all_callees = set()
    for idx, func_ea in enumerate(all_funcs):
        if idx % 500 == 0:
            print("  {}/{}".format(idx, total))
        func = ida_funcs.get_func(func_ea)
        if not func:
            continue
        func_type = classify_function(func_ea)
        if func_ea in entry_points:
            func_type = "entry"
        if func_ea not in nodes:
            nodes[func_ea] = {"name": get_function_name(func_ea), "type": func_type,
                              "out": 0, "in": 0, "size": func.size()}
        for head in idautils.FuncItems(func_ea):
            for xref in idautils.XrefsFrom(head, 0):
                if xref.type not in (ida_xref.fl_CF, ida_xref.fl_CN,
                                     ida_xref.fl_JF, ida_xref.fl_JN):
                    continue
                target = xref.to
                target_func = ida_funcs.get_func(target)
                if xref.type in (ida_xref.fl_JF, ida_xref.fl_JN):
                    if target_func and target_func.start_ea == func_ea:
                        continue
                if target_func:
                    target = target_func.start_ea
                elif not ida_name.get_name(target):
                    continue
                if target == func_ea:
                    continue
                all_callees.add(target)
                if target not in nodes:
                    t_type = classify_function(target)
                    if target in entry_points:
                        t_type = "entry"
                    t_func = ida_funcs.get_func(target)
                    nodes[target] = {"name": get_function_name(target), "type": t_type,
                                     "out": 0, "in": 0,
                                     "size": t_func.size() if t_func else 0}
                key = (func_ea, target)
                if key not in edge_set:
                    edge_set.add(key)
                    edges.append({"from": func_ea, "to": target})
                    nodes[func_ea]["out"] += 1
                    nodes[target]["in"] += 1
    for ea in nodes:
        if ea not in all_callees and nodes[ea]["type"] == "normal":
            nodes[ea]["type"] = "root"
    print("[*] {} 节点, {} 边".format(len(nodes), len(edges)))
    return nodes, edges


def generate_html(nodes, edges, output_path):
    cm = {
        "entry":   {"bg": "#C62828", "lb": u"入口函数"},
        "root":    {"bg": "#E65100", "lb": u"根函数"},
        "normal":  {"bg": "#1565C0", "lb": u"普通函数"},
        "import":  {"bg": "#2E7D32", "lb": u"导入函数"},
        "thunk":   {"bg": "#F9A825", "lb": u"Thunk"},
        "library": {"bg": "#6A1B9A", "lb": u"库函数"},
    }
    ea_to_id = {}
    vn = []
    ve = []
    adj_down = defaultdict(list)
    adj_up = defaultdict(list)

    for idx, (ea, info) in enumerate(sorted(nodes.items())):
        ea_to_id[ea] = idx
        c = cm.get(info["type"], cm["normal"])
        size = 30 + min(info["out"] * 3, 40)
        if info["type"] in ("entry", "root"):
            size = max(size, 55)
        short = info["name"]
        if len(short) > 20:
            short = short[:17] + "..."
        label = u"{}\n\u2191{} \u2193{}".format(short, info["in"], info["out"])
        fc = "#222" if info["type"] == "thunk" else "#FFF"
        vn.append({
            "id": idx, "label": label,
            "color": {"background": c["bg"], "border": c["bg"],
                      "highlight": {"background": c["bg"], "border": c["bg"]}},
            "shadow": {"enabled": True, "color": "rgba(0,0,0,0.15)", "size": 15, "x": 0, "y": 5},
            "shape": "dot", "size": size,
            "font": {"color": fc, "size": 13, "face": "Arial", "multi": True, "align": "center"},
            "borderWidth": 0, "borderWidthSelected": 0,
            "ntype": info["type"], "ea": "0x{:X}".format(ea),
            "fullName": info["name"], "outDeg": info["out"], "inDeg": info["in"],
            "funcSize": info["size"], "typeLabel": c["lb"], "bgColor": c["bg"], "ftColor": fc
        })

    for i, e in enumerate(edges):
        if e["from"] in ea_to_id and e["to"] in ea_to_id:
            fid = ea_to_id[e["from"]]
            tid = ea_to_id[e["to"]]
            adj_down[fid].append(tid)
            adj_up[tid].append(fid)
            ve.append({
                "id": i, "from": fid, "to": tid,
                "arrows": {"to": {"enabled": True, "scaleFactor": 0.5, "type": "vee"}},
                "color": {"color": "rgba(100,100,100,0.18)", "highlight": "#C62828"},
                "width": 1.2,
                "smooth": {"type": "cubicBezier", "forceDirection": "none", "roundness": 0.2},
                "selectionWidth": 2
            })

    adj_down_dict = {str(k): v for k, v in adj_down.items()}
    adj_up_dict = {str(k): v for k, v in adj_up.items()}

    page = r'''<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Call Graph</title>
<script src="https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#F5F5F5;color:#333;font-family:'Segoe UI',Arial,sans-serif;overflow:hidden}
#bar{position:fixed;top:0;left:0;right:0;height:48px;background:#fff;border-bottom:1px solid #E8E8E8;
     display:flex;align-items:center;padding:0 18px;gap:10px;z-index:100;box-shadow:0 1px 8px rgba(0,0,0,0.04)}
.t{color:#C62828;font-size:16px;font-weight:bold;white-space:nowrap}
.s{color:#AAA;font-size:11px;white-space:nowrap}
#search{background:#F0F0F0;color:#333;border:1px solid #DDD;padding:6px 14px;border-radius:8px;width:220px;font-size:12px}
#search:focus{border-color:#1565C0;outline:none;box-shadow:0 0 0 3px rgba(21,101,192,0.12);background:#fff}
.b{background:#F0F0F0;color:#555;border:1px solid #DDD;padding:6px 14px;border-radius:8px;cursor:pointer;
   font-size:11px;white-space:nowrap;transition:all .2s}
.b:hover{background:#E3F2FD;border-color:#1565C0;color:#1565C0}
.b.on{background:#1565C0;border-color:#1565C0;color:#fff}
#graph{position:fixed;top:48px;left:0;right:0;bottom:0;background:#F5F5F5;cursor:grab}
#info{position:fixed;bottom:20px;left:20px;background:#fff;border:1px solid #E8E8E8;border-radius:14px;
      padding:16px 20px;max-width:340px;font-size:12px;z-index:99;display:none;line-height:1.8;
      box-shadow:0 8px 30px rgba(0,0,0,0.08)}
#info .x{position:absolute;top:10px;right:14px;cursor:pointer;color:#ccc;font-size:18px}
#info .x:hover{color:#C62828}
#legend{position:fixed;top:66px;right:20px;background:rgba(255,255,255,0.98);border:1px solid #E8E8E8;
        border-radius:14px;padding:14px 18px;font-size:11px;z-index:99;line-height:2.6;
        box-shadow:0 4px 16px rgba(0,0,0,0.04)}
.lr{display:flex;align-items:center;gap:9px}
.ld{width:14px;height:14px;border-radius:50%;display:inline-block}
#sr{position:fixed;top:48px;left:265px;background:#fff;border:1px solid #E8E8E8;border-radius:0 0 10px 10px;
    max-height:300px;overflow-y:auto;width:300px;z-index:101;display:none;box-shadow:0 6px 20px rgba(0,0,0,0.06)}
.si{padding:7px 14px;cursor:pointer;font-size:11px;border-bottom:1px solid #F5F5F5;
    white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.si:hover{background:#F5F8FF}
.sa{color:#BBB;margin-left:6px}
#back{position:fixed;bottom:20px;right:20px;z-index:99;display:none}
#mode{position:fixed;top:54px;left:50%;transform:translateX(-50%);background:#C62828;color:#fff;
      padding:5px 18px;border-radius:0 0 10px 10px;font-size:11px;z-index:99;display:none;
      box-shadow:0 3px 10px rgba(198,40,40,0.25)}
</style>
</head>
<body>
<div id="bar">
  <span class="t">◉ Call Graph</span>
  <span class="s">__TN__ nodes · __TE__ edges</span>
  <input id="search" placeholder="🔍 搜索..." oninput="doSearch(this.value)">
  <button class="b" onclick="fitAll()">适应窗口</button>
  <button class="b" onclick="resetAll()">显示全部</button>
  <button class="b" id="bp" onclick="togPhys()">物理引擎</button>
  <button class="b" onclick="isolateNeighbor()">隔离邻居</button>
  <button class="b" onclick="expPNG()">导出PNG</button>
</div>
<div id="sr"></div>
<div id="graph"></div>
<div id="info"><span class="x" onclick="this.parentElement.style.display='none'">×</span><div id="ic"></div></div>
<div id="legend">
  <div class="lr"><span class="ld" style="background:#C62828"></span>入口函数</div>
  <div class="lr"><span class="ld" style="background:#E65100"></span>根函数</div>
  <div class="lr"><span class="ld" style="background:#1565C0"></span>普通函数</div>
  <div class="lr"><span class="ld" style="background:#2E7D32"></span>导入函数</div>
  <div class="lr"><span class="ld" style="background:#F9A825"></span>Thunk</div>
  <div class="lr"><span class="ld" style="background:#6A1B9A"></span>库函数</div>
  <div style="margin-top:10px;color:#bbb;font-size:9px;line-height:1.8">
    ↑被调用 ↓调用<br>滚轮=缩放<br>左键拖空白=平移<br>左键拖节点=移动<br>单击=详情<br>双击=隔离调用链
  </div>
</div>
<div id="mode"></div>
<div id="back"><button class="b" style="padding:10px 24px;font-size:13px;background:#1565C0;color:#fff;
     border-color:#1565C0;border-radius:10px;box-shadow:0 3px 12px rgba(21,101,192,0.3)"
     onclick="resetAll()">← 返回全部</button></div>

<script>
var RN=__NODES__;
var RE=__EDGES__;
var ADJ_DOWN=__ADJ_DOWN__;
var ADJ_UP=__ADJ_UP__;

var nodes=new vis.DataSet(RN);
var edges=new vis.DataSet(RE);
var ctr=document.getElementById('graph');
var isolated=false;
var physOn=false;

var net=new vis.Network(ctr,{nodes:nodes,edges:edges},{
    layout:{improvedLayout:true, hierarchical:false},
    physics:{
        enabled:true,
        solver:'forceAtlas2Based',
        forceAtlas2Based:{
            gravitationalConstant:-60,
            centralGravity:0.002,
            springLength:180,
            springConstant:0.006,
            damping:0.4,
            avoidOverlap:1.0
        },
        maxVelocity:25,
        minVelocity:0.08,
        stabilization:{enabled:true, iterations:800, updateInterval:10}
    },
    edges:{
        smooth:{type:'cubicBezier', forceDirection:'none', roundness:0.2},
        color:{inherit:false},
        selectionWidth:2
    },
    nodes:{
        shadow:true,
        borderWidth:0,
        borderWidthSelected:0,
        chosen:false
    },
    interaction:{
        hover:false,
        tooltipDelay:999999,
        dragNodes:true,
        dragView:false,
        zoomView:false,
        keyboard:false,
        multiselect:false,
        navigationButtons:false,
        hideEdgesOnDrag:false,
        hideEdgesOnZoom:false,
        hoverConnectedEdges:false
    }
});

/* =============================================================
   自维护视口状态 —— 彻底消除抖动
   
   核心思路:自己维护 viewX, viewY, viewScale 三个变量,
   所有平移/缩放操作只修改这三个变量,
   然后统一在 rAF 中调用一次 moveTo 同步给 vis。
   
   绝不在 moveTo 之后再读 getViewPosition,
   切断 vis 内部状态的反馈回路。
   ============================================================= */

var view = {x:0, y:0, scale:1, dirty:false, rafId:0};

/* 从 vis 同步一次初始状态 */
function syncFromVis(){
    var vp = net.getViewPosition();
    view.x = vp.x;
    view.y = vp.y;
    view.scale = net.getScale();
}

/* 唯一的渲染入口:rAF 驱动 */
function viewCommit(){
    if(!view.dirty){ view.rafId = 0; return; }
    net.moveTo({
        position: {x: view.x, y: view.y},
        scale: view.scale,
        animation: false
    });
    view.dirty = false;
    view.rafId = 0;
}

function viewSchedule(){
    if(!view.rafId){
        view.rafId = requestAnimationFrame(viewCommit);
    }
}

/* ===== 平移状态 ===== */
var pan = {active:false, sx:0, sy:0, svx:0, svy:0};

ctr.addEventListener('mousedown', function(e){
    if(e.button !== 0) return;
    var hit = net.getNodeAt({x:e.offsetX, y:e.offsetY});
    if(hit !== undefined) return;
    
    syncFromVis();
    pan.active = true;
    pan.sx = e.clientX;
    pan.sy = e.clientY;
    pan.svx = view.x;
    pan.svy = view.y;
    ctr.style.cursor = 'grabbing';
    e.preventDefault();
    e.stopPropagation();
}, true);

window.addEventListener('mousemove', function(e){
    if(!pan.active) return;
    var dx = e.clientX - pan.sx;
    var dy = e.clientY - pan.sy;
    view.x = pan.svx - dx / view.scale;
    view.y = pan.svy - dy / view.scale;
    view.dirty = true;
    viewSchedule();
}, true);

window.addEventListener('mouseup', function(e){
    if(!pan.active) return;
    /* 最终精确提交 */
    var dx = e.clientX - pan.sx;
    var dy = e.clientY - pan.sy;
    view.x = pan.svx - dx / view.scale;
    view.y = pan.svy - dy / view.scale;
    if(view.rafId){ cancelAnimationFrame(view.rafId); view.rafId = 0; }
    net.moveTo({
        position: {x: view.x, y: view.y},
        scale: view.scale,
        animation: false
    });
    view.dirty = false;
    pan.active = false;
    ctr.style.cursor = 'grab';
}, true);

/* ===== 滚轮缩放 ===== */
ctr.addEventListener('wheel', function(e){
    e.preventDefault();
    e.stopPropagation();
    
    syncFromVis();
    
    var factor = e.deltaY < 0 ? 1.15 : 1/1.15;
    var newScale = view.scale * factor;
    if(newScale < 0.003) newScale = 0.003;
    if(newScale > 30) newScale = 30;
    
    /* 以鼠标位置为中心 */
    var pointer = net.DOMtoCanvas({x:e.offsetX, y:e.offsetY});
    var r = view.scale / newScale;
    view.x = pointer.x - (pointer.x - view.x) * r;
    view.y = pointer.y - (pointer.y - view.y) * r;
    view.scale = newScale;
    
    /* 缩放立即提交,不延迟 */
    if(view.rafId){ cancelAnimationFrame(view.rafId); view.rafId = 0; }
    net.moveTo({
        position: {x: view.x, y: view.y},
        scale: view.scale,
        animation: false
    });
    view.dirty = false;
},{passive:false});

/* ===== 稳定后关闭物理 ===== */
net.on('stabilizationIterationsDone', function(){
    net.setOptions({physics:{enabled:false}});
    physOn = false;
    document.getElementById('bp').classList.remove('on');
    net.fit({animation:{duration:600}});
});

/* ===== 拖节点 ===== */
net.on('dragStart', function(p){
    if(p.nodes.length > 0)
        net.setOptions({physics:{enabled:false}});
});
net.on('dragEnd', function(p){
    if(p.nodes.length > 0 && physOn)
        net.setOptions({physics:{enabled:true}});
});

/* ===== 邻接表追踪 ===== */
function traceDown(id,lim){
    var s=new Set([id]),q=[id];
    while(q.length){var c=q.shift();(ADJ_DOWN[c]||[]).forEach(function(n){
        if(!s.has(n)){s.add(n);if(s.size<lim)q.push(n);}});}return s;}
function traceUp(id,lim){
    var s=new Set([id]),q=[id];
    while(q.length){var c=q.shift();(ADJ_UP[c]||[]).forEach(function(n){
        if(!s.has(n)){s.add(n);if(s.size<lim)q.push(n);}});}return s;}
function traceAll(id,lim){
    var s=traceUp(id,lim);traceDown(id,lim).forEach(function(v){s.add(v);});return s;}

function showInfo(id){
    var n=nodes.get(id);if(!n)return;
    document.getElementById('ic').innerHTML=
        "<div style='font-family:monospace;line-height:2.1'>"
        +"<b style='color:"+n.bgColor+";font-size:15px'>● "+esc(n.fullName)+"</b><br>"
        +"<span style='color:#999'>地址</span>&ensp;"+n.ea+"<br>"
        +"<span style='color:#999'>类型</span>&ensp;"+n.typeLabel+"<br>"
        +"<span style='color:#999'>大小</span>&ensp;"+n.funcSize+" bytes<br>"
        +"<div style='border-top:1px solid #EEE;margin:8px 0'></div>"
        +"<span style='color:#999'>被调用 ↑</span>&ensp;<b style='font-size:16px;color:"+n.bgColor+"'>"+n.inDeg+"</b><br>"
        +"<span style='color:#999'>调用 ↓</span>&ensp;<b style='font-size:16px;color:"+n.bgColor+"'>"+n.outDeg+"</b></div>";
    document.getElementById('info').style.display='block';
}

net.on('click', function(p){if(p.nodes.length)showInfo(p.nodes[0]);});

net.on('doubleClick', function(p){
    if(!p.nodes.length)return; var id=p.nodes[0];
    if(isolated){
        net.focus(id,{scale:1.5, animation:{duration:300}});
        net.selectNodes([id]); showInfo(id);
    }else{isoChain(id);}
});

function isoChain(id){
    var keep=traceAll(id,500);
    applyFilter(keep); isolated=true;
    net.selectNodes([id]);
    var n=nodes.get(id); var ml=document.getElementById('mode');
    ml.textContent='调用链: '+(n?n.fullName:'#'+id)+' ('+keep.size+'个节点)';
    ml.style.display='block';
    document.getElementById('back').style.display='block';
    setTimeout(function(){net.fit({animation:{duration:400}});},200);
}

function applyFilter(keep){
    nodes.update(RN.map(function(n){return{id:n.id, hidden:!keep.has(n.id)};}));
    edges.update(RE.map(function(e){return{id:e.id, hidden:!(keep.has(e.from)&&keep.has(e.to))};}));
}

function resetAll(){
    nodes.update(RN.map(function(n){return{id:n.id, hidden:false};}));
    edges.update(RE.map(function(e){return{id:e.id, hidden:false};}));
    net.unselectAll(); isolated=false;
    document.getElementById('back').style.display='none';
    document.getElementById('info').style.display='none';
    document.getElementById('mode').style.display='none';
    net.fit({animation:{duration:400}});
}

function fitAll(){net.fit({animation:{duration:400}});}

function togPhys(){
    var b=document.getElementById('bp');
    if(physOn){
        net.setOptions({physics:{enabled:false}});
        physOn=false; b.classList.remove('on');
    }else{
        net.setOptions({physics:{enabled:true, solver:'barnesHut',
            barnesHut:{gravitationalConstant:-800, centralGravity:0.003, springLength:200,
                       springConstant:0.004, damping:0.9, avoidOverlap:1.0},
            maxVelocity:5, minVelocity:0.01, timestep:0.3}});
        physOn=true; b.classList.add('on');
    }
}

function isolateNeighbor(){
    var s=net.getSelectedNodes(); if(!s.length){alert('请先选择节点');return;}
    var keep=new Set(s);
    s.forEach(function(id){
        (ADJ_DOWN[id]||[]).forEach(function(n){keep.add(n);});
        (ADJ_UP[id]||[]).forEach(function(n){keep.add(n);});
    });
    applyFilter(keep); isolated=true;
    document.getElementById('back').style.display='block';
    var n=nodes.get(s[0]); var ml=document.getElementById('mode');
    ml.textContent='邻居: '+(n?n.fullName:''); ml.style.display='block';
    setTimeout(function(){net.fit({animation:{duration:300}});},150);
}

function esc(s){var d=document.createElement('div');d.textContent=s;return d.innerHTML;}

function doSearch(q){
    var box=document.getElementById('sr');
    if(!q||q.length<2){box.style.display='none';return;} q=q.toLowerCase();
    var m=RN.filter(function(n){
        return n.fullName.toLowerCase().indexOf(q)>=0 || n.ea.toLowerCase().indexOf(q)>=0;
    }).slice(0,30);
    if(!m.length){box.style.display='none';return;} box.innerHTML='';
    m.forEach(function(n){
        var d=document.createElement('div'); d.className='si';
        d.innerHTML='<span style="color:'+n.color.background+'">●</span> '+esc(n.fullName)
            +' <span class="sa">'+n.ea+''+n.inDeg+''+n.outDeg+'</span>';
        d.onclick=function(){
            box.style.display='none'; document.getElementById('search').value='';
            if(isolated) resetAll();
            setTimeout(function(){
                net.focus(n.id,{scale:1.8, animation:true});
                net.selectNodes([n.id]);
            }, isolated?500:50);
        };
        box.appendChild(d);
    });
    box.style.display='block';
}

document.addEventListener('click',function(e){
    if(e.target.id!=='search') document.getElementById('sr').style.display='none';
});

function expPNG(){
    var c=ctr.getElementsByTagName('canvas')[0]; if(!c)return;
    var a=document.createElement('a'); a.download='callgraph.png';
    a.href=c.toDataURL('image/png'); a.click();
}
</script>
</body>
</html>'''

    page = page.replace('__NODES__', json.dumps(vn, ensure_ascii=False))
    page = page.replace('__EDGES__', json.dumps(ve, ensure_ascii=False))
    page = page.replace('__ADJ_DOWN__', json.dumps(adj_down_dict, ensure_ascii=False))
    page = page.replace('__ADJ_UP__', json.dumps(adj_up_dict, ensure_ascii=False))
    page = page.replace('__TN__', str(len(vn)))
    page = page.replace('__TE__', str(len(ve)))

    with open(output_path, 'w', encoding='utf-8') as f:
        f.write(page)
    print("[+] 已导出: {}".format(output_path))


print("=" * 50)
print("  IDA Call Graph Exporter")
print("=" * 50)
_nodes, _edges = build_call_graph()
if not _nodes:
    print("[!] 未找到函数")
else:
    _inp = ida_nalt.get_input_file_path() or ""
    _dir = os.path.dirname(_inp) if _inp else os.path.expanduser("~")
    _bin = ida_nalt.get_root_filename() or "binary"
    _out = os.path.join(_dir, "{}_callgraph.html".format(os.path.splitext(_bin)[0]))
    generate_html(_nodes, _edges, _out)
    _ty = defaultdict(int)
    for _n in _nodes.values():
        _ty[_n["type"]] += 1
    print("\n节点类型:")
    for t, c in sorted(_ty.items(), key=lambda x: -x[1]):
        print("  {:>10}: {}".format(t, c))
    print("\n浏览器打开: {}".format(_out))

 

 

 下面代码仅做参考

2D图

# -*- coding: utf-8 -*-
"""
IDA Function Call Graph - 2D Disk Sphere Visualization (Enhanced Call Chain)
功能:
  - 导出所有函数控制流(调用关系)
  - 统计每个函数被调用次数,不同颜色节点
  - Fibonacci Sunflower 圆盘布局(热点函数居中)
  - 鼠标滚轮缩放、左键拖拽、悬停高亮、搜索
  - 【新增】完整调用链展示:双击节点递归展开上下游调用链
  - 【新增】调用链树形面板、深度控制、链路动画
  - 【新增】右键菜单:隔离子图、复制路径
"""
import idautils
import idaapi
import idc
import json
import os
import math
import webbrowser
from collections import defaultdict


def extract_call_graph():
    """从IDA提取函数调用图"""
    functions = {}
    call_count = defaultdict(int)
    edge_set = defaultdict(int)

    for func_ea in idautils.Functions():
        name = idc.get_func_name(func_ea)
        if name:
            functions[func_ea] = name
            call_count[func_ea] = 0

    print(f"[*] Found {len(functions)} functions")

    done = 0
    for caller_ea in list(functions.keys()):
        func = idaapi.get_func(caller_ea)
        if not func:
            continue
        for head in idautils.Heads(func.start_ea, func.end_ea):
            for xref in idautils.XrefsFrom(head, 0):
                if xref.type in (idaapi.fl_CF, idaapi.fl_CN):
                    cf = idaapi.get_func(xref.to)
                    if cf and cf.start_ea in functions and cf.start_ea != caller_ea:
                        edge_set[(caller_ea, cf.start_ea)] += 1
                        call_count[cf.start_ea] += 1
        done += 1
        if done % 500 == 0:
            print(f"    Scanned {done}/{len(functions)}...")

    # XrefsTo 补充
    for ea in functions:
        xc = sum(1 for x in idautils.XrefsTo(ea, 0)
                 if x.type in (idaapi.fl_CF, idaapi.fl_CN))
        call_count[ea] = max(call_count[ea], xc)

    edges = [(s, d, w) for (s, d), w in edge_set.items()]
    return functions, edges, dict(call_count)


def sunflower_layout(n, radius=600):
    """Fibonacci Sunflower 均匀圆盘布局"""
    if n == 0:
        return []
    if n == 1:
        return [(0.0, 0.0)]
    golden_angle = math.pi * (3.0 - math.sqrt(5.0))
    pts = []
    for i in range(n):
        r = radius * math.sqrt((i + 0.5) / n)
        theta = i * golden_angle
        pts.append((round(r * math.cos(theta), 2),
                     round(r * math.sin(theta), 2)))
    return pts


def build_html(nodes_json, edges_json, n_nodes, n_edges, max_cc, layout_r):
    """生成完整 HTML - 增强版:支持完整调用链展示"""
    return '''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>IDA 2D Function Call Graph - Call Chain Explorer</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#0d1117;overflow:hidden;font-family:'Segoe UI',Consolas,monospace;color:#c9d1d9}
canvas{display:block;cursor:grab}
canvas.dragging{cursor:grabbing}

#panel{position:absolute;top:10px;left:10px;width:300px;background:rgba(13,17,23,.95);
  border:1px solid #30363d;border-radius:10px;padding:14px;z-index:100;
  backdrop-filter:blur(8px);user-select:none;max-height:calc(100vh - 20px);overflow-y:auto}
#panel::-webkit-scrollbar{width:4px}
#panel::-webkit-scrollbar-thumb{background:#30363d;border-radius:2px}
#panel h2{font-size:14px;color:#58a6ff;margin-bottom:6px}
.st{font-size:11px;color:#8b949e;margin:2px 0}.st b{color:#c9d1d9}

#hover-info{display:none;margin-top:8px;padding-top:8px;border-top:1px solid #30363d}
.hi-n{color:#58a6ff;font-size:13px;font-weight:bold;word-break:break-all}
.hi-a{color:#8b949e;font-size:11px}
.hi-c{color:#f0883e;font-size:12px;margin-top:2px}
.hi-d{color:#6e7681;font-size:10px;margin-top:2px}

/* 调用链面板 */
#chain-panel{display:none;margin-top:10px;padding-top:10px;border-top:1px solid #30363d}
#chain-panel h3{font-size:12px;color:#f0883e;margin-bottom:6px;display:flex;align-items:center;justify-content:space-between}
#chain-panel h3 span.close-chain{cursor:pointer;color:#8b949e;font-size:14px}
#chain-panel h3 span.close-chain:hover{color:#da3633}
.chain-section{margin-bottom:8px}
.chain-section-title{font-size:11px;color:#58a6ff;margin-bottom:3px;cursor:pointer;user-select:none}
.chain-section-title:hover{color:#79c0ff}
.chain-tree{font-size:10px;line-height:1.8;max-height:200px;overflow-y:auto;padding-left:4px}
.chain-tree::-webkit-scrollbar{width:3px}
.chain-tree::-webkit-scrollbar-thumb{background:#30363d;border-radius:2px}
.chain-item{cursor:pointer;padding:1px 4px;border-radius:3px;white-space:nowrap;display:flex;align-items:center}
.chain-item:hover{background:rgba(56,139,253,0.15)}
.chain-item .depth-bar{display:inline-block;margin-right:2px;color:#484f58}
.chain-item .ci-name{color:#c9d1d9;flex:1;overflow:hidden;text-overflow:ellipsis}
.chain-item .ci-cc{color:#f0883e;font-size:9px;margin-left:4px;flex-shrink:0}
.chain-item.ci-root{color:#f0883e;font-weight:bold}
.chain-item.ci-root .ci-name{color:#f0883e}
.chain-item.ci-caller .ci-name{color:#79c0ff}
.chain-item.ci-callee .ci-name{color:#7ee787}
.chain-stats{font-size:10px;color:#8b949e;margin-top:4px}

/* 深度控制 */
.depth-ctrl{display:flex;align-items:center;gap:6px;margin-top:6px;margin-bottom:6px}
.depth-ctrl label{font-size:10px;color:#8b949e;flex-shrink:0}
.depth-ctrl input[type=range]{flex:1;accent-color:#58a6ff;height:4px}
.depth-ctrl .dv{font-size:11px;color:#58a6ff;width:20px;text-align:center}

.hint{font-size:10px;color:#484f58;margin-top:8px;line-height:1.5}

#search-box{position:absolute;top:10px;right:10px;z-index:100}
#search-box input{width:240px;padding:8px 12px;border-radius:8px;
  background:rgba(13,17,23,.93);border:1px solid #30363d;color:#c9d1d9;
  font-size:12px;outline:none;backdrop-filter:blur(8px)}
#search-box input:focus{border-color:#58a6ff}
#search-box input::placeholder{color:#484f58}

#legend{position:absolute;bottom:10px;left:10px;background:rgba(13,17,23,.93);
  border:1px solid #30363d;border-radius:10px;padding:12px;z-index:100;
  backdrop-filter:blur(8px);user-select:none}
#legend h3{font-size:11px;color:#58a6ff;margin-bottom:5px}
.lr{display:flex;align-items:center;margin:2px 0;font-size:10px}
.ld{width:10px;height:10px;border-radius:50%;margin-right:8px;flex-shrink:0}

#btns{position:absolute;bottom:10px;right:10px;z-index:100;display:flex;gap:5px;flex-wrap:wrap;justify-content:flex-end;max-width:500px}
#btns button{padding:7px 11px;border-radius:6px;background:rgba(13,17,23,.93);
  border:1px solid #30363d;color:#c9d1d9;cursor:pointer;font-size:11px;
  backdrop-filter:blur(8px);white-space:nowrap}
#btns button:hover{border-color:#58a6ff;color:#58a6ff}
#btns button.active{border-color:#f0883e;color:#f0883e}

/* 右键菜单 */
#ctx-menu{display:none;position:absolute;z-index:300;background:rgba(13,17,23,.97);
  border:1px solid #30363d;border-radius:8px;padding:4px 0;min-width:200px;
  backdrop-filter:blur(12px);box-shadow:0 8px 24px rgba(0,0,0,.4)}
#ctx-menu .cm-item{padding:7px 16px;font-size:11px;cursor:pointer;display:flex;align-items:center;gap:8px}
#ctx-menu .cm-item:hover{background:rgba(56,139,253,0.15)}
#ctx-menu .cm-sep{height:1px;background:#30363d;margin:4px 0}
#ctx-menu .cm-item .cm-icon{width:16px;text-align:center}

/* 调用链模式指示器 */
#chain-mode-bar{display:none;position:absolute;top:10px;left:50%;transform:translateX(-50%);
  z-index:150;background:rgba(240,136,62,.15);border:1px solid #f0883e;border-radius:8px;
  padding:6px 16px;font-size:11px;color:#f0883e;backdrop-filter:blur(8px);
  display:flex;align-items:center;gap:8px}
#chain-mode-bar .cmb-text{flex:1}
#chain-mode-bar .cmb-close{cursor:pointer;font-size:14px;color:#f0883e}
#chain-mode-bar .cmb-close:hover{color:#da3633}
</style>
</head>
<body>
<canvas id="cv"></canvas>

<div id="panel">
  <h2>&#128202; Call Graph Explorer</h2>
  <div class="st">Functions: <b>''' + str(n_nodes) + '''</b></div>
  <div class="st">Call Edges: <b>''' + str(n_edges) + '''</b></div>
  <div class="st">Max Called: <b>''' + str(max_cc) + '''</b></div>

  <div id="hover-info">
    <div class="hi-n" id="h-name"></div>
    <div class="hi-a" id="h-addr"></div>
    <div class="hi-c" id="h-cc"></div>
    <div class="hi-d" id="h-out"></div>
    <div class="hi-d" id="h-in"></div>
  </div>

  <div id="chain-panel">
    <h3>
      <span>&#128279; Call Chain</span>
      <span class="close-chain" onclick="clearChain()" title="Close">&#10005;</span>
    </h3>
    <div class="depth-ctrl">
      <label>Depth:</label>
      <input type="range" id="depth-slider" min="1" max="20" value="8">
      <span class="dv" id="depth-val">8</span>
    </div>
    <div class="chain-section">
      <div class="chain-section-title" id="caller-title" onclick="toggleSection('callers')">&#9660; Callers (upstream)</div>
      <div class="chain-tree" id="caller-tree"></div>
    </div>
    <div class="chain-section">
      <div class="chain-section-title" id="callee-title" onclick="toggleSection('callees')">&#9660; Callees (downstream)</div>
      <div class="chain-tree" id="callee-tree"></div>
    </div>
    <div class="chain-stats" id="chain-stats"></div>
  </div>

  <div class="hint">
    &#128433; Drag to pan &nbsp; &#128269; Scroll to zoom<br>
    &#128073; <b>Double-click</b>: show full call chain<br>
    &#128073; <b>Right-click</b>: context menu<br>
    &#9000; Search box to filter
  </div>
</div>

<div id="search-box">
  <input id="sch" type="text" placeholder="&#128269; Search function name...">
</div>

<div id="legend">
  <h3>Call Count Legend</h3>
  <div class="lr"><div class="ld" style="background:#3572A5"></div>0 (leaf)</div>
  <div class="lr"><div class="ld" style="background:#2188ff"></div>1 ~ 3</div>
  <div class="lr"><div class="ld" style="background:#3fb950"></div>4 ~ 10</div>
  <div class="lr"><div class="ld" style="background:#9be54a"></div>11 ~ 30</div>
  <div class="lr"><div class="ld" style="background:#d29922"></div>31 ~ 80</div>
  <div class="lr"><div class="ld" style="background:#f0883e"></div>81 ~ 200</div>
  <div class="lr"><div class="ld" style="background:#da3633"></div>200+</div>
  <div style="margin-top:6px;border-top:1px solid #30363d;padding-top:6px">
    <div class="lr" style="color:#79c0ff">&#9644; Blue edges = callers (in)</div>
    <div class="lr" style="color:#7ee787">&#9644; Green edges = callees (out)</div>
    <div class="lr" style="color:#f0883e">&#9679; Orange = selected root</div>
  </div>
</div>

<div id="btns">
  <button onclick="zoomBy(1.3)">&#10133;</button>
  <button onclick="zoomBy(0.7)">&#10134;</button>
  <button onclick="resetView()">&#128260; Reset</button>
  <button id="be" onclick="togEdge()">&#128208; Hide Edges</button>
  <button onclick="togLabel()">&#127991; Labels</button>
  <button id="btn-anim" onclick="togAnim()">&#9654; Animate</button>
  <button onclick="togArrow()">&#10145; Arrows</button>
  <button onclick="exportChain()">&#128190; Export</button>
</div>

<div id="chain-mode-bar" style="display:none">
  <span class="cmb-text" id="cmb-text"></span>
  <span class="cmb-close" onclick="clearChain()" title="Exit chain mode">&#10005;</span>
</div>

<!-- Right-click context menu -->
<div id="ctx-menu">
  <div class="cm-item" onclick="cmShowChain()"><span class="cm-icon">&#128279;</span>Show Full Call Chain</div>
  <div class="cm-item" onclick="cmIsolate()"><span class="cm-icon">&#127758;</span>Isolate Subgraph</div>
  <div class="cm-sep"></div>
  <div class="cm-item" onclick="cmCallers()"><span class="cm-icon">&#11014;</span>Trace Callers Only</div>
  <div class="cm-item" onclick="cmCallees()"><span class="cm-icon">&#11015;</span>Trace Callees Only</div>
  <div class="cm-sep"></div>
  <div class="cm-item" onclick="cmFocusNode()"><span class="cm-icon">&#127919;</span>Focus on This Node</div>
  <div class="cm-item" onclick="cmCopyName()"><span class="cm-icon">&#128203;</span>Copy Function Name</div>
  <div class="cm-item" onclick="cmCopyChainText()"><span class="cm-icon">&#128196;</span>Copy Chain as Text</div>
</div>

<script>
// ======================= DATA =======================
var N=''' + nodes_json + ''';
var E=''' + edges_json + ''';
var LR=''' + str(layout_r) + ''';

// ======================= CANVAS & STATE =======================
var cv=document.getElementById("cv"), ctx=cv.getContext("2d"),
    W,H,panX,panY,sc,
    drag=false,dragX,dragY,
    hov=-1,showE=true,showL=false,showArrows=false,
    schQ="",schSet=new Set(),
    outE=[],inE=[],
    raf=0;

// ===== CALL CHAIN STATE =====
var chainRoot=-1,          // 被选中展开调用链的节点索引
    chainCallers=new Set(),// 上游所有caller节点
    chainCallees=new Set(),// 下游所有callee节点
    chainEdges=new Set(),  // 链上的所有边 (编码为 "s-t")
    chainDepth=8,          // 展开深度
    chainMode="both",      // "both","callers","callees"
    isolateMode=false,     // 隔离模式
    animOn=false,animT=0,  // 链路动画
    callerDepthMap={},     // nodeIdx -> depth from root (upstream)
    calleeDepthMap={},     // nodeIdx -> depth from root (downstream)
    maxCallerDepth=0,
    maxCalleeDepth=0;

// 右键菜单
var ctxNode=-1;

function init(){
  resize();
  for(var i=0;i<N.length;i++){outE.push([]);inE.push([])}
  E.forEach(function(e){outE[e.s].push(e.t);inE[e.t].push(e.s)});
  document.getElementById("depth-slider").addEventListener("input",function(e){
    chainDepth=parseInt(e.target.value);
    document.getElementById("depth-val").textContent=chainDepth;
    if(chainRoot>=0) buildChain(chainRoot,chainMode);
  });
  bindEvt();
  resetView();
}

function resize(){
  W=window.innerWidth;H=window.innerHeight;
  cv.width=W;cv.height=H;
  scheduleDraw();
}

function resetView(){
  var pad=80,dim=Math.min(W,H)-pad*2;
  sc=dim/(LR*2);
  panX=W/2;panY=H/2;
  scheduleDraw();
}

// ======================= TRANSFORM =======================
function w2s(wx,wy){return[wx*sc+panX,wy*sc+panY]}
function s2w(sx,sy){return[(sx-panX)/sc,(sy-panY)/sc]}

// ======================= COLORS =======================
function ccCol(c){
  if(c===0)return"#3572A5";if(c<=3)return"#2188ff";if(c<=10)return"#3fb950";
  if(c<=30)return"#9be54a";if(c<=80)return"#d29922";if(c<=200)return"#f0883e";return"#da3633"
}
function ccR(c){
  if(c===0)return 3;if(c<=3)return 4;if(c<=10)return 5.5;
  if(c<=30)return 7;if(c<=80)return 9;if(c<=200)return 11;return 14
}

// depth-based color for chain visualization
function depthColor(depth,maxD,isCaller){
  var t=maxD>0?Math.min(depth/maxD,1):0;
  if(isCaller){
    // blue gradient: bright -> dim
    var r=Math.round(33+t*30), g=Math.round(136-t*60), b=Math.round(255-t*80);
    return"rgb("+r+","+g+","+b+")";
  } else {
    // green gradient
    var r=Math.round(63+t*40), g=Math.round(185-t*70), b=Math.round(80-t*30);
    return"rgb("+r+","+g+","+b+")";
  }
}

// ======================= CALL CHAIN LOGIC =======================
function buildChain(rootIdx, mode){
  chainRoot=rootIdx;
  chainMode=mode;
  chainCallers.clear();
  chainCallees.clear();
  chainEdges.clear();
  callerDepthMap={};
  calleeDepthMap={};
  maxCallerDepth=0;
  maxCalleeDepth=0;

  var depth=chainDepth;

  // BFS upstream (callers)
  if(mode==="both"||mode==="callers"){
    var queue=[rootIdx],visited=new Set([rootIdx]),d=0;
    var levelMap={};levelMap[rootIdx]=0;
    while(queue.length>0&&d<depth){
      var next=[];
      queue.forEach(function(ni){
        inE[ni].forEach(function(pi){
          if(!visited.has(pi)){
            visited.add(pi);
            chainCallers.add(pi);
            var dd=(levelMap[ni]||0)+1;
            levelMap[pi]=dd;
            callerDepthMap[pi]=dd;
            if(dd>maxCallerDepth) maxCallerDepth=dd;
            chainEdges.add(pi+"-"+ni);
            next.push(pi);
          }
        });
      });
      queue=next;d++;
    }
  }

  // BFS downstream (callees)
  if(mode==="both"||mode==="callees"){
    var queue=[rootIdx],visited=new Set([rootIdx]),d=0;
    var levelMap={};levelMap[rootIdx]=0;
    while(queue.length>0&&d<depth){
      var next=[];
      queue.forEach(function(ni){
        outE[ni].forEach(function(ci){
          if(!visited.has(ci)){
            visited.add(ci);
            chainCallees.add(ci);
            var dd=(levelMap[ni]||0)+1;
            levelMap[ci]=dd;
            calleeDepthMap[ci]=dd;
            if(dd>maxCalleeDepth) maxCalleeDepth=dd;
            chainEdges.add(ni+"-"+ci);
            next.push(ci);
          }
        });
      });
      queue=next;d++;
    }
  }

  updateChainPanel();
  updateChainModeBar();
  scheduleDraw();
}

function clearChain(){
  chainRoot=-1;
  chainCallers.clear();chainCallees.clear();chainEdges.clear();
  callerDepthMap={};calleeDepthMap={};
  isolateMode=false;animOn=false;
  document.getElementById("chain-panel").style.display="none";
  document.getElementById("chain-mode-bar").style.display="none";
  document.getElementById("btn-anim").classList.remove("active");
  scheduleDraw();
}

function updateChainModeBar(){
  var bar=document.getElementById("chain-mode-bar");
  if(chainRoot<0){bar.style.display="none";return}
  bar.style.display="flex";
  var nd=N[chainRoot];
  var total=chainCallers.size+chainCallees.size;
  var modeStr=chainMode==="both"?"Full Chain":(chainMode==="callers"?"Callers Only":"Callees Only");
  document.getElementById("cmb-text").innerHTML=
    "&#128279; <b>"+nd.nm+"</b> | "+modeStr+" | "+total+" nodes in chain | Depth: "+chainDepth;
}

function updateChainPanel(){
  var panel=document.getElementById("chain-panel");
  if(chainRoot<0){panel.style.display="none";return}
  panel.style.display="block";

  // Build caller tree (BFS order, show depth)
  var callerTree=document.getElementById("caller-tree");
  var callerHtml="";
  if(chainMode==="both"||chainMode==="callers"){
    // sort by depth then by name
    var callerArr=Array.from(chainCallers);
    callerArr.sort(function(a,b){
      var da=callerDepthMap[a]||0,db=callerDepthMap[b]||0;
      if(da!==db)return da-db;
      return N[a].nm.localeCompare(N[b].nm);
    });
    callerArr.forEach(function(ci){
      var d=callerDepthMap[ci]||0;
      var indent="";
      for(var k=0;k<d;k++) indent+="&nbsp;&nbsp;";
      var prefix=d>0?("&#9492;"+"&#9472;".repeat(1)):"";
      callerHtml+='<div class="chain-item ci-caller" onmouseenter="chainHover('+ci+')" onclick="chainClick('+ci+')">';
      callerHtml+='<span class="depth-bar">'+indent+prefix+'</span>';
      callerHtml+='<span class="ci-name" title="'+N[ci].nm+' '+N[ci].ad+'">'+N[ci].nm+'</span>';
      callerHtml+='<span class="ci-cc">x'+N[ci].cc+'</span>';
      callerHtml+='</div>';
    });
  }
  callerTree.innerHTML=callerHtml||'<span style="color:#484f58;font-size:10px">No callers found</span>';
  document.getElementById("caller-title").textContent="\\u25BC Callers ("+chainCallers.size+")";

  // Build callee tree
  var calleeTree=document.getElementById("callee-tree");
  var calleeHtml="";
  if(chainMode==="both"||chainMode==="callees"){
    var calleeArr=Array.from(chainCallees);
    calleeArr.sort(function(a,b){
      var da=calleeDepthMap[a]||0,db=calleeDepthMap[b]||0;
      if(da!==db)return da-db;
      return N[a].nm.localeCompare(N[b].nm);
    });
    calleeArr.forEach(function(ci){
      var d=calleeDepthMap[ci]||0;
      var indent="";
      for(var k=0;k<d;k++) indent+="&nbsp;&nbsp;";
      var prefix=d>0?("&#9492;"+"&#9472;".repeat(1)):"";
      calleeHtml+='<div class="chain-item ci-callee" onmouseenter="chainHover('+ci+')" onclick="chainClick('+ci+')">';
      calleeHtml+='<span class="depth-bar">'+indent+prefix+'</span>';
      calleeHtml+='<span class="ci-name" title="'+N[ci].nm+' '+N[ci].ad+'">'+N[ci].nm+'</span>';
      calleeHtml+='<span class="ci-cc">x'+N[ci].cc+'</span>';
      calleeHtml+='</div>';
    });
  }
  calleeTree.innerHTML=calleeHtml||'<span style="color:#484f58;font-size:10px">No callees found</span>';
  document.getElementById("callee-title").textContent="\\u25BC Callees ("+chainCallees.size+")";

  document.getElementById("chain-stats").textContent=
    "Total chain: "+(chainCallers.size+chainCallees.size+1)+" nodes, "+chainEdges.size+" edges";
}

function chainHover(idx){hov=idx;updateHoverInfo();scheduleDraw()}
function chainClick(idx){
  // Focus on clicked node
  var nd=N[idx];
  panX=W/2-nd.x*sc;panY=H/2-nd.y*sc;
  scheduleDraw();
}
function toggleSection(which){
  var tree=document.getElementById(which==="callers"?"caller-tree":"callee-tree");
  tree.style.display=tree.style.display==="none"?"block":"none";
}
// ======================= DRAW =======================
function scheduleDraw(){
  if(raf)return;
  raf=requestAnimationFrame(function(){raf=0;draw()})
}
function draw(){
  ctx.clearRect(0,0,W,H);
  drawBgCircle();
  if(showE) drawEdges();
  if(chainRoot>=0) drawChainEdges();
  if(hov>=0&&chainRoot<0) drawHLEdges();
  drawNodes();
  if(animOn&&chainRoot>=0) drawAnimParticles();
}
function drawBgCircle(){
  var p=w2s(0,0),r=LR*sc;
  ctx.strokeStyle="rgba(48,54,61,0.15)";ctx.lineWidth=1;
  for(var i=1;i<=4;i++){ctx.beginPath();ctx.arc(p[0],p[1],r*i/4,0,Math.PI*2);ctx.stroke()}
}
function drawEdges(){
  var hasSearch=schQ.length>=2;
  var hasChain=chainRoot>=0;
  ctx.lineWidth=0.5;
  if(hasChain){
    // In chain mode, non-chain edges are very dim
    ctx.strokeStyle="rgba(50,80,120,0.02)";
    if(isolateMode) ctx.strokeStyle="rgba(0,0,0,0)";
    ctx.beginPath();
    E.forEach(function(e){
      if(chainEdges.has(e.s+"-"+e.t))return;
      if(isolateMode)return;
      var a=w2s(N[e.s].x,N[e.s].y),b=w2s(N[e.t].x,N[e.t].y);
      ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);
    });
    ctx.stroke();
  } else if(hasSearch){
    ctx.strokeStyle="rgba(50,80,120,0.03)";
    ctx.beginPath();
    E.forEach(function(e){
      if(schSet.has(e.s)||schSet.has(e.t))return;
      var a=w2s(N[e.s].x,N[e.s].y),b=w2s(N[e.t].x,N[e.t].y);
      ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);
    });
    ctx.stroke();
    ctx.strokeStyle="rgba(80,160,255,0.2)";ctx.lineWidth=0.8;
    ctx.beginPath();
    E.forEach(function(e){
      if(!schSet.has(e.s)&&!schSet.has(e.t))return;
      var a=w2s(N[e.s].x,N[e.s].y),b=w2s(N[e.t].x,N[e.t].y);
      ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);
    });
    ctx.stroke();
  } else {
    ctx.strokeStyle="rgba(50,100,160,0.07)";
    ctx.beginPath();
    E.forEach(function(e){
      var a=w2s(N[e.s].x,N[e.s].y),b=w2s(N[e.t].x,N[e.t].y);
      ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);
    });
    ctx.stroke();
  }
}
function drawChainEdges(){
  if(chainRoot<0)return;
  // Draw chain edges with direction-based coloring
  E.forEach(function(e){
    var key=e.s+"-"+e.t;
    if(!chainEdges.has(key))return;
    var a=w2s(N[e.s].x,N[e.s].y),b=w2s(N[e.t].x,N[e.t].y);
    // Determine if this is a caller-side or callee-side edge
    var isCaller=chainCallers.has(e.s)||(e.t===chainRoot&&chainCallers.has(e.s));
    var isCallee=chainCallees.has(e.t)||(e.s===chainRoot&&chainCallees.has(e.t));
    if(isCaller){
      ctx.strokeStyle="rgba(121,192,255,0.5)";
    } else if(isCallee){
      ctx.strokeStyle="rgba(126,231,135,0.5)";
    } else {
      ctx.strokeStyle="rgba(240,136,62,0.5)";
    }
    ctx.lineWidth=2;
    ctx.beginPath();
    ctx.moveTo(a[0],a[1]);ctx.lineTo(b[0],b[1]);
    ctx.stroke();
    // Draw arrow
    if(showArrows){
      drawArrow(a[0],a[1],b[0],b[1],ctx.strokeStyle);
    }
  });
}
function drawArrow(x1,y1,x2,y2,color){
  var dx=x2-x1,dy=y2-y1;
  var len=Math.sqrt(dx*dx+dy*dy);
  if(len<10)return;
  var mx=x1+dx*0.65,my=y1+dy*0.65;
  var ux=dx/len,uy=dy/len;
  var sz=Math.min(8,len*0.15);
  ctx.fillStyle=color;
  ctx.beginPath();
  ctx.moveTo(mx+ux*sz, my+uy*sz);
  ctx.lineTo(mx-ux*sz*0.3+uy*sz*0.6, my-uy*sz*0.3-ux*sz*0.6);
  ctx.lineTo(mx-ux*sz*0.3-uy*sz*0.6, my-uy*sz*0.3+ux*sz*0.6);
  ctx.closePath();ctx.fill();
}
// Animated particles along chain edges
function drawAnimParticles(){
  animT+=0.008;
  if(animT>1)animT-=1;
  E.forEach(function(e){
    if(!chainEdges.has(e.s+"-"+e.t))return;
    var a=w2s(N[e.s].x,N[e.s].y),b=w2s(N[e.t].x,N[e.t].y);
    var isCaller=chainCallers.has(e.s);
    // Multiple particles
    for(var p=0;p<3;p++){
      var t=(animT+p*0.33)%1;
      var px=a[0]+(b[0]-a[0])*t;
      var py=a[1]+(b[1]-a[1])*t;
      var alpha=Math.sin(t*Math.PI)*0.8;
      ctx.globalAlpha=alpha;
      ctx.fillStyle=isCaller?"#79c0ff":"#7ee787";
      ctx.beginPath();
      ctx.arc(px,py,2.5,0,Math.PI*2);
      ctx.fill();
    }
  });
  ctx.globalAlpha=1;
  scheduleDraw(); // keep animating
}
function drawHLEdges(){
  var nd=N[hov],p0=w2s(nd.x,nd.y);
  ctx.strokeStyle="rgba(33,136,255,0.6)";ctx.lineWidth=1.5;
  ctx.beginPath();
  inE[hov].forEach(function(j){
    var p=w2s(N[j].x,N[j].y);ctx.moveTo(p[0],p[1]);ctx.lineTo(p0[0],p0[1]);
  });
  ctx.stroke();
  ctx.strokeStyle="rgba(63,185,80,0.6)";
  ctx.beginPath();
  outE[hov].forEach(function(j){
    var p=w2s(N[j].x,N[j].y);ctx.moveTo(p0[0],p0[1]);ctx.lineTo(p[0],p[1]);
  });
  ctx.stroke();
}
function drawNodes(){
  var hasSearch=schQ.length>=2;
  var hasChain=chainRoot>=0;
  var labelSize=Math.max(8,Math.min(13,10*sc));
  ctx.textBaseline="middle";ctx.font=labelSize+"px monospace";
  for(var i=0;i<N.length;i++){
    var nd=N[i],p=w2s(nd.x,nd.y),r=ccR(nd.cc)*sc;
    var isHL=(i===hov), isRoot=(i===chainRoot);
    var inChain=isRoot||chainCallers.has(i)||chainCallees.has(i);
    var isNb=false, dim=false;
    if(r<0.3&&!isRoot&&!inChain)continue;
    if(hasChain){
      if(!inChain){dim=true; if(isolateMode)continue;}
    } else if(hov>=0){
      isNb=(inE[hov].indexOf(i)>=0||outE[hov].indexOf(i)>=0);
      if(!isHL&&!isNb)dim=true;
    }
    if(hasSearch&&!schSet.has(i)&&!inChain){dim=true}
    // Determine color
    var col=ccCol(nd.cc);
    if(hasChain&&inChain){
      if(isRoot){col="#f0883e"}
      else if(chainCallers.has(i)){
        col=depthColor(callerDepthMap[i]||1,maxCallerDepth,true);
      } else if(chainCallees.has(i)){
        col=depthColor(calleeDepthMap[i]||1,maxCalleeDepth,false);
      }
    }
    var alpha=dim?0.04:(isHL?1.0:(isRoot?1.0:(inChain?0.92:(isNb?0.9:0.85))));
    var drawR=Math.max(r,1.2);
    if(isRoot){drawR=Math.max(r*2.8,10)}
    else if(isHL){drawR=Math.max(r*2.2,6)}
    else if(isNb||inChain){drawR=Math.max(r*1.5,4)}
    ctx.globalAlpha=alpha;
    ctx.fillStyle=col;
    ctx.beginPath();ctx.arc(p[0],p[1],drawR,0,Math.PI*2);ctx.fill();
    // glow
    if(isHL||isRoot){
      ctx.save();ctx.shadowColor=col;ctx.shadowBlur=isRoot?25:18;
      ctx.beginPath();ctx.arc(p[0],p[1],drawR,0,Math.PI*2);ctx.fill();
      ctx.restore();
    }
    // ring for chain nodes
    if(inChain&&!isRoot&&!dim){
      ctx.strokeStyle=col;ctx.lineWidth=1.5;
      ctx.beginPath();ctx.arc(p[0],p[1],drawR+2,0,Math.PI*2);ctx.stroke();
    }
    // Root pulsing ring
    if(isRoot){
      var pulse=0.5+0.5*Math.sin(Date.now()*0.004);
      ctx.globalAlpha=pulse*0.4;
      ctx.strokeStyle="#f0883e";ctx.lineWidth=2;
      ctx.beginPath();ctx.arc(p[0],p[1],drawR+6+pulse*4,0,Math.PI*2);ctx.stroke();
      ctx.globalAlpha=alpha;
    }
    // depth label for chain nodes
    if(hasChain&&inChain&&!isRoot&&drawR*2>3){
      var depthNum=callerDepthMap[i]||calleeDepthMap[i]||0;
      if(depthNum>0){
        ctx.globalAlpha=0.6;
        ctx.fillStyle="#0d1117";
        ctx.font="bold "+Math.max(7,drawR*0.9)+"px monospace";
        ctx.textAlign="center";
        ctx.fillText(depthNum,p[0],p[1]+0.5);
        ctx.textAlign="left";
        ctx.font=labelSize+"px monospace";
      }
    }
    // label
    var showThisLabel=showL||(isHL||isRoot||isNb||(inChain&&drawR>3))&&!dim;
    if(showThisLabel&&drawR*2>3){
      ctx.globalAlpha=isHL||isRoot?1.0:(inChain?0.85:(isNb?0.8:(dim?0.05:0.55)));
      ctx.fillStyle=isRoot?"#f0883e":"#c9d1d9";
      ctx.textAlign="left";
      var tx=p[0]+drawR+5,ty=p[1];
      var tw=ctx.measureText(nd.nm).width;
      if(tx+tw>W-10){tx=p[0]-drawR-5-tw}
      ctx.fillText(nd.nm,tx,ty);
    }
  }
  ctx.globalAlpha=1.0;
  ctx.textAlign="left";
}
// ======================= EVENTS =======================
function bindEvt(){
  window.addEventListener("resize",resize);
  cv.addEventListener("wheel",function(e){
    e.preventDefault();
    var factor=e.deltaY<0?1.12:0.89;
    zoomAt(e.clientX,e.clientY,factor);
  },{passive:false});
  cv.addEventListener("mousedown",function(e){
    if(e.button===0){drag=true;dragX=e.clientX;dragY=e.clientY;cv.classList.add("dragging")}
    hideCtxMenu();
  });
  window.addEventListener("mousemove",function(e){
    if(drag){panX+=e.clientX-dragX;panY+=e.clientY-dragY;dragX=e.clientX;dragY=e.clientY;scheduleDraw();return}
    doHover(e.clientX,e.clientY);
  });
  window.addEventListener("mouseup",function(){drag=false;cv.classList.remove("dragging")});
  cv.addEventListener("dblclick",function(e){
    var idx=pickNode(e.clientX,e.clientY);
    if(idx>=0){
      buildChain(idx,"both");
      var nd=N[idx];
      panX=W/2-nd.x*sc;panY=H/2-nd.y*sc;
      scheduleDraw();
    }
  });
  // Right-click context menu
  cv.addEventListener("contextmenu",function(e){
    e.preventDefault();
    var idx=pickNode(e.clientX,e.clientY);
    if(idx>=0){
      ctxNode=idx;
      showCtxMenu(e.clientX,e.clientY);
    } else {
      hideCtxMenu();
    }
  });
  document.addEventListener("click",function(e){
    if(!e.target.closest("#ctx-menu")) hideCtxMenu();
  });
  document.getElementById("sch").addEventListener("input",function(e){
    schQ=e.target.value.trim().toLowerCase();
    schSet.clear();
    if(schQ.length>=2){
      N.forEach(function(nd,i){if(nd.nm.toLowerCase().indexOf(schQ)>=0)schSet.add(i)});
    }
    scheduleDraw();
  });
}
function zoomAt(sx,sy,factor){
  var wpt=s2w(sx,sy);sc*=factor;
  if(sc<0.005)sc=0.005;if(sc>80)sc=80;
  panX=sx-wpt[0]*sc;panY=sy-wpt[1]*sc;
  scheduleDraw();
}
function zoomBy(f){zoomAt(W/2,H/2,f)}
function doHover(mx,my){
  var prev=hov;hov=pickNode(mx,my);
  if(hov!==prev){updateHoverInfo();scheduleDraw()}
}
function pickNode(mx,my){
  var wpt=s2w(mx,my),best=-1,bestD=Infinity;
  for(var i=0;i<N.length;i++){
    if(isolateMode&&chainRoot>=0&&i!==chainRoot&&!chainCallers.has(i)&&!chainCallees.has(i))continue;
    var dx=N[i].x-wpt[0],dy=N[i].y-wpt[1],d=dx*dx+dy*dy;
    var rw=ccR(N[i].cc)+5/sc;
    if(i===chainRoot)rw*=2.5;
    if(d<rw*rw&&d<bestD){bestD=d;best=i}
  }
  return best;
}
function updateHoverInfo(){
  var box=document.getElementById("hover-info");
  if(hov<0){box.style.display="none";return}
  box.style.display="block";
  var nd=N[hov];
  document.getElementById("h-name").textContent=nd.nm;
  document.getElementById("h-addr").textContent="Address: "+nd.ad;
  document.getElementById("h-cc").textContent="Called "+nd.cc+" times";
  document.getElementById("h-out").textContent="Calls out: "+outE[hov].length+" functions";
  document.getElementById("h-in").textContent="Called by: "+inE[hov].length+" functions";
  if(chainRoot>=0){
    var extra="";
    if(hov===chainRoot) extra=" [CHAIN ROOT]";
    else if(chainCallers.has(hov)) extra=" [Caller, depth "+(callerDepthMap[hov]||"?")+"]";
    else if(chainCallees.has(hov)) extra=" [Callee, depth "+(calleeDepthMap[hov]||"?")+"]";
    document.getElementById("h-cc").textContent+= extra;
  }
}
// ======================= CONTEXT MENU =======================
function showCtxMenu(x,y){
  var m=document.getElementById("ctx-menu");
  m.style.display="block";
  // Prevent overflow
  var mw=220,mh=220;
  if(x+mw>W)x=W-mw-10;
  if(y+mh>H)y=H-mh-10;
  m.style.left=x+"px";m.style.top=y+"px";
}
function hideCtxMenu(){document.getElementById("ctx-menu").style.display="none"}
function cmShowChain(){
  hideCtxMenu();
  if(ctxNode>=0) buildChain(ctxNode,"both");
}
function cmCallers(){
  hideCtxMenu();
  if(ctxNode>=0) buildChain(ctxNode,"callers");
}
function cmCallees(){
  hideCtxMenu();
  if(ctxNode>=0) buildChain(ctxNode,"callees");
}
function cmIsolate(){
  hideCtxMenu();
  if(ctxNode>=0){
    if(chainRoot<0) buildChain(ctxNode,"both");
    isolateMode=!isolateMode;
    scheduleDraw();
  }
}
function cmFocusNode(){
  hideCtxMenu();
  if(ctxNode>=0){
    var nd=N[ctxNode];
    panX=W/2-nd.x*sc;panY=H/2-nd.y*sc;
    zoomAt(W/2,H/2,2);
  }
}
function cmCopyName(){
  hideCtxMenu();
  if(ctxNode>=0){
    copyText(N[ctxNode].nm);
  }
}
function cmCopyChainText(){
  hideCtxMenu();
  if(chainRoot<0){alert("No chain selected. Double-click a node first.");return}
  var lines=[];
  lines.push("=== Call Chain for: "+N[chainRoot].nm+" ("+N[chainRoot].ad+") ===");
  lines.push("");
  if(chainCallers.size>0){
    lines.push("--- Callers (upstream) ---");
    var arr=Array.from(chainCallers).sort(function(a,b){return(callerDepthMap[a]||0)-(callerDepthMap[b]||0)});
    arr.forEach(function(ci){
      var d=callerDepthMap[ci]||0;
      lines.push("  ".repeat(d)+"["+d+"] "+N[ci].nm+" ("+N[ci].ad+") called "+N[ci].cc+"x");
    });
  }
  lines.push("");
  lines.push(">>> ROOT: "+N[chainRoot].nm+" ("+N[chainRoot].ad+") <<<");
  lines.push("");
  if(chainCallees.size>0){
    lines.push("--- Callees (downstream) ---");
    var arr=Array.from(chainCallees).sort(function(a,b){return(calleeDepthMap[a]||0)-(calleeDepthMap[b]||0)});
    arr.forEach(function(ci){
      var d=calleeDepthMap[ci]||0;
      lines.push("  ".repeat(d)+"["+d+"] "+N[ci].nm+" ("+N[ci].ad+") called "+N[ci].cc+"x");
    });
  }
  copyText(lines.join("\\n"));
}
function copyText(t){
  if(navigator.clipboard){navigator.clipboard.writeText(t).catch(function(){})}
  else{var ta=document.createElement("textarea");ta.value=t;document.body.appendChild(ta);ta.select();document.execCommand("copy");document.body.removeChild(ta)}
}
// ======================= TOOLBAR =======================
function togEdge(){
  showE=!showE;
  document.getElementById("be").textContent=showE?"\\u{1F4D0} Hide Edges":"\\u{1F4D0} Show Edges";
  scheduleDraw();
}
function togLabel(){showL=!showL;scheduleDraw()}
function togAnim(){
  animOn=!animOn;animT=0;
  document.getElementById("btn-anim").classList.toggle("active",animOn);
  if(animOn)scheduleDraw();
}
function togArrow(){showArrows=!showArrows;scheduleDraw()}
function exportChain(){
  if(chainRoot<0){alert("Double-click a function node to select a call chain first.");return}
  // Export as JSON
  var data={
    root:{name:N[chainRoot].nm, addr:N[chainRoot].ad, callCount:N[chainRoot].cc},
    callers:Array.from(chainCallers).map(function(i){return{name:N[i].nm,addr:N[i].ad,cc:N[i].cc,depth:callerDepthMap[i]||0}}),
    callees:Array.from(chainCallees).map(function(i){return{name:N[i].nm,addr:N[i].ad,cc:N[i].cc,depth:calleeDepthMap[i]||0}}),
    edges:Array.from(chainEdges).map(function(k){var p=k.split("-");return{from:N[parseInt(p[0])].nm,to:N[parseInt(p[1])].nm}})
  };
  var blob=new Blob([JSON.stringify(data,null,2)],{type:"application/json"});
  var url=URL.createObjectURL(blob);
  var a=document.createElement("a");
  a.href=url;a.download="call_chain_"+N[chainRoot].nm+".json";
  document.body.appendChild(a);a.click();document.body.removeChild(a);
  URL.revokeObjectURL(url);
}
// ======================= START =======================
init();
</script>
</body>
</html>'''
def generate_html(functions, edges, call_count, out_path):
    """生成可视化HTML文件"""
    func_list = sorted(functions.keys(),
                       key=lambda ea: call_count.get(ea, 0), reverse=True)
    idx_map = {ea: i for i, ea in enumerate(func_list)}
    n = len(func_list)
    if n == 0:
        print("[!] No functions!")
        return False
    layout_r = max(300, min(800, n * 0.4 + 200))
    pts = sunflower_layout(n, layout_r)
    max_cc = max(call_count.values()) if call_count else 1
    if max_cc == 0:
        max_cc = 1
    nodes = []
    for i, ea in enumerate(func_list):
        nm = functions[ea]
        if len(nm) > 50:
            nm = nm[:47] + "..."
        nodes.append({
            "nm": nm,
            "ad": "0x{:X}".format(ea),
            "cc": call_count.get(ea, 0),
            "x": pts[i][0],
            "y": pts[i][1]
        })
    edge_data = []
    seen = set()
    for src, dst, w in edges:
        if src in idx_map and dst in idx_map:
            si, di = idx_map[src], idx_map[dst]
            if (si, di) not in seen:
                seen.add((si, di))
                edge_data.append({"s": si, "t": di})
    nj = json.dumps(nodes, ensure_ascii=False, separators=(',', ':'))
    ej = json.dumps(edge_data, separators=(',', ':'))
    html = build_html(nj, ej, n, len(edge_data), max_cc, int(layout_r))
    with open(out_path, 'w', encoding='utf-8') as f:
        f.write(html)
    return True
def main():
    print("=" * 60)
    print("  IDA 2D Function Call Graph - Call Chain Explorer")
    print("=" * 60)
    print("\n[1/3] Extracting call graph...")
    functions, edges, call_count = extract_call_graph()
    if not functions:
        print("[!] No functions found!")
        return
    max_cc = max(call_count.values()) if call_count else 0
    sorted_cc = sorted(call_count.items(), key=lambda x: x[1], reverse=True)
    print(f"\n    Functions : {len(functions)}")
    print(f"    Edges     : {len(edges)}")
    print(f"    Max called: {max_cc}")
    print(f"\n    Top 15 hottest functions:")
    print(f"    {'Name':<42s} {'Addr':<14s} {'Calls':>5s}")
    print(f"    {'─' * 42} {'─' * 14} {'─' * 5}")
    for ea, cc in sorted_cc[:15]:
        nm = functions.get(ea, "?")
        if len(nm) > 41:
            nm = nm[:38] + "..."
        print(f"    {nm:<42s} 0x{ea:08X}   {cc:>4d}")
    print(f"\n[2/3] Generating HTML...")
    idb_path = idc.get_idb_path()
    out_dir = os.path.dirname(idb_path) if idb_path else os.getcwd()
    out_file = os.path.join(out_dir, "call_graph_2d_chain.html")
    if not generate_html(functions, edges, call_count, out_file):
        return
    print(f"    Saved: {out_file}")
    print(f"\n[3/3] Opening browser...")
    try:
        webbrowser.open('file:///' + os.path.abspath(out_file).replace('\\', '/'))
        print("    Done!")
    except Exception as e:
        print(f"    Open manually: {out_file}")
    print("\n" + "=" * 60)
if __name__ == "__main__":
    main()
else:
    main()

3D图

# -*- coding: utf-8 -*-
"""
IDA Function Call Graph - 3D Sphere Visualization
功能:
  1. 导出所有函数间控制流(调用关系)
  2. 统计每个函数被调用次数,以不同颜色展示
  3. 球形布局展示所有函数节点
  4. 支持鼠标滚轮缩放、拖拽旋转、右键平移
  5. 支持函数名搜索、悬停高亮
"""

import idautils
import idaapi
import idc
import json
import os
import math
import webbrowser
from collections import defaultdict


# ================================================================
#  第一部分: 从 IDA 数据库提取函数调用图
# ================================================================

def extract_call_graph():
    """提取函数调用图: 所有函数、调用边、被调用次数"""
    
    functions = {}       # ea -> name
    edges = []           # [(caller_ea, callee_ea), ...]
    call_count = defaultdict(int)  # callee_ea -> count
    edge_weight = defaultdict(int) # (caller, callee) -> weight
    
    # 1) 收集所有函数
    for func_ea in idautils.Functions():
        func_name = idc.get_func_name(func_ea)
        if func_name:
            functions[func_ea] = func_name
            call_count[func_ea] = 0  # 初始化
    
    print(f"[*] Collected {len(functions)} functions")
    
    # 2) 遍历每个函数体, 寻找调用指令
    processed = 0
    for caller_ea in list(functions.keys()):
        func = idaapi.get_func(caller_ea)
        if not func:
            continue
        
        # 遍历函数内所有指令地址
        for head in idautils.Heads(func.start_ea, func.end_ea):
            # 获取从该指令发出的所有代码引用
            for xref in idautils.XrefsFrom(head, 0):
                # fl_CF = 普通调用流, fl_CN = 近调用流
                if xref.type in (idaapi.fl_CF, idaapi.fl_CN):
                    callee_func = idaapi.get_func(xref.to)
                    if callee_func and callee_func.start_ea in functions:
                        callee_ea = callee_func.start_ea
                        if callee_ea != caller_ea:  # 排除自递归
                            pair = (caller_ea, callee_ea)
                            edge_weight[pair] += 1
                            call_count[callee_ea] += 1
        
        processed += 1
        if processed % 500 == 0:
            print(f"    Processed {processed}/{len(functions)} functions...")
    
    # 3) 去重边, 保留权重
    unique_edges = []
    for (src, dst), weight in edge_weight.items():
        unique_edges.append((src, dst, weight))
    
    # 4) 同时用 XrefsTo 补充被调用次数统计(更准确)
    for func_ea in functions:
        xref_count = 0
        for xref in idautils.XrefsTo(func_ea, 0):
            if xref.type in (idaapi.fl_CF, idaapi.fl_CN):
                xref_count += 1
        # 取两种方式的最大值
        if xref_count > call_count[func_ea]:
            call_count[func_ea] = xref_count
    
    return functions, unique_edges, dict(call_count)


# ================================================================
#  第二部分: 球形布局算法 (Fibonacci Sphere)
# ================================================================

def fibonacci_sphere(n, radius=500):
    """
    使用 Fibonacci 螺旋在球面上均匀分布 n 个点
    返回 [(x, y, z), ...]
    """
    if n == 0:
        return []
    if n == 1:
        return [(0, 0, 0)]
    
    points = []
    golden_ratio = (1 + math.sqrt(5)) / 2.0
    
    for i in range(n):
        theta = 2.0 * math.pi * i / golden_ratio
        phi = math.acos(1.0 - 2.0 * (i + 0.5) / n)
        
        x = radius * math.sin(phi) * math.cos(theta)
        y = radius * math.sin(phi) * math.sin(theta)
        z = radius * math.cos(phi)
        
        points.append((round(x, 2), round(y, 2), round(z, 2)))
    
    return points


# ================================================================
#  第三部分: 生成交互式 3D HTML 可视化
# ================================================================

def generate_visualization(functions, edges, call_count, output_path):
    """生成带有 Three.js 的 3D 交互可视化 HTML 文件"""
    
    # 建立索引映射
    func_list = sorted(functions.keys())  # 排序保证一致性
    func_index = {ea: i for i, ea in enumerate(func_list)}
    n = len(func_list)
    
    if n == 0:
        print("[!] No functions found!")
        return False
    
    # 球形布局
    positions = fibonacci_sphere(n, radius=500)
    
    # 计算最大被调用次数
    max_count = max(call_count.values()) if call_count else 1
    if max_count == 0:
        max_count = 1
    
    # 构建节点数据 JSON
    nodes_data = []
    for i, ea in enumerate(func_list):
        cc = call_count.get(ea, 0)
        nodes_data.append({
            "id": i,
            "name": functions[ea],
            "addr": "0x{:X}".format(ea),
            "cc": cc,  # call count
            "x": positions[i][0],
            "y": positions[i][1],
            "z": positions[i][2],
        })
    
    # 构建边数据 JSON
    edges_data = []
    seen_edges = set()
    for src_ea, dst_ea, weight in edges:
        if src_ea in func_index and dst_ea in func_index:
            si, di = func_index[src_ea], func_index[dst_ea]
            key = (si, di)
            if key not in seen_edges:
                seen_edges.add(key)
                edges_data.append({"s": si, "t": di, "w": weight})
    
    nodes_json = json.dumps(nodes_data, ensure_ascii=False)
    edges_json = json.dumps(edges_data)
    
    # 生成 HTML
    html_content = _build_html(nodes_json, edges_json, n, len(edges_data), max_count)
    
    with open(output_path, 'w', encoding='utf-8') as f:
        f.write(html_content)
    
    return True


def _build_html(nodes_json, edges_json, num_nodes, num_edges, max_count):
    """构建完整的 HTML 页面"""
    
    return f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>IDA 3D Function Call Graph</title>
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
body {{ 
    overflow:hidden; 
    background:#0a0a1a; 
    font-family: 'Segoe UI', Consolas, monospace; 
    color:#ccc;
}}
canvas {{ display:block; }}

#panel {{
    position:absolute; top:12px; left:12px; z-index:100;
    background:rgba(10,10,30,0.88); border:1px solid #334;
    border-radius:10px; padding:16px; width:340px;
    backdrop-filter:blur(8px);
}}
#panel h2 {{ 
    font-size:16px; color:#6af; margin-bottom:10px; 
    border-bottom:1px solid #334; padding-bottom:8px;
}}
#panel .stat {{ font-size:12px; color:#89a; margin:3px 0; }}
#panel .stat b {{ color:#adf; }}
#panel .hint {{ font-size:11px; color:#556; margin-top:10px; line-height:1.6; }}

#search-box {{
    position:absolute; top:12px; right:12px; z-index:100;
}}
#search-box input {{
    padding:10px 14px; width:240px; border-radius:8px;
    border:1px solid #445; background:rgba(10,10,30,0.85);
    color:#eee; font-size:13px; outline:none;
    backdrop-filter:blur(8px);
}}
#search-box input:focus {{ border-color:#6af; }}
#search-box input::placeholder {{ color:#556; }}

#legend {{
    position:absolute; bottom:12px; left:12px; z-index:100;
    background:rgba(10,10,30,0.88); border:1px solid #334;
    border-radius:10px; padding:14px;
    backdrop-filter:blur(8px);
}}
#legend h3 {{ font-size:13px; color:#6af; margin-bottom:8px; }}
.lg {{ display:flex; align-items:center; margin:4px 0; font-size:12px; }}
.lg-c {{ 
    width:14px; height:14px; border-radius:50%; margin-right:10px; 
    box-shadow: 0 0 6px currentColor;
}}

#tooltip {{
    position:absolute; display:none; z-index:200;
    background:rgba(5,5,20,0.92); border:1px solid #56a;
    border-radius:8px; padding:12px 16px; pointer-events:none;
    font-size:12px; line-height:1.6; min-width:200px;
    backdrop-filter:blur(10px);
    box-shadow: 0 4px 20px rgba(0,100,255,0.2);
}}
#tooltip .fn {{ color:#6cf; font-size:14px; font-weight:bold; }}
#tooltip .ad {{ color:#789; }}
#tooltip .cc {{ color:#fa4; }}

#loading {{
    position:fixed; top:0; left:0; width:100%; height:100%;
    background:#0a0a1a; display:flex; align-items:center;
    justify-content:center; z-index:9999; font-size:18px; color:#6af;
}}
</style>
</head>
<body>

<div id="loading">⏳ Loading 3D Call Graph...</div>

<div id="panel">
    <h2>📊 Function Call Graph</h2>
    <div class="stat">Functions: <b>{num_nodes}</b></div>
    <div class="stat">Call Edges: <b>{num_edges}</b></div>
    <div class="stat">Max Calls: <b>{max_count}</b></div>
    <div class="hint">
        🖱 Left Drag → Rotate<br>
        🔍 Scroll → Zoom In/Out<br>
        🖱 Right Drag → Pan<br>
        ⌨ Input → Search Function
    </div>
</div>

<div id="search-box">
    <input type="text" id="search" placeholder="🔍 Search function name...">
</div>

<div id="legend">
    <h3>被调用次数 (Call Count)</h3>
    <div class="lg"><div class="lg-c" style="background:#3355cc;color:#3355cc"></div> 0 次 (未被调用)</div>
    <div class="lg"><div class="lg-c" style="background:#33aaff;color:#33aaff"></div> 1 ~ 3 次</div>
    <div class="lg"><div class="lg-c" style="background:#33ffaa;color:#33ffaa"></div> 4 ~ 10 次</div>
    <div class="lg"><div class="lg-c" style="background:#aaff33;color:#aaff33"></div> 11 ~ 30 次</div>
    <div class="lg"><div class="lg-c" style="background:#ffcc33;color:#ffcc33"></div> 31 ~ 80 次</div>
    <div class="lg"><div class="lg-c" style="background:#ff6633;color:#ff6633"></div> 81 ~ 200 次</div>
    <div class="lg"><div class="lg-c" style="background:#ff2222;color:#ff2222"></div> 200+ 次 (热点函数)</div>
</div>

<div id="tooltip">
    <div class="fn" id="tip-name"></div>
    <div class="ad" id="tip-addr"></div>
    <div class="cc" id="tip-cc"></div>
</div>

<!-- Three.js CDN -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script>

<script>
// ===================== DATA =====================
const NODES = {nodes_json};
const EDGES = {edges_json};
const MAX_CC = {max_count};

// ===================== COLOR SCHEME =====================
function ccColor(cc) {{
    if (cc === 0)   return 0x3355cc;
    if (cc <= 3)    return 0x33aaff;
    if (cc <= 10)   return 0x33ffaa;
    if (cc <= 30)   return 0xaaff33;
    if (cc <= 80)   return 0xffcc33;
    if (cc <= 200)  return 0xff6633;
    return 0xff2222;
}}

function ccSize(cc) {{
    if (cc === 0)   return 2.5;
    if (cc <= 3)    return 3.5;
    if (cc <= 10)   return 5;
    if (cc <= 30)   return 6.5;
    if (cc <= 80)   return 8;
    if (cc <= 200)  return 10;
    return 13;
}}

// ===================== THREE.JS SETUP =====================
const W = window.innerWidth, H = window.innerHeight;

const renderer = new THREE.WebGLRenderer({{ antialias: true, alpha: false }});
renderer.setSize(W, H);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a1a);
scene.fog = new THREE.FogExp2(0x0a0a1a, 0.00035);

const camera = new THREE.PerspectiveCamera(55, W / H, 1, 8000);
camera.position.set(0, 200, 1400);

// OrbitControls: 支持鼠标滚轮缩放 + 拖拽旋转
const controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.06;
controls.minDistance = 80;
controls.maxDistance = 4000;
controls.autoRotate = true;
controls.autoRotateSpeed = 0.3;
controls.enablePan = true;  // 右键平移

// ===================== LIGHTS =====================
scene.add(new THREE.AmbientLight(0x404060, 1.2));
const dLight1 = new THREE.DirectionalLight(0xffffff, 0.7);
dLight1.position.set(600, 600, 600);
scene.add(dLight1);
const dLight2 = new THREE.DirectionalLight(0x4488ff, 0.3);
dLight2.position.set(-400, -400, -400);
scene.add(dLight2);
const pLight = new THREE.PointLight(0x6688ff, 0.5, 2000);
pLight.position.set(0, 0, 0);
scene.add(pLight);

// ===================== SPHERE WIREFRAME GUIDE =====================
const guideGeo = new THREE.SphereGeometry(502, 24, 24);
const guideEdge = new THREE.EdgesGeometry(guideGeo);
const guideMat = new THREE.LineBasicMaterial({{ 
    color: 0x1a2040, transparent: true, opacity: 0.15 
}});
scene.add(new THREE.LineSegments(guideEdge, guideMat));

// ===================== BUILD NODES =====================
const nodeMeshes = [];
const nodeGroup = new THREE.Group();

NODES.forEach((nd, i) => {{
    const sz = ccSize(nd.cc);
    const col = new THREE.Color(ccColor(nd.cc));
    
    const geo = new THREE.SphereGeometry(sz, 14, 14);
    const mat = new THREE.MeshPhongMaterial({{
        color: col,
        emissive: col.clone().multiplyScalar(0.35),
        specular: 0x222244,
        shininess: 60,
        transparent: true,
        opacity: 0.92
    }});
    
    const mesh = new THREE.Mesh(geo, mat);
    mesh.position.set(nd.x, nd.y, nd.z);
    mesh.userData = {{ idx: i }};
    
    nodeGroup.add(mesh);
    nodeMeshes.push(mesh);
}});
scene.add(nodeGroup);

// ===================== BUILD EDGES (使用 BufferGeometry 优化) =====================
const edgeGroup = new THREE.Group();

// 为减少 draw call, 将所有普通边合并为一个 geometry
const edgePositions = [];
EDGES.forEach(e => {{
    const s = NODES[e.s], t = NODES[e.t];
    edgePositions.push(s.x, s.y, s.z, t.x, t.y, t.z);
}});

if (edgePositions.length > 0) {{
    const edgeGeo = new THREE.BufferGeometry();
    edgeGeo.setAttribute('position', 
        new THREE.Float32BufferAttribute(edgePositions, 3));
    const edgeMat = new THREE.LineBasicMaterial({{
        color: 0x1a3050,
        transparent: true,
        opacity: 0.12
    }});
    const edgeLines = new THREE.LineSegments(edgeGeo, edgeMat);
    edgeGroup.add(edgeLines);
}}

scene.add(edgeGroup);

// 为高亮用途, 也保留单独的边引用 (使用一个 overlay group)
const hlGroup = new THREE.Group();
scene.add(hlGroup);

// ===================== GLOW PARTICLES (装饰性粒子) =====================
const particleCount = Math.min(NODES.length * 2, 3000);
const pGeo = new THREE.BufferGeometry();
const pPositions = new Float32Array(particleCount * 3);
for (let i = 0; i < particleCount; i++) {{
    const theta = Math.random() * Math.PI * 2;
    const phi = Math.acos(2 * Math.random() - 1);
    const r = 480 + Math.random() * 40;
    pPositions[i*3]   = r * Math.sin(phi) * Math.cos(theta);
    pPositions[i*3+1] = r * Math.sin(phi) * Math.sin(theta);
    pPositions[i*3+2] = r * Math.cos(phi);
}}
pGeo.setAttribute('position', new THREE.Float32BufferAttribute(pPositions, 3));
const pMat = new THREE.PointsMaterial({{ 
    color: 0x334466, size: 1.2, transparent: true, opacity: 0.4 
}});
scene.add(new THREE.Points(pGeo, pMat));

// ===================== RAYCASTER & TOOLTIP =====================
const raycaster = new THREE.Raycaster();
const mouseVec = new THREE.Vector2();
const tooltip = document.getElementById('tooltip');
const tipName = document.getElementById('tip-name');
const tipAddr = document.getElementById('tip-addr');
const tipCC   = document.getElementById('tip-cc');

let hoveredIdx = -1;
let prevScale = null;

// 预建每个节点的邻接表(用于高亮连接边)
const adjEdges = new Map();
NODES.forEach((_, i) => adjEdges.set(i, []));
EDGES.forEach((e, ei) => {{
    adjEdges.get(e.s).push({{ ei, other: e.t }});
    adjEdges.get(e.t).push({{ ei, other: e.s }});
}});

function onMouseMove(evt) {{
    mouseVec.x =  (evt.clientX / window.innerWidth)  * 2 - 1;
    mouseVec.y = -(evt.clientY / window.innerHeight) * 2 + 1;
    
    raycaster.setFromCamera(mouseVec, camera);
    const hits = raycaster.intersectObjects(nodeMeshes);
    
    // 清除旧高亮
    if (hoveredIdx >= 0) {{
        resetNodeHL(hoveredIdx);
        clearEdgeHL();
        hoveredIdx = -1;
    }}
    
    if (hits.length > 0) {{
        const mesh = hits[0].object;
        const idx = mesh.userData.idx;
        const nd = NODES[idx];
        hoveredIdx = idx;
        
        // 高亮节点
        mesh.material.emissive.set(0xffffff);
        mesh.scale.set(2, 2, 2);
        
        // 显示 tooltip
        tooltip.style.display = 'block';
        tooltip.style.left = (evt.clientX + 18) + 'px';
        tooltip.style.top  = (evt.clientY + 18) + 'px';
        tipName.textContent = nd.name;
        tipAddr.textContent = 'Address: ' + nd.addr;
        tipCC.textContent   = 'Called: ' + nd.cc + ' times';
        
        // 高亮相连的边
        highlightEdges(idx);
        
        // 停止自动旋转
        controls.autoRotate = false;
        
    }} else {{
        tooltip.style.display = 'none';
        controls.autoRotate = true;
    }}
}}

function resetNodeHL(idx) {{
    const mesh = nodeMeshes[idx];
    const col = new THREE.Color(ccColor(NODES[idx].cc));
    mesh.material.emissive = col.clone().multiplyScalar(0.35);
    mesh.scale.set(1, 1, 1);
}}

function highlightEdges(nodeIdx) {{
    clearEdgeHL();
    const adj = adjEdges.get(nodeIdx) || [];
    
    adj.forEach(a => {{
        const s = NODES[nodeIdx], t = NODES[a.other];
        const geo = new THREE.BufferGeometry().setFromPoints([
            new THREE.Vector3(s.x, s.y, s.z),
            new THREE.Vector3(t.x, t.y, t.z)
        ]);
        const mat = new THREE.LineBasicMaterial({{ 
            color: 0x00ddff, transparent: true, opacity: 0.85, linewidth: 2
        }});
        hlGroup.add(new THREE.Line(geo, mat));
        
        // 也高亮对端节点
        const oMesh = nodeMeshes[a.other];
        oMesh.material.emissive.set(0x00aaff);
        oMesh.scale.set(1.5, 1.5, 1.5);
    }});
}}

function clearEdgeHL() {{
    while (hlGroup.children.length > 0) {{
        const c = hlGroup.children[0];
        c.geometry.dispose();
        c.material.dispose();
        hlGroup.remove(c);
    }}
    // reset all node scales that might have been changed
    nodeMeshes.forEach((m, i) => {{
        if (i !== hoveredIdx) {{
            const col = new THREE.Color(ccColor(NODES[i].cc));
            m.material.emissive = col.clone().multiplyScalar(0.35);
            m.scale.set(1, 1, 1);
        }}
    }});
}}

renderer.domElement.addEventListener('mousemove', onMouseMove, false);

// ===================== SEARCH =====================
let searchHL = [];

document.getElementById('search').addEventListener('input', function(e) {{
    const q = e.target.value.trim().toLowerCase();
    
    // 恢复所有节点
    searchHL.forEach(idx => {{
        const m = nodeMeshes[idx];
        m.scale.set(1, 1, 1);
        m.material.opacity = 0.92;
        const col = new THREE.Color(ccColor(NODES[idx].cc));
        m.material.emissive = col.clone().multiplyScalar(0.35);
    }});
    nodeMeshes.forEach(m => {{ m.material.opacity = 0.92; }});
    searchHL = [];
    
    if (q.length < 2) return;
    
    // 半透明化所有节点
    nodeMeshes.forEach(m => {{ m.material.opacity = 0.08; }});
    
    // 高亮匹配节点
    NODES.forEach((nd, i) => {{
        if (nd.name.toLowerCase().includes(q)) {{
            const m = nodeMeshes[i];
            m.material.opacity = 1.0;
            m.material.emissive.set(0xffffff);
            m.scale.set(3, 3, 3);
            searchHL.push(i);
        }}
    }});
    
    // 如果只找到一个, 飞向它
    if (searchHL.length === 1) {{
        const nd = NODES[searchHL[0]];
        const target = new THREE.Vector3(nd.x, nd.y, nd.z);
        controls.target.lerp(target, 0.5);
    }}
}});

// ===================== DOUBLE CLICK: Focus =====================
renderer.domElement.addEventListener('dblclick', function(evt) {{
    mouseVec.x =  (evt.clientX / window.innerWidth)  * 2 - 1;
    mouseVec.y = -(evt.clientY / window.innerHeight) * 2 + 1;
    raycaster.setFromCamera(mouseVec, camera);
    const hits = raycaster.intersectObjects(nodeMeshes);
    if (hits.length > 0) {{
        const nd = NODES[hits[0].object.userData.idx];
        const target = new THREE.Vector3(nd.x, nd.y, nd.z);
        controls.target.copy(target);
        
        // 移动相机靠近
        const dir = target.clone().normalize();
        camera.position.copy(target.clone().add(dir.multiplyScalar(150)));
    }}
}});

// ===================== RESIZE =====================
window.addEventListener('resize', () => {{
    camera.aspect = window.innerWidth / window.innerHeight;
    camera.updateProjectionMatrix();
    renderer.setSize(window.innerWidth, window.innerHeight);
}});

// ===================== ANIMATION LOOP =====================
function animate() {{
    requestAnimationFrame(animate);
    controls.update();
    renderer.render(scene, camera);
}}

// 隐藏加载界面并启动
document.getElementById('loading').style.display = 'none';
animate();

console.log('✅ Call Graph loaded:', NODES.length, 'nodes,', EDGES.length, 'edges');
</script>
</body>
</html>'''


# ================================================================
#  第四部分: 主入口
# ================================================================

def main():
    print("=" * 62)
    print("  IDA Function Call Graph → 3D Sphere Visualization")
    print("=" * 62)
    
    # 1) 提取调用图
    print("\n[1/3] Extracting function call graph from IDB...")
    functions, edges, call_count = extract_call_graph()
    
    if not functions:
        print("[!] No functions found in database!")
        return
    
    print(f"\n[*] Summary:")
    print(f"    Total functions : {len(functions)}")
    print(f"    Total call edges: {len(edges)}")
    
    # 统计分布
    max_cc = max(call_count.values()) if call_count else 0
    print(f"    Max call count  : {max_cc}")
    
    # 输出 Top 20 热点函数
    sorted_cc = sorted(call_count.items(), key=lambda x: x[1], reverse=True)
    print(f"\n[*] Top 20 Most Called Functions:")
    print(f"    {'Function':<45s} {'Address':<14s} {'Calls':>6s}")
    print(f"    {'─'*45} {'─'*14} {'─'*6}")
    for ea, cc in sorted_cc[:20]:
        name = functions.get(ea, "???")
        if len(name) > 44:
            name = name[:41] + "..."
        print(f"    {name:<45s} 0x{ea:08X}     {cc:>4d}")
    
    # 2) 生成 HTML 可视化
    print(f"\n[2/3] Generating 3D sphere visualization...")
    
    # 输出到 IDB 同目录
    idb_path = idc.get_idb_path()
    out_dir = os.path.dirname(idb_path) if idb_path else os.getcwd()
    out_file = os.path.join(out_dir, "call_graph_3d_sphere.html")
    
    ok = generate_visualization(functions, edges, call_count, out_file)
    
    if not ok:
        return
    
    print(f"    ✅ Saved to: {out_file}")
    
    # 3) 在浏览器中打开
    print(f"\n[3/3] Opening in default browser...")
    try:
        webbrowser.open('file:///' + os.path.abspath(out_file).replace('\\', '/'))
        print(f"    ✅ Browser opened!")
    except Exception as e:
        print(f"    ⚠ Could not open browser: {e}")
        print(f"    Please open manually: {out_file}")
    
    print("\n" + "=" * 62)
    print("  Done! Enjoy the 3D visualization 🎉")
    print("=" * 62)


# 执行
if __name__ == "__main__":
    main()
else:
    main()

 

 

 

分析你的假设

你的推论 有一定道理,但存在明显漏洞,不能作为可靠规律使用。


✅ 正确的部分

text
系统库函数 → 调用其他系统库函数
例如:malloc → _int_malloc → sysmalloc
      pthread_create → clone (syscall)
      printf → vfprintf → __strlen_sse2

这种情况确实很常见,系统库内部调用链确实基本都是系统库函数。


❌ 问题所在

1. 回调函数机制打破这个规律

C
// 系统库函数,但会调用业务代码
qsort(arr, n, sizeof(int), your_compare_func);  // compare是业务函数
pthread_create(&t, NULL, your_thread_func, arg); // thread_func是业务函数
dlopen/dlsym 动态加载业务so

2. Hook / PLT 劫持

text
业务代码 Hook 了系统函数
系统库调用 malloc → 实际跳转到 业务层的 hook_malloc

3. JNI 调用链

text
系统库 → JNI Bridge → 业务 native 代码

4. 信号处理函数

C
signal(SIGALRM, business_handler); 
// 系统库触发信号 → 调用业务handler

5. C++ 虚函数 / 函数指针

C++
// 系统库拿着你的对象指针调用虚函数
std::sort(vec.begin(), vec.end(), BusinessComparator());

更可靠的识别方法

方法说明
检查符号来源 看函数是否在 libc/libstdc++ 等系统库的导出表里
检查地址范围 系统库有固定加载段,业务so地址范围不同
IDA FLIRT签名 自动识别系统库函数
检查是否有源码符号 系统库通常有完整符号名
分析参数语义 系统库函数参数通常是通用类型

结论

你的规律可以作为启发式参考,但不能作为确定性判断依据,尤其遇到回调、Hook、函数指针时会大量误判。

你目前逆向遇到什么具体问题?可以帮你更针对性地分析。

 

posted on 2026-02-11 18:29  GKLBB  阅读(18)  评论(0)    收藏  举报