#!/usr/bin/env python3 """ WASM 스택의 `wasm-function[IDX]:0xOFFSET`를 이름/소스 라인으로 매핑합니다. 사용 예시: python3 scripts/map_wasm_stack.py \ --wasm userfront/build/web/main.dart.wasm \ --sourcemap userfront/build/web/main.dart.wasm.map \ --frame "19112:0x2cd913" --frame "765:0x10af0e" """ from __future__ import annotations import argparse import bisect import json import re from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Tuple BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" BASE64_MAP = {c: i for i, c in enumerate(BASE64_CHARS)} def read_u32_leb128(buf: bytes, i: int) -> Tuple[int, int]: value = 0 shift = 0 while True: b = buf[i] i += 1 value |= (b & 0x7F) << shift if b < 0x80: return value, i shift += 7 def decode_vlq_segment(segment: str) -> List[int]: out: List[int] = [] i = 0 while i < len(segment): shift = 0 value = 0 while True: d = BASE64_MAP[segment[i]] i += 1 value |= (d & 0x1F) << shift shift += 5 if (d & 0x20) == 0: break sign = value & 1 value >>= 1 out.append(-value if sign else value) return out @dataclass class SourcePoint: generated_col: int source_index: Optional[int] source_line: Optional[int] source_col: Optional[int] name_index: Optional[int] class WasmSourceMap: def __init__(self, sourcemap_path: Path): data = json.loads(sourcemap_path.read_text(encoding="utf-8")) self.sources: List[str] = data["sources"] self.names: List[str] = data.get("names", []) mappings: str = data["mappings"] # wasm sourcemap은 generated line 1개를 쓰는 형태라 ',' 단위로만 파싱합니다. segments = mappings.split(",") points: List[SourcePoint] = [] generated_col = 0 source_index = 0 source_line = 0 source_col = 0 name_index = 0 for seg in segments: if not seg: continue vals = decode_vlq_segment(seg) generated_col += vals[0] si: Optional[int] = None sl: Optional[int] = None sc: Optional[int] = None ni: Optional[int] = None if len(vals) >= 4: source_index += vals[1] source_line += vals[2] source_col += vals[3] si = source_index sl = source_line sc = source_col if len(vals) >= 5: name_index += vals[4] ni = name_index points.append( SourcePoint( generated_col=generated_col, source_index=si, source_line=sl, source_col=sc, name_index=ni, ) ) self.points = points self.columns = [p.generated_col for p in points] def lookup(self, offset: int) -> Optional[SourcePoint]: idx = bisect.bisect_right(self.columns, offset) - 1 if idx < 0: return None return self.points[idx] def source_name(self, index: Optional[int]) -> Optional[str]: if index is None or index < 0 or index >= len(self.sources): return None return self.sources[index] def symbol_name(self, index: Optional[int]) -> Optional[str]: if index is None or index < 0 or index >= len(self.names): return None return self.names[index] def parse_wasm_function_names(wasm_path: Path) -> Dict[int, str]: b = wasm_path.read_bytes() if b[:4] != b"\x00asm": raise ValueError(f"Not a wasm binary: {wasm_path}") function_names: Dict[int, str] = {} i = 8 # magic + version while i < len(b): section_id = b[i] i += 1 section_size, i = read_u32_leb128(b, i) section_start = i section_end = i + section_size if section_id == 0: # custom section name_len, j = read_u32_leb128(b, i) custom_name = b[j : j + name_len].decode("utf-8", errors="replace") payload_start = j + name_len if custom_name == "name": k = payload_start while k < section_end: subsection_id = b[k] k += 1 subsection_size, k = read_u32_leb128(b, k) subsection_end = k + subsection_size if subsection_id == 1: # function names count, k = read_u32_leb128(b, k) for _ in range(count): fn_idx, k = read_u32_leb128(b, k) nlen, k = read_u32_leb128(b, k) name = b[k : k + nlen].decode("utf-8", errors="replace") k += nlen function_names[fn_idx] = name else: k = subsection_end i = section_end return function_names def parse_frame(raw: str) -> Tuple[int, int]: m = re.match(r"^\s*(\d+)\s*:\s*(0x[0-9a-fA-F]+)\s*$", raw) if not m: raise ValueError(f"Invalid --frame format: {raw!r} (expected IDX:0xOFFSET)") return int(m.group(1)), int(m.group(2), 16) def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="Map wasm stack frames to source locations") p.add_argument("--wasm", required=True, type=Path, help="WASM binary path") p.add_argument("--sourcemap", required=True, type=Path, help="WASM sourcemap path") p.add_argument( "--frame", action="append", default=[], help="Frame in IDX:0xOFFSET format (repeatable)", ) p.add_argument( "--offset", action="append", default=[], help="Offset only (hex), function index unknown", ) return p.parse_args() def main() -> None: args = parse_args() source_map = WasmSourceMap(args.sourcemap) function_names = parse_wasm_function_names(args.wasm) targets: List[Tuple[Optional[int], int]] = [] for f in args.frame: idx, off = parse_frame(f) targets.append((idx, off)) for off in args.offset: targets.append((None, int(off, 16))) if not targets: raise SystemExit("No targets. Provide --frame or --offset.") for fn_idx, off in targets: point = source_map.lookup(off) fn_name = function_names.get(fn_idx) if fn_idx is not None else None mapped_col = point.generated_col if point else None src = source_map.source_name(point.source_index) if point else None src_line = (point.source_line + 1) if point and point.source_line is not None else None src_col = (point.source_col + 1) if point and point.source_col is not None else None symbol = source_map.symbol_name(point.name_index) if point else None print( json.dumps( { "function_index": fn_idx, "function_name": fn_name, "offset_hex": hex(off), "mapped_generated_col_hex": hex(mapped_col) if mapped_col is not None else None, "source": src, "source_line": src_line, "source_column": src_col, "symbol": symbol, }, ensure_ascii=False, ) ) if __name__ == "__main__": main()