#!/usr/bin/env python3 """Qt editor for decompiler var_names and method docstrings.""" import argparse import bisect import json import re import sys from dataclasses import dataclass, field from pathlib import Path from PyQt5 import QtCore, QtGui, QtWidgets DEFAULT_VAR_NAMES = Path("decompiler/config/jak1/ntsc_v1/var_names.jsonc") DEFAULT_METHOD_DOCS = Path("docs/jak1_cleanup_inputs/method_names.jsonc") FUNCTION_COMMENT_RE = re.compile( r"^;; definition (?:\(debug\) )?for (?:function|behavior) (?P.+?)\s*$" ) METHOD_COMMENT_RE = re.compile(r"^;; definition for method (?P\d+) of type (?P\S+)\s*$") DEF_HEADER_RE = re.compile(r"\((?:defun|defun-debug|defmethod|defbehavior)\s+(?P\S+)") STATE_HEADER_RE = re.compile( r"^[ \t]*\(\s*defstate\s+(?P\S+)\s+\(\s*(?P\S+)\s*\)", re.MULTILINE ) BEHAVIOR_HEADER_RE = re.compile(r"\(\s*behavior\b") LOCAL_VAR_RE = re.compile(r"\b(?:(?:[avts]\d)|(?:f\d+)|(?:sv\d*)|gp|sp|fp|ra|r0|pp)-\d+\b") STATE_HANDLER_NAMES = ("event", "enter", "post", "trans", "exit", "code") @dataclass class DefinitionBlock: key: str kind: str display_name: str text: str start_line: int end_line: int method_id: int | None = None type_name: str | None = None args: list[str] = field(default_factory=list) @property def label(self) -> str: return f"{self.kind:8} {self.key} ({self.start_line}-{self.end_line})" def strip_jsonc(text: str) -> str: out = [] i = 0 in_string = False escaped = False while i < len(text): char = text[i] if in_string: out.append(char) if escaped: escaped = False elif char == "\\": escaped = True elif char == '"': in_string = False i += 1 continue if char == '"': in_string = True out.append(char) i += 1 continue if char == "/" and i + 1 < len(text) and text[i + 1] == "/": i += 2 while i < len(text) and text[i] not in "\r\n": i += 1 continue if char == "/" and i + 1 < len(text) and text[i + 1] == "*": i += 2 while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"): i += 1 i += 2 continue out.append(char) i += 1 return "".join(out) def remove_trailing_commas(text: str) -> str: out = [] i = 0 in_string = False escaped = False while i < len(text): char = text[i] if in_string: out.append(char) if escaped: escaped = False elif char == "\\": escaped = True elif char == '"': in_string = False i += 1 continue if char == '"': in_string = True out.append(char) i += 1 continue if char == ",": j = i + 1 while j < len(text) and text[j].isspace(): j += 1 if j < len(text) and text[j] in "}]": i += 1 continue out.append(char) i += 1 return "".join(out) def load_jsonc(path: Path) -> dict: if not path or not path.exists(): return {} return json.loads(remove_trailing_commas(strip_jsonc(path.read_text()))) def save_json(path: Path, data: dict) -> None: path.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n") def monospace_font() -> QtGui.QFont: font = QtGui.QFont("monospace") font.setStyleHint(QtGui.QFont.Monospace) return font def sexpr_text_from_header(text: str) -> str: start = text.find("(def") if start < 0: return "" depth = 0 in_string = False escaped = False for idx, char in enumerate(text[start:], start): if in_string: if escaped: escaped = False elif char == "\\": escaped = True elif char == '"': in_string = False continue if char == '"': in_string = True elif char == "(": depth += 1 elif char == ")": depth -= 1 if depth == 0: return text[start : idx + 1] return "" def first_list_after_def_name(header: str) -> str: if not header: return "" header_match = DEF_HEADER_RE.search(header) if not header_match: return "" start = header.find("(", header_match.end()) if start < 0: return "" depth = 0 in_string = False escaped = False for idx, char in enumerate(header[start:], start): if in_string: if escaped: escaped = False elif char == "\\": escaped = True elif char == '"': in_string = False continue if char == '"': in_string = True elif char == "(": depth += 1 elif char == ")": depth -= 1 if depth == 0: return header[start : idx + 1] return "" def parse_arg_names_from_list(arg_list: str) -> list[str]: if len(arg_list) < 2: return [] args = [] inner = arg_list[1:-1] depth = 0 start = None bare_token_start = None for idx, char in enumerate(inner): if char == "(": if depth == 0 and bare_token_start is not None: args.append(inner[bare_token_start:idx].strip()) bare_token_start = None if depth == 0: start = idx depth += 1 elif char == ")": depth -= 1 if depth == 0 and start is not None: arg_expr = inner[start : idx + 1] arg_match = re.match(r"\(\s*(\S+)", arg_expr) if arg_match: args.append(arg_match.group(1)) start = None elif depth == 0 and char.isspace(): if bare_token_start is not None: args.append(inner[bare_token_start:idx].strip()) bare_token_start = None elif depth == 0 and bare_token_start is None: bare_token_start = idx if bare_token_start is not None: args.append(inner[bare_token_start:].strip()) return [arg for arg in args if arg] def parse_args_from_header(header: str) -> list[str]: return parse_arg_names_from_list(first_list_after_def_name(header)) def first_list_after_behavior_name(header: str) -> str: if not header: return "" header_match = BEHAVIOR_HEADER_RE.search(header) if not header_match: return "" start = header.find("(", header_match.end()) if start < 0: return "" depth = 0 in_string = False escaped = False for idx, char in enumerate(header[start:], start): if in_string: if escaped: escaped = False elif char == "\\": escaped = True elif char == '"': in_string = False continue if char == '"': in_string = True elif char == "(": depth += 1 elif char == ")": depth -= 1 if depth == 0: return header[start : idx + 1] return "" def parse_args_from_behavior(header: str) -> list[str]: return parse_arg_names_from_list(first_list_after_behavior_name(header)) def line_start_offsets(text: str) -> list[int]: starts = [0] for idx, char in enumerate(text): if char == "\n": starts.append(idx + 1) return starts def line_number_for_offset(starts: list[int], offset: int) -> int: return bisect.bisect_right(starts, offset) def skip_ws_and_line_comments(text: str, pos: int, end: int | None = None) -> int: if end is None: end = len(text) while pos < end: if text[pos].isspace(): pos += 1 continue if text[pos] == ";": newline = text.find("\n", pos, end) if newline < 0: return end pos = newline + 1 continue break return pos def find_matching_paren(text: str, open_idx: int, limit: int | None = None) -> int: if limit is None: limit = len(text) depth = 0 in_string = False escaped = False in_line_comment = False idx = open_idx while idx < limit: char = text[idx] if in_line_comment: if char == "\n": in_line_comment = False idx += 1 continue if in_string: if escaped: escaped = False elif char == "\\": escaped = True elif char == '"': in_string = False idx += 1 continue if char == ";": in_line_comment = True elif char == '"': in_string = True elif char == "(": depth += 1 elif char == ")": depth -= 1 if depth == 0: return idx idx += 1 return -1 def read_expr_range(text: str, pos: int, limit: int) -> tuple[int, int] | None: pos = skip_ws_and_line_comments(text, pos, limit) if pos >= limit: return None if text[pos] == "(": end = find_matching_paren(text, pos, limit) if end < 0: return None return pos, end + 1 end = pos while end < limit and not text[end].isspace() and text[end] not in "()": end += 1 return pos, end def iter_state_handler_exprs(text: str, form_start: int, form_end: int): depth = 1 in_string = False escaped = False in_line_comment = False idx = form_start + 1 while idx < form_end: char = text[idx] if in_line_comment: if char == "\n": in_line_comment = False idx += 1 continue if in_string: if escaped: escaped = False elif char == "\\": escaped = True elif char == '"': in_string = False idx += 1 continue if char == ";": in_line_comment = True idx += 1 continue if char == '"': in_string = True idx += 1 continue if depth == 1 and char == ":": for handler in STATE_HANDLER_NAMES: keyword = f":{handler}" keyword_end = idx + len(keyword) if text.startswith(keyword, idx) and ( keyword_end >= form_end or text[keyword_end].isspace() or text[keyword_end] in "()" ): expr_range = read_expr_range(text, keyword_end, form_end) if expr_range: expr_start, expr_end = expr_range yield handler, idx, expr_start, expr_end idx = expr_end break else: idx += 1 continue if char == "(": depth += 1 elif char == ")": depth -= 1 idx += 1 def parse_state_blocks(path: Path) -> list[DefinitionBlock]: text = path.read_text() starts = line_start_offsets(text) blocks = [] for match in STATE_HEADER_RE.finditer(text): form_start = match.start() form_end = find_matching_paren(text, form_start) if form_end < 0: continue state_name = match.group("state") type_name = match.group("type") for handler, keyword_start, expr_start, expr_end in iter_state_handler_exprs(text, form_start, form_end): expr_text = text[expr_start:expr_end] if not BEHAVIOR_HEADER_RE.match(expr_text.lstrip()): continue block_text = f";; state {handler} for {state_name} ({type_name})\n{text[keyword_start:expr_end]}\n" key = f"({handler} {state_name} {type_name})" blocks.append( DefinitionBlock( key=key, kind=f"state-{handler}", display_name=f"{handler} {state_name} {type_name}", text=block_text, start_line=line_number_for_offset(starts, keyword_start), end_line=line_number_for_offset(starts, expr_end), type_name=type_name, args=parse_args_from_behavior(expr_text), ) ) return blocks def parse_display_name(header: str, fallback: str) -> str: match = DEF_HEADER_RE.search(header) return match.group("name") if match else fallback def parse_disasm(path: Path) -> list[DefinitionBlock]: lines = path.read_text().splitlines() starts = [] for idx, line in enumerate(lines): function_match = FUNCTION_COMMENT_RE.match(line) if function_match: name = function_match.group("name") starts.append((idx, "function", name, None, None)) continue method_match = METHOD_COMMENT_RE.match(line) if method_match: method_id = int(method_match.group("id")) type_name = method_match.group("type") key = f"(method {method_id} {type_name})" starts.append((idx, "method", key, method_id, type_name)) blocks = [] for start_idx, (line_idx, kind, key, method_id, type_name) in enumerate(starts): end_idx = starts[start_idx + 1][0] if start_idx + 1 < len(starts) else len(lines) text = "\n".join(lines[line_idx:end_idx]) if end_idx > line_idx: text += "\n" header = sexpr_text_from_header(text) blocks.append( DefinitionBlock( key=key, kind=kind, display_name=parse_display_name(header, key), text=text, start_line=line_idx + 1, end_line=end_idx, method_id=method_id, type_name=type_name, args=parse_args_from_header(header), ) ) blocks.extend(parse_state_blocks(path)) blocks.sort(key=lambda block: (block.start_line, block.end_line, block.kind)) return blocks def get_local_override_name(value): if isinstance(value, str): return value if isinstance(value, list) and value: return value[0] return None def set_local_override(entry: dict, var_id: str, new_name: str) -> None: vars_entry = entry.setdefault("vars", {}) existing = vars_entry.get(var_id) if isinstance(existing, list) and len(existing) >= 2: updated = list(existing) updated[0] = new_name vars_entry[var_id] = updated else: vars_entry[var_id] = new_name class DefinitionTextEdit(QtWidgets.QTextEdit): clicked_at = QtCore.pyqtSignal(QtCore.QPoint) def mousePressEvent(self, event): super().mousePressEvent(event) self.clicked_at.emit(event.pos()) class ColumnGuideTextEdit(QtWidgets.QTextEdit): def __init__(self, column: int, parent=None): super().__init__(parent) self.column = column self.setFont(monospace_font()) self.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) def paintEvent(self, event): super().paintEvent(event) font_metrics = QtGui.QFontMetrics(self.font()) column_x = ( self.document().documentMargin() + font_metrics.horizontalAdvance(" ") * self.column - self.horizontalScrollBar().value() ) if column_x < 0 or column_x > self.viewport().width(): return painter = QtGui.QPainter(self.viewport()) pen = QtGui.QPen(QtGui.QColor("#d1d5db")) pen.setWidth(1) painter.setPen(pen) x = int(column_x) painter.drawLine(x, 0, x, self.viewport().height()) class VarNameEditor(QtWidgets.QMainWindow): def __init__(self, args): super().__init__() self.setWindowTitle("OpenGOAL Variable Name Editor") self.resize(1320, 840) self.disasm_path = Path(args.disasm).resolve() if args.disasm else None self.var_names_path = Path(args.var_names).resolve() if args.var_names else DEFAULT_VAR_NAMES.resolve() self.method_docs_path = ( Path(args.method_docs).resolve() if args.method_docs else DEFAULT_METHOD_DOCS.resolve() ) self.blocks: list[DefinitionBlock] = [] self.filtered_blocks: list[DefinitionBlock] = [] self.current_block: DefinitionBlock | None = None self.var_names = {} self.method_docs = {} self.disasm_files: list[Path] = [] self.filtered_disasm_files: list[Path] = [] self.token_ranges = [] self.selected_token = None self.dirty_vars = False self.dirty_docs = False self._build_ui() self._load_initial_files() def _build_ui(self) -> None: root = QtWidgets.QWidget() self.setCentralWidget(root) layout = QtWidgets.QVBoxLayout(root) toolbar = QtWidgets.QHBoxLayout() layout.addLayout(toolbar) open_disasm = QtWidgets.QPushButton("Open Disasm") open_disasm.clicked.connect(self.open_disasm) toolbar.addWidget(open_disasm) self.disasm_edit = QtWidgets.QLineEdit() toolbar.addWidget(self.disasm_edit, 2) load_vars = QtWidgets.QPushButton("Load Vars") load_vars.clicked.connect(self.load_var_names_dialog) toolbar.addWidget(load_vars) self.var_path_edit = QtWidgets.QLineEdit() toolbar.addWidget(self.var_path_edit, 2) save_vars = QtWidgets.QPushButton("Save Vars") save_vars.clicked.connect(self.save_var_names) toolbar.addWidget(save_vars) load_docs = QtWidgets.QPushButton("Load Docs") load_docs.clicked.connect(self.load_method_docs_dialog) toolbar.addWidget(load_docs) self.docs_path_edit = QtWidgets.QLineEdit() toolbar.addWidget(self.docs_path_edit, 2) save_docs = QtWidgets.QPushButton("Save Docs") save_docs.clicked.connect(self.save_method_docs) toolbar.addWidget(save_docs) filter_layout = QtWidgets.QHBoxLayout() layout.addLayout(filter_layout) filter_layout.addWidget(QtWidgets.QLabel("Definition Filter")) self.filter_edit = QtWidgets.QLineEdit() self.filter_edit.textChanged.connect(self.refresh_definition_list) filter_layout.addWidget(self.filter_edit) splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal) layout.addWidget(splitter, 1) file_panel = QtWidgets.QWidget() file_layout = QtWidgets.QVBoxLayout(file_panel) file_layout.setContentsMargins(0, 0, 0, 0) file_layout.addWidget(QtWidgets.QLabel("File Filter")) self.file_filter_edit = QtWidgets.QLineEdit() self.file_filter_edit.setPlaceholderText("Partial filename") self.file_filter_edit.textChanged.connect(lambda _text: self.apply_file_filter()) file_layout.addWidget(self.file_filter_edit) self.file_list = QtWidgets.QListWidget() self.file_list.currentRowChanged.connect(self.on_file_selected) file_layout.addWidget(self.file_list, 1) splitter.addWidget(file_panel) self.definition_list = QtWidgets.QListWidget() self.definition_list.currentRowChanged.connect(self.on_definition_selected) splitter.addWidget(self.definition_list) self.text = DefinitionTextEdit() self.text.setReadOnly(False) self.text.setLineWrapMode(QtWidgets.QTextEdit.NoWrap) self.text.setFont(monospace_font()) self.text.clicked_at.connect(self.on_text_click) splitter.addWidget(self.text) splitter.setStretchFactor(0, 1) splitter.setStretchFactor(1, 2) splitter.setStretchFactor(2, 6) bottom = QtWidgets.QGridLayout() layout.addLayout(bottom) bottom.addWidget(QtWidgets.QLabel("Selected"), 0, 0) self.selected_edit = QtWidgets.QLineEdit() self.selected_edit.setReadOnly(True) bottom.addWidget(self.selected_edit, 0, 1) bottom.addWidget(QtWidgets.QLabel("Rename To"), 0, 2) self.rename_edit = QtWidgets.QLineEdit() self.rename_edit.returnPressed.connect(self.apply_rename) bottom.addWidget(self.rename_edit, 0, 3) apply_rename = QtWidgets.QPushButton("Apply Rename") apply_rename.clicked.connect(self.apply_rename) bottom.addWidget(apply_rename, 0, 4) doc_group = QtWidgets.QGroupBox("Function/Method Docstring") doc_layout = QtWidgets.QHBoxLayout(doc_group) self.doc_text = ColumnGuideTextEdit(110) self.doc_text.setFixedHeight(90) doc_layout.addWidget(self.doc_text, 1) apply_doc = QtWidgets.QPushButton("Apply Docstring") apply_doc.clicked.connect(self.apply_docstring) doc_layout.addWidget(apply_doc) bottom.addWidget(doc_group, 1, 0, 1, 5) self.statusBar() def _load_initial_files(self) -> None: self.var_path_edit.setText(str(self.var_names_path)) self.docs_path_edit.setText(str(self.method_docs_path)) self.var_names = load_jsonc(self.var_names_path) self.method_docs = load_jsonc(self.method_docs_path) if self.disasm_path: self.load_disasm(self.disasm_path) else: self.set_status("Open a *_disasm.gc file to begin.") def set_status(self, text: str) -> None: self.statusBar().showMessage(text) def open_disasm(self) -> None: path, _ = QtWidgets.QFileDialog.getOpenFileName( self, "Open decompiler output", "", "GOAL disassembly (*_disasm.gc);;GOAL (*.gc);;All (*)" ) if path: self.load_disasm(Path(path)) def load_disasm(self, path: Path) -> None: self.disasm_path = path.resolve() self.disasm_edit.setText(str(self.disasm_path)) self.refresh_file_list(self.disasm_path.parent, self.disasm_path) self.blocks = parse_disasm(self.disasm_path) self.refresh_definition_list() if self.blocks: self.definition_list.setCurrentRow(0) self.set_status(f"Loaded {len(self.blocks)} definitions from {self.disasm_path}") def refresh_file_list(self, directory: Path, current_path: Path | None = None) -> None: self.disasm_files = sorted(directory.glob("*_disasm.gc")) self.apply_file_filter(current_path) def apply_file_filter(self, current_path: Path | None = None) -> None: query = self.file_filter_edit.text().strip().lower() self.filtered_disasm_files = [ path for path in self.disasm_files if not query or query in path.name.lower() ] self.file_list.blockSignals(True) self.file_list.clear() selected_row = -1 active_path = current_path or self.disasm_path for index, path in enumerate(self.filtered_disasm_files): self.file_list.addItem(path.name) if active_path and path.resolve() == active_path.resolve(): selected_row = index if selected_row >= 0: self.file_list.setCurrentRow(selected_row) self.file_list.blockSignals(False) def on_file_selected(self, row: int) -> None: if row < 0 or row >= len(self.filtered_disasm_files): return path = self.filtered_disasm_files[row] if self.disasm_path and path.resolve() == self.disasm_path.resolve(): return self.load_disasm(path) def load_var_names_dialog(self) -> None: path, _ = QtWidgets.QFileDialog.getOpenFileName( self, "Load var_names.jsonc", "", "JSONC (*.jsonc);;JSON (*.json);;All (*)" ) if path: self.var_names_path = Path(path).resolve() self.var_path_edit.setText(str(self.var_names_path)) self.var_names = load_jsonc(self.var_names_path) self.dirty_vars = False self.render_current_block() self.set_status(f"Loaded variable names from {self.var_names_path}") def load_method_docs_dialog(self) -> None: path, _ = QtWidgets.QFileDialog.getOpenFileName( self, "Load method docstrings", "", "JSONC (*.jsonc);;JSON (*.json);;All (*)" ) if path: self.method_docs_path = Path(path).resolve() self.docs_path_edit.setText(str(self.method_docs_path)) self.method_docs = load_jsonc(self.method_docs_path) self.dirty_docs = False self.render_current_block() self.set_status(f"Loaded method docstrings from {self.method_docs_path}") def save_var_names(self) -> None: path = Path(self.var_path_edit.text()).expanduser() save_json(path, self.var_names) self.var_names_path = path.resolve() self.dirty_vars = False self.set_status(f"Saved variable names to {self.var_names_path}") def save_method_docs(self) -> None: path = Path(self.docs_path_edit.text()).expanduser() save_json(path, self.method_docs) self.method_docs_path = path.resolve() self.dirty_docs = False self.set_status(f"Saved method docstrings to {self.method_docs_path}") def refresh_definition_list(self) -> None: query = self.filter_edit.text().strip().lower() self.filtered_blocks = [ block for block in self.blocks if not query or query in block.label.lower() or query in block.display_name.lower() ] self.definition_list.clear() for block in self.filtered_blocks: self.definition_list.addItem(block.label) def on_definition_selected(self, row: int) -> None: if row < 0 or row >= len(self.filtered_blocks): return self.current_block = self.filtered_blocks[row] self.selected_token = None self.selected_edit.clear() self.rename_edit.clear() self.render_current_block() def entry_for_current_block(self) -> dict: if not self.current_block: return {} return self.var_names.setdefault(self.current_block.key, {}) def current_arg_names(self) -> list[str]: block = self.current_block if not block: return [] entry = self.entry_for_current_block() configured = entry.get("args", []) result = [] for index, original in enumerate(block.args): result.append(configured[index] if index < len(configured) else original) return result def current_docstring(self) -> str: block = self.current_block if not block: return "" entry = self.method_docs.get(block.key) if isinstance(entry, list) and len(entry) >= 2: return entry[1] return "" def render_current_block(self) -> None: block = self.current_block self.text.clear() self.doc_text.clear() self.token_ranges = [] if not block: return self.doc_text.setEnabled(block.kind in ("function", "method")) if block.kind in ("function", "method"): self.doc_text.setPlainText(self.current_docstring()) entry = self.entry_for_current_block() local_overrides = { key: get_local_override_name(value) for key, value in entry.get("vars", {}).items() } arg_names = self.current_arg_names() arg_index_by_original = {arg: index for index, arg in enumerate(block.args)} arg_pattern = "|".join(re.escape(arg) for arg in sorted(block.args, key=len, reverse=True)) token_re = re.compile(f"{LOCAL_VAR_RE.pattern}|\\b(?:{arg_pattern})\\b") if arg_pattern else LOCAL_VAR_RE output = [] cursor = 0 out_pos = 0 for match in token_re.finditer(block.text): prefix = block.text[cursor : match.start()] output.append(prefix) out_pos += len(prefix) token = match.group(0) kind = "local" token_id = token replacement = local_overrides.get(token, token) if token in arg_index_by_original: kind = "arg" token_id = str(arg_index_by_original[token]) replacement = arg_names[arg_index_by_original[token]] start_pos = out_pos output.append(replacement) out_pos += len(replacement) self.token_ranges.append((start_pos, out_pos, kind, token_id, replacement)) cursor = match.end() output.append(block.text[cursor:]) self.text.setPlainText("".join(output)) self.apply_token_formatting() def apply_token_formatting(self) -> None: self.update_extra_selections() def update_extra_selections(self) -> None: selections = [] token_format = QtGui.QTextCharFormat() token_format.setForeground(QtGui.QBrush(QtGui.QColor("#075985"))) for start, end, _kind, _token_id, _replacement in self.token_ranges: cursor = self.text.textCursor() cursor.setPosition(start) cursor.setPosition(end, QtGui.QTextCursor.KeepAnchor) selection = QtWidgets.QTextEdit.ExtraSelection() selection.cursor = cursor selection.format = token_format selections.append(selection) if self.selected_token: kind, token_id, _replacement = self.selected_token selection_format = QtGui.QTextCharFormat() selection_format.setBackground(QtGui.QBrush(QtGui.QColor("#fde68a"))) for start, end, range_kind, range_token_id, _range_replacement in self.token_ranges: if range_kind == kind and range_token_id == token_id: cursor = self.text.textCursor() cursor.setPosition(start) cursor.setPosition(end, QtGui.QTextCursor.KeepAnchor) selection = QtWidgets.QTextEdit.ExtraSelection() selection.cursor = cursor selection.format = selection_format selections.append(selection) self.text.setExtraSelections(selections) def token_at_position(self, position: int): for start, end, kind, token_id, replacement in self.token_ranges: if start <= position < end: return kind, token_id, replacement return None def on_text_click(self, point: QtCore.QPoint) -> None: cursor = self.text.cursorForPosition(point) token = self.token_at_position(cursor.position()) if not token: return self.selected_token = token kind, token_id, replacement = token self.selected_edit.setText(f"arg[{token_id}]" if kind == "arg" else token_id) self.rename_edit.setText(replacement) self.rename_edit.setFocus(QtCore.Qt.MouseFocusReason) self.rename_edit.selectAll() self.highlight_selected_token() def highlight_selected_token(self) -> None: self.update_extra_selections() def apply_rename(self) -> None: if not self.current_block or not self.selected_token: QtWidgets.QMessageBox.information(self, "No Variable Selected", "Click a variable first.") return new_name = self.rename_edit.text().strip() if not new_name: QtWidgets.QMessageBox.information(self, "Missing Name", "Enter a replacement name.") return kind, token_id, _replacement = self.selected_token entry = self.entry_for_current_block() if kind == "arg": index = int(token_id) args = list(entry.get("args", [])) while len(args) < len(self.current_block.args): args.append(self.current_block.args[len(args)]) args[index] = new_name entry["args"] = args else: set_local_override(entry, token_id, new_name) self.dirty_vars = True self.render_current_block() self.set_status(f"Renamed {self.selected_edit.text()} to {new_name} in {self.current_block.key}") def apply_docstring(self) -> None: block = self.current_block if not block or block.kind not in ("function", "method"): QtWidgets.QMessageBox.information( self, "No Function/Method Selected", "Select a function or method first." ) return docstring = self.doc_text.toPlainText().strip() entry = self.method_docs.get(block.key) if isinstance(entry, list) and len(entry) >= 2: entry[1] = docstring else: self.method_docs[block.key] = [block.display_name, docstring] self.dirty_docs = True self.set_status(f"Updated docstring for {block.key}") def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("disasm", nargs="?", help="input *_disasm.gc file") parser.add_argument("--var-names", default=str(DEFAULT_VAR_NAMES), help="var_names JSON/JSONC file") parser.add_argument("--method-docs", default=str(DEFAULT_METHOD_DOCS), help="method docstrings JSON/JSONC file") args = parser.parse_args() app = QtWidgets.QApplication(sys.argv) window = VarNameEditor(args) window.show() sys.exit(app.exec_()) if __name__ == "__main__": main()