#!/usr/bin/env python3 from __future__ import annotations import argparse import os import re import shutil import subprocess from datetime import datetime, timezone from pathlib import Path RUNTIME_ROOT = Path("{{RUNTIME_ROOT}}") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Generate a backtrace for a Metin runtime core file") parser.add_argument("--core", help="Core file path. Defaults to the newest core under the runtime tree.") parser.add_argument("--exe", help="Executable path override. If omitted, infer it from the core path.") return parser.parse_args() def ensure_root() -> None: if os.geteuid() != 0: raise SystemExit("Run as root.") def run(command: list[str], check: bool = False) -> subprocess.CompletedProcess[str]: return subprocess.run(command, check=check, capture_output=True, text=True) def iter_core_files() -> list[Path]: return sorted( (path for path in RUNTIME_ROOT.glob("channels/**/core*") if path.is_file()), key=lambda path: path.stat().st_mtime, reverse=True, ) def resolve_core_path(core_arg: str | None) -> Path: if core_arg: candidate = Path(core_arg) if not candidate.is_absolute(): runtime_relative = RUNTIME_ROOT / core_arg if runtime_relative.exists(): candidate = runtime_relative candidate = candidate.resolve() if not candidate.is_file(): raise SystemExit(f"Core file not found: {candidate}") return candidate cores = iter_core_files() if not cores: raise SystemExit(f"No core files found under {RUNTIME_ROOT}") return cores[0] def infer_execfn_from_file_output(core_path: Path) -> Path | None: completed = run(["file", str(core_path)]) if completed.returncode != 0: return None match = re.search(r"execfn: '([^']+)'", completed.stdout) if not match: return None candidate = Path(match.group(1)) if candidate.is_file(): return candidate return None def infer_executable(core_path: Path, exe_arg: str | None) -> Path: if exe_arg: exe_path = Path(exe_arg).resolve() if not exe_path.is_file(): raise SystemExit(f"Executable not found: {exe_path}") return exe_path execfn_candidate = infer_execfn_from_file_output(core_path) parent_name = core_path.parent.name grandparent_name = core_path.parent.parent.name if core_path.parent.parent else "" candidates: list[Path] = [] if execfn_candidate: candidates.append(execfn_candidate) if parent_name == "db": candidates.append(core_path.parent / "db") elif parent_name == "auth": candidates.append(core_path.parent / "game_auth") elif parent_name.startswith("core") and grandparent_name.startswith("channel"): candidates.append(core_path.parent / f"{grandparent_name}_{parent_name}") for candidate in candidates: if candidate.is_file(): return candidate.resolve() raise SystemExit(f"Could not infer executable for core file: {core_path}") def preferred_debugger() -> str | None: for tool in ("gdb", "lldb"): if shutil.which(tool): return tool return None def format_section(title: str, body: str) -> str: return f"== {title} ==\n{body.rstrip()}\n" def render_file_info(path: Path) -> str: completed = run(["file", str(path)]) body = completed.stdout or completed.stderr or "" return format_section(f"file {path}", body) def render_executable_freshness(core_path: Path, exe_path: Path) -> str: core_stat = core_path.stat() exe_stat = exe_path.stat() core_mtime = datetime.fromtimestamp(core_stat.st_mtime, tz=timezone.utc).isoformat() exe_mtime = datetime.fromtimestamp(exe_stat.st_mtime, tz=timezone.utc).isoformat() lines = [ f"core_mtime: {core_mtime}", f"exe_mtime: {exe_mtime}", ] if exe_stat.st_mtime > core_stat.st_mtime + 1: lines.append( "warning: executable is newer than the core file; symbols may not match. " "Prefer an executable snapshot from an incident bundle or pass --exe explicitly." ) else: lines.append("status: executable is not newer than the core file") return format_section("core/executable freshness", "\n".join(lines)) def render_readelf_notes(core_path: Path) -> str: if not shutil.which("readelf"): return "" completed = run(["readelf", "-n", str(core_path)]) body = completed.stdout or completed.stderr or "" return format_section(f"readelf -n {core_path}", body) def render_debugger_backtrace(debugger: str, exe_path: Path, core_path: Path) -> str: if debugger == "gdb": command = [ "gdb", "-batch", "-ex", "set pagination off", "-ex", "thread apply all bt full", str(exe_path), str(core_path), ] elif debugger == "lldb": command = [ "lldb", "--batch", "-o", "thread backtrace all", "-c", str(core_path), str(exe_path), ] else: raise SystemExit(f"Unsupported debugger: {debugger}") completed = run(command) output = completed.stdout or completed.stderr or "" return format_section("backtrace", f"$ {' '.join(command)}\n\n{output}") def main() -> int: args = parse_args() ensure_root() core_path = resolve_core_path(args.core) exe_path = infer_executable(core_path, args.exe) debugger = preferred_debugger() sections = [ format_section( "summary", "\n".join( [ f"core: {core_path}", f"executable: {exe_path}", f"debugger: {debugger or ''}", ] ), ), render_file_info(core_path), render_file_info(exe_path), render_executable_freshness(core_path, exe_path), ] readelf_section = render_readelf_notes(core_path) if readelf_section: sections.append(readelf_section) if debugger: sections.append(render_debugger_backtrace(debugger, exe_path, core_path)) else: sections.append( format_section( "backtrace", "No supported debugger found. Install gdb or lldb on the host to generate a stack trace.", ) ) print("\n".join(section.rstrip() for section in sections if section).rstrip()) return 0 if __name__ == "__main__": raise SystemExit(main())